Skip to content

feat(annotate): dismiss abandoned gate sessions - #1143

Merged
backnotprop merged 1 commit into
backnotprop:mainfrom
rNoz:rnoz/feat-annotate-close-signal
Jul 30, 2026
Merged

feat(annotate): dismiss abandoned gate sessions#1143
backnotprop merged 1 commit into
backnotprop:mainfrom
rNoz:rnoz/feat-annotate-close-signal

Conversation

@rNoz

@rNoz rNoz commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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 dismissed while keeping the saved annotation draft.

The signal is an SSE client lease, not pagehide or 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 --json waits 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

  • Add a runtime-independent client-lease tracker: first-client detection, active count, reconnect, cancellation, one-shot expiry.
  • Add a runtime-independent stream session owning one connected client: acquire the slot, write the ready comment, heartbeat, end the stream, release exactly once. Each server passes only its own write and end primitives, so accounting cannot drift, and a write that throws closes the session instead of holding a slot for a client that is already gone. A held slot would make the gate un-dismissable for the rest of the run.
  • Route every decision producer through one one-shot settlement. Connected surfaces and lease expiry race by construction; without this the loser still deleted the reviewer's draft and answered ok, so a tab reported success for an outcome the caller never received. The loser now changes nothing and answers 409.
  • Close live lease streams on server stop, so a long-lived host process (Pi) does not retain a heartbeat timer and an open response per finished session.
  • Advertise the capability only for local direct --gate --json sessions outside hook transport, and serve matching Bun and Pi /api/annotate/client-lease SSE endpoints.
  • Heartbeat every 5 seconds, and start the 30 second grace only when the transport reports the last client gone.
  • Cancel a pending expiry when approve, feedback, explicit exit, or server stop wins.
  • Open one editor EventSource only when the server advertises the capability, and close it on authoritative completion and on cleanup.
  • Keep remote, shared, hook-native, legacy plaintext, archive, plan, and review sessions disabled. Single-file, annotate-last, and folder gates are all covered, since each is a local direct structured gate that can hang.
  • Vendor the shared modules into the Pi extension through vendor.sh.
  • Document the endpoint and the abandonment semantics in 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

  • A session that never receives a first client never auto-dismisses, so browser-launch failures still need a caller-side timeout.
  • Clean local disconnects start the grace promptly: 31s to dismissal for a closed stream, 33s for a killed client, against a 30s grace.
  • Half-open transport loss is best effort with no bounded detection time. A local socket teardown surfaces as a close event; a peer that vanished without one is noticed only when a write fails, and TCP can accept writes after the peer is gone. When a write does fail, the session releases its slot rather than keeping the gate alive.
  • The two runtimes detect loss through different primitives: Bun's stream cancel plus an enqueue that throws, Pi's request and response close events, since Node's res.write signals backpressure by returning false rather than throwing. The shared session is what keeps the resulting accounting identical.
  • Expiry produces the same dismissed decision value as explicit Close and takes the same publication path, so it inherits the strict-result contract unchanged: stdout record first, --result-file publication second, exit 1 only under --require-approval. Exit 2 still 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.
  • Whichever producer settles first wins, and a loser answers 409 and 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 had dismissed.
  • Pi consumes the same exit: true decision 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 answering 409 for approve, feedback, and exit, and stop() 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 typecheck across 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 --json with PLANNOTATOR_BROWSER pointed at a no-op so no tab launches, then drives /api/annotate/client-lease with 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:

Case Observed
Capability advertised for local direct --gate --json clientLease.enabled true, reconnectGraceMs 30000
Session that never receives a client still waiting after 35s
Heartbeat cadence on an open stream ready comment once, 2 heartbeats in 12s
Last client disconnects, then reconnects inside grace no dismissal at 5s, session kept, still waiting 35s later
Approve while a grace is pending exit 0, decision approved
Last client closes and grace expires decision dismissed after 31s, exit 0
Two clients, one closes still waiting after 35s
Two clients, both close dismissed
Client killed with SIGKILL dismissed after 33s
Dismissal under --require-approval exit 1
Session without --gate --json capability false, stream 404s, never auto-dismisses
Remote session (PLANNOTATOR_REMOTE=1) capability false, stream 404s
Late decision after dismissal the CLI had already exited before the probe, so this one is proven by the server suites and by the concurrent-decision probe below

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/exit requests on one live session answered first=200 second=409 and 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 EventSource reconnect 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. No pagehide, 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_plan hang 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!

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
rNoz marked this pull request as ready for review July 27, 2026 21:44
@rNoz

rNoz commented Jul 28, 2026

Copy link
Copy Markdown
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
backnotprop merged commit c750427 into backnotprop:main Jul 30, 2026
13 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants