@zhuxixi/pi-agent-board 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side terminal attach protocol state machine (issue #91 Phase 4, D2).
|
|
3
|
+
*
|
|
4
|
+
* Pure logic: no sockets, no fs, no timers. `send` (client→runner messages)
|
|
5
|
+
* and `emit` (UI-facing events) are injected; the probe timeout goes through
|
|
6
|
+
* an injected `scheduleTimeout(delayMs, fn) → cancelFn` so tests drive time
|
|
7
|
+
* deterministically. The runner-side counterpart is
|
|
8
|
+
* `src/core/terminal-attach-protocol.mjs` — message shapes and boundary
|
|
9
|
+
* semantics there are authoritative.
|
|
10
|
+
*
|
|
11
|
+
* ## States
|
|
12
|
+
*
|
|
13
|
+
* - `probing` — `subscribe_terminal` sent, awaiting `snapshot_begin`.
|
|
14
|
+
* - `snapshot_begin` → `collecting` (mode decision: protocol)
|
|
15
|
+
* - probe timeout → `legacy` (old runner silently ignores the probe)
|
|
16
|
+
* - `error frame_version_mismatch` → `legacy`
|
|
17
|
+
* - `error snapshot_failed` / `error invalid_since_seq` → resend fresh
|
|
18
|
+
* subscribe (same probe window); up to 3 recovery attempts, then
|
|
19
|
+
* `legacy` fallback
|
|
20
|
+
* - `collecting` — `snapshot_begin` seen; waiting for `snapshot_end`.
|
|
21
|
+
* - `snapshot_frame` → frame stashed
|
|
22
|
+
* - `output` WITH seq between frame and end = runner catch-up flush
|
|
23
|
+
* (tested runner contract): stashed, applied after the frame, never
|
|
24
|
+
* counted before `snapshotReady`
|
|
25
|
+
* - `output` WITH seq before any frame = protocol violation → resync
|
|
26
|
+
* - `snapshot_end` → verify frame present (or empty baseline) and
|
|
27
|
+
* `nextSeq === stashed tail + 1` (or `begin.snapshotSeq + 1` with no
|
|
28
|
+
* flush), then emit `snapshotReady` followed by the stashed flush in
|
|
29
|
+
* order → `live`
|
|
30
|
+
* - `resnapshot_required` / `snapshot_failed` (interrupted snapshot) →
|
|
31
|
+
* discard the partial snapshot → resync
|
|
32
|
+
* - `live` — seq-checked consumption against `lastSeq`.
|
|
33
|
+
* - `seq === lastSeq + 1` → emit `output`, advance
|
|
34
|
+
* - `seq <= lastSeq` → duplicate/stale, silently ignored
|
|
35
|
+
* - `seq > lastSeq + 1` → gap → resync (fresh snapshot, never stitched)
|
|
36
|
+
* - `resnapshot_required` → resync; unsolicited `snapshot_begin` → resync
|
|
37
|
+
* - `resyncing` — a fresh `subscribe_terminal` (no `sinceSeq`: the runner
|
|
38
|
+
* answers a seq-less request with a fresh snapshot, always; the replay
|
|
39
|
+
* continuation below is defensive for cursor reconnects only) is in flight.
|
|
40
|
+
* - `snapshot_begin` → `collecting`
|
|
41
|
+
* - `output` with `seq === lastSeq + 1` (complete replay) → `live`,
|
|
42
|
+
* seamless (no `snapshotReady`, UI keeps its buffer)
|
|
43
|
+
* - stray outputs / duplicate markers → ignored
|
|
44
|
+
* - `snapshot_failed` / `resnapshot_required` → resend; up to 3 recovery
|
|
45
|
+
* attempts, then `legacy` fallback
|
|
46
|
+
* - `reconciling` — (phase 5, D4) reconnect gate: `reconcile` sent on the new
|
|
47
|
+
* socket, awaiting `reconcile_result` (or the reconcile deadline). Spec D4
|
|
48
|
+
* binds the reconnect order hello → reconcile → snapshot/subscribe. All
|
|
49
|
+
* runner messages in this state are consumed; outputs are ALSO EMITTED —
|
|
50
|
+
* they are legacy-broadcast strays that must reach the UI exactly once
|
|
51
|
+
* (strays cover (cursor, strayHighWater]; the replay starts past the
|
|
52
|
+
* high-water; see the reconciling output branch). Resolution:
|
|
53
|
+
* - `reconcile_result` → epoch rule: generation CHANGED since last seen ⇒
|
|
54
|
+
* discard the cursor (`lastSeq = 0`, `epochReset` event) ⇒ seq-less
|
|
55
|
+
* subscribe (fresh snapshot — ring replay across runner generations is
|
|
56
|
+
* structurally impossible); same generation ⇒ subscribe with `sinceSeq`
|
|
57
|
+
* (seamless replay)
|
|
58
|
+
* - deadline without an answer (phase-4 runner) ⇒ phase-4 semantics:
|
|
59
|
+
* subscribe with `sinceSeq` (the runner's resnapshot/empty begin flags
|
|
60
|
+
* protect correctness)
|
|
61
|
+
* - `error instance_mismatch` ⇒ adopt `currentInstanceId`, re-reconcile
|
|
62
|
+
* - `legacy` — mode decided against this runner: inert, every message
|
|
63
|
+
* returns false so the UI legacy path (screen.log replay + live output +
|
|
64
|
+
* jiggle) owns everything.
|
|
65
|
+
* - `closed` — inert; timers cancelled.
|
|
66
|
+
*
|
|
67
|
+
* `reconnect(lastSeq)` (new socket after a drop, protocol already achieved):
|
|
68
|
+
* subscribe with `sinceSeq: lastSeq`. Ring retained → replay → seamless
|
|
69
|
+
* `live`; evicted / ahead-of-runner / restarted-runner → fresh snapshot
|
|
70
|
+
* (`begin.resnapshot` and/or `begin.empty` set → UI resets its buffer).
|
|
71
|
+
* While still `probing`, `reconnect()` re-issues the probe instead.
|
|
72
|
+
*
|
|
73
|
+
* ## Events (emit)
|
|
74
|
+
*
|
|
75
|
+
* - `mode` — `"protocol"` (first `snapshot_begin`) or `"legacy"` (fallback
|
|
76
|
+
* decided — including a downgrade after a protocol session, which the UI
|
|
77
|
+
* must observe to switch paths); each direction fires exactly once.
|
|
78
|
+
* - `snapshotBegin` — `{ cols, rows }` from every `snapshot_begin` (fresh,
|
|
79
|
+
* reconnect, and empty baselines alike). The UI compares against its own
|
|
80
|
+
* terminal size and resizes the child when they differ: legacy attach
|
|
81
|
+
* resized at every connect (jiggle start), the protocol probe carries no
|
|
82
|
+
* size, so without this sync a full-screen TUI child would keep its
|
|
83
|
+
* host-creation geometry (CR R1 blocking). Matched sizes send nothing.
|
|
84
|
+
* - `snapshotReady` — `{ frame?, empty?, resnapshot?, nextSeq }` after
|
|
85
|
+
* continuity is verified at `snapshot_end`. UI: `term.reset()` +
|
|
86
|
+
* `write(frame)` (the frame is self-contained on dirty terminals), or
|
|
87
|
+
* loading baseline for `empty`. Empty baselines carry no frame.
|
|
88
|
+
* - `output` — live/replay chunk data (protocol-managed only), including gate
|
|
89
|
+
* strays emitted during `reconciling`.
|
|
90
|
+
* - `resubscribing` — the client sent a recovery subscribe itself. The plan
|
|
91
|
+
* contract originally made resubscribe UI-actionable; it is internal now
|
|
92
|
+
* (send is injected and socket-scoped) so the UI wiring stays minimal —
|
|
93
|
+
* the event is informational.
|
|
94
|
+
* - `protocolError` — `{ code, message }` for runner-side protocol failures
|
|
95
|
+
* (observability/diagnostics; recovery is automatic until the fallback).
|
|
96
|
+
* - `cmdAck` — (phase 5) `{ commandId, type, stage, ... }` for every enveloped
|
|
97
|
+
* control ack (`applied`/`observed`/`superseded`) and enveloped errors
|
|
98
|
+
* (`stage: "error"`, `code`). Correlation bookkeeping is internal; the UI
|
|
99
|
+
* acts only on what it already shows.
|
|
100
|
+
* - `reconciled` — `{ generation, hostRevision, terminalCursor,
|
|
101
|
+
* stateMaterializedRevision, unresolved }` after a reconnect reconcile.
|
|
102
|
+
* - `epochReset` — `{ previous, current }` when the generation change forced a
|
|
103
|
+
* cursor discard (structural fix for the phase-4 epoch ambiguity).
|
|
104
|
+
*
|
|
105
|
+
* ## Control commands (phase 5, D4)
|
|
106
|
+
*
|
|
107
|
+
* `sendControl(type, payload)` envelopes `resize`/`interrupt`/`terminate`/
|
|
108
|
+
* `detach` with `{commandId, clientId, seq, viewId, instanceId}` (identity
|
|
109
|
+
* derived from hello/status — the UI passes no identity options). Returns the
|
|
110
|
+
* commandId, or `null` when the host has no instance fence (legacy-mode main):
|
|
111
|
+
* the caller then falls back to the legacy plain message. Keystroke `input`
|
|
112
|
+
* is never enveloped (fire-and-forget by contract — throws). A resize that
|
|
113
|
+
* lands in the runner's starting window answers `host_starting`; the client
|
|
114
|
+
* retries with a fresh commandId (bounded chain, latest-wins) — parity with
|
|
115
|
+
* the legacy runner-side cachedResize.
|
|
116
|
+
*
|
|
117
|
+
* The frame/DTO version axes live in the runner module. This client advertises
|
|
118
|
+
* its `frameVersion` on every `subscribe_terminal` (the runner's mismatch gate
|
|
119
|
+
* is only reachable when the field is present) and treats
|
|
120
|
+
* `frame_version_mismatch` as legacy fallback.
|
|
121
|
+
*
|
|
122
|
+
* `AGENT_BOARD_TERMINAL_SNAPSHOT=0` (read once at factory time, overridable
|
|
123
|
+
* via the `forceLegacy` option) forces the legacy path without probing —
|
|
124
|
+
* escape hatch and deterministic legacy-test switch.
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
import { TERMINAL_FRAME_VERSION } from "./terminal-attach-protocol.mjs";
|
|
128
|
+
import { classifyCommandAck, encodeCommand, CONTROL_ERROR_CODES, TERMINAL_ERROR_CODES } from "./control-protocol.mjs";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {"probing" | "collecting" | "live" | "resyncing" | "reconciling" | "legacy" | "closed"} AttachClientState
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @typedef {(
|
|
136
|
+
* event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "resubscribing" | "protocolError" |
|
|
137
|
+
* "cmdAck" | "reconciled" | "epochReset",
|
|
138
|
+
* payload?: any,
|
|
139
|
+
* ) => void} AttachClientEmit
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
const MAX_SNAPSHOT_FAILURES = 3; // up to 3 recovery attempts, then legacy fallback
|
|
143
|
+
const MAX_RESIZE_START_RETRIES = 5; // max TOTAL sends per size change (initial + retries; legacy cachedResize parity is "eventually applied", not infinite)
|
|
144
|
+
const RESIZE_RETRY_DELAY_MS = 300;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @typedef {{
|
|
148
|
+
* send: (msg: Record<string, unknown>) => void,
|
|
149
|
+
* emit: AttachClientEmit,
|
|
150
|
+
* probeTimeoutMs?: number,
|
|
151
|
+
* scheduleTimeout?: (delayMs: number, fn: () => void) => () => void,
|
|
152
|
+
* forceLegacy?: boolean,
|
|
153
|
+
* }} AttachClientOptions
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
/** @type {NonNullable<AttachClientOptions["scheduleTimeout"]>} */
|
|
157
|
+
const defaultScheduleTimeout = (delayMs, fn) => {
|
|
158
|
+
const t = setTimeout(fn, delayMs);
|
|
159
|
+
// Never hold the runner/UI process open for a probe deadline.
|
|
160
|
+
t.unref?.();
|
|
161
|
+
return () => clearTimeout(t);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {{
|
|
166
|
+
* send: (msg: Record<string, unknown>) => void,
|
|
167
|
+
* emit: AttachClientEmit,
|
|
168
|
+
* probeTimeoutMs?: number,
|
|
169
|
+
* scheduleTimeout?: (delayMs: number, fn: () => void) => () => void,
|
|
170
|
+
* forceLegacy?: boolean,
|
|
171
|
+
* clientId?: string,
|
|
172
|
+
* reconcileTimeoutMs?: number,
|
|
173
|
+
* }} opts
|
|
174
|
+
*/
|
|
175
|
+
export function createTerminalAttachClient({
|
|
176
|
+
send: rawSend,
|
|
177
|
+
emit,
|
|
178
|
+
probeTimeoutMs = 1500,
|
|
179
|
+
scheduleTimeout = defaultScheduleTimeout,
|
|
180
|
+
forceLegacy,
|
|
181
|
+
clientId = `ui-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
|
|
182
|
+
reconcileTimeoutMs = 1500,
|
|
183
|
+
}) {
|
|
184
|
+
// Env is read once at factory time (plan contract); the explicit option
|
|
185
|
+
// wins so tests and embedders can pin behavior regardless of environment.
|
|
186
|
+
const forced =
|
|
187
|
+
forceLegacy !== undefined ? forceLegacy : process.env.AGENT_BOARD_TERMINAL_SNAPSHOT === "0";
|
|
188
|
+
|
|
189
|
+
/** @type {AttachClientState} */
|
|
190
|
+
let state = forced ? "legacy" : "probing";
|
|
191
|
+
let modeDecided = false; // "mode" fires once per direction, never both
|
|
192
|
+
let lastSeq = 0;
|
|
193
|
+
let probeTimer = null;
|
|
194
|
+
let snapshotFailures = 0;
|
|
195
|
+
|
|
196
|
+
// --- Control lifecycle (issue #91 phase 5, D4) ---
|
|
197
|
+
// Identity is DERIVED from hello/status (review ruling 1): the UI passes no
|
|
198
|
+
// viewId/instanceId options. `instanceId === null` means a legacy-mode host
|
|
199
|
+
// (instance fence does not exist): envelopes are NEVER sent to it — control
|
|
200
|
+
// commands fall back to the legacy plain messages; the attach snapshot
|
|
201
|
+
// protocol itself still works there (the legacy main serves subscribe).
|
|
202
|
+
let instanceId = null;
|
|
203
|
+
let viewId = null;
|
|
204
|
+
let generation = null; // last seen generation token (epoch detection)
|
|
205
|
+
let seq = 0; // per-connection envelope ordering (resets on reconnect)
|
|
206
|
+
let commandCounter = 0;
|
|
207
|
+
/** @type {Map<string, { type: string }>} */
|
|
208
|
+
const pendingCommands = new Map(); // commandId → correlation record
|
|
209
|
+
let reconcileTimer = null;
|
|
210
|
+
/** Pending reconnect gate: reconcile must resolve (or time out) before the
|
|
211
|
+
* subscribe decision — spec D4 binds hello → reconcile → snapshot/subscribe. */
|
|
212
|
+
let reconnectCursor = null;
|
|
213
|
+
/** Generation baseline AT reconnect time — the fresh socket's hello must
|
|
214
|
+
* not clobber it before the epoch comparison (that comparison is the whole
|
|
215
|
+
* point of the gate). */
|
|
216
|
+
let reconnectFromGeneration = null;
|
|
217
|
+
/** Highest seq seen in legacy-broadcast strays during a reconnect gate. The
|
|
218
|
+
* subscribe cursor must start past these or the runner's replay re-sends
|
|
219
|
+
* them (wire-level duplicate — see the reconciling output branch). */
|
|
220
|
+
let strayHighWater = 0;
|
|
221
|
+
/** Generation observed at disconnect time — the epoch baseline. The live
|
|
222
|
+
* `generation` field is refreshed by every hello/status/snapshot_begin,
|
|
223
|
+
* INCLUDING the replacement runner's hello that precedes reconnect(), so
|
|
224
|
+
* comparing against it can never detect a generation change. */
|
|
225
|
+
let generationAtDisconnect = null;
|
|
226
|
+
/** Guards against a double reconcile within one reconnect gate (reconnect()
|
|
227
|
+
* sends with the remembered identity; the fresh hello would otherwise send
|
|
228
|
+
* a second one). */
|
|
229
|
+
let reconcileInFlight = false;
|
|
230
|
+
/** Active starting-window resize retry chain (latest-wins: a new user
|
|
231
|
+
* resize replaces it). { commandId, cols, rows, attempt } | null */
|
|
232
|
+
let resizeStartRetry = null;
|
|
233
|
+
|
|
234
|
+
// Partial-snapshot stash (collecting): the runner sends frame, then its
|
|
235
|
+
// catch-up flush outputs, then end — one microtask batch, tiny by contract
|
|
236
|
+
// (ring pressure during capture yields resnapshot_required, not an
|
|
237
|
+
// unbounded flush). Buffered here so the UI applies frame-then-flush in
|
|
238
|
+
// exact order after snapshotReady.
|
|
239
|
+
/** @type {{ frame: string | null, empty: boolean, resnapshot: boolean, beginSeq: number, flush: { seq: number, data: string }[] }} */
|
|
240
|
+
let partial = { frame: null, empty: false, resnapshot: false, beginSeq: 0, flush: [] };
|
|
241
|
+
|
|
242
|
+
const send = (msg) => {
|
|
243
|
+
rawSend(msg);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const fireProbeTimer = () => {
|
|
247
|
+
cancelProbeTimer();
|
|
248
|
+
probeTimer = scheduleTimeout(probeTimeoutMs, () => {
|
|
249
|
+
probeTimer = null;
|
|
250
|
+
if (modeDecided) return; // begin already decided protocol (timer would be cancelled anyway)
|
|
251
|
+
fallbackToLegacy("probe_timeout");
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const cancelProbeTimer = () => {
|
|
256
|
+
if (probeTimer) {
|
|
257
|
+
probeTimer();
|
|
258
|
+
probeTimer = null;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const cancelReconcileTimer = () => {
|
|
263
|
+
if (reconcileTimer) {
|
|
264
|
+
reconcileTimer();
|
|
265
|
+
reconcileTimer = null;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const decideProtocol = () => {
|
|
270
|
+
if (!modeDecided) {
|
|
271
|
+
modeDecided = true;
|
|
272
|
+
emit("mode", "protocol");
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const fallbackToLegacy = (reason) => {
|
|
277
|
+
cancelProbeTimer();
|
|
278
|
+
state = "legacy";
|
|
279
|
+
// The legacy decision is ALWAYS reported, including after a protocol
|
|
280
|
+
// session: the UI switched to the snapshot path at "protocol" and must
|
|
281
|
+
// observe the downgrade to switch back (jiggle, raw output). A silent
|
|
282
|
+
// protocol→legacy transition would leave the UI frozen on a stale frame.
|
|
283
|
+
modeDecided = true;
|
|
284
|
+
emit("mode", "legacy");
|
|
285
|
+
emit("protocolError", { code: "legacy_fallback", message: reason });
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Recovery subscribe: a fresh full snapshot. Deliberately WITHOUT
|
|
290
|
+
* `sinceSeq` — the runner answers a seq-less request with a fresh snapshot,
|
|
291
|
+
* always (its replay branch exists only for cursor reconnects), so this can
|
|
292
|
+
* never receive a partial tail (spec: never stitch).
|
|
293
|
+
*/
|
|
294
|
+
const resync = (reason) => {
|
|
295
|
+
// Before the mode is decided the probe deadline still governs the legacy
|
|
296
|
+
// fallback: a runner that fails the snapshot and then goes silent must
|
|
297
|
+
// not strand the client outside both paths. Once protocol is decided the
|
|
298
|
+
// timer is meaningless and gets cancelled.
|
|
299
|
+
if (modeDecided) cancelProbeTimer();
|
|
300
|
+
partial = { frame: null, empty: false, resnapshot: false, beginSeq: 0, flush: [] };
|
|
301
|
+
state = "resyncing";
|
|
302
|
+
send({ type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION });
|
|
303
|
+
emit("resubscribing", { reason });
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const resyncOrFail = (reason) => {
|
|
307
|
+
snapshotFailures += 1;
|
|
308
|
+
if (snapshotFailures > MAX_SNAPSHOT_FAILURES) {
|
|
309
|
+
fallbackToLegacy(reason);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
resync(reason);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// --- Control lifecycle helpers (phase 5, D4) ---
|
|
316
|
+
|
|
317
|
+
/** Capture identity + generation from hello/status (UI-owned messages the
|
|
318
|
+
* client only peeks at; they remain false/non-consumed). */
|
|
319
|
+
const observeIdentity = (msg) => {
|
|
320
|
+
const status = msg.status ?? {};
|
|
321
|
+
// Normalize: an owned host carries a non-empty instanceId; a legacy-mode
|
|
322
|
+
// host has none (undefined) — both mean "no envelope possible".
|
|
323
|
+
instanceId = typeof status.instanceId === "string" && status.instanceId ? status.instanceId : null;
|
|
324
|
+
viewId = typeof status.viewId === "string" && status.viewId ? status.viewId : viewId;
|
|
325
|
+
if (typeof msg.generation === "string" && msg.generation) generation = msg.generation;
|
|
326
|
+
// A reconnect waiting for identity on the fresh socket can now reconcile.
|
|
327
|
+
if (state === "reconciling" && instanceId && reconnectCursor !== null && !reconcileInFlight) sendReconcile();
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const sendReconcile = () => {
|
|
331
|
+
reconcileInFlight = true;
|
|
332
|
+
const commandId = `${clientId}-rec-${++commandCounter}`;
|
|
333
|
+
seq += 1;
|
|
334
|
+
send(encodeCommand("reconcile", {}, { commandId, clientId, seq, viewId: viewId ?? "unknown", instanceId }));
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const issueReconnectSubscribe = (sinceSeq) => {
|
|
338
|
+
// The cursor must clear every stray the legacy broadcast already
|
|
339
|
+
// delivered to this socket during the gate (wire-level no-dup).
|
|
340
|
+
const effective = Math.max(sinceSeq, strayHighWater);
|
|
341
|
+
strayHighWater = 0;
|
|
342
|
+
const cursor = reconnectCursor;
|
|
343
|
+
reconnectCursor = null;
|
|
344
|
+
reconcileInFlight = false;
|
|
345
|
+
reconnectFromGeneration = null;
|
|
346
|
+
cancelReconcileTimer();
|
|
347
|
+
lastSeq = effective;
|
|
348
|
+
partial = { frame: null, empty: false, resnapshot: false, beginSeq: 0, flush: [] };
|
|
349
|
+
state = "resyncing";
|
|
350
|
+
send(
|
|
351
|
+
effective > 0
|
|
352
|
+
? { type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION, sinceSeq: effective }
|
|
353
|
+
: { type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION },
|
|
354
|
+
);
|
|
355
|
+
return cursor;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** Epoch rule (spec D4 + phase-4 residual fix): a generation CHANGE versus
|
|
359
|
+
* the baseline captured at reconnect() means the runner (and its child)
|
|
360
|
+
* was replaced — the remembered cursor describes a dead stream, so ring
|
|
361
|
+
* replay across generations is structurally impossible: discard the cursor
|
|
362
|
+
* and take a fresh snapshot. Same generation → seamless replay from the
|
|
363
|
+
* applied cursor. No generation (unreachable: reconcile needs an
|
|
364
|
+
* instanceId) or an unanswered reconcile (phase-4 runner) → phase-4
|
|
365
|
+
* semantics: the runner's resnapshot/empty begin flags protect correctness. */
|
|
366
|
+
const resolveReconnect = (result) => {
|
|
367
|
+
const nextGeneration = typeof result?.generation === "string" && result.generation ? result.generation : null;
|
|
368
|
+
generation = nextGeneration ?? generation;
|
|
369
|
+
emit("reconciled", {
|
|
370
|
+
generation,
|
|
371
|
+
hostRevision: result?.hostRevision,
|
|
372
|
+
terminalCursor: result?.terminalCursor,
|
|
373
|
+
stateMaterializedRevision: result?.stateMaterializedRevision,
|
|
374
|
+
unresolved: result?.unresolved,
|
|
375
|
+
});
|
|
376
|
+
if (nextGeneration && reconnectFromGeneration && nextGeneration !== reconnectFromGeneration) {
|
|
377
|
+
lastSeq = 0;
|
|
378
|
+
emit("epochReset", { previous: reconnectFromGeneration, current: nextGeneration });
|
|
379
|
+
// Epoch reset demands a seq-less subscribe (fresh snapshot). Gate strays
|
|
380
|
+
// from the NEW runner must not turn it into a tail replay onto the stale
|
|
381
|
+
// buffer — the max() in issueReconnectSubscribe is for the SAME-generation
|
|
382
|
+
// continuity path only. The fresh frame subsumes any emitted strays.
|
|
383
|
+
strayHighWater = 0;
|
|
384
|
+
issueReconnectSubscribe(0);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
issueReconnectSubscribe(reconnectCursor ?? 0);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const cancelResizeStartRetry = () => {
|
|
391
|
+
resizeStartRetry = null;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/** Re-arm the starting-window retry chain (bounded, latest-wins). Object
|
|
395
|
+
* identity is the liveness marker: a newer resize replaces the chain, a
|
|
396
|
+
* terminal ack cancels it, and a stale timer is a no-op. */
|
|
397
|
+
const scheduleResizeStartRetry = (chain) => {
|
|
398
|
+
if (chain.attempt + 1 >= MAX_RESIZE_START_RETRIES) {
|
|
399
|
+
resizeStartRetry = null;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const next = { ...chain, attempt: chain.attempt + 1 };
|
|
403
|
+
resizeStartRetry = next;
|
|
404
|
+
scheduleTimeout(RESIZE_RETRY_DELAY_MS, () => {
|
|
405
|
+
if (resizeStartRetry !== next) return; // superseded or cancelled
|
|
406
|
+
sendControl("resize", { cols: next.cols, rows: next.rows }, { isRetry: true });
|
|
407
|
+
});
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Send a control command with the phase-5 envelope (spec D4). Returns the
|
|
412
|
+
* commandId, or null when no instance fence is known (legacy-mode host):
|
|
413
|
+
* the caller falls back to the legacy plain message. Keystroke `input` is
|
|
414
|
+
* deliberately NOT sendable here — plain keyboard input is fire-and-forget
|
|
415
|
+
* by contract (never enveloped, never acked, never replayed).
|
|
416
|
+
*
|
|
417
|
+
* @param {"resize" | "interrupt" | "terminate" | "detach"} type
|
|
418
|
+
* @param {Record<string, unknown>} payload
|
|
419
|
+
* @param {{ isRetry?: boolean }} [opts] internal: retry sends keep the chain
|
|
420
|
+
*/
|
|
421
|
+
function sendControl(type, payload = {}, opts = {}) {
|
|
422
|
+
if (type === "input") throw new TypeError("sendControl: keystroke input is never enveloped (fire-and-forget contract)");
|
|
423
|
+
if (state === "closed" || state === "legacy" || !instanceId || !viewId) return null;
|
|
424
|
+
const commandId = `${clientId}-${++commandCounter}`;
|
|
425
|
+
seq += 1;
|
|
426
|
+
send(encodeCommand(type, payload, { commandId, clientId, seq, viewId, instanceId }));
|
|
427
|
+
pendingCommands.set(commandId, { type });
|
|
428
|
+
if (type === "resize" && !opts.isRetry) {
|
|
429
|
+
// latest-wins: a new user resize replaces any retry chain
|
|
430
|
+
resizeStartRetry = { cols: payload.cols, rows: payload.rows, attempt: 0 };
|
|
431
|
+
}
|
|
432
|
+
return { commandId };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Enter collecting from a begin message (both probing and resyncing).
|
|
436
|
+
* Deliberately does NOT reset the failure budget: a runner that keeps
|
|
437
|
+
* sending begins interleaved with garbage must still trip the cap — only
|
|
438
|
+
* a VERIFIED snapshot (or verified replay) pays the budget back. */
|
|
439
|
+
const beginCollecting = (msg) => {
|
|
440
|
+
decideProtocol();
|
|
441
|
+
cancelProbeTimer();
|
|
442
|
+
// Size-sync hook (CR R1 blocking): every begin carries the runner's
|
|
443
|
+
// captured geometry — empty baselines included, so a child that has not
|
|
444
|
+
// produced output yet still gets corrected BEFORE its first bytes land.
|
|
445
|
+
if (typeof msg.cols === "number" && typeof msg.rows === "number") {
|
|
446
|
+
emit("snapshotBegin", { cols: msg.cols, rows: msg.rows });
|
|
447
|
+
}
|
|
448
|
+
partial = {
|
|
449
|
+
frame: null,
|
|
450
|
+
empty: msg.empty === true,
|
|
451
|
+
resnapshot: msg.resnapshot === true,
|
|
452
|
+
beginSeq: typeof msg.snapshotSeq === "number" ? msg.snapshotSeq : 0,
|
|
453
|
+
flush: [],
|
|
454
|
+
};
|
|
455
|
+
state = "collecting";
|
|
456
|
+
if (partial.empty) {
|
|
457
|
+
// Empty baselines carry no frame and no flush; the end message is
|
|
458
|
+
// already in the runner's send batch.
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const finishCollecting = (msg) => {
|
|
464
|
+
const nextSeq = msg.nextSeq;
|
|
465
|
+
if (!partial.frame && !partial.empty) {
|
|
466
|
+
// begin (non-empty) + end with no frame in between: never paint from
|
|
467
|
+
// an unverified window. Bounded recovery: fresh snapshot.
|
|
468
|
+
resyncOrFail("snapshot_frame_missing");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const expected = partial.flush.length
|
|
472
|
+
? partial.flush[partial.flush.length - 1].seq + 1
|
|
473
|
+
: (partial.empty ? 0 : partial.beginSeq) + 1;
|
|
474
|
+
if (nextSeq !== expected) {
|
|
475
|
+
// Continuity broken inside the snapshot window: never paint a
|
|
476
|
+
// partially-verified frame. Recovery: fresh snapshot.
|
|
477
|
+
resyncOrFail("snapshot_end_mismatch");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
snapshotFailures = 0; // a verified snapshot pays back the recovery budget
|
|
481
|
+
emit("snapshotReady", {
|
|
482
|
+
frame: partial.frame ?? undefined,
|
|
483
|
+
empty: partial.empty || undefined,
|
|
484
|
+
resnapshot: partial.resnapshot || undefined,
|
|
485
|
+
nextSeq,
|
|
486
|
+
});
|
|
487
|
+
for (const chunk of partial.flush) emit("output", chunk.data);
|
|
488
|
+
lastSeq = nextSeq - 1;
|
|
489
|
+
state = "live";
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Handle one runner→client message. Returns true when the message is
|
|
494
|
+
* protocol-managed (consumed); false means the caller (UI) should process
|
|
495
|
+
* it through its existing path.
|
|
496
|
+
*
|
|
497
|
+
* @param {any} msg
|
|
498
|
+
* @returns {boolean}
|
|
499
|
+
*/
|
|
500
|
+
function handleMessage(msg) {
|
|
501
|
+
if (!msg || typeof msg !== "object" || state === "closed" || state === "legacy") return false;
|
|
502
|
+
switch (msg.type) {
|
|
503
|
+
case "hello":
|
|
504
|
+
case "status": {
|
|
505
|
+
// Identity/generation peek (UI-owned message, not consumed).
|
|
506
|
+
observeIdentity(msg);
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
case "cmd_ack": {
|
|
510
|
+
// Control-lifecycle correlation (spec D4). The ack is consumed even
|
|
511
|
+
// for unknown commandIds (a late ack after internal retry cleanup is
|
|
512
|
+
// not a UI message).
|
|
513
|
+
const pending = pendingCommands.get(msg.commandId);
|
|
514
|
+
if (pending) {
|
|
515
|
+
const verdict = classifyCommandAck(pending.type, msg);
|
|
516
|
+
if (verdict.ok) {
|
|
517
|
+
if (verdict.stage === "applied" || verdict.stage === "observed" || verdict.stage === "superseded") {
|
|
518
|
+
pendingCommands.delete(msg.commandId);
|
|
519
|
+
if (pending.type === "resize") cancelResizeStartRetry();
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
emit("protocolError", { code: "ack_invalid", message: verdict.reason, commandId: msg.commandId });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: msg.stage, cols: msg.cols, rows: msg.rows, byCommandId: msg.byCommandId, value: msg.value, exitConfirmed: msg.exitConfirmed, runnerFinalizing: msg.runnerFinalizing });
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
case "reconcile_result": {
|
|
529
|
+
if (state === "reconciling") resolveReconnect(msg);
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
case "error": {
|
|
533
|
+
if (msg.code === "frame_version_mismatch") {
|
|
534
|
+
// Wire contract mismatch: treat this runner as incompatible.
|
|
535
|
+
fallbackToLegacy("frame_version_mismatch");
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
if (msg.code === "snapshot_failed" || msg.code === "invalid_since_seq") {
|
|
539
|
+
if (state === "probing" || state === "resyncing" || state === "collecting") {
|
|
540
|
+
resyncOrFail(msg.code);
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
if (msg.code === "instance_mismatch" && state === "reconciling" && typeof msg.currentInstanceId === "string" && msg.currentInstanceId) {
|
|
546
|
+
// Recovery signal (review ruling 1): the host was replaced with a
|
|
547
|
+
// new instance fence — adopt and re-reconcile (same deadline).
|
|
548
|
+
instanceId = msg.currentInstanceId;
|
|
549
|
+
sendReconcile();
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
if (msg.code === "host_starting" && msg.commandId) {
|
|
553
|
+
const pending = pendingCommands.get(msg.commandId);
|
|
554
|
+
pendingCommands.delete(msg.commandId);
|
|
555
|
+
emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: "error", code: msg.code });
|
|
556
|
+
if (pending?.type === "resize" && resizeStartRetry) {
|
|
557
|
+
scheduleResizeStartRetry(resizeStartRetry);
|
|
558
|
+
}
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
if (msg.code && CONTROL_ERROR_CODES[msg.code] && msg.commandId) {
|
|
562
|
+
// Task 4 (review P2): a taxonomy error correlated by commandId must
|
|
563
|
+
// consume the pending entry and surface as a cmdAck error — a
|
|
564
|
+
// swallowed error leaves the command pending forever. Terminal codes
|
|
565
|
+
// also cancel the resize starting-window retry chain (retrying into
|
|
566
|
+
// a moved fence or a caller-bug rejection is futile; host_starting
|
|
567
|
+
// above keeps its bounded-retry semantics).
|
|
568
|
+
const pending = pendingCommands.get(msg.commandId);
|
|
569
|
+
pendingCommands.delete(msg.commandId);
|
|
570
|
+
if (pending?.type === "resize" && TERMINAL_ERROR_CODES.includes(msg.code)) {
|
|
571
|
+
cancelResizeStartRetry();
|
|
572
|
+
}
|
|
573
|
+
emit("cmdAck", { commandId: msg.commandId, type: pending?.type, stage: "error", code: msg.code, currentInstanceId: msg.currentInstanceId });
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
return false; // UI-owned status errors
|
|
577
|
+
}
|
|
578
|
+
case "snapshot_begin": {
|
|
579
|
+
if (typeof msg.generation === "string" && msg.generation) generation = msg.generation;
|
|
580
|
+
if (state === "probing" || state === "resyncing") {
|
|
581
|
+
beginCollecting(msg);
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
if (state === "reconciling") return true; // pre-subscribe stray: consume
|
|
585
|
+
// Unsolicited begin in collecting (duplicate) or live: protocol
|
|
586
|
+
// violation — strictness surfaces runner bugs; a fresh subscribe
|
|
587
|
+
// converges either way.
|
|
588
|
+
resyncOrFail("unexpected_snapshot_begin");
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
case "snapshot_frame": {
|
|
592
|
+
if (state === "reconciling") return true; // pre-subscribe stray: consume
|
|
593
|
+
if (state !== "collecting" || partial.empty || typeof msg.data !== "string") {
|
|
594
|
+
resyncOrFail("unexpected_snapshot_frame");
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
partial.frame = msg.data;
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
case "snapshot_end": {
|
|
601
|
+
if (state === "reconciling") return true; // pre-subscribe stray: consume
|
|
602
|
+
if (state !== "collecting") {
|
|
603
|
+
resyncOrFail("unexpected_snapshot_end");
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
finishCollecting(msg);
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
case "resnapshot_required": {
|
|
610
|
+
if (state === "live" || state === "collecting" || state === "resyncing") {
|
|
611
|
+
resyncOrFail("resnapshot_required");
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
if (state === "reconciling") return true; // pre-subscribe stray: consume
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
case "output": {
|
|
618
|
+
if (state === "live") {
|
|
619
|
+
if (typeof msg.seq !== "number") {
|
|
620
|
+
// A protocol-owned socket must never receive seq-less
|
|
621
|
+
// output; consume and ignore so unsequenced bytes can
|
|
622
|
+
// never corrupt the canonical-state buffer.
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
if (msg.seq === lastSeq + 1) {
|
|
626
|
+
lastSeq = msg.seq;
|
|
627
|
+
emit("output", msg.data);
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
if (msg.seq <= lastSeq) return true; // duplicate/stale
|
|
631
|
+
resyncOrFail("seq_gap");
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
if (state === "collecting") {
|
|
635
|
+
if (typeof msg.seq !== "number") return true; // consume+ignore, see live branch
|
|
636
|
+
if (!partial.frame) {
|
|
637
|
+
// Output before the frame: the runner never emits this
|
|
638
|
+
// (its catch-up flush is frame → outputs → end). Violation.
|
|
639
|
+
resyncOrFail("output_before_snapshot_frame");
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
const expected = partial.flush.length
|
|
643
|
+
? partial.flush[partial.flush.length - 1].seq + 1
|
|
644
|
+
: partial.beginSeq + 1;
|
|
645
|
+
if (msg.seq !== expected) {
|
|
646
|
+
resyncOrFail("snapshot_flush_gap");
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
partial.flush.push({ seq: msg.seq, data: msg.data });
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
if (state === "resyncing") {
|
|
653
|
+
// Complete-replay continuation is the only accepted output:
|
|
654
|
+
// it proves the runner answered with the full retained range.
|
|
655
|
+
if (typeof msg.seq === "number" && msg.seq === lastSeq + 1) {
|
|
656
|
+
decideProtocol();
|
|
657
|
+
lastSeq = msg.seq;
|
|
658
|
+
emit("output", msg.data);
|
|
659
|
+
state = "live";
|
|
660
|
+
snapshotFailures = 0; // verified complete replay pays the budget back
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
return true; // strays (pre-resync in-flight chunks): ignored
|
|
664
|
+
}
|
|
665
|
+
if (state === "reconciling") {
|
|
666
|
+
// The fresh socket is not subscribed yet: everything here is
|
|
667
|
+
// broadcast stray. Consume so the UI legacy path can never
|
|
668
|
+
// double-feed — and EMIT it: these bytes must reach the UI exactly
|
|
669
|
+
// once (strays cover (cursor, strayHighWater]; the replay starts
|
|
670
|
+
// past strayHighWater). Consuming WITHOUT emitting would silently
|
|
671
|
+
// drop them from the rendered output — a data-loss regression the
|
|
672
|
+
// task-5 review caught. lastSeq deliberately does NOT advance here:
|
|
673
|
+
// the subscribe cursor (max(cursor, strayHighWater)) carries the
|
|
674
|
+
// applied position once the replay/stream resumes.
|
|
675
|
+
if (typeof msg.seq === "number" && msg.seq > strayHighWater) strayHighWater = msg.seq;
|
|
676
|
+
emit("output", msg.data);
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
// probing: outputs belong to the legacy broadcast window (old
|
|
680
|
+
// runner, or new runner before subscribe processing). Not ours.
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
// (A duplicate dead `case "error":` block that survived the phase-5
|
|
684
|
+
// insertion was removed here — task 3 review P2: the live error case
|
|
685
|
+
// above returns on every path, so a second label was unreachable.)
|
|
686
|
+
default:
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Arm the probe. Sends only `subscribe_terminal` — the UI keeps ownership
|
|
692
|
+
* of `hello` (it carries UI-specific fields) and sends it itself. */
|
|
693
|
+
function start() {
|
|
694
|
+
if (state === "legacy") {
|
|
695
|
+
if (!modeDecided) {
|
|
696
|
+
modeDecided = true;
|
|
697
|
+
emit("mode", "legacy");
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (state !== "probing") return;
|
|
702
|
+
send({ type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION });
|
|
703
|
+
fireProbeTimer();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* New socket after a drop. Protocol mode: spec D4 binds the reconnect order
|
|
708
|
+
* `hello → reconcile → snapshot/subscribe` — the reconcile answer's
|
|
709
|
+
* generation drives the epoch rule (changed ⇒ discard cursor ⇒ fresh
|
|
710
|
+
* snapshot; same ⇒ replay from the applied cursor). Still probing: re-issue
|
|
711
|
+
* the probe on the new socket. Legacy: nothing — the UI legacy path
|
|
712
|
+
* reconnects on its own. A legacy-mode HOST (instanceId null) cannot be
|
|
713
|
+
* reconciled by contract (envelopes are never sent there) → phase-4
|
|
714
|
+
* semantics directly (the runner's resnapshot/empty flags protect).
|
|
715
|
+
*
|
|
716
|
+
* @param {number} cursorSeq last applied protocol seq (getLastSeq())
|
|
717
|
+
*/
|
|
718
|
+
function reconnect(cursorSeq) {
|
|
719
|
+
if (state === "closed" || state === "legacy") return;
|
|
720
|
+
if (state === "probing") {
|
|
721
|
+
send({ type: "subscribe_terminal", frameVersion: TERMINAL_FRAME_VERSION });
|
|
722
|
+
fireProbeTimer();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
seq = 0; // per-connection ordering resets with the socket (spec D4)
|
|
726
|
+
snapshotFailures = 0; // fresh connection, fresh recovery budget
|
|
727
|
+
partial = { frame: null, empty: false, resnapshot: false, beginSeq: 0, flush: [] };
|
|
728
|
+
if (!instanceId) {
|
|
729
|
+
issueReconnectSubscribe(cursorSeq);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
reconnectCursor = cursorSeq;
|
|
733
|
+
reconnectFromGeneration = generationAtDisconnect ?? generation;
|
|
734
|
+
state = "reconciling";
|
|
735
|
+
cancelReconcileTimer();
|
|
736
|
+
reconcileTimer = scheduleTimeout(reconcileTimeoutMs, () => {
|
|
737
|
+
reconcileTimer = null;
|
|
738
|
+
if (state !== "reconciling") return;
|
|
739
|
+
// Unanswered reconcile (phase-4 runner or dead window): fall back to
|
|
740
|
+
// the phase-4 reconnect (sinceSeq; the runner's begin flags protect).
|
|
741
|
+
issueReconnectSubscribe(reconnectCursor ?? 0);
|
|
742
|
+
});
|
|
743
|
+
if (instanceId && viewId) sendReconcile();
|
|
744
|
+
// else: wait for the fresh hello (observeIdentity triggers sendReconcile).
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Tear down: cancel timers, go inert. */
|
|
748
|
+
function close() {
|
|
749
|
+
cancelProbeTimer();
|
|
750
|
+
cancelReconcileTimer();
|
|
751
|
+
// CR R1 advisory: undeliverable correlation entries must not outlive the
|
|
752
|
+
// client — a new connection issues fresh commandIds, so a stale entry can
|
|
753
|
+
// never be consumed by a late ack.
|
|
754
|
+
pendingCommands.clear();
|
|
755
|
+
state = "closed";
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Socket dropped (CR R1 advisory): cancel the probe deadline so a dead
|
|
759
|
+
* window can never downgrade a protocol-capable runner. The timer is
|
|
760
|
+
* absolute — a reconnect landing after the 1500ms deadline would otherwise
|
|
761
|
+
* inherit the stale "no snapshot" verdict for the whole session. The next
|
|
762
|
+
* `start()`/`reconnect()` on the new socket re-arms everything; decided
|
|
763
|
+
* modes (legacy) and closed stay inert. */
|
|
764
|
+
function onDisconnect() {
|
|
765
|
+
if (state === "closed" || state === "legacy") return;
|
|
766
|
+
// Epoch baseline: snapshot the generation AS OF THE DISCONNECT. The live
|
|
767
|
+
// `generation` field will be refreshed by the replacement runner's hello
|
|
768
|
+
// before reconnect() runs, so only this capture can detect a change.
|
|
769
|
+
if (generation) generationAtDisconnect = generation;
|
|
770
|
+
cancelProbeTimer();
|
|
771
|
+
cancelReconcileTimer();
|
|
772
|
+
// CR R1 advisory: pending correlation entries cannot be acked through a
|
|
773
|
+
// dead socket; a reconnect uses fresh commandIds. Clearing here means a
|
|
774
|
+
// half-open late ack is treated as uncorrelated — fine, the socket died.
|
|
775
|
+
pendingCommands.clear();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
start,
|
|
780
|
+
reconnect,
|
|
781
|
+
onDisconnect,
|
|
782
|
+
handleMessage,
|
|
783
|
+
close,
|
|
784
|
+
/** @returns {"probing" | "protocol" | "legacy" | "closed"} UI-facing mode */
|
|
785
|
+
getMode() {
|
|
786
|
+
if (state === "collecting" || state === "live" || state === "resyncing" || state === "reconciling") return "protocol";
|
|
787
|
+
return state;
|
|
788
|
+
},
|
|
789
|
+
/** Last applied protocol seq (reconnect cursor). */
|
|
790
|
+
getLastSeq() {
|
|
791
|
+
return lastSeq;
|
|
792
|
+
},
|
|
793
|
+
sendControl,
|
|
794
|
+
/** Identity snapshot (test/diagnostics aid). */
|
|
795
|
+
getIdentity() {
|
|
796
|
+
return { instanceId, viewId, generation };
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* @typedef {ReturnType<typeof createTerminalAttachClient>} TerminalAttachClient
|
|
803
|
+
*/
|