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

248 lines
9.4 KiB
TypeScript

/**
* Annotate client-lease tracker — pure last-client abandonment detector.
*
* Local direct structured annotate gates (`plannotator annotate --gate --json`)
* block a CLI/hook caller on `waitForDecision()`. If the browser tab that owns
* the decision goes away without ever sending `/api/exit`/`/api/approve`
* (closed tab, killed terminal) the gate would otherwise hang forever.
*
* This tracker gives the server a safe, connection-based signal instead: it
* counts concurrently connected SSE clients (normally exactly one — the tab's
* own client-lease stream), and once the *last* one disconnects, waits a grace
* period (default 30s) for a reconnect (tab refresh) before firing a one-shot
* expiry callback. A tab that never connects at all never expires — there is
* nothing to abandon yet.
*
* The grace clock only starts once the server's transport actually reports a
* disconnect; this tracker has no notion of the network itself. A clean close
* (tab closed, navigated away) reports promptly. An abrupt loss — killed
* process, unplugged network, a half-open TCP connection with no traffic —
* is detected on a best-effort basis by the transport (e.g. failing
* heartbeat writes) and is not bounded by `graceMs`: it can take longer than
* the grace period for the transport to notice the peer is gone at all,
* during which this tracker still believes a client is connected.
*
* Deliberately dependency-free: no fetch, no DOM, no framework. Bun and Pi
* servers each wire this to their own SSE transport; the editor never talks
* to it directly (see packages/editor/annotateClientLease.ts for the client
* side of the wire protocol).
*/
/** Default reconnect grace period after the last client disconnects. */
export const ANNOTATE_CLIENT_LEASE_GRACE_MS = 30_000;
/** Default SSE heartbeat interval used by server transports. */
export const ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS = 5_000;
/** SSE route path shared by the Bun and Pi annotate servers and the editor client. */
export const ANNOTATE_CLIENT_LEASE_STREAM_PATH = "/api/annotate/client-lease";
/** First byte written once a client-lease stream is open and has acquired the tracker. */
export const ANNOTATE_CLIENT_LEASE_READY_COMMENT = ": ready\n\n";
/** Keep-alive comment written every heartbeat interval. */
export const ANNOTATE_CLIENT_LEASE_HEARTBEAT_COMMENT = ": heartbeat\n\n";
export type AnnotateClientLeaseTimerHandle = unknown;
export interface AnnotateClientLeaseTrackerOptions {
/** Milliseconds to wait after the last client disconnects before expiring. Default 30_000. */
graceMs?: number;
/** Injectable scheduler — production uses real timers, tests supply a virtual clock. */
setTimer?: (callback: () => void, ms: number) => AnnotateClientLeaseTimerHandle;
/** Injectable timer cancellation matching `setTimer`. */
clearTimer?: (handle: AnnotateClientLeaseTimerHandle) => void;
}
export interface AnnotateClientLeaseTracker {
/**
* Register a newly connected client. Returns a release callback to call on
* disconnect — idempotent, safe to call more than once for the same client.
*/
acquire: () => () => void;
/**
* Permanently stop tracking — e.g. an explicit decision (approve/feedback/
* exit) already resolved the gate, so abandonment no longer matters. Safe
* to call repeatedly, and safe to call before any client ever connects.
*/
cancel: () => void;
/**
* Close every live stream session created against this tracker. Servers call
* this while shutting down: releasing the slot is not enough, because each
* session also owns a heartbeat timer and an open response that would
* otherwise outlive the session (in a long-lived host process, forever).
*/
closeSessions: () => void;
/** Number of currently connected (not yet released) clients. */
activeCount: () => number;
/** Whether the one-shot expiry callback has already fired. */
isExpired: () => boolean;
/** @internal Session bookkeeping for `closeSessions`. */
registerSession: (session: { close: () => void }) => () => void;
}
/**
* Create a tracker that invokes `onExpire` at most once, after the last
* connected client has stayed disconnected for the full grace period.
*/
export function createAnnotateClientLeaseTracker(
onExpire: () => void,
options: AnnotateClientLeaseTrackerOptions = {},
): AnnotateClientLeaseTracker {
const graceMs = options.graceMs ?? ANNOTATE_CLIENT_LEASE_GRACE_MS;
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
let active = 0;
let cancelled = false;
let expired = false;
let graceTimer: AnnotateClientLeaseTimerHandle | null = null;
const sessions = new Set<{ close: () => void }>();
function clearGraceTimer(): void {
if (graceTimer !== null) {
clearTimer(graceTimer);
graceTimer = null;
}
}
function scheduleExpiry(): void {
clearGraceTimer();
graceTimer = setTimer(() => {
graceTimer = null;
if (cancelled || expired || active > 0) return;
expired = true;
onExpire();
}, graceMs);
}
return {
acquire() {
if (cancelled || expired) return () => {};
active += 1;
// A (re)connect always cancels a pending expiry — including the very
// first connect, which is a no-op here since none is scheduled yet.
clearGraceTimer();
let released = false;
return () => {
if (released) return;
released = true;
// Always account for the disconnect, so activeCount() stays truthful
// after cancellation; only the expiry scheduling is suppressed.
active = Math.max(0, active - 1);
if (cancelled || expired) return;
if (active === 0) scheduleExpiry();
};
},
cancel() {
cancelled = true;
clearGraceTimer();
},
closeSessions() {
// close() unregisters, so iterate a snapshot.
for (const session of [...sessions]) session.close();
},
activeCount() {
return active;
},
isExpired() {
return expired;
},
registerSession(session) {
sessions.add(session);
return () => sessions.delete(session);
},
};
}
export interface AnnotateClientLeaseStreamSessionOptions {
/** Tracker owning this session's presence slot. */
tracker: AnnotateClientLeaseTracker;
/**
* End the underlying response, if the transport can. Called on close so a
* server-side shutdown actually finishes the stream instead of leaving the
* client hanging on a connection nobody heartbeats any more.
*/
endStream?: () => void;
/** Transport write. Must throw (or be replaced) when the peer is gone. */
write: (chunk: string) => void;
/** Heartbeat interval. Defaults to `ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS`. */
heartbeatMs?: number;
/** Injectable repeating scheduler; production uses real timers. */
setHeartbeat?: (callback: () => void, ms: number) => AnnotateClientLeaseTimerHandle;
/** Injectable cancellation matching `setHeartbeat`. */
clearHeartbeat?: (handle: AnnotateClientLeaseTimerHandle) => void;
}
export interface AnnotateClientLeaseStreamSession {
/** Stop heartbeating and release the presence slot. Idempotent. */
close: () => void;
/** Whether the session has already been closed. */
isClosed: () => boolean;
}
/**
* Wire one connected client-lease stream to a tracker.
*
* Runtime-agnostic on purpose: the Bun server passes a `ReadableStream`
* controller enqueue, the Pi server passes `res.write`, and tests pass a
* writer that throws. Keeping the acquire/ready/heartbeat/release sequence in
* one place is what makes the two runtimes provably identical, including the
* case that is otherwise unreachable from an integration test: a write that
* fails must close the session, because a stream that can no longer be written
* to is a client that is no longer present. Leaving the slot held there would
* make the gate un-dismissable for the rest of the session.
*/
export function createAnnotateClientLeaseStreamSession(
options: AnnotateClientLeaseStreamSessionOptions,
): AnnotateClientLeaseStreamSession {
const heartbeatMs = options.heartbeatMs ?? ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS;
const setHeartbeat = options.setHeartbeat ?? ((callback, ms) => setInterval(callback, ms));
const clearHeartbeat =
options.clearHeartbeat ?? ((handle) => clearInterval(handle as ReturnType<typeof setInterval>));
const release = options.tracker.acquire();
let closed = false;
let heartbeat: AnnotateClientLeaseTimerHandle | null = null;
function close(): void {
if (closed) return;
closed = true;
if (heartbeat !== null) {
clearHeartbeat(heartbeat);
heartbeat = null;
}
unregister();
release();
// Ending an already-dead stream throws in both runtimes; that is exactly
// the case where there is nothing left to end.
try {
options.endStream?.();
} catch {
// ignore
}
}
const unregister = options.tracker.registerSession({ close: () => close() });
try {
options.write(ANNOTATE_CLIENT_LEASE_READY_COMMENT);
} catch {
close();
return { close, isClosed: () => closed };
}
heartbeat = setHeartbeat(() => {
if (closed) return;
try {
options.write(ANNOTATE_CLIENT_LEASE_HEARTBEAT_COMMENT);
} catch {
close();
}
}, heartbeatMs);
return { close, isClosed: () => closed };
}