@zhuxixi/pi-agent-board 0.7.0 → 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 +26 -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-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-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.mjs +8 -7
- package/runner/pty-runner.mjs +616 -27
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- 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/core/control-protocol.mjs +482 -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/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- 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/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +41 -33
- package/src/ui/pty-attach.ts +236 -72
- package/src/core/pty-input.mjs +0 -47
package/runner/pty-runner.mjs
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
12
13
|
import { createServer } from "node:net";
|
|
13
|
-
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
|
|
14
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { tmpdir } from "node:os";
|
|
15
16
|
import { join } from "node:path";
|
|
16
17
|
import { appendLine, readJson } from "../src/core/atomic.mjs";
|
|
@@ -21,9 +22,22 @@ import { lastVisibleLogLine } from "../src/core/heuristics.mjs";
|
|
|
21
22
|
import { acquireOwnedViewLock } from "../src/core/locks.mjs";
|
|
22
23
|
import * as P from "../src/core/paths.mjs";
|
|
23
24
|
import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
|
|
25
|
+
import { createTerminalModel, feedOutput, resizeChildAndModel } from "../src/core/terminal-model.mjs";
|
|
26
|
+
import { createTerminalSubscription } from "../src/core/terminal-attach-protocol.mjs";
|
|
24
27
|
import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
|
|
28
|
+
import {
|
|
29
|
+
checkSeq,
|
|
30
|
+
CONTROL_COMMAND_TYPES,
|
|
31
|
+
createResizeTracker,
|
|
32
|
+
journalAppendRecord,
|
|
33
|
+
journalGc,
|
|
34
|
+
journalUnresolved,
|
|
35
|
+
JOURNAL_KEEP_DEFAULT,
|
|
36
|
+
validateCommandEnvelope,
|
|
37
|
+
} from "../src/core/control-protocol.mjs";
|
|
25
38
|
import { readHost, readState, updateOwnedHost, writeHost } from "../src/core/store.mjs";
|
|
26
39
|
import { sendStateCommand } from "../src/core/coordinator-client.mjs";
|
|
40
|
+
import { classifyClientHello } from "../src/core/host-protocol.mjs";
|
|
27
41
|
import { markRowFailedDirect } from "./pty-runner-legacy.mjs";
|
|
28
42
|
import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
|
|
29
43
|
|
|
@@ -42,6 +56,14 @@ const HEARTBEAT_MS = 1000;
|
|
|
42
56
|
const HOST_ACK_DEDUP_MAX = 1000;
|
|
43
57
|
/** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
|
|
44
58
|
const HOST_RUNNER_LOCK_WAIT_MS = 5_000;
|
|
59
|
+
/** Boot identity for the control lifecycle (issue #91 phase 5, spec D4 generation
|
|
60
|
+
* token): one runner process = one generation. Clients detect a runner
|
|
61
|
+
* replacement by the generation changing, which structurally disambiguates a
|
|
62
|
+
* reconnect ring-replay from a fresh child (phase 4 epoch ambiguity). */
|
|
63
|
+
const GENERATION = randomUUID();
|
|
64
|
+
/** GC trigger: rewrite the journal when records exceed 2× the keep bound.
|
|
65
|
+
* Append-only between rewrites, so the common path stays O(1) per command. */
|
|
66
|
+
const JOURNAL_GC_TRIGGER = JOURNAL_KEEP_DEFAULT * 2;
|
|
45
67
|
/** How much of the screen.log tail to scan when attributing an abnormal child
|
|
46
68
|
* exit (issue #90). Tail-only: the log can be 100MB+. */
|
|
47
69
|
const EXIT_LOG_TAIL_BYTES = 8_192;
|
|
@@ -119,6 +141,8 @@ function legacyMain(config) {
|
|
|
119
141
|
|
|
120
142
|
/** @type {Set<import("node:net").Socket>} */
|
|
121
143
|
const clients = new Set();
|
|
144
|
+
/** Resident editor-state reporters: connected but never "attached" (#103). */
|
|
145
|
+
const editorReporters = new Set();
|
|
122
146
|
let childPid = null;
|
|
123
147
|
let child = null;
|
|
124
148
|
let exitCode = null;
|
|
@@ -155,7 +179,57 @@ function legacyMain(config) {
|
|
|
155
179
|
rows: config.rows || 36,
|
|
156
180
|
attachedClients: 0,
|
|
157
181
|
attachedEver: false,
|
|
182
|
+
revision: 0,
|
|
158
183
|
};
|
|
184
|
+
// Canonical terminal model + per-socket snapshot subscriptions (issue #91
|
|
185
|
+
// phase 3). Fed from child.onData alongside the legacy screen.log/broadcast
|
|
186
|
+
// path; legacy message semantics unchanged (the `seq` field is additive).
|
|
187
|
+
const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
|
|
188
|
+
/** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
|
|
189
|
+
const terminalSubscriptions = new Map();
|
|
190
|
+
// Control lifecycle runtime (issue #91 phase 5). instanceId is null in this
|
|
191
|
+
// legacy-mode main: no instance fence exists, so EVERY envelope claims a
|
|
192
|
+
// foreign instance and is rejected with `instance_mismatch` (documented
|
|
193
|
+
// contract — legacy-mode hosts serve only envelope-less clients).
|
|
194
|
+
const control = createControlRuntime({
|
|
195
|
+
viewId: config.viewId,
|
|
196
|
+
root: config.root,
|
|
197
|
+
instanceId: null,
|
|
198
|
+
send,
|
|
199
|
+
diag: (code, message, details) => {
|
|
200
|
+
try {
|
|
201
|
+
appendDiagnostic(config.root, config.viewId, { source: "runner", code, message, ...(details ? { details } : {}) });
|
|
202
|
+
} catch {
|
|
203
|
+
/* diagnostics must never kill the host */
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
actions: {
|
|
207
|
+
childReady: () => Boolean(child),
|
|
208
|
+
writeInput: (data) => {
|
|
209
|
+
child.write(data);
|
|
210
|
+
},
|
|
211
|
+
hostCols: () => host.cols,
|
|
212
|
+
hostRows: () => host.rows,
|
|
213
|
+
applyResize: (cols, rows) => {
|
|
214
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
215
|
+
update({ cols, rows });
|
|
216
|
+
},
|
|
217
|
+
currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
218
|
+
applyInterrupt: () => {
|
|
219
|
+
if (child) child.write("\x1b");
|
|
220
|
+
},
|
|
221
|
+
applyTerminate: () => {
|
|
222
|
+
killChild(child, childPid, "SIGTERM");
|
|
223
|
+
setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
|
|
224
|
+
},
|
|
225
|
+
applyDetach: (s) => {
|
|
226
|
+
s.end();
|
|
227
|
+
},
|
|
228
|
+
hostRevision: () => host.revision ?? 0,
|
|
229
|
+
cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
230
|
+
stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
159
233
|
/** Persist host.json. A transient failure (e.g. Windows rename EPERM racing a
|
|
160
234
|
* reader) must degrade, not kill the host: record a diagnostic and let the
|
|
161
235
|
* next heartbeat tick retry. Socket protocol is the attach main channel, so
|
|
@@ -179,9 +253,9 @@ function legacyMain(config) {
|
|
|
179
253
|
for (const c of clients) c.write(line);
|
|
180
254
|
};
|
|
181
255
|
const update = (patch = {}) => {
|
|
182
|
-
host = { ...host, ...patch, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
256
|
+
host = { ...host, ...patch, revision: (host.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
183
257
|
persist();
|
|
184
|
-
broadcast({ type: "status", status: host });
|
|
258
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
185
259
|
};
|
|
186
260
|
persist();
|
|
187
261
|
|
|
@@ -224,6 +298,9 @@ function legacyMain(config) {
|
|
|
224
298
|
AGENT_BOARD_VIEW_ID: config.viewId,
|
|
225
299
|
AGENT_BOARD_CHILD: "1",
|
|
226
300
|
AGENT_BOARD_HOSTED: "pty",
|
|
301
|
+
// The endpoint this host actually bound: the child's editor-state reporter
|
|
302
|
+
// dials it instead of guessing the stable per-view address (issue #103).
|
|
303
|
+
AGENT_BOARD_CONTROL_SOCKET: socketPath,
|
|
227
304
|
// Legacy names are exported too so older child extension builds still behave.
|
|
228
305
|
AGENT_VIEW_ROOT: config.root,
|
|
229
306
|
AGENT_VIEW_VIEW_ID: config.viewId,
|
|
@@ -254,12 +331,27 @@ function legacyMain(config) {
|
|
|
254
331
|
|
|
255
332
|
child.onData((data) => {
|
|
256
333
|
screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
|
|
257
|
-
|
|
334
|
+
const outputSeq = feedOutput(terminalModel, data);
|
|
335
|
+
// Per-socket output delivery (issue #91 phase 3): sockets that speak the
|
|
336
|
+
// subscribe_terminal protocol get exactly-once, gap-checked chunks from
|
|
337
|
+
// their subscription state machine; every other client keeps the legacy
|
|
338
|
+
// fire-and-forget broadcast (additive seq). Both streams to one socket
|
|
339
|
+
// would duplicate every chunk.
|
|
340
|
+
const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data }) + "\n";
|
|
341
|
+
for (const [socket, sub] of terminalSubscriptions) {
|
|
342
|
+
if (sub.subscribed()) sub.onOutput(outputSeq, data);
|
|
343
|
+
// Per-socket guard: a synchronous throw from a dead socket must never
|
|
344
|
+
// escape into the uncaughtException crash path (whole-branch review).
|
|
345
|
+
else { try { socket.write(outputLine); } catch { /* 'error' handler cleans up */ } }
|
|
346
|
+
}
|
|
258
347
|
});
|
|
259
348
|
child.onExit((code) => {
|
|
260
349
|
childExited = true;
|
|
261
350
|
resolveChildExit?.();
|
|
262
351
|
exitCode = code ?? 0;
|
|
352
|
+
// Terminate lifecycle (spec D4): a confirmed child exit is the observed
|
|
353
|
+
// evidence for any enveloped terminate still pending on a live socket.
|
|
354
|
+
control.flushTerminateObservations(exitCode, false);
|
|
263
355
|
// After a crash the handler already persisted "failed" and broadcast
|
|
264
356
|
// exit; this callback must not overwrite that state.
|
|
265
357
|
if (!crashed) {
|
|
@@ -281,8 +373,11 @@ function legacyMain(config) {
|
|
|
281
373
|
let server;
|
|
282
374
|
server = createServer((socket) => {
|
|
283
375
|
clients.add(socket);
|
|
284
|
-
|
|
285
|
-
|
|
376
|
+
terminalSubscriptions.set(
|
|
377
|
+
socket,
|
|
378
|
+
createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION }),
|
|
379
|
+
);
|
|
380
|
+
socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
|
|
286
381
|
let buffer = "";
|
|
287
382
|
socket.on("data", (chunk) => {
|
|
288
383
|
buffer += chunk.toString("utf8");
|
|
@@ -292,10 +387,16 @@ function legacyMain(config) {
|
|
|
292
387
|
});
|
|
293
388
|
socket.on("close", () => {
|
|
294
389
|
clients.delete(socket);
|
|
390
|
+
editorReporters.delete(socket);
|
|
391
|
+
terminalSubscriptions.delete(socket);
|
|
392
|
+
control.closeSocket(socket);
|
|
295
393
|
update();
|
|
296
394
|
});
|
|
297
395
|
socket.on("error", () => {
|
|
298
396
|
clients.delete(socket);
|
|
397
|
+
editorReporters.delete(socket);
|
|
398
|
+
terminalSubscriptions.delete(socket);
|
|
399
|
+
control.closeSocket(socket);
|
|
299
400
|
update();
|
|
300
401
|
});
|
|
301
402
|
});
|
|
@@ -309,23 +410,50 @@ function legacyMain(config) {
|
|
|
309
410
|
if (!line.trim()) return;
|
|
310
411
|
let msg;
|
|
311
412
|
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
413
|
+
// Enveloped control commands take the phase-5 lifecycle path; everything
|
|
414
|
+
// else (no commandId, or a non-control type carrying one) falls through to
|
|
415
|
+
// the legacy switch byte-identically.
|
|
416
|
+
const envelope = validateCommandEnvelope(msg);
|
|
417
|
+
if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
|
|
418
|
+
control.handle(msg, socket, envelope);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
312
421
|
switch (msg.type) {
|
|
313
|
-
case "hello":
|
|
314
|
-
|
|
422
|
+
case "hello": {
|
|
423
|
+
// Bookkeeping-only clients must never pin the host against warm-host
|
|
424
|
+
// reclaim (issue #103 §C): probes are read-only, the editor reporter
|
|
425
|
+
// is resident. A reporter socket leaves `clients` (the attachedClients
|
|
426
|
+
// source) but stays writable so editor_state keeps flowing.
|
|
427
|
+
const kind = classifyClientHello(msg);
|
|
428
|
+
if (kind === "client") update({ attachedEver: true });
|
|
429
|
+
if (kind === "editor-reporter") {
|
|
430
|
+
clients.delete(socket);
|
|
431
|
+
terminalSubscriptions.delete(socket);
|
|
432
|
+
editorReporters.add(socket);
|
|
433
|
+
update();
|
|
434
|
+
}
|
|
435
|
+
send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
|
|
315
436
|
break;
|
|
437
|
+
}
|
|
316
438
|
case "input":
|
|
317
439
|
if (typeof msg.data === "string") child.write(msg.data);
|
|
318
440
|
break;
|
|
319
441
|
case "resize": {
|
|
320
442
|
const cols = clampInt(msg.cols, 20, 300, host.cols);
|
|
321
443
|
const rows = clampInt(msg.rows, 5, 120, host.rows);
|
|
322
|
-
|
|
444
|
+
// Paired step (CR R1 advisory): model reflows only when the real PTY
|
|
445
|
+
// resize succeeded. host.cols/rows keep recording the intended size
|
|
446
|
+
// (new-client clamp baseline), deliberately outside the guard.
|
|
447
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
323
448
|
update({ cols, rows });
|
|
324
449
|
break;
|
|
325
450
|
}
|
|
326
451
|
case "interrupt":
|
|
327
452
|
child.write("\x1b");
|
|
328
453
|
break;
|
|
454
|
+
case "subscribe_terminal":
|
|
455
|
+
terminalSubscriptions.get(socket)?.handleMessage(msg);
|
|
456
|
+
break;
|
|
329
457
|
case "terminate": {
|
|
330
458
|
killChild(child, childPid, "SIGTERM");
|
|
331
459
|
setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
|
|
@@ -335,7 +463,7 @@ function legacyMain(config) {
|
|
|
335
463
|
socket.end();
|
|
336
464
|
break;
|
|
337
465
|
case "get_status":
|
|
338
|
-
send(socket, { type: "status", status: host });
|
|
466
|
+
send(socket, { type: "status", status: host, generation: GENERATION });
|
|
339
467
|
break;
|
|
340
468
|
case "editor_state": {
|
|
341
469
|
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
@@ -370,11 +498,19 @@ function legacyMain(config) {
|
|
|
370
498
|
if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
|
|
371
499
|
if (shutdownStarted) return;
|
|
372
500
|
shutdownStarted = true;
|
|
501
|
+
// Terminate lifecycle (spec D4): pending enveloped terminates get the
|
|
502
|
+
// runner-finalizing evidence before sockets end (exit-confirm flush in
|
|
503
|
+
// child.onExit covers the natural path; this covers deliberate shutdown).
|
|
504
|
+
control.flushTerminateObservations(null, true);
|
|
373
505
|
try { server?.close(); } catch {}
|
|
374
506
|
try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
|
|
375
507
|
for (const client of clients) {
|
|
376
508
|
try { client.end(); } catch {}
|
|
377
509
|
}
|
|
510
|
+
for (const reporter of editorReporters) {
|
|
511
|
+
try { reporter.end(); } catch { /* best effort */ }
|
|
512
|
+
}
|
|
513
|
+
editorReporters.clear();
|
|
378
514
|
killChild(child, childPid, "SIGTERM");
|
|
379
515
|
if (!(await waitForChildExit(4000)) && !childExited) {
|
|
380
516
|
killChild(child, childPid, "SIGKILL");
|
|
@@ -415,6 +551,8 @@ async function ownedMain(config) {
|
|
|
415
551
|
|
|
416
552
|
/** @type {Set<import("node:net").Socket>} */
|
|
417
553
|
const clients = new Set();
|
|
554
|
+
/** Resident editor-state reporters: connected but never "attached" (#103). */
|
|
555
|
+
const editorReporters = new Set();
|
|
418
556
|
let childPid = null;
|
|
419
557
|
let child = null;
|
|
420
558
|
let exitCode = null;
|
|
@@ -467,7 +605,10 @@ async function ownedMain(config) {
|
|
|
467
605
|
const ownedUpdate = (mutate) => {
|
|
468
606
|
const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
|
|
469
607
|
const next = mutate(cur);
|
|
470
|
-
|
|
608
|
+
// Record revision (phase 5 reconcile baseline): every committed fenced
|
|
609
|
+
// write bumps it, heartbeats included — a client comparing revisions
|
|
610
|
+
// sees any host-record movement, which is exactly the contract.
|
|
611
|
+
return { ...next, revision: (cur.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
471
612
|
});
|
|
472
613
|
if (result.updated && result.host) host = result.host;
|
|
473
614
|
return result;
|
|
@@ -514,6 +655,11 @@ async function ownedMain(config) {
|
|
|
514
655
|
async function finishHost(reason, requestedExitCode = null) {
|
|
515
656
|
if (shutdownStarted) return;
|
|
516
657
|
shutdownStarted = true;
|
|
658
|
+
// Terminate lifecycle (spec D4): pending enveloped terminates get the
|
|
659
|
+
// runner-finalizing evidence before teardown ends sockets. The child-exit
|
|
660
|
+
// flush (exitConfirmed) normally wins the race; whichever fires first
|
|
661
|
+
// clears the pending set, so exactly one observed ack goes out.
|
|
662
|
+
control.flushTerminateObservations(null, true);
|
|
517
663
|
if (heartbeatTimer) {
|
|
518
664
|
clearInterval(heartbeatTimer);
|
|
519
665
|
heartbeatTimer = null;
|
|
@@ -522,13 +668,17 @@ async function ownedMain(config) {
|
|
|
522
668
|
// superseded owner (or a duplicate that never claimed) never writes.
|
|
523
669
|
if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
|
|
524
670
|
ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
|
|
525
|
-
broadcast({ type: "status", status: host });
|
|
671
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
526
672
|
}
|
|
527
673
|
try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
|
|
528
674
|
for (const c of clients) {
|
|
529
675
|
try { c.destroy(); } catch { /* best effort */ }
|
|
530
676
|
}
|
|
531
677
|
clients.clear();
|
|
678
|
+
for (const reporter of editorReporters) {
|
|
679
|
+
try { reporter.destroy(); } catch { /* best effort */ }
|
|
680
|
+
}
|
|
681
|
+
editorReporters.clear();
|
|
532
682
|
// Bounded server close (1s): never let a stuck client block cleanup.
|
|
533
683
|
await new Promise((resolve) => {
|
|
534
684
|
if (!server) return resolve();
|
|
@@ -652,6 +802,61 @@ async function ownedMain(config) {
|
|
|
652
802
|
}
|
|
653
803
|
host = current;
|
|
654
804
|
|
|
805
|
+
// Canonical terminal model + per-socket snapshot subscriptions (issue #91
|
|
806
|
+
// phase 3). Created from the owned host record before the endpoint binds,
|
|
807
|
+
// so every connect (including probes) can carry a subscription. Legacy
|
|
808
|
+
// message semantics unchanged (the `seq` field on output is additive).
|
|
809
|
+
const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
|
|
810
|
+
/** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
|
|
811
|
+
const terminalSubscriptions = new Map();
|
|
812
|
+
// Control lifecycle runtime (issue #91 phase 5). Created after the terminal
|
|
813
|
+
// model (actions close over it) and BEFORE the endpoint binds, so the durable
|
|
814
|
+
// journal is loaded from disk before any client can reconcile. The action
|
|
815
|
+
// closures read `child`/`host` at call time (both are let-bound above).
|
|
816
|
+
const control = createControlRuntime({
|
|
817
|
+
viewId: config.viewId,
|
|
818
|
+
root: config.root,
|
|
819
|
+
instanceId: config.instanceId,
|
|
820
|
+
send,
|
|
821
|
+
diag,
|
|
822
|
+
actions: {
|
|
823
|
+
childReady: () => Boolean(child),
|
|
824
|
+
writeInput: (data) => {
|
|
825
|
+
child.write(data);
|
|
826
|
+
},
|
|
827
|
+
hostCols: () => host.cols,
|
|
828
|
+
hostRows: () => host.rows,
|
|
829
|
+
applyResize: (cols, rows) => {
|
|
830
|
+
// The runtime pre-checks childReady (host_starting reply otherwise),
|
|
831
|
+
// so this only runs against a live child — the starting window's
|
|
832
|
+
// cachedResize path belongs exclusively to the legacy switch below.
|
|
833
|
+
// Paired step (CR R1 advisory): model reflows only when the real
|
|
834
|
+
// PTY resize succeeded; cachedResize cleared either way — a failed
|
|
835
|
+
// resize is not retried against a exiting child.
|
|
836
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
837
|
+
notifyChildResize(childPid);
|
|
838
|
+
cachedResize = null;
|
|
839
|
+
ownedUpdate((cur) => ({ ...cur, cols, rows }));
|
|
840
|
+
},
|
|
841
|
+
currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
842
|
+
applyInterrupt: () => {
|
|
843
|
+
if (child) child.write("\x1b");
|
|
844
|
+
},
|
|
845
|
+
applyTerminate: () => {
|
|
846
|
+
// Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
|
|
847
|
+
// so a SIGTERM-immune child still terminates within a bounded window.
|
|
848
|
+
// The applied ack was already sent by the runtime BEFORE this hook.
|
|
849
|
+
finish("terminated", 0);
|
|
850
|
+
},
|
|
851
|
+
applyDetach: (s) => {
|
|
852
|
+
s.end();
|
|
853
|
+
},
|
|
854
|
+
hostRevision: () => host.revision ?? 0,
|
|
855
|
+
cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
856
|
+
stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
|
|
857
|
+
},
|
|
858
|
+
});
|
|
859
|
+
|
|
655
860
|
// 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
|
|
656
861
|
// instance; an occupied path means someone else owns it.
|
|
657
862
|
const listenOutcome = await new Promise((resolveListen) => {
|
|
@@ -662,8 +867,12 @@ async function ownedMain(config) {
|
|
|
662
867
|
const probeSockets = new WeakSet();
|
|
663
868
|
server = createServer((socket) => {
|
|
664
869
|
clients.add(socket);
|
|
665
|
-
|
|
666
|
-
|
|
870
|
+
terminalSubscriptions.set(
|
|
871
|
+
socket,
|
|
872
|
+
createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION }),
|
|
873
|
+
);
|
|
874
|
+
socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
|
|
875
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
667
876
|
let buffer = "";
|
|
668
877
|
socket.on("data", (chunk) => {
|
|
669
878
|
buffer += chunk.toString("utf8");
|
|
@@ -673,6 +882,9 @@ async function ownedMain(config) {
|
|
|
673
882
|
});
|
|
674
883
|
socket.on("close", () => {
|
|
675
884
|
clients.delete(socket);
|
|
885
|
+
editorReporters.delete(socket);
|
|
886
|
+
terminalSubscriptions.delete(socket);
|
|
887
|
+
control.closeSocket(socket);
|
|
676
888
|
if (probeSockets.has(socket)) return;
|
|
677
889
|
// Merge into the live record (a stale closure spread here erases a
|
|
678
890
|
// concurrent revoke — final review finding 2).
|
|
@@ -680,6 +892,9 @@ async function ownedMain(config) {
|
|
|
680
892
|
});
|
|
681
893
|
socket.on("error", () => {
|
|
682
894
|
clients.delete(socket);
|
|
895
|
+
editorReporters.delete(socket);
|
|
896
|
+
terminalSubscriptions.delete(socket);
|
|
897
|
+
control.closeSocket(socket);
|
|
683
898
|
if (probeSockets.has(socket)) return;
|
|
684
899
|
ownedUpdate((cur) => ({ ...cur }));
|
|
685
900
|
});
|
|
@@ -721,7 +936,7 @@ async function ownedMain(config) {
|
|
|
721
936
|
return;
|
|
722
937
|
}
|
|
723
938
|
claimedRecord = true;
|
|
724
|
-
broadcast({ type: "status", status: host });
|
|
939
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
725
940
|
|
|
726
941
|
// Test seam (issue #70 Task 8): a config-level delay between publishing the
|
|
727
942
|
// runner identity and spawning the child creates a REAL starting window
|
|
@@ -743,6 +958,9 @@ async function ownedMain(config) {
|
|
|
743
958
|
AGENT_BOARD_VIEW_ID: config.viewId,
|
|
744
959
|
AGENT_BOARD_CHILD: "1",
|
|
745
960
|
AGENT_BOARD_HOSTED: "pty",
|
|
961
|
+
// The endpoint this host actually bound: the child's editor-state reporter
|
|
962
|
+
// dials it instead of guessing the stable per-view address (issue #103).
|
|
963
|
+
AGENT_BOARD_CONTROL_SOCKET: socketPath,
|
|
746
964
|
// Legacy names are exported too so older child extension builds still behave.
|
|
747
965
|
AGENT_VIEW_ROOT: config.root,
|
|
748
966
|
AGENT_VIEW_VIEW_ID: config.viewId,
|
|
@@ -767,12 +985,25 @@ async function ownedMain(config) {
|
|
|
767
985
|
childPid = child.pid ?? null;
|
|
768
986
|
child.onData((data) => {
|
|
769
987
|
screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
|
|
770
|
-
|
|
988
|
+
const outputSeq = feedOutput(terminalModel, data);
|
|
989
|
+
// Per-socket output delivery — same contract as legacyMain above.
|
|
990
|
+
const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data }) + "\n";
|
|
991
|
+
for (const [socket, sub] of terminalSubscriptions) {
|
|
992
|
+
if (sub.subscribed()) sub.onOutput(outputSeq, data);
|
|
993
|
+
// Same per-socket guard the legacy broadcast had: a synchronous throw
|
|
994
|
+
// from a dead socket must never escape into the uncaughtException
|
|
995
|
+
// crash path (whole-branch review finding).
|
|
996
|
+
else { try { socket.write(outputLine); } catch { /* socket 'error' handler cleans up */ } }
|
|
997
|
+
}
|
|
771
998
|
});
|
|
772
999
|
child.onExit((code) => {
|
|
773
1000
|
childExited = true;
|
|
774
1001
|
resolveChildExit?.();
|
|
775
1002
|
exitCode = code ?? 0;
|
|
1003
|
+
// Terminate lifecycle (spec D4): a confirmed child exit is the observed
|
|
1004
|
+
// evidence for any enveloped terminate still pending — flush before the
|
|
1005
|
+
// finish teardown ends the client sockets.
|
|
1006
|
+
control.flushTerminateObservations(exitCode, false);
|
|
776
1007
|
editorEmpty = null;
|
|
777
1008
|
broadcast({ type: "editor_state", empty: null });
|
|
778
1009
|
finish("child_exit", exitCode);
|
|
@@ -798,7 +1029,7 @@ async function ownedMain(config) {
|
|
|
798
1029
|
await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
|
|
799
1030
|
return;
|
|
800
1031
|
}
|
|
801
|
-
broadcast({ type: "status", status: host });
|
|
1032
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
802
1033
|
|
|
803
1034
|
// A starting-window resize cannot be applied at spawn-return nor at first
|
|
804
1035
|
// output: the child node process is still bootstrapping and a SIGWINCH
|
|
@@ -809,7 +1040,8 @@ async function ownedMain(config) {
|
|
|
809
1040
|
if (cachedResize) {
|
|
810
1041
|
const applyHeld = setTimeout(() => {
|
|
811
1042
|
if (!cachedResize || !child || shutdownStarted) return;
|
|
812
|
-
|
|
1043
|
+
// Paired step (CR R1 advisory): model follows the real PTY, never leads.
|
|
1044
|
+
resizeChildAndModel(child, terminalModel, cachedResize.cols, cachedResize.rows);
|
|
813
1045
|
notifyChildResize(childPid);
|
|
814
1046
|
cachedResize = null;
|
|
815
1047
|
}, 500);
|
|
@@ -846,19 +1078,34 @@ async function ownedMain(config) {
|
|
|
846
1078
|
if (!line.trim()) return;
|
|
847
1079
|
let msg;
|
|
848
1080
|
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
1081
|
+
// Enveloped control commands take the phase-5 lifecycle path; everything
|
|
1082
|
+
// else (no commandId, or a non-control type carrying one — e.g.
|
|
1083
|
+
// subscribe_terminal with correlation metadata) falls through to the
|
|
1084
|
+
// legacy switch byte-identically.
|
|
1085
|
+
const envelope = validateCommandEnvelope(msg);
|
|
1086
|
+
if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
|
|
1087
|
+
control.handle(msg, socket, envelope);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
849
1090
|
switch (msg.type) {
|
|
850
|
-
case "hello":
|
|
851
|
-
// Probe
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
if (
|
|
1091
|
+
case "hello": {
|
|
1092
|
+
// Probe and reporter sockets are bookkeeping-only: neither may flip
|
|
1093
|
+
// attachedEver nor keep attachedClients non-zero, or warm-host reclaim
|
|
1094
|
+
// never fires and hosts leak (issue #103 §C).
|
|
1095
|
+
const kind = classifyClientHello(msg);
|
|
1096
|
+
if (kind === "probe") {
|
|
856
1097
|
socket.markProbe?.();
|
|
1098
|
+
} else if (kind === "editor-reporter") {
|
|
1099
|
+
clients.delete(socket);
|
|
1100
|
+
terminalSubscriptions.delete(socket);
|
|
1101
|
+
editorReporters.add(socket);
|
|
1102
|
+
ownedUpdate((cur) => ({ ...cur }));
|
|
857
1103
|
} else {
|
|
858
1104
|
ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
|
|
859
1105
|
}
|
|
860
|
-
send(socket, { type: "hello", status: host, editorEmpty });
|
|
1106
|
+
send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
|
|
861
1107
|
break;
|
|
1108
|
+
}
|
|
862
1109
|
case "input": {
|
|
863
1110
|
if (typeof msg.data !== "string") break;
|
|
864
1111
|
if (typeof msg.requestId !== "string" || !msg.requestId) {
|
|
@@ -886,7 +1133,10 @@ async function ownedMain(config) {
|
|
|
886
1133
|
const cols = clampInt(msg.cols, 20, 300, host.cols);
|
|
887
1134
|
const rows = clampInt(msg.rows, 5, 120, host.rows);
|
|
888
1135
|
if (child) {
|
|
889
|
-
|
|
1136
|
+
// Paired step (CR R1 advisory): model reflows only when the real
|
|
1137
|
+
// PTY resize succeeded; cachedResize cleared either way — a failed
|
|
1138
|
+
// resize is not retried against a exiting child.
|
|
1139
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
890
1140
|
notifyChildResize(childPid);
|
|
891
1141
|
cachedResize = null;
|
|
892
1142
|
} else {
|
|
@@ -898,6 +1148,9 @@ async function ownedMain(config) {
|
|
|
898
1148
|
case "interrupt":
|
|
899
1149
|
if (child) child.write("\x1b");
|
|
900
1150
|
break;
|
|
1151
|
+
case "subscribe_terminal":
|
|
1152
|
+
terminalSubscriptions.get(socket)?.handleMessage(msg);
|
|
1153
|
+
break;
|
|
901
1154
|
case "terminate":
|
|
902
1155
|
// Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
|
|
903
1156
|
// so a SIGTERM-immune child still terminates within a bounded window.
|
|
@@ -907,7 +1160,7 @@ async function ownedMain(config) {
|
|
|
907
1160
|
socket.end();
|
|
908
1161
|
break;
|
|
909
1162
|
case "get_status":
|
|
910
|
-
send(socket, { type: "status", status: host });
|
|
1163
|
+
send(socket, { type: "status", status: host, generation: GENERATION });
|
|
911
1164
|
break;
|
|
912
1165
|
case "editor_state": {
|
|
913
1166
|
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
@@ -1004,6 +1257,342 @@ function clampInt(value, min, max, fallback) {
|
|
|
1004
1257
|
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
1005
1258
|
}
|
|
1006
1259
|
|
|
1260
|
+
// ---------------------------------------------------------------------------
|
|
1261
|
+
// Control-command lifecycle runtime (issue #91 phase 5, spec D4). Pure
|
|
1262
|
+
// decision logic lives in src/core/control-protocol.mjs; this is the runner's
|
|
1263
|
+
// serial side-effect shell: per-socket seq tracking, the durable command
|
|
1264
|
+
// journal, resize latest-wins routing, and staged ack emission. Messages
|
|
1265
|
+
// WITHOUT an envelope (no commandId) never reach this code — the legacy
|
|
1266
|
+
// switch keeps byte-identical behavior.
|
|
1267
|
+
// ---------------------------------------------------------------------------
|
|
1268
|
+
|
|
1269
|
+
/** Load journal records, dropping torn tail lines from a crash mid-append. */
|
|
1270
|
+
function loadJournalRecords(path) {
|
|
1271
|
+
try {
|
|
1272
|
+
const records = [];
|
|
1273
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1274
|
+
if (!line.trim()) continue;
|
|
1275
|
+
try {
|
|
1276
|
+
records.push(JSON.parse(line));
|
|
1277
|
+
} catch {
|
|
1278
|
+
/* torn tail: the accepted/applied truth for that line is unknown —
|
|
1279
|
+
dropping it can only widen the accepted_unknown set, never hide one */
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return records;
|
|
1283
|
+
} catch {
|
|
1284
|
+
return [];
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* One control runtime per main. `actions` bundles the main-specific side
|
|
1290
|
+
* effects (child shape differs between the legacy and owned lifecycles); the
|
|
1291
|
+
* decision order here is shared and binding:
|
|
1292
|
+
* envelope validity → instance fence → seq monotonicity → per-type dispatch.
|
|
1293
|
+
* @param {{
|
|
1294
|
+
* viewId: string, root: string, instanceId: string|null,
|
|
1295
|
+
* send: (socket: import("node:net").Socket, msg: object) => void,
|
|
1296
|
+
* diag: (code: string, message: string, details?: object) => void,
|
|
1297
|
+
* actions: {
|
|
1298
|
+
* childReady(): boolean,
|
|
1299
|
+
* writeInput(data: string): void,
|
|
1300
|
+
* hostCols(): number, hostRows(): number,
|
|
1301
|
+
* applyResize(cols: number, rows: number): void, // runtime pre-checks childReady — never called without a child
|
|
1302
|
+
* currentDims(): { cols: number, rows: number },
|
|
1303
|
+
* applyInterrupt(): void,
|
|
1304
|
+
* applyTerminate(): void,
|
|
1305
|
+
* applyDetach(socket: import("node:net").Socket): void,
|
|
1306
|
+
* hostRevision(): number,
|
|
1307
|
+
* cursor(): { lastSeq: number, cols: number, rows: number },
|
|
1308
|
+
* stateStamp(): number | null,
|
|
1309
|
+
* },
|
|
1310
|
+
* }} opts
|
|
1311
|
+
*/
|
|
1312
|
+
function createControlRuntime({ viewId, root, instanceId, send, diag, actions }) {
|
|
1313
|
+
/** Per-connection seq watermark (ordering aid only — never dedup). */
|
|
1314
|
+
const connectionSeq = new Map();
|
|
1315
|
+
const journalPath = P.controlJournalPath(root, viewId);
|
|
1316
|
+
const journalRecords = loadJournalRecords(journalPath);
|
|
1317
|
+
/** O(1) journal dedup — rebuilt after GC so it mirrors the retained records. */
|
|
1318
|
+
const journalIds = new Set(journalRecords.map((r) => r.commandId));
|
|
1319
|
+
const resizes = createResizeTracker();
|
|
1320
|
+
/** commandId → socket awaiting the resize ack (superseded/applied routing). */
|
|
1321
|
+
const pendingResizeSockets = new Map();
|
|
1322
|
+
/** commandId → socket that requested terminate (observed routing). */
|
|
1323
|
+
const pendingTerminates = new Map();
|
|
1324
|
+
|
|
1325
|
+
const rawAppend = (record) => {
|
|
1326
|
+
appendFileSync(journalPath, JSON.stringify(record) + "\n");
|
|
1327
|
+
};
|
|
1328
|
+
const maybeGcJournal = () => {
|
|
1329
|
+
if (journalRecords.length <= JOURNAL_GC_TRIGGER) return;
|
|
1330
|
+
const kept = journalGc(journalRecords, JOURNAL_KEEP_DEFAULT);
|
|
1331
|
+
journalRecords.length = 0;
|
|
1332
|
+
journalRecords.push(...kept);
|
|
1333
|
+
journalIds.clear();
|
|
1334
|
+
for (const record of kept) journalIds.add(record.commandId);
|
|
1335
|
+
try {
|
|
1336
|
+
writeFileSync(journalPath, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
diag("control_journal_rewrite_failed", err instanceof Error ? err.message : String(err));
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1341
|
+
/** @returns {boolean} false when the append failed — the command is NOT accepted. */
|
|
1342
|
+
const journalAccept = (commandId, command, now) => {
|
|
1343
|
+
// Ruling (b): a journaled commandId is NEVER re-appended — the dedup
|
|
1344
|
+
// check is the caller's, and this guard is the last line of defense.
|
|
1345
|
+
if (journalIds.has(commandId)) return true;
|
|
1346
|
+
const record = { kind: "accepted", commandId, command, acceptedAt: now };
|
|
1347
|
+
journalAppendRecord(journalRecords, record); // shape validation; throws on a runner bug
|
|
1348
|
+
try {
|
|
1349
|
+
rawAppend(record);
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
// Nothing was pushed into journalRecords for this record (pure
|
|
1352
|
+
// validate + manual push on success), so there is nothing to unwind —
|
|
1353
|
+
// the command is simply not accepted.
|
|
1354
|
+
diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
journalRecords.push(record);
|
|
1358
|
+
journalIds.add(commandId);
|
|
1359
|
+
maybeGcJournal();
|
|
1360
|
+
return true;
|
|
1361
|
+
};
|
|
1362
|
+
const journalApply = (commandId, now) => {
|
|
1363
|
+
const record = { kind: "applied", commandId, appliedAt: now };
|
|
1364
|
+
try {
|
|
1365
|
+
rawAppend(record);
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
journalRecords.push(record);
|
|
1371
|
+
maybeGcJournal();
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
/** Socket-death semantics for every ack: a dead client must never turn a
|
|
1375
|
+
* control reply into a runner crash (same invariant as the broadcast guard). */
|
|
1376
|
+
const reply = (socket, msg) => {
|
|
1377
|
+
try {
|
|
1378
|
+
send(socket, msg);
|
|
1379
|
+
} catch {
|
|
1380
|
+
/* socket 'error'/'close' handler owns cleanup */
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
const flushTerminateObservations = (exitCode, runnerFinalizing) => {
|
|
1385
|
+
for (const [commandId, termSocket] of pendingTerminates) {
|
|
1386
|
+
reply(termSocket, {
|
|
1387
|
+
type: "cmd_ack",
|
|
1388
|
+
commandId,
|
|
1389
|
+
stage: "observed",
|
|
1390
|
+
...(exitCode != null ? { exitConfirmed: true, exitCode } : {}),
|
|
1391
|
+
...(runnerFinalizing ? { runnerFinalizing: true } : {}),
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
pendingTerminates.clear();
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Dispatch an enveloped control command. Caller guarantees
|
|
1399
|
+
* `env.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)`.
|
|
1400
|
+
*
|
|
1401
|
+
* Crash containment: a throwing action (e.g. child.write on a pty that died
|
|
1402
|
+
* mid-command) must degrade to an error reply + diagnostic, never escape
|
|
1403
|
+
* into the runner's uncaughtException path — the legacy switch predates this
|
|
1404
|
+
* invariant, but new protocol code must not inherit that crash class.
|
|
1405
|
+
*/
|
|
1406
|
+
const handle = (msg, socket, env) => {
|
|
1407
|
+
// (d) errors are only meaningful for enveloped messages — this function is
|
|
1408
|
+
// only entered for enveloped control types, so every error below is legal.
|
|
1409
|
+
if (env.errors.length > 0) {
|
|
1410
|
+
reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: env.errors });
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
try {
|
|
1414
|
+
handleChecked(msg, socket, env);
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
diag("control_command_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId, type: msg.type });
|
|
1417
|
+
reply(socket, { type: "error", code: "command_failed", commandId: msg.commandId });
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
const handleChecked = (msg, socket, env) => {
|
|
1422
|
+
// (a) instance fencing, reject-with-current: a client naming a DIFFERENT
|
|
1423
|
+
// instance is talking across a runner replacement (stale socket race);
|
|
1424
|
+
// silently applying its commands would resurrect phantom controls — the
|
|
1425
|
+
// exact class #70 fencing exists to prevent. The error carries the current
|
|
1426
|
+
// instanceId so a well-behaved client re-hellos and recovers. Legacy-mode
|
|
1427
|
+
// hosts (instanceId null) fence EVERY envelope: an envelope claims an
|
|
1428
|
+
// instance a legacy-mode runner never had.
|
|
1429
|
+
if (msg.instanceId !== instanceId) {
|
|
1430
|
+
diag("instance_mismatch", "control command carries a foreign instanceId", {
|
|
1431
|
+
commandId: msg.commandId,
|
|
1432
|
+
got: msg.instanceId,
|
|
1433
|
+
current: instanceId,
|
|
1434
|
+
});
|
|
1435
|
+
reply(socket, { type: "error", code: "instance_mismatch", commandId: msg.commandId, currentInstanceId: instanceId });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
// (c) per-connection monotonic seq: an ordering aid. Repeats/regressions
|
|
1439
|
+
// are dropped with a diagnostic — NOT a dedup signal (dedup keys on
|
|
1440
|
+
// commandId only); a legitimate re-send after reconnect uses a new socket.
|
|
1441
|
+
const lastSeq = connectionSeq.get(socket) ?? 0;
|
|
1442
|
+
const seqVerdict = checkSeq(lastSeq, msg.seq);
|
|
1443
|
+
if (!seqVerdict.ok) {
|
|
1444
|
+
diag("seq_out_of_order", "control command dropped for non-monotonic seq", {
|
|
1445
|
+
commandId: msg.commandId,
|
|
1446
|
+
seq: msg.seq,
|
|
1447
|
+
lastSeq,
|
|
1448
|
+
});
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
connectionSeq.set(socket, msg.seq);
|
|
1452
|
+
|
|
1453
|
+
switch (msg.type) {
|
|
1454
|
+
case "input": {
|
|
1455
|
+
if (typeof msg.data !== "string") {
|
|
1456
|
+
reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: ["input_data_missing"] });
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (msg.durable !== true) {
|
|
1460
|
+
// Keystroke with a correlation id: fire-and-forget preserved — no
|
|
1461
|
+
// ack, no journal (journal writes must never sit on the keystroke
|
|
1462
|
+
// path). The envelope is correlation/debugging metadata only.
|
|
1463
|
+
if (actions.childReady()) actions.writeInput(msg.data);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
// Durable follow-up: accepted (journaled) → applied (written). A
|
|
1467
|
+
// re-send of a journaled commandId returns the cached final stage —
|
|
1468
|
+
// never re-appended (ruling b), never re-written (commandId dedup).
|
|
1469
|
+
if (journalIds.has(msg.commandId)) {
|
|
1470
|
+
const wasApplied = journalRecords.some((r) => r.kind === "applied" && r.commandId === msg.commandId);
|
|
1471
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: wasApplied ? "applied" : "accepted", durable: true });
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (!actions.childReady()) {
|
|
1475
|
+
// Starting window: nothing accepted (matches the requestId path's
|
|
1476
|
+
// host_starting contract; the service retries with its own policy).
|
|
1477
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
if (!journalAccept(msg.commandId, msg.data, Date.now())) {
|
|
1481
|
+
// accepted means JOURNALED — without the journal entry the stage
|
|
1482
|
+
// would be a lie, so the command is refused, not silently applied.
|
|
1483
|
+
reply(socket, { type: "error", code: "journal_unavailable", commandId: msg.commandId });
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "accepted", durable: true });
|
|
1487
|
+
try {
|
|
1488
|
+
actions.writeInput(msg.data);
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
// §10 honesty: accepted but the write failed — the command stays
|
|
1491
|
+
// accepted_unknown; reconcile reports it; never auto-replayed.
|
|
1492
|
+
diag("input_apply_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId });
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
journalApply(msg.commandId, Date.now());
|
|
1496
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
case "resize": {
|
|
1500
|
+
// Uniform starting-window contract with input: without a child there
|
|
1501
|
+
// is nothing to resize and no honest applied value — the client
|
|
1502
|
+
// retries (new commandId). This also keeps latest-wins tracking off
|
|
1503
|
+
// the books for commands that never applied.
|
|
1504
|
+
if (!actions.childReady()) {
|
|
1505
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
const cols = clampInt(msg.cols, 20, 300, actions.hostCols());
|
|
1509
|
+
const rows = clampInt(msg.rows, 5, 120, actions.hostRows());
|
|
1510
|
+
const tracked = resizes.track({ commandId: msg.commandId, clientId: msg.clientId, cols, rows });
|
|
1511
|
+
for (const sup of tracked.superseded) {
|
|
1512
|
+
const oldSocket = pendingResizeSockets.get(sup.commandId) ?? socket;
|
|
1513
|
+
pendingResizeSockets.delete(sup.commandId);
|
|
1514
|
+
reply(oldSocket, { type: "cmd_ack", commandId: sup.commandId, stage: "superseded", byCommandId: sup.byCommandId });
|
|
1515
|
+
}
|
|
1516
|
+
if (tracked.duplicate) {
|
|
1517
|
+
// Same commandId re-request: applyResize is synchronous, so a
|
|
1518
|
+
// duplicate always finds the command already applied — return the
|
|
1519
|
+
// cached result. (CR R1 advisory: the pendingResizeSockets entry is
|
|
1520
|
+
// deliberately NOT set here — the original resolved synchronously,
|
|
1521
|
+
// so a re-pointed entry would be a stale leak.)
|
|
1522
|
+
const cached = resizes.resultFor(msg.commandId);
|
|
1523
|
+
if (cached) reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: cached.cols, rows: cached.rows });
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
pendingResizeSockets.set(msg.commandId, socket);
|
|
1527
|
+
actions.applyResize(cols, rows);
|
|
1528
|
+
// The paired resize step keeps the model mirroring the REAL PTY
|
|
1529
|
+
// (reflow only on successful child.resize), so the model's dims are
|
|
1530
|
+
// the honest applied value — even when the PTY kept its old geometry.
|
|
1531
|
+
const dims = actions.currentDims();
|
|
1532
|
+
resizes.applied(msg.commandId, dims.cols, dims.rows);
|
|
1533
|
+
pendingResizeSockets.delete(msg.commandId);
|
|
1534
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: dims.cols, rows: dims.rows });
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
case "interrupt": {
|
|
1538
|
+
// Starting-window honesty (CR R1 blocking): applyInterrupt no-ops
|
|
1539
|
+
// without a child, so an unconditional applied ack would be a lie —
|
|
1540
|
+
// same contract as input/resize. The client consumes host_starting
|
|
1541
|
+
// as a cmdAck error (no retry chain: interrupt is transient and
|
|
1542
|
+
// user-timed; a re-send carries a fresh commandId).
|
|
1543
|
+
if (!actions.childReady()) {
|
|
1544
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
actions.applyInterrupt();
|
|
1548
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
case "terminate": {
|
|
1552
|
+
// Idempotent: a repeat while the termination is in flight returns the
|
|
1553
|
+
// current stage instead of claiming a fresh start.
|
|
1554
|
+
if (pendingTerminates.has(msg.commandId)) {
|
|
1555
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
pendingTerminates.set(msg.commandId, socket);
|
|
1559
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
|
|
1560
|
+
actions.applyTerminate();
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
case "detach": {
|
|
1564
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "detach_accepted" });
|
|
1565
|
+
actions.applyDetach(socket);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
case "reconcile": {
|
|
1569
|
+
reply(socket, {
|
|
1570
|
+
type: "reconcile_result",
|
|
1571
|
+
commandId: msg.commandId,
|
|
1572
|
+
generation: GENERATION,
|
|
1573
|
+
hostRevision: actions.hostRevision(),
|
|
1574
|
+
terminalCursor: actions.cursor(),
|
|
1575
|
+
stateMaterializedRevision: actions.stateStamp(),
|
|
1576
|
+
unresolved: journalUnresolved(journalRecords).map((u) => ({ ...u, status: "accepted_unknown" })),
|
|
1577
|
+
});
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
/** Socket teardown: drop the seq watermark and any ack-routing entries. */
|
|
1584
|
+
const closeSocket = (socket) => {
|
|
1585
|
+
connectionSeq.delete(socket);
|
|
1586
|
+
for (const [commandId, sock] of pendingResizeSockets) if (sock === socket) pendingResizeSockets.delete(commandId);
|
|
1587
|
+
for (const [commandId, sock] of pendingTerminates) if (sock === socket) pendingTerminates.delete(commandId);
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
// (completeHeldResize was removed as dead code: the starting-window resize
|
|
1591
|
+
// path rejects with host_starting instead of holding, and applyResize is
|
|
1592
|
+
// synchronous — there is no held-resize completion path. Task 2 review P2-C.)
|
|
1593
|
+
return { handle, closeSocket, flushTerminateObservations, journalIds };
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1007
1596
|
/** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
|
|
1008
1597
|
* across exec(2). Recorded in published identities so service-side recovery
|
|
1009
1598
|
* (issue #70 Task 11) can tell an owned-live pid from a reused one; null on
|