mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
c750427ab8
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.
38 lines
1.3 KiB
TypeScript
38 lines
1.3 KiB
TypeScript
/**
|
|
* One-shot settlement for an annotate session's decision.
|
|
*
|
|
* An annotate session has several independent decision producers: any connected
|
|
* tab can approve, send feedback, or close, and the client lease can expire on
|
|
* its own (see annotate-client-lease.ts). The awaited promise already ignores a
|
|
* second resolve, which silently hides the problem: a late producer still runs
|
|
* its side effects (deleting the reviewer's draft) and still answers `ok`, so a
|
|
* tab reports success for a decision the caller never received.
|
|
*
|
|
* Routing every producer through one settler makes the winner explicit. A
|
|
* producer that loses must run no side effect and must tell its caller it lost,
|
|
* rather than claiming an outcome that did not happen.
|
|
*/
|
|
export interface AnnotateDecisionSettler<TDecision> {
|
|
/** Resolve the session with this decision. Returns false if one already won. */
|
|
settle: (decision: TDecision) => boolean;
|
|
/** Whether some producer has already won. */
|
|
isSettled: () => boolean;
|
|
}
|
|
|
|
export function createAnnotateDecisionSettler<TDecision>(
|
|
resolve: (decision: TDecision) => void,
|
|
): AnnotateDecisionSettler<TDecision> {
|
|
let settled = false;
|
|
return {
|
|
settle(decision) {
|
|
if (settled) return false;
|
|
settled = true;
|
|
resolve(decision);
|
|
return true;
|
|
},
|
|
isSettled() {
|
|
return settled;
|
|
},
|
|
};
|
|
}
|