@zhuxixi/pi-agent-board 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
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,8 +22,23 @@ 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";
|
|
25
|
-
import {
|
|
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";
|
|
38
|
+
import { readHost, readState, updateOwnedHost, writeHost } from "../src/core/store.mjs";
|
|
39
|
+
import { sendStateCommand } from "../src/core/coordinator-client.mjs";
|
|
40
|
+
import { classifyClientHello } from "../src/core/host-protocol.mjs";
|
|
41
|
+
import { markRowFailedDirect } from "./pty-runner-legacy.mjs";
|
|
26
42
|
import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
|
|
27
43
|
|
|
28
44
|
const requireForPty = createRequire(import.meta.url);
|
|
@@ -40,6 +56,14 @@ const HEARTBEAT_MS = 1000;
|
|
|
40
56
|
const HOST_ACK_DEDUP_MAX = 1000;
|
|
41
57
|
/** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
|
|
42
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;
|
|
43
67
|
/** How much of the screen.log tail to scan when attributing an abnormal child
|
|
44
68
|
* exit (issue #90). Tail-only: the log can be 100MB+. */
|
|
45
69
|
const EXIT_LOG_TAIL_BYTES = 8_192;
|
|
@@ -117,6 +141,8 @@ function legacyMain(config) {
|
|
|
117
141
|
|
|
118
142
|
/** @type {Set<import("node:net").Socket>} */
|
|
119
143
|
const clients = new Set();
|
|
144
|
+
/** Resident editor-state reporters: connected but never "attached" (#103). */
|
|
145
|
+
const editorReporters = new Set();
|
|
120
146
|
let childPid = null;
|
|
121
147
|
let child = null;
|
|
122
148
|
let exitCode = null;
|
|
@@ -153,7 +179,57 @@ function legacyMain(config) {
|
|
|
153
179
|
rows: config.rows || 36,
|
|
154
180
|
attachedClients: 0,
|
|
155
181
|
attachedEver: false,
|
|
182
|
+
revision: 0,
|
|
156
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
|
+
});
|
|
157
233
|
/** Persist host.json. A transient failure (e.g. Windows rename EPERM racing a
|
|
158
234
|
* reader) must degrade, not kill the host: record a diagnostic and let the
|
|
159
235
|
* next heartbeat tick retry. Socket protocol is the attach main channel, so
|
|
@@ -177,9 +253,9 @@ function legacyMain(config) {
|
|
|
177
253
|
for (const c of clients) c.write(line);
|
|
178
254
|
};
|
|
179
255
|
const update = (patch = {}) => {
|
|
180
|
-
host = { ...host, ...patch, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
256
|
+
host = { ...host, ...patch, revision: (host.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
181
257
|
persist();
|
|
182
|
-
broadcast({ type: "status", status: host });
|
|
258
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
183
259
|
};
|
|
184
260
|
persist();
|
|
185
261
|
|
|
@@ -222,6 +298,9 @@ function legacyMain(config) {
|
|
|
222
298
|
AGENT_BOARD_VIEW_ID: config.viewId,
|
|
223
299
|
AGENT_BOARD_CHILD: "1",
|
|
224
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,
|
|
225
304
|
// Legacy names are exported too so older child extension builds still behave.
|
|
226
305
|
AGENT_VIEW_ROOT: config.root,
|
|
227
306
|
AGENT_VIEW_VIEW_ID: config.viewId,
|
|
@@ -240,20 +319,39 @@ function legacyMain(config) {
|
|
|
240
319
|
} catch (err) {
|
|
241
320
|
const message = err instanceof Error ? err.message : String(err);
|
|
242
321
|
update({ state: "failed", endedAt: Date.now(), exitCode: 1, error: message });
|
|
243
|
-
|
|
244
|
-
|
|
322
|
+
// The command settles within the client's own timeout (never throws), so
|
|
323
|
+
// the exit stays bounded while the fenced write gets its chance. Return
|
|
324
|
+
// instead of falling through: child is null here and the rest of this
|
|
325
|
+
// function assumes a spawned child.
|
|
326
|
+
void markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`).finally(() => process.exit(1));
|
|
327
|
+
return;
|
|
245
328
|
}
|
|
246
329
|
childPid = child.pid ?? null;
|
|
247
330
|
update({ childPid });
|
|
248
331
|
|
|
249
332
|
child.onData((data) => {
|
|
250
333
|
screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
|
|
251
|
-
|
|
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
|
+
}
|
|
252
347
|
});
|
|
253
348
|
child.onExit((code) => {
|
|
254
349
|
childExited = true;
|
|
255
350
|
resolveChildExit?.();
|
|
256
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);
|
|
257
355
|
// After a crash the handler already persisted "failed" and broadcast
|
|
258
356
|
// exit; this callback must not overwrite that state.
|
|
259
357
|
if (!crashed) {
|
|
@@ -275,8 +373,11 @@ function legacyMain(config) {
|
|
|
275
373
|
let server;
|
|
276
374
|
server = createServer((socket) => {
|
|
277
375
|
clients.add(socket);
|
|
278
|
-
|
|
279
|
-
|
|
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");
|
|
280
381
|
let buffer = "";
|
|
281
382
|
socket.on("data", (chunk) => {
|
|
282
383
|
buffer += chunk.toString("utf8");
|
|
@@ -286,10 +387,16 @@ function legacyMain(config) {
|
|
|
286
387
|
});
|
|
287
388
|
socket.on("close", () => {
|
|
288
389
|
clients.delete(socket);
|
|
390
|
+
editorReporters.delete(socket);
|
|
391
|
+
terminalSubscriptions.delete(socket);
|
|
392
|
+
control.closeSocket(socket);
|
|
289
393
|
update();
|
|
290
394
|
});
|
|
291
395
|
socket.on("error", () => {
|
|
292
396
|
clients.delete(socket);
|
|
397
|
+
editorReporters.delete(socket);
|
|
398
|
+
terminalSubscriptions.delete(socket);
|
|
399
|
+
control.closeSocket(socket);
|
|
293
400
|
update();
|
|
294
401
|
});
|
|
295
402
|
});
|
|
@@ -303,23 +410,50 @@ function legacyMain(config) {
|
|
|
303
410
|
if (!line.trim()) return;
|
|
304
411
|
let msg;
|
|
305
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
|
+
}
|
|
306
421
|
switch (msg.type) {
|
|
307
|
-
case "hello":
|
|
308
|
-
|
|
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 });
|
|
309
436
|
break;
|
|
437
|
+
}
|
|
310
438
|
case "input":
|
|
311
439
|
if (typeof msg.data === "string") child.write(msg.data);
|
|
312
440
|
break;
|
|
313
441
|
case "resize": {
|
|
314
442
|
const cols = clampInt(msg.cols, 20, 300, host.cols);
|
|
315
443
|
const rows = clampInt(msg.rows, 5, 120, host.rows);
|
|
316
|
-
|
|
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);
|
|
317
448
|
update({ cols, rows });
|
|
318
449
|
break;
|
|
319
450
|
}
|
|
320
451
|
case "interrupt":
|
|
321
452
|
child.write("\x1b");
|
|
322
453
|
break;
|
|
454
|
+
case "subscribe_terminal":
|
|
455
|
+
terminalSubscriptions.get(socket)?.handleMessage(msg);
|
|
456
|
+
break;
|
|
323
457
|
case "terminate": {
|
|
324
458
|
killChild(child, childPid, "SIGTERM");
|
|
325
459
|
setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
|
|
@@ -329,7 +463,7 @@ function legacyMain(config) {
|
|
|
329
463
|
socket.end();
|
|
330
464
|
break;
|
|
331
465
|
case "get_status":
|
|
332
|
-
send(socket, { type: "status", status: host });
|
|
466
|
+
send(socket, { type: "status", status: host, generation: GENERATION });
|
|
333
467
|
break;
|
|
334
468
|
case "editor_state": {
|
|
335
469
|
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
@@ -364,11 +498,19 @@ function legacyMain(config) {
|
|
|
364
498
|
if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
|
|
365
499
|
if (shutdownStarted) return;
|
|
366
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);
|
|
367
505
|
try { server?.close(); } catch {}
|
|
368
506
|
try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
|
|
369
507
|
for (const client of clients) {
|
|
370
508
|
try { client.end(); } catch {}
|
|
371
509
|
}
|
|
510
|
+
for (const reporter of editorReporters) {
|
|
511
|
+
try { reporter.end(); } catch { /* best effort */ }
|
|
512
|
+
}
|
|
513
|
+
editorReporters.clear();
|
|
372
514
|
killChild(child, childPid, "SIGTERM");
|
|
373
515
|
if (!(await waitForChildExit(4000)) && !childExited) {
|
|
374
516
|
killChild(child, childPid, "SIGKILL");
|
|
@@ -409,6 +551,8 @@ async function ownedMain(config) {
|
|
|
409
551
|
|
|
410
552
|
/** @type {Set<import("node:net").Socket>} */
|
|
411
553
|
const clients = new Set();
|
|
554
|
+
/** Resident editor-state reporters: connected but never "attached" (#103). */
|
|
555
|
+
const editorReporters = new Set();
|
|
412
556
|
let childPid = null;
|
|
413
557
|
let child = null;
|
|
414
558
|
let exitCode = null;
|
|
@@ -461,7 +605,10 @@ async function ownedMain(config) {
|
|
|
461
605
|
const ownedUpdate = (mutate) => {
|
|
462
606
|
const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
|
|
463
607
|
const next = mutate(cur);
|
|
464
|
-
|
|
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 };
|
|
465
612
|
});
|
|
466
613
|
if (result.updated && result.host) host = result.host;
|
|
467
614
|
return result;
|
|
@@ -508,6 +655,11 @@ async function ownedMain(config) {
|
|
|
508
655
|
async function finishHost(reason, requestedExitCode = null) {
|
|
509
656
|
if (shutdownStarted) return;
|
|
510
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);
|
|
511
663
|
if (heartbeatTimer) {
|
|
512
664
|
clearInterval(heartbeatTimer);
|
|
513
665
|
heartbeatTimer = null;
|
|
@@ -516,13 +668,17 @@ async function ownedMain(config) {
|
|
|
516
668
|
// superseded owner (or a duplicate that never claimed) never writes.
|
|
517
669
|
if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
|
|
518
670
|
ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
|
|
519
|
-
broadcast({ type: "status", status: host });
|
|
671
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
520
672
|
}
|
|
521
673
|
try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
|
|
522
674
|
for (const c of clients) {
|
|
523
675
|
try { c.destroy(); } catch { /* best effort */ }
|
|
524
676
|
}
|
|
525
677
|
clients.clear();
|
|
678
|
+
for (const reporter of editorReporters) {
|
|
679
|
+
try { reporter.destroy(); } catch { /* best effort */ }
|
|
680
|
+
}
|
|
681
|
+
editorReporters.clear();
|
|
526
682
|
// Bounded server close (1s): never let a stuck client block cleanup.
|
|
527
683
|
await new Promise((resolve) => {
|
|
528
684
|
if (!server) return resolve();
|
|
@@ -646,6 +802,61 @@ async function ownedMain(config) {
|
|
|
646
802
|
}
|
|
647
803
|
host = current;
|
|
648
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
|
+
|
|
649
860
|
// 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
|
|
650
861
|
// instance; an occupied path means someone else owns it.
|
|
651
862
|
const listenOutcome = await new Promise((resolveListen) => {
|
|
@@ -656,8 +867,12 @@ async function ownedMain(config) {
|
|
|
656
867
|
const probeSockets = new WeakSet();
|
|
657
868
|
server = createServer((socket) => {
|
|
658
869
|
clients.add(socket);
|
|
659
|
-
|
|
660
|
-
|
|
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 });
|
|
661
876
|
let buffer = "";
|
|
662
877
|
socket.on("data", (chunk) => {
|
|
663
878
|
buffer += chunk.toString("utf8");
|
|
@@ -667,6 +882,9 @@ async function ownedMain(config) {
|
|
|
667
882
|
});
|
|
668
883
|
socket.on("close", () => {
|
|
669
884
|
clients.delete(socket);
|
|
885
|
+
editorReporters.delete(socket);
|
|
886
|
+
terminalSubscriptions.delete(socket);
|
|
887
|
+
control.closeSocket(socket);
|
|
670
888
|
if (probeSockets.has(socket)) return;
|
|
671
889
|
// Merge into the live record (a stale closure spread here erases a
|
|
672
890
|
// concurrent revoke — final review finding 2).
|
|
@@ -674,6 +892,9 @@ async function ownedMain(config) {
|
|
|
674
892
|
});
|
|
675
893
|
socket.on("error", () => {
|
|
676
894
|
clients.delete(socket);
|
|
895
|
+
editorReporters.delete(socket);
|
|
896
|
+
terminalSubscriptions.delete(socket);
|
|
897
|
+
control.closeSocket(socket);
|
|
677
898
|
if (probeSockets.has(socket)) return;
|
|
678
899
|
ownedUpdate((cur) => ({ ...cur }));
|
|
679
900
|
});
|
|
@@ -715,7 +936,7 @@ async function ownedMain(config) {
|
|
|
715
936
|
return;
|
|
716
937
|
}
|
|
717
938
|
claimedRecord = true;
|
|
718
|
-
broadcast({ type: "status", status: host });
|
|
939
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
719
940
|
|
|
720
941
|
// Test seam (issue #70 Task 8): a config-level delay between publishing the
|
|
721
942
|
// runner identity and spawning the child creates a REAL starting window
|
|
@@ -737,6 +958,9 @@ async function ownedMain(config) {
|
|
|
737
958
|
AGENT_BOARD_VIEW_ID: config.viewId,
|
|
738
959
|
AGENT_BOARD_CHILD: "1",
|
|
739
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,
|
|
740
964
|
// Legacy names are exported too so older child extension builds still behave.
|
|
741
965
|
AGENT_VIEW_ROOT: config.root,
|
|
742
966
|
AGENT_VIEW_VIEW_ID: config.viewId,
|
|
@@ -754,19 +978,32 @@ async function ownedMain(config) {
|
|
|
754
978
|
} catch (err) {
|
|
755
979
|
const message = err instanceof Error ? err.message : String(err);
|
|
756
980
|
diag("child_spawn_failed", message);
|
|
757
|
-
markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
|
|
981
|
+
await markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
|
|
758
982
|
await finishHost("child_spawn_failed", 1);
|
|
759
983
|
return;
|
|
760
984
|
}
|
|
761
985
|
childPid = child.pid ?? null;
|
|
762
986
|
child.onData((data) => {
|
|
763
987
|
screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
|
|
764
|
-
|
|
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
|
+
}
|
|
765
998
|
});
|
|
766
999
|
child.onExit((code) => {
|
|
767
1000
|
childExited = true;
|
|
768
1001
|
resolveChildExit?.();
|
|
769
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);
|
|
770
1007
|
editorEmpty = null;
|
|
771
1008
|
broadcast({ type: "editor_state", empty: null });
|
|
772
1009
|
finish("child_exit", exitCode);
|
|
@@ -792,7 +1029,7 @@ async function ownedMain(config) {
|
|
|
792
1029
|
await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
|
|
793
1030
|
return;
|
|
794
1031
|
}
|
|
795
|
-
broadcast({ type: "status", status: host });
|
|
1032
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
796
1033
|
|
|
797
1034
|
// A starting-window resize cannot be applied at spawn-return nor at first
|
|
798
1035
|
// output: the child node process is still bootstrapping and a SIGWINCH
|
|
@@ -803,7 +1040,8 @@ async function ownedMain(config) {
|
|
|
803
1040
|
if (cachedResize) {
|
|
804
1041
|
const applyHeld = setTimeout(() => {
|
|
805
1042
|
if (!cachedResize || !child || shutdownStarted) return;
|
|
806
|
-
|
|
1043
|
+
// Paired step (CR R1 advisory): model follows the real PTY, never leads.
|
|
1044
|
+
resizeChildAndModel(child, terminalModel, cachedResize.cols, cachedResize.rows);
|
|
807
1045
|
notifyChildResize(childPid);
|
|
808
1046
|
cachedResize = null;
|
|
809
1047
|
}, 500);
|
|
@@ -840,19 +1078,34 @@ async function ownedMain(config) {
|
|
|
840
1078
|
if (!line.trim()) return;
|
|
841
1079
|
let msg;
|
|
842
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
|
+
}
|
|
843
1090
|
switch (msg.type) {
|
|
844
|
-
case "hello":
|
|
845
|
-
// Probe
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
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") {
|
|
850
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 }));
|
|
851
1103
|
} else {
|
|
852
1104
|
ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
|
|
853
1105
|
}
|
|
854
|
-
send(socket, { type: "hello", status: host, editorEmpty });
|
|
1106
|
+
send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
|
|
855
1107
|
break;
|
|
1108
|
+
}
|
|
856
1109
|
case "input": {
|
|
857
1110
|
if (typeof msg.data !== "string") break;
|
|
858
1111
|
if (typeof msg.requestId !== "string" || !msg.requestId) {
|
|
@@ -880,7 +1133,10 @@ async function ownedMain(config) {
|
|
|
880
1133
|
const cols = clampInt(msg.cols, 20, 300, host.cols);
|
|
881
1134
|
const rows = clampInt(msg.rows, 5, 120, host.rows);
|
|
882
1135
|
if (child) {
|
|
883
|
-
|
|
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);
|
|
884
1140
|
notifyChildResize(childPid);
|
|
885
1141
|
cachedResize = null;
|
|
886
1142
|
} else {
|
|
@@ -892,6 +1148,9 @@ async function ownedMain(config) {
|
|
|
892
1148
|
case "interrupt":
|
|
893
1149
|
if (child) child.write("\x1b");
|
|
894
1150
|
break;
|
|
1151
|
+
case "subscribe_terminal":
|
|
1152
|
+
terminalSubscriptions.get(socket)?.handleMessage(msg);
|
|
1153
|
+
break;
|
|
895
1154
|
case "terminate":
|
|
896
1155
|
// Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
|
|
897
1156
|
// so a SIGTERM-immune child still terminates within a bounded window.
|
|
@@ -901,7 +1160,7 @@ async function ownedMain(config) {
|
|
|
901
1160
|
socket.end();
|
|
902
1161
|
break;
|
|
903
1162
|
case "get_status":
|
|
904
|
-
send(socket, { type: "status", status: host });
|
|
1163
|
+
send(socket, { type: "status", status: host, generation: GENERATION });
|
|
905
1164
|
break;
|
|
906
1165
|
case "editor_state": {
|
|
907
1166
|
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
@@ -998,6 +1257,342 @@ function clampInt(value, min, max, fallback) {
|
|
|
998
1257
|
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
999
1258
|
}
|
|
1000
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
|
+
|
|
1001
1596
|
/** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
|
|
1002
1597
|
* across exec(2). Recorded in published identities so service-side recovery
|
|
1003
1598
|
* (issue #70 Task 11) can tell an owned-live pid from a reused one; null on
|
|
@@ -1018,34 +1613,66 @@ function captureStartToken(pid) {
|
|
|
1018
1613
|
}
|
|
1019
1614
|
|
|
1020
1615
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1616
|
+
/**
|
|
1617
|
+
* Finalize the view row as failed when the PTY host itself fails (child spawn
|
|
1618
|
+
* failure). Routed through the View State Coordinator as a fenced
|
|
1619
|
+
* `host_run_failed` command (issue #91, PR #1 residual risk #2): a manually
|
|
1620
|
+
* completed row rejects with manual_fence and stays completed, and a row
|
|
1621
|
+
* re-pointed to a newer run rejects with stale_run.
|
|
1622
|
+
*
|
|
1623
|
+
* The runId is sourced from the row's current state.json (null = the row had
|
|
1624
|
+
* no run) so the coordinator's stale_run guard stays effective; the
|
|
1625
|
+
* coordinator cannot enforce this from its side because it has no visibility
|
|
1626
|
+
* into what the host knows.
|
|
1627
|
+
*
|
|
1628
|
+
* Never throws: every client outcome is handled so the crash path cannot hang.
|
|
1629
|
+
* - applied → done (journaled + materialized by the coordinator).
|
|
1630
|
+
* - coordinator_disabled → legacy direct write (documented escape hatch).
|
|
1631
|
+
* - decided rejections (manual_fence / stale_run / no_change) → info
|
|
1632
|
+
* diagnostic; the coordinator's verdict governs.
|
|
1633
|
+
* - everything else (timeout / connection reset / unavailable) → warn
|
|
1634
|
+
* diagnostic: the outcome is unknown or the row could not be marked — the
|
|
1635
|
+
* runner still shuts down.
|
|
1636
|
+
* @param {string} root
|
|
1637
|
+
* @param {string} viewId
|
|
1638
|
+
* @param {string} message
|
|
1639
|
+
*/
|
|
1640
|
+
async function markRowFailed(root, viewId, message) {
|
|
1641
|
+
const knownRunId = readState(root, viewId)?.currentRunId ?? null;
|
|
1642
|
+
const result = await sendStateCommand(root, {
|
|
1643
|
+
type: "state_command",
|
|
1025
1644
|
viewId,
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1645
|
+
runId: knownRunId,
|
|
1646
|
+
source: "pty-runner",
|
|
1647
|
+
kind: "host_run_failed",
|
|
1648
|
+
payload: { error: message },
|
|
1649
|
+
});
|
|
1650
|
+
if (result.status === "applied") return;
|
|
1651
|
+
if (result.reason === "coordinator_disabled") {
|
|
1652
|
+
markRowFailedDirect(root, viewId, message);
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
if (result.reason === "manual_fence" || result.reason === "stale_run" || result.reason === "no_change") {
|
|
1656
|
+
try {
|
|
1657
|
+
appendDiagnostic(root, viewId, {
|
|
1658
|
+
source: "runner",
|
|
1659
|
+
level: "info",
|
|
1660
|
+
code: "host_run_failed_skipped",
|
|
1661
|
+
message: `Host failure not applied to the row (${result.reason})`,
|
|
1662
|
+
details: { reason: result.reason },
|
|
1663
|
+
});
|
|
1664
|
+
} catch { /* best effort */ }
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
try {
|
|
1668
|
+
appendDiagnostic(root, viewId, {
|
|
1669
|
+
source: "runner",
|
|
1670
|
+
level: "warn",
|
|
1671
|
+
code: "host_run_failed_ambiguous",
|
|
1672
|
+
message: `Host failure outcome unknown (${result.reason}); the row may not reflect the failed host`,
|
|
1673
|
+
details: { reason: result.reason },
|
|
1674
|
+
});
|
|
1675
|
+
} catch { /* best effort */ }
|
|
1049
1676
|
}
|
|
1050
1677
|
|
|
1051
1678
|
function failEarly(message) {
|