@zhuxixi/pi-agent-board 0.7.0 → 0.9.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 +46 -0
- package/README.md +16 -12
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/RELEASE.md +148 -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/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
- package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
- package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
- package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
- package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
- package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -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/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
- package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
- package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
- package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
- package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
- package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
- package/package.json +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +633 -31
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/release.mjs +446 -0
- 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 +62 -0
- package/src/core/launch-options.mjs +37 -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 +809 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-query-sequences.mjs +132 -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 +61 -41
- package/src/ui/pty-attach.ts +318 -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, helloBookkeeping } 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,54 @@ 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 / #130): probes are read-only and transient,
|
|
425
|
+
// the editor reporter is resident. Both leave `clients` — the sole
|
|
426
|
+
// source of `attachedClients` — while their socket stays writable so
|
|
427
|
+
// probe replies and editor_state keep flowing. The policy lives in
|
|
428
|
+
// `helloBookkeeping`, so this path and the owned one cannot drift.
|
|
429
|
+
const book = helloBookkeeping(classifyClientHello(msg));
|
|
430
|
+
if (!book.keepInClients) {
|
|
431
|
+
clients.delete(socket);
|
|
432
|
+
terminalSubscriptions.delete(socket);
|
|
433
|
+
}
|
|
434
|
+
if (book.registerReporter) editorReporters.add(socket);
|
|
435
|
+
// A real client writes via flipAttachedEver alone (hence the `else
|
|
436
|
+
// if`); `persist` is the write flag for rows that write as-is (reporter).
|
|
437
|
+
if (book.flipAttachedEver) update({ attachedEver: true });
|
|
438
|
+
else if (book.persist) update();
|
|
439
|
+
send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
|
|
315
440
|
break;
|
|
441
|
+
}
|
|
316
442
|
case "input":
|
|
317
443
|
if (typeof msg.data === "string") child.write(msg.data);
|
|
318
444
|
break;
|
|
319
445
|
case "resize": {
|
|
320
446
|
const cols = clampInt(msg.cols, 20, 300, host.cols);
|
|
321
447
|
const rows = clampInt(msg.rows, 5, 120, host.rows);
|
|
322
|
-
|
|
448
|
+
// Paired step (CR R1 advisory): model reflows only when the real PTY
|
|
449
|
+
// resize succeeded. host.cols/rows keep recording the intended size
|
|
450
|
+
// (new-client clamp baseline), deliberately outside the guard.
|
|
451
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
323
452
|
update({ cols, rows });
|
|
324
453
|
break;
|
|
325
454
|
}
|
|
326
455
|
case "interrupt":
|
|
327
456
|
child.write("\x1b");
|
|
328
457
|
break;
|
|
458
|
+
case "subscribe_terminal":
|
|
459
|
+
terminalSubscriptions.get(socket)?.handleMessage(msg);
|
|
460
|
+
break;
|
|
329
461
|
case "terminate": {
|
|
330
462
|
killChild(child, childPid, "SIGTERM");
|
|
331
463
|
setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
|
|
@@ -335,7 +467,7 @@ function legacyMain(config) {
|
|
|
335
467
|
socket.end();
|
|
336
468
|
break;
|
|
337
469
|
case "get_status":
|
|
338
|
-
send(socket, { type: "status", status: host });
|
|
470
|
+
send(socket, { type: "status", status: host, generation: GENERATION });
|
|
339
471
|
break;
|
|
340
472
|
case "editor_state": {
|
|
341
473
|
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
@@ -370,11 +502,19 @@ function legacyMain(config) {
|
|
|
370
502
|
if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
|
|
371
503
|
if (shutdownStarted) return;
|
|
372
504
|
shutdownStarted = true;
|
|
505
|
+
// Terminate lifecycle (spec D4): pending enveloped terminates get the
|
|
506
|
+
// runner-finalizing evidence before sockets end (exit-confirm flush in
|
|
507
|
+
// child.onExit covers the natural path; this covers deliberate shutdown).
|
|
508
|
+
control.flushTerminateObservations(null, true);
|
|
373
509
|
try { server?.close(); } catch {}
|
|
374
510
|
try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
|
|
375
511
|
for (const client of clients) {
|
|
376
512
|
try { client.end(); } catch {}
|
|
377
513
|
}
|
|
514
|
+
for (const reporter of editorReporters) {
|
|
515
|
+
try { reporter.end(); } catch { /* best effort */ }
|
|
516
|
+
}
|
|
517
|
+
editorReporters.clear();
|
|
378
518
|
killChild(child, childPid, "SIGTERM");
|
|
379
519
|
if (!(await waitForChildExit(4000)) && !childExited) {
|
|
380
520
|
killChild(child, childPid, "SIGKILL");
|
|
@@ -415,6 +555,8 @@ async function ownedMain(config) {
|
|
|
415
555
|
|
|
416
556
|
/** @type {Set<import("node:net").Socket>} */
|
|
417
557
|
const clients = new Set();
|
|
558
|
+
/** Resident editor-state reporters: connected but never "attached" (#103). */
|
|
559
|
+
const editorReporters = new Set();
|
|
418
560
|
let childPid = null;
|
|
419
561
|
let child = null;
|
|
420
562
|
let exitCode = null;
|
|
@@ -467,7 +609,10 @@ async function ownedMain(config) {
|
|
|
467
609
|
const ownedUpdate = (mutate) => {
|
|
468
610
|
const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
|
|
469
611
|
const next = mutate(cur);
|
|
470
|
-
|
|
612
|
+
// Record revision (phase 5 reconcile baseline): every committed fenced
|
|
613
|
+
// write bumps it, heartbeats included — a client comparing revisions
|
|
614
|
+
// sees any host-record movement, which is exactly the contract.
|
|
615
|
+
return { ...next, revision: (cur.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
471
616
|
});
|
|
472
617
|
if (result.updated && result.host) host = result.host;
|
|
473
618
|
return result;
|
|
@@ -514,6 +659,11 @@ async function ownedMain(config) {
|
|
|
514
659
|
async function finishHost(reason, requestedExitCode = null) {
|
|
515
660
|
if (shutdownStarted) return;
|
|
516
661
|
shutdownStarted = true;
|
|
662
|
+
// Terminate lifecycle (spec D4): pending enveloped terminates get the
|
|
663
|
+
// runner-finalizing evidence before teardown ends sockets. The child-exit
|
|
664
|
+
// flush (exitConfirmed) normally wins the race; whichever fires first
|
|
665
|
+
// clears the pending set, so exactly one observed ack goes out.
|
|
666
|
+
control.flushTerminateObservations(null, true);
|
|
517
667
|
if (heartbeatTimer) {
|
|
518
668
|
clearInterval(heartbeatTimer);
|
|
519
669
|
heartbeatTimer = null;
|
|
@@ -522,13 +672,17 @@ async function ownedMain(config) {
|
|
|
522
672
|
// superseded owner (or a duplicate that never claimed) never writes.
|
|
523
673
|
if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
|
|
524
674
|
ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
|
|
525
|
-
broadcast({ type: "status", status: host });
|
|
675
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
526
676
|
}
|
|
527
677
|
try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
|
|
528
678
|
for (const c of clients) {
|
|
529
679
|
try { c.destroy(); } catch { /* best effort */ }
|
|
530
680
|
}
|
|
531
681
|
clients.clear();
|
|
682
|
+
for (const reporter of editorReporters) {
|
|
683
|
+
try { reporter.destroy(); } catch { /* best effort */ }
|
|
684
|
+
}
|
|
685
|
+
editorReporters.clear();
|
|
532
686
|
// Bounded server close (1s): never let a stuck client block cleanup.
|
|
533
687
|
await new Promise((resolve) => {
|
|
534
688
|
if (!server) return resolve();
|
|
@@ -652,18 +806,79 @@ async function ownedMain(config) {
|
|
|
652
806
|
}
|
|
653
807
|
host = current;
|
|
654
808
|
|
|
809
|
+
// Canonical terminal model + per-socket snapshot subscriptions (issue #91
|
|
810
|
+
// phase 3). Created from the owned host record before the endpoint binds,
|
|
811
|
+
// so every connect (including probes) can carry a subscription. Legacy
|
|
812
|
+
// message semantics unchanged (the `seq` field on output is additive).
|
|
813
|
+
const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
|
|
814
|
+
/** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
|
|
815
|
+
const terminalSubscriptions = new Map();
|
|
816
|
+
// Control lifecycle runtime (issue #91 phase 5). Created after the terminal
|
|
817
|
+
// model (actions close over it) and BEFORE the endpoint binds, so the durable
|
|
818
|
+
// journal is loaded from disk before any client can reconcile. The action
|
|
819
|
+
// closures read `child`/`host` at call time (both are let-bound above).
|
|
820
|
+
const control = createControlRuntime({
|
|
821
|
+
viewId: config.viewId,
|
|
822
|
+
root: config.root,
|
|
823
|
+
instanceId: config.instanceId,
|
|
824
|
+
send,
|
|
825
|
+
diag,
|
|
826
|
+
actions: {
|
|
827
|
+
childReady: () => Boolean(child),
|
|
828
|
+
writeInput: (data) => {
|
|
829
|
+
child.write(data);
|
|
830
|
+
},
|
|
831
|
+
hostCols: () => host.cols,
|
|
832
|
+
hostRows: () => host.rows,
|
|
833
|
+
applyResize: (cols, rows) => {
|
|
834
|
+
// The runtime pre-checks childReady (host_starting reply otherwise),
|
|
835
|
+
// so this only runs against a live child — the starting window's
|
|
836
|
+
// cachedResize path belongs exclusively to the legacy switch below.
|
|
837
|
+
// Paired step (CR R1 advisory): model reflows only when the real
|
|
838
|
+
// PTY resize succeeded; cachedResize cleared either way — a failed
|
|
839
|
+
// resize is not retried against a exiting child.
|
|
840
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
841
|
+
notifyChildResize(childPid);
|
|
842
|
+
cachedResize = null;
|
|
843
|
+
ownedUpdate((cur) => ({ ...cur, cols, rows }));
|
|
844
|
+
},
|
|
845
|
+
currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
846
|
+
applyInterrupt: () => {
|
|
847
|
+
if (child) child.write("\x1b");
|
|
848
|
+
},
|
|
849
|
+
applyTerminate: () => {
|
|
850
|
+
// Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
|
|
851
|
+
// so a SIGTERM-immune child still terminates within a bounded window.
|
|
852
|
+
// The applied ack was already sent by the runtime BEFORE this hook.
|
|
853
|
+
finish("terminated", 0);
|
|
854
|
+
},
|
|
855
|
+
applyDetach: (s) => {
|
|
856
|
+
s.end();
|
|
857
|
+
},
|
|
858
|
+
hostRevision: () => host.revision ?? 0,
|
|
859
|
+
cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
860
|
+
stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
|
|
655
864
|
// 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
|
|
656
865
|
// instance; an occupied path means someone else owns it.
|
|
657
866
|
const listenOutcome = await new Promise((resolveListen) => {
|
|
658
867
|
const onError = (err) => resolveListen({ ok: false, error: err });
|
|
659
868
|
// Probe connections (clientId:"probe" hello) must not write host.json: the
|
|
660
869
|
// attach resolver's 150ms probe loop would amplify fenced writes and flip
|
|
661
|
-
// attachedEver with no client ever attached (CR round-1 finding 3).
|
|
870
|
+
// attachedEver with no client ever attached (CR round-1 finding 3). The
|
|
871
|
+
// "not counted" half is handled at hello time instead (#130): the socket
|
|
872
|
+
// leaves `clients` there, so this WeakSet only suppresses the write.
|
|
662
873
|
const probeSockets = new WeakSet();
|
|
663
874
|
server = createServer((socket) => {
|
|
664
875
|
clients.add(socket);
|
|
665
|
-
|
|
666
|
-
|
|
876
|
+
terminalSubscriptions.set(
|
|
877
|
+
socket,
|
|
878
|
+
createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION }),
|
|
879
|
+
);
|
|
880
|
+
socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
|
|
881
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
667
882
|
let buffer = "";
|
|
668
883
|
socket.on("data", (chunk) => {
|
|
669
884
|
buffer += chunk.toString("utf8");
|
|
@@ -673,6 +888,9 @@ async function ownedMain(config) {
|
|
|
673
888
|
});
|
|
674
889
|
socket.on("close", () => {
|
|
675
890
|
clients.delete(socket);
|
|
891
|
+
editorReporters.delete(socket);
|
|
892
|
+
terminalSubscriptions.delete(socket);
|
|
893
|
+
control.closeSocket(socket);
|
|
676
894
|
if (probeSockets.has(socket)) return;
|
|
677
895
|
// Merge into the live record (a stale closure spread here erases a
|
|
678
896
|
// concurrent revoke — final review finding 2).
|
|
@@ -680,6 +898,9 @@ async function ownedMain(config) {
|
|
|
680
898
|
});
|
|
681
899
|
socket.on("error", () => {
|
|
682
900
|
clients.delete(socket);
|
|
901
|
+
editorReporters.delete(socket);
|
|
902
|
+
terminalSubscriptions.delete(socket);
|
|
903
|
+
control.closeSocket(socket);
|
|
683
904
|
if (probeSockets.has(socket)) return;
|
|
684
905
|
ownedUpdate((cur) => ({ ...cur }));
|
|
685
906
|
});
|
|
@@ -721,7 +942,7 @@ async function ownedMain(config) {
|
|
|
721
942
|
return;
|
|
722
943
|
}
|
|
723
944
|
claimedRecord = true;
|
|
724
|
-
broadcast({ type: "status", status: host });
|
|
945
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
725
946
|
|
|
726
947
|
// Test seam (issue #70 Task 8): a config-level delay between publishing the
|
|
727
948
|
// runner identity and spawning the child creates a REAL starting window
|
|
@@ -743,6 +964,9 @@ async function ownedMain(config) {
|
|
|
743
964
|
AGENT_BOARD_VIEW_ID: config.viewId,
|
|
744
965
|
AGENT_BOARD_CHILD: "1",
|
|
745
966
|
AGENT_BOARD_HOSTED: "pty",
|
|
967
|
+
// The endpoint this host actually bound: the child's editor-state reporter
|
|
968
|
+
// dials it instead of guessing the stable per-view address (issue #103).
|
|
969
|
+
AGENT_BOARD_CONTROL_SOCKET: socketPath,
|
|
746
970
|
// Legacy names are exported too so older child extension builds still behave.
|
|
747
971
|
AGENT_VIEW_ROOT: config.root,
|
|
748
972
|
AGENT_VIEW_VIEW_ID: config.viewId,
|
|
@@ -767,12 +991,25 @@ async function ownedMain(config) {
|
|
|
767
991
|
childPid = child.pid ?? null;
|
|
768
992
|
child.onData((data) => {
|
|
769
993
|
screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
|
|
770
|
-
|
|
994
|
+
const outputSeq = feedOutput(terminalModel, data);
|
|
995
|
+
// Per-socket output delivery — same contract as legacyMain above.
|
|
996
|
+
const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data }) + "\n";
|
|
997
|
+
for (const [socket, sub] of terminalSubscriptions) {
|
|
998
|
+
if (sub.subscribed()) sub.onOutput(outputSeq, data);
|
|
999
|
+
// Same per-socket guard the legacy broadcast had: a synchronous throw
|
|
1000
|
+
// from a dead socket must never escape into the uncaughtException
|
|
1001
|
+
// crash path (whole-branch review finding).
|
|
1002
|
+
else { try { socket.write(outputLine); } catch { /* socket 'error' handler cleans up */ } }
|
|
1003
|
+
}
|
|
771
1004
|
});
|
|
772
1005
|
child.onExit((code) => {
|
|
773
1006
|
childExited = true;
|
|
774
1007
|
resolveChildExit?.();
|
|
775
1008
|
exitCode = code ?? 0;
|
|
1009
|
+
// Terminate lifecycle (spec D4): a confirmed child exit is the observed
|
|
1010
|
+
// evidence for any enveloped terminate still pending — flush before the
|
|
1011
|
+
// finish teardown ends the client sockets.
|
|
1012
|
+
control.flushTerminateObservations(exitCode, false);
|
|
776
1013
|
editorEmpty = null;
|
|
777
1014
|
broadcast({ type: "editor_state", empty: null });
|
|
778
1015
|
finish("child_exit", exitCode);
|
|
@@ -798,7 +1035,7 @@ async function ownedMain(config) {
|
|
|
798
1035
|
await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
|
|
799
1036
|
return;
|
|
800
1037
|
}
|
|
801
|
-
broadcast({ type: "status", status: host });
|
|
1038
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
802
1039
|
|
|
803
1040
|
// A starting-window resize cannot be applied at spawn-return nor at first
|
|
804
1041
|
// output: the child node process is still bootstrapping and a SIGWINCH
|
|
@@ -809,7 +1046,8 @@ async function ownedMain(config) {
|
|
|
809
1046
|
if (cachedResize) {
|
|
810
1047
|
const applyHeld = setTimeout(() => {
|
|
811
1048
|
if (!cachedResize || !child || shutdownStarted) return;
|
|
812
|
-
|
|
1049
|
+
// Paired step (CR R1 advisory): model follows the real PTY, never leads.
|
|
1050
|
+
resizeChildAndModel(child, terminalModel, cachedResize.cols, cachedResize.rows);
|
|
813
1051
|
notifyChildResize(childPid);
|
|
814
1052
|
cachedResize = null;
|
|
815
1053
|
}, 500);
|
|
@@ -846,19 +1084,41 @@ async function ownedMain(config) {
|
|
|
846
1084
|
if (!line.trim()) return;
|
|
847
1085
|
let msg;
|
|
848
1086
|
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
1087
|
+
// Enveloped control commands take the phase-5 lifecycle path; everything
|
|
1088
|
+
// else (no commandId, or a non-control type carrying one — e.g.
|
|
1089
|
+
// subscribe_terminal with correlation metadata) falls through to the
|
|
1090
|
+
// legacy switch byte-identically.
|
|
1091
|
+
const envelope = validateCommandEnvelope(msg);
|
|
1092
|
+
if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
|
|
1093
|
+
control.handle(msg, socket, envelope);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
849
1096
|
switch (msg.type) {
|
|
850
|
-
case "hello":
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1097
|
+
case "hello": {
|
|
1098
|
+
// Same bookkeeping contract as the legacy path (issue #103 §C /
|
|
1099
|
+
// #130): probes and the resident editor reporter are
|
|
1100
|
+
// bookkeeping-only — neither may flip attachedEver nor keep
|
|
1101
|
+
// attachedClients non-zero, or warm-host reclaim never fires and
|
|
1102
|
+
// hosts leak. The policy lives in `helloBookkeeping`.
|
|
1103
|
+
const book = helloBookkeeping(classifyClientHello(msg));
|
|
1104
|
+
if (!book.keepInClients) {
|
|
1105
|
+
clients.delete(socket);
|
|
1106
|
+
terminalSubscriptions.delete(socket);
|
|
859
1107
|
}
|
|
860
|
-
|
|
1108
|
+
if (book.registerReporter) editorReporters.add(socket);
|
|
1109
|
+
// The probe's close-time host.json refresh stays suppressed: the
|
|
1110
|
+
// resolver probes every HOST_PROBE_RETRY_MS, so flushing there
|
|
1111
|
+
// would amplify fenced writes (see the probeSockets comment at the
|
|
1112
|
+
// listen block). Being out of `clients` is what keeps the count
|
|
1113
|
+
// right — this only avoids a pointless write.
|
|
1114
|
+
if (book.suppressCloseWrite) socket.markProbe?.();
|
|
1115
|
+
// A real client writes via flipAttachedEver alone (hence the `else
|
|
1116
|
+
// if`); `persist` is the write flag for rows that write as-is (reporter).
|
|
1117
|
+
if (book.flipAttachedEver) ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
|
|
1118
|
+
else if (book.persist) ownedUpdate((cur) => ({ ...cur }));
|
|
1119
|
+
send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
|
|
861
1120
|
break;
|
|
1121
|
+
}
|
|
862
1122
|
case "input": {
|
|
863
1123
|
if (typeof msg.data !== "string") break;
|
|
864
1124
|
if (typeof msg.requestId !== "string" || !msg.requestId) {
|
|
@@ -886,7 +1146,10 @@ async function ownedMain(config) {
|
|
|
886
1146
|
const cols = clampInt(msg.cols, 20, 300, host.cols);
|
|
887
1147
|
const rows = clampInt(msg.rows, 5, 120, host.rows);
|
|
888
1148
|
if (child) {
|
|
889
|
-
|
|
1149
|
+
// Paired step (CR R1 advisory): model reflows only when the real
|
|
1150
|
+
// PTY resize succeeded; cachedResize cleared either way — a failed
|
|
1151
|
+
// resize is not retried against a exiting child.
|
|
1152
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
890
1153
|
notifyChildResize(childPid);
|
|
891
1154
|
cachedResize = null;
|
|
892
1155
|
} else {
|
|
@@ -898,6 +1161,9 @@ async function ownedMain(config) {
|
|
|
898
1161
|
case "interrupt":
|
|
899
1162
|
if (child) child.write("\x1b");
|
|
900
1163
|
break;
|
|
1164
|
+
case "subscribe_terminal":
|
|
1165
|
+
terminalSubscriptions.get(socket)?.handleMessage(msg);
|
|
1166
|
+
break;
|
|
901
1167
|
case "terminate":
|
|
902
1168
|
// Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
|
|
903
1169
|
// so a SIGTERM-immune child still terminates within a bounded window.
|
|
@@ -907,7 +1173,7 @@ async function ownedMain(config) {
|
|
|
907
1173
|
socket.end();
|
|
908
1174
|
break;
|
|
909
1175
|
case "get_status":
|
|
910
|
-
send(socket, { type: "status", status: host });
|
|
1176
|
+
send(socket, { type: "status", status: host, generation: GENERATION });
|
|
911
1177
|
break;
|
|
912
1178
|
case "editor_state": {
|
|
913
1179
|
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
@@ -1004,6 +1270,342 @@ function clampInt(value, min, max, fallback) {
|
|
|
1004
1270
|
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
1005
1271
|
}
|
|
1006
1272
|
|
|
1273
|
+
// ---------------------------------------------------------------------------
|
|
1274
|
+
// Control-command lifecycle runtime (issue #91 phase 5, spec D4). Pure
|
|
1275
|
+
// decision logic lives in src/core/control-protocol.mjs; this is the runner's
|
|
1276
|
+
// serial side-effect shell: per-socket seq tracking, the durable command
|
|
1277
|
+
// journal, resize latest-wins routing, and staged ack emission. Messages
|
|
1278
|
+
// WITHOUT an envelope (no commandId) never reach this code — the legacy
|
|
1279
|
+
// switch keeps byte-identical behavior.
|
|
1280
|
+
// ---------------------------------------------------------------------------
|
|
1281
|
+
|
|
1282
|
+
/** Load journal records, dropping torn tail lines from a crash mid-append. */
|
|
1283
|
+
function loadJournalRecords(path) {
|
|
1284
|
+
try {
|
|
1285
|
+
const records = [];
|
|
1286
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1287
|
+
if (!line.trim()) continue;
|
|
1288
|
+
try {
|
|
1289
|
+
records.push(JSON.parse(line));
|
|
1290
|
+
} catch {
|
|
1291
|
+
/* torn tail: the accepted/applied truth for that line is unknown —
|
|
1292
|
+
dropping it can only widen the accepted_unknown set, never hide one */
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return records;
|
|
1296
|
+
} catch {
|
|
1297
|
+
return [];
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* One control runtime per main. `actions` bundles the main-specific side
|
|
1303
|
+
* effects (child shape differs between the legacy and owned lifecycles); the
|
|
1304
|
+
* decision order here is shared and binding:
|
|
1305
|
+
* envelope validity → instance fence → seq monotonicity → per-type dispatch.
|
|
1306
|
+
* @param {{
|
|
1307
|
+
* viewId: string, root: string, instanceId: string|null,
|
|
1308
|
+
* send: (socket: import("node:net").Socket, msg: object) => void,
|
|
1309
|
+
* diag: (code: string, message: string, details?: object) => void,
|
|
1310
|
+
* actions: {
|
|
1311
|
+
* childReady(): boolean,
|
|
1312
|
+
* writeInput(data: string): void,
|
|
1313
|
+
* hostCols(): number, hostRows(): number,
|
|
1314
|
+
* applyResize(cols: number, rows: number): void, // runtime pre-checks childReady — never called without a child
|
|
1315
|
+
* currentDims(): { cols: number, rows: number },
|
|
1316
|
+
* applyInterrupt(): void,
|
|
1317
|
+
* applyTerminate(): void,
|
|
1318
|
+
* applyDetach(socket: import("node:net").Socket): void,
|
|
1319
|
+
* hostRevision(): number,
|
|
1320
|
+
* cursor(): { lastSeq: number, cols: number, rows: number },
|
|
1321
|
+
* stateStamp(): number | null,
|
|
1322
|
+
* },
|
|
1323
|
+
* }} opts
|
|
1324
|
+
*/
|
|
1325
|
+
function createControlRuntime({ viewId, root, instanceId, send, diag, actions }) {
|
|
1326
|
+
/** Per-connection seq watermark (ordering aid only — never dedup). */
|
|
1327
|
+
const connectionSeq = new Map();
|
|
1328
|
+
const journalPath = P.controlJournalPath(root, viewId);
|
|
1329
|
+
const journalRecords = loadJournalRecords(journalPath);
|
|
1330
|
+
/** O(1) journal dedup — rebuilt after GC so it mirrors the retained records. */
|
|
1331
|
+
const journalIds = new Set(journalRecords.map((r) => r.commandId));
|
|
1332
|
+
const resizes = createResizeTracker();
|
|
1333
|
+
/** commandId → socket awaiting the resize ack (superseded/applied routing). */
|
|
1334
|
+
const pendingResizeSockets = new Map();
|
|
1335
|
+
/** commandId → socket that requested terminate (observed routing). */
|
|
1336
|
+
const pendingTerminates = new Map();
|
|
1337
|
+
|
|
1338
|
+
const rawAppend = (record) => {
|
|
1339
|
+
appendFileSync(journalPath, JSON.stringify(record) + "\n");
|
|
1340
|
+
};
|
|
1341
|
+
const maybeGcJournal = () => {
|
|
1342
|
+
if (journalRecords.length <= JOURNAL_GC_TRIGGER) return;
|
|
1343
|
+
const kept = journalGc(journalRecords, JOURNAL_KEEP_DEFAULT);
|
|
1344
|
+
journalRecords.length = 0;
|
|
1345
|
+
journalRecords.push(...kept);
|
|
1346
|
+
journalIds.clear();
|
|
1347
|
+
for (const record of kept) journalIds.add(record.commandId);
|
|
1348
|
+
try {
|
|
1349
|
+
writeFileSync(journalPath, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
diag("control_journal_rewrite_failed", err instanceof Error ? err.message : String(err));
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
/** @returns {boolean} false when the append failed — the command is NOT accepted. */
|
|
1355
|
+
const journalAccept = (commandId, command, now) => {
|
|
1356
|
+
// Ruling (b): a journaled commandId is NEVER re-appended — the dedup
|
|
1357
|
+
// check is the caller's, and this guard is the last line of defense.
|
|
1358
|
+
if (journalIds.has(commandId)) return true;
|
|
1359
|
+
const record = { kind: "accepted", commandId, command, acceptedAt: now };
|
|
1360
|
+
journalAppendRecord(journalRecords, record); // shape validation; throws on a runner bug
|
|
1361
|
+
try {
|
|
1362
|
+
rawAppend(record);
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
// Nothing was pushed into journalRecords for this record (pure
|
|
1365
|
+
// validate + manual push on success), so there is nothing to unwind —
|
|
1366
|
+
// the command is simply not accepted.
|
|
1367
|
+
diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1370
|
+
journalRecords.push(record);
|
|
1371
|
+
journalIds.add(commandId);
|
|
1372
|
+
maybeGcJournal();
|
|
1373
|
+
return true;
|
|
1374
|
+
};
|
|
1375
|
+
const journalApply = (commandId, now) => {
|
|
1376
|
+
const record = { kind: "applied", commandId, appliedAt: now };
|
|
1377
|
+
try {
|
|
1378
|
+
rawAppend(record);
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
journalRecords.push(record);
|
|
1384
|
+
maybeGcJournal();
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
/** Socket-death semantics for every ack: a dead client must never turn a
|
|
1388
|
+
* control reply into a runner crash (same invariant as the broadcast guard). */
|
|
1389
|
+
const reply = (socket, msg) => {
|
|
1390
|
+
try {
|
|
1391
|
+
send(socket, msg);
|
|
1392
|
+
} catch {
|
|
1393
|
+
/* socket 'error'/'close' handler owns cleanup */
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
const flushTerminateObservations = (exitCode, runnerFinalizing) => {
|
|
1398
|
+
for (const [commandId, termSocket] of pendingTerminates) {
|
|
1399
|
+
reply(termSocket, {
|
|
1400
|
+
type: "cmd_ack",
|
|
1401
|
+
commandId,
|
|
1402
|
+
stage: "observed",
|
|
1403
|
+
...(exitCode != null ? { exitConfirmed: true, exitCode } : {}),
|
|
1404
|
+
...(runnerFinalizing ? { runnerFinalizing: true } : {}),
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
pendingTerminates.clear();
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Dispatch an enveloped control command. Caller guarantees
|
|
1412
|
+
* `env.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)`.
|
|
1413
|
+
*
|
|
1414
|
+
* Crash containment: a throwing action (e.g. child.write on a pty that died
|
|
1415
|
+
* mid-command) must degrade to an error reply + diagnostic, never escape
|
|
1416
|
+
* into the runner's uncaughtException path — the legacy switch predates this
|
|
1417
|
+
* invariant, but new protocol code must not inherit that crash class.
|
|
1418
|
+
*/
|
|
1419
|
+
const handle = (msg, socket, env) => {
|
|
1420
|
+
// (d) errors are only meaningful for enveloped messages — this function is
|
|
1421
|
+
// only entered for enveloped control types, so every error below is legal.
|
|
1422
|
+
if (env.errors.length > 0) {
|
|
1423
|
+
reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: env.errors });
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
try {
|
|
1427
|
+
handleChecked(msg, socket, env);
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
diag("control_command_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId, type: msg.type });
|
|
1430
|
+
reply(socket, { type: "error", code: "command_failed", commandId: msg.commandId });
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
const handleChecked = (msg, socket, env) => {
|
|
1435
|
+
// (a) instance fencing, reject-with-current: a client naming a DIFFERENT
|
|
1436
|
+
// instance is talking across a runner replacement (stale socket race);
|
|
1437
|
+
// silently applying its commands would resurrect phantom controls — the
|
|
1438
|
+
// exact class #70 fencing exists to prevent. The error carries the current
|
|
1439
|
+
// instanceId so a well-behaved client re-hellos and recovers. Legacy-mode
|
|
1440
|
+
// hosts (instanceId null) fence EVERY envelope: an envelope claims an
|
|
1441
|
+
// instance a legacy-mode runner never had.
|
|
1442
|
+
if (msg.instanceId !== instanceId) {
|
|
1443
|
+
diag("instance_mismatch", "control command carries a foreign instanceId", {
|
|
1444
|
+
commandId: msg.commandId,
|
|
1445
|
+
got: msg.instanceId,
|
|
1446
|
+
current: instanceId,
|
|
1447
|
+
});
|
|
1448
|
+
reply(socket, { type: "error", code: "instance_mismatch", commandId: msg.commandId, currentInstanceId: instanceId });
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
// (c) per-connection monotonic seq: an ordering aid. Repeats/regressions
|
|
1452
|
+
// are dropped with a diagnostic — NOT a dedup signal (dedup keys on
|
|
1453
|
+
// commandId only); a legitimate re-send after reconnect uses a new socket.
|
|
1454
|
+
const lastSeq = connectionSeq.get(socket) ?? 0;
|
|
1455
|
+
const seqVerdict = checkSeq(lastSeq, msg.seq);
|
|
1456
|
+
if (!seqVerdict.ok) {
|
|
1457
|
+
diag("seq_out_of_order", "control command dropped for non-monotonic seq", {
|
|
1458
|
+
commandId: msg.commandId,
|
|
1459
|
+
seq: msg.seq,
|
|
1460
|
+
lastSeq,
|
|
1461
|
+
});
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
connectionSeq.set(socket, msg.seq);
|
|
1465
|
+
|
|
1466
|
+
switch (msg.type) {
|
|
1467
|
+
case "input": {
|
|
1468
|
+
if (typeof msg.data !== "string") {
|
|
1469
|
+
reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: ["input_data_missing"] });
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (msg.durable !== true) {
|
|
1473
|
+
// Keystroke with a correlation id: fire-and-forget preserved — no
|
|
1474
|
+
// ack, no journal (journal writes must never sit on the keystroke
|
|
1475
|
+
// path). The envelope is correlation/debugging metadata only.
|
|
1476
|
+
if (actions.childReady()) actions.writeInput(msg.data);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
// Durable follow-up: accepted (journaled) → applied (written). A
|
|
1480
|
+
// re-send of a journaled commandId returns the cached final stage —
|
|
1481
|
+
// never re-appended (ruling b), never re-written (commandId dedup).
|
|
1482
|
+
if (journalIds.has(msg.commandId)) {
|
|
1483
|
+
const wasApplied = journalRecords.some((r) => r.kind === "applied" && r.commandId === msg.commandId);
|
|
1484
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: wasApplied ? "applied" : "accepted", durable: true });
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (!actions.childReady()) {
|
|
1488
|
+
// Starting window: nothing accepted (matches the requestId path's
|
|
1489
|
+
// host_starting contract; the service retries with its own policy).
|
|
1490
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (!journalAccept(msg.commandId, msg.data, Date.now())) {
|
|
1494
|
+
// accepted means JOURNALED — without the journal entry the stage
|
|
1495
|
+
// would be a lie, so the command is refused, not silently applied.
|
|
1496
|
+
reply(socket, { type: "error", code: "journal_unavailable", commandId: msg.commandId });
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "accepted", durable: true });
|
|
1500
|
+
try {
|
|
1501
|
+
actions.writeInput(msg.data);
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
// §10 honesty: accepted but the write failed — the command stays
|
|
1504
|
+
// accepted_unknown; reconcile reports it; never auto-replayed.
|
|
1505
|
+
diag("input_apply_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId });
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
journalApply(msg.commandId, Date.now());
|
|
1509
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
case "resize": {
|
|
1513
|
+
// Uniform starting-window contract with input: without a child there
|
|
1514
|
+
// is nothing to resize and no honest applied value — the client
|
|
1515
|
+
// retries (new commandId). This also keeps latest-wins tracking off
|
|
1516
|
+
// the books for commands that never applied.
|
|
1517
|
+
if (!actions.childReady()) {
|
|
1518
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
const cols = clampInt(msg.cols, 20, 300, actions.hostCols());
|
|
1522
|
+
const rows = clampInt(msg.rows, 5, 120, actions.hostRows());
|
|
1523
|
+
const tracked = resizes.track({ commandId: msg.commandId, clientId: msg.clientId, cols, rows });
|
|
1524
|
+
for (const sup of tracked.superseded) {
|
|
1525
|
+
const oldSocket = pendingResizeSockets.get(sup.commandId) ?? socket;
|
|
1526
|
+
pendingResizeSockets.delete(sup.commandId);
|
|
1527
|
+
reply(oldSocket, { type: "cmd_ack", commandId: sup.commandId, stage: "superseded", byCommandId: sup.byCommandId });
|
|
1528
|
+
}
|
|
1529
|
+
if (tracked.duplicate) {
|
|
1530
|
+
// Same commandId re-request: applyResize is synchronous, so a
|
|
1531
|
+
// duplicate always finds the command already applied — return the
|
|
1532
|
+
// cached result. (CR R1 advisory: the pendingResizeSockets entry is
|
|
1533
|
+
// deliberately NOT set here — the original resolved synchronously,
|
|
1534
|
+
// so a re-pointed entry would be a stale leak.)
|
|
1535
|
+
const cached = resizes.resultFor(msg.commandId);
|
|
1536
|
+
if (cached) reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: cached.cols, rows: cached.rows });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
pendingResizeSockets.set(msg.commandId, socket);
|
|
1540
|
+
actions.applyResize(cols, rows);
|
|
1541
|
+
// The paired resize step keeps the model mirroring the REAL PTY
|
|
1542
|
+
// (reflow only on successful child.resize), so the model's dims are
|
|
1543
|
+
// the honest applied value — even when the PTY kept its old geometry.
|
|
1544
|
+
const dims = actions.currentDims();
|
|
1545
|
+
resizes.applied(msg.commandId, dims.cols, dims.rows);
|
|
1546
|
+
pendingResizeSockets.delete(msg.commandId);
|
|
1547
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: dims.cols, rows: dims.rows });
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
case "interrupt": {
|
|
1551
|
+
// Starting-window honesty (CR R1 blocking): applyInterrupt no-ops
|
|
1552
|
+
// without a child, so an unconditional applied ack would be a lie —
|
|
1553
|
+
// same contract as input/resize. The client consumes host_starting
|
|
1554
|
+
// as a cmdAck error (no retry chain: interrupt is transient and
|
|
1555
|
+
// user-timed; a re-send carries a fresh commandId).
|
|
1556
|
+
if (!actions.childReady()) {
|
|
1557
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
actions.applyInterrupt();
|
|
1561
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
case "terminate": {
|
|
1565
|
+
// Idempotent: a repeat while the termination is in flight returns the
|
|
1566
|
+
// current stage instead of claiming a fresh start.
|
|
1567
|
+
if (pendingTerminates.has(msg.commandId)) {
|
|
1568
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
pendingTerminates.set(msg.commandId, socket);
|
|
1572
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
|
|
1573
|
+
actions.applyTerminate();
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
case "detach": {
|
|
1577
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "detach_accepted" });
|
|
1578
|
+
actions.applyDetach(socket);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
case "reconcile": {
|
|
1582
|
+
reply(socket, {
|
|
1583
|
+
type: "reconcile_result",
|
|
1584
|
+
commandId: msg.commandId,
|
|
1585
|
+
generation: GENERATION,
|
|
1586
|
+
hostRevision: actions.hostRevision(),
|
|
1587
|
+
terminalCursor: actions.cursor(),
|
|
1588
|
+
stateMaterializedRevision: actions.stateStamp(),
|
|
1589
|
+
unresolved: journalUnresolved(journalRecords).map((u) => ({ ...u, status: "accepted_unknown" })),
|
|
1590
|
+
});
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
/** Socket teardown: drop the seq watermark and any ack-routing entries. */
|
|
1597
|
+
const closeSocket = (socket) => {
|
|
1598
|
+
connectionSeq.delete(socket);
|
|
1599
|
+
for (const [commandId, sock] of pendingResizeSockets) if (sock === socket) pendingResizeSockets.delete(commandId);
|
|
1600
|
+
for (const [commandId, sock] of pendingTerminates) if (sock === socket) pendingTerminates.delete(commandId);
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
// (completeHeldResize was removed as dead code: the starting-window resize
|
|
1604
|
+
// path rejects with host_starting instead of holding, and applyResize is
|
|
1605
|
+
// synchronous — there is no held-resize completion path. Task 2 review P2-C.)
|
|
1606
|
+
return { handle, closeSocket, flushTerminateObservations, journalIds };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1007
1609
|
/** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
|
|
1008
1610
|
* across exec(2). Recorded in published identities so service-side recovery
|
|
1009
1611
|
* (issue #70 Task 11) can tell an owned-live pid from a reused one; null on
|