feat(annotate): dismiss abandoned gate sessions - #1143
Merged
backnotprop merged 1 commit intoJul 30, 2026
Conversation
A direct local `plannotator annotate --gate --json` waits for one authoritative decision. If every review surface disappears without approving, sending feedback, or exiting, the caller blocks forever: the server has no notion of whether a client ever connected, whether another tab is still open, or whether a disconnect is a reload. Page lifecycle events cannot answer that. `pagehide` and `beforeunload` also fire on reload and navigation, so dismissing from them ends reviews the user expects to resume. Use connection presence instead, which is exactly what the transport can observe. Local direct structured gates advertise a client lease in /api/plan and serve /api/annotate/client-lease as SSE. One open stream is one connected review surface. The server heartbeats every 5s and, only after at least one client has connected, starts a 30s reconnect grace when the last one disconnects. A reconnect inside the grace continues the same review; expiry resolves the gate through the same path as explicit Close, so it produces an ordinary `dismissed` decision and inherits the strict-result contract unchanged. Approve, feedback, explicit exit, and server stop all cancel a pending expiry. Presence lives in two runtime-independent pieces so Bun and Pi cannot drift. createAnnotateClientLeaseTracker owns first-client, active-count, reconnect, cancellation, and one-shot expiry. createAnnotateClientLease- StreamSession owns one connected client: acquire the slot, write the ready comment, heartbeat, release exactly once. Each server passes only its own write primitive (a ReadableStream controller for Bun, res.write for Pi). A write that fails closes the session, because a stream that can no longer be written to is a client that is no longer present; holding the slot there would make the gate un-dismissable for the rest of the run, which is reachable only through a half-open connection and so is covered by unit tests rather than an integration test. Scope is deliberately narrow. The capability stays off for remote and shared sessions, where tunnel disconnects would read as abandonment, and off for hook transport, legacy plaintext, archive, plan, review, and folder-picker sessions. A session that never receives its first client never auto-dismisses, so browser-launch failures still need a caller-side timeout. Decision settlement is explicit for the same reason: a connected surface and the lease can both try to settle the session, and the awaited promise ignoring the second resolve was not enough. The loser still deleted the reviewer's draft and answered ok, so a tab reported success for a decision the caller never received. createAnnotateDecisionSettler makes the winner explicit; a loser changes nothing and answers 409. Expiry deliberately keeps the saved draft, unlike explicit Close, so an abandoned review stays recoverable. Stopping the server closes live lease streams instead of only releasing their slots, so a long-lived host process does not retain a heartbeat timer and an open response for every finished session.
rNoz
marked this pull request as ready for review
July 27, 2026 21:44
Contributor
Author
|
@backnotprop this and #1145 totally tested and ready for you to review/merge. I have been using both in by local integration branch to be used for all projects, so far so good. |
backnotprop
added a commit
that referenced
this pull request
Jul 30, 2026
Conflict resolutions: - packages/server/annotate.ts: carried main's #1143 client-lease cleanup (clientLease.cancel() + closeSessions()) into the branch's memoized async stop closure, ahead of server.stop, keeping the presenter dismiss after server stop. A HEAD-favoring resolve would have regressed the abandoned-gate dismissal. - apps/pi-extension/index.ts: kept main's sessionAlive latch (383ac97) assigned synchronously before the branch's awaited stopActivePlannotatorBrowserSessions(), so the stale-ctx guard is not delayed by the presenter dismiss budget. - apps/pi-extension/plannotator-browser.ts: composed main's .catch + console.error on the fire-and-forget browser open with the branch's presenter presentation flow. - apps/pi-extension/vendor.sh: union of both module lists (main's annotate-client-lease + annotate-decision, branch's presenter). Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
backnotprop
added a commit
that referenced
this pull request
Jul 30, 2026
…e bridge #1143 wired abandoned-gate dismissal into three of the four startAnnotateServer call sites. The OpenCode annotate-last bridge takes gate from stdin JSON rather than CLI flags and was missed, so /plannotator-last --gate under OpenCode still hung on waitForDecision forever once every review tab was abandoned: exactly the hang that commit set out to close. The bridge's inputs map onto the same predicate the other three use: gate from the stdin payload, json unconditionally true because emitOpenCodeAnnotateOutcome is the branch's only output path and always writes a structured record the bridge parses back, hook false because no flags are parsed here. The new test scans every startAnnotateServer call site in index.ts rather than pinning this one line, so the next site added cannot repeat the omission. Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Local direct structured annotate gates now track connected browser review clients. Once at least one client has connected, losing the last one starts a 30 second reconnect grace; a reconnect continues the same review, and expiry resolves the gate as
dismissedwhile keeping the saved annotation draft.The signal is an SSE client lease, not
pagehideor another page lifecycle event. Reload and navigation reconnect instead of being read as a close, and multiple tabs hold independent leases.Solves http://github.com/backnotprop/plannotator/issues/1142 that I discovered during previous weeks of work. Proposal to control this scenario in this PR. Please, let me know if you want some further e2e validation/test/proofs, or even extensions. Happy to contribute!
Why
plannotator annotate <file> --gate --jsonwaits for one authoritative decision. If every review surface disappears without approving, sending feedback, or exiting, the caller blocks with no timeout.Page lifecycle callbacks cannot separate close from reload or navigation. Connection state can: a client connected, the last one disconnected, one reconnected, the grace expired.
Changes
ok, so a tab reported success for an outcome the caller never received. The loser now changes nothing and answers409.--gate --jsonsessions outside hook transport, and serve matching Bun and Pi/api/annotate/client-leaseSSE endpoints.EventSourceonly when the server advertises the capability, and close it on authoritative completion and on cleanup.annotate-last, and folder gates are all covered, since each is a local direct structured gate that can hang.vendor.sh.AGENTS.md, and replace the annotate-gates guide's "closing the browser is not guaranteed to produce a decision" paragraph with the real behavior and its two remaining caller-recovery cases.Semantics and limits
cancelplus an enqueue that throws, Pi's request and responsecloseevents, since Node'sres.writesignals backpressure by returningfalserather than throwing. The shared session is what keeps the resulting accounting identical.dismisseddecision value as explicit Close and takes the same publication path, so it inherits the strict-result contract unchanged: stdout record first,--result-filepublication second, exit1only under--require-approval. Exit2still means a misconfigured gate, a strict startup failure, a stdout decision-output failure, or a failed result-file publication. Expiry differs from Close in one deliberate way: it keeps the saved annotation draft, because nobody asked for an abandoned review's work to be discarded.409and changes nothing. That is what makes keeping the draft safe: otherwise a tab returning after expiry would delete it and claim its approval applied while the caller already haddismissed.exit: truedecision through its existing closed-session notification path rather than CLI stdout.Validation
Automated:
packages/shared/annotate-client-lease.test.ts: 19 passed. Tracker semantics plus stream-session cases: failed ready write, failed heartbeat write releasing the slot, shutdown closing live sessions, truthful active count after cancellation.packages/shared/annotate-decision.test.ts: 3 passed.packages/editor/annotateClientLease.test.ts: 9 passed.packages/server/annotate.test.ts: 34 passed, including a late decision after expiry answering409for approve, feedback, and exit, andstop()ending a live lease stream.apps/hook/server/annotate-output.test.ts: 5 passed.apps/pi-extension/server.test.ts: 28 passed, 1 expected JJ skip, covering the same two guarantees on the Pi transport.bun run typecheckacross core, shared, AI, server, UI, strict consumer, and Pi: passed, with Pi vendoring regenerated in the run.bun run build:marketing: passed, 40 pages.git diff --check: clean.End-to-end, against a real annotate server over the real SSE wire on macOS with Bun 1.3.14. Each case starts
plannotator annotate <file> --gate --jsonwithPLANNOTATOR_BROWSERpointed at a no-op so no tab launches, then drives/api/annotate/client-leasewith independent long-lived HTTP streams. These are protocol-level lease clients, not browsers: one open stream is one connected review surface, which is the whole of the editor's client-side contract. Closing a stream stands in for a closed tab, killing its process for a local abrupt loss. 24 assertions, 24 passed:--gate --jsonclientLease.enabledtrue,reconnectGraceMs30000approveddismissedafter 31s, exit 0SIGKILL--require-approval--gate --jsonPLANNOTATOR_REMOTE=1)Reload and same-tab navigation are a disconnect followed by a reconnect at the transport layer, which the reconnect-inside-grace case covers. The browser adds no signal the server can observe, which is the reason this design does not use
pagehide.Separately, against a binary built from this commit, two concurrent
POST /api/exitrequests on one live session answeredfirst=200 second=409and produced exactly one{"decision":"dismissed"}record on stdout, so one-shot settlement holds over the real transport and not only in tests.Not covered by that harness, and covered by unit and server tests instead: the built React effect and native
EventSourcereconnect behavior, a genuinely half-open TCP path, a failing heartbeat write, server stop with a client attached, the late-decision race, and the Pi transport. What the harness would catch: a zero or ignored grace, a missing ready or heartbeat comment, a capability advertised out of scope, and a dismissal that never arrives.Compatibility
The capability is off unless a session is local, direct,
--gate --json, and outside hook transport. Clients that never open the lease keep current behavior. Nopagehide,beforeunload, or beacon handler is registered, and no existing decision payload changes.Related work
#636 (refresh the same tab while iterating) and #678 (feedback lost after a CLI timeout) overlap review-surface lifecycle and recovery. #982 (headless and detached sessions) is closed. PR #750 proposes remote decision poll and SSE endpoints, and #1004 fixes an OpenChamber
submit_planhang in plan mode. All adjacent, none defines gate abandonment.This change is limited to local structured-gate abandonment after connected review clients disappear.
Real e2e testing
I am using plannotator on a daily basis. I use a custom local-only integration branch including this branch/PR and also #1145, to fully review/check/test it in real scenarios. So far so good. Please, feel free to ask me about further testing, tweaks or refactors (or modify directly the branch yourself, it's fine!). Happy to help, as you know me!