Files
Raúl c750427ab8 feat(annotate): dismiss abandoned gate sessions (#1143)
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.
2026-07-29 23:02:49 -07:00

69 lines
2.2 KiB
TypeScript

/**
* Editor-side client-lease helper.
*
* The annotate server advertises a last-client abandonment lease (see
* packages/shared/annotate-client-lease.ts) via `/api/plan`'s `clientLease`
* field for local direct structured annotate gates. When enabled, this tab
* opens a single EventSource against the shared stream path so the server
* can detect the tab going away and dismiss the pending gate after a grace
* period — no pagehide/beforeunload/sendBeacon involved; presence is
* inferred purely from the open connection.
*/
import { ANNOTATE_CLIENT_LEASE_STREAM_PATH } from '@plannotator/shared/annotate-client-lease';
export { ANNOTATE_CLIENT_LEASE_STREAM_PATH };
export interface AnnotateClientLeaseConfig {
enabled: boolean;
reconnectGraceMs?: number;
}
export interface ShouldConnectAnnotateClientLeaseInput {
annotateMode: boolean;
isSharedSession: boolean;
/** Decision already taken, if any. Nullish means the session is still open. */
submitted: string | null | undefined;
clientLease: AnnotateClientLeaseConfig | null | undefined;
}
/**
* Whether this tab should open the client-lease stream right now. Only one
* live annotate session — not shared/static, not already decided — with the
* server-advertised capability enabled should ever connect.
*/
export function shouldConnectAnnotateClientLease(
input: ShouldConnectAnnotateClientLeaseInput,
): boolean {
return (
input.annotateMode &&
!input.isSharedSession &&
input.submitted == null &&
!!input.clientLease?.enabled
);
}
export interface AnnotateClientLeaseStream {
/** Idempotent — safe to call more than once (e.g. cleanup + explicit completion both fire it). */
close: () => void;
}
/**
* Open the single client-lease EventSource for this tab. No message payload
* is read — the connection itself (open vs. closed) is the entire signal;
* the server's ready/heartbeat comments only keep the stream alive.
*/
export function openAnnotateClientLeaseStream(
EventSourceCtor: typeof EventSource,
): AnnotateClientLeaseStream {
const source = new EventSourceCtor(ANNOTATE_CLIENT_LEASE_STREAM_PATH);
let closed = false;
return {
close: () => {
if (closed) return;
closed = true;
source.close();
},
};
}