@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,2175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Detached PTY host runner.
|
|
4
|
+
*
|
|
5
|
+
* Owns one long-lived interactive Pi child, captures raw terminal output, and exposes
|
|
6
|
+
* a small JSONL-over-Unix-socket protocol for live attach from the dashboard.
|
|
7
|
+
* Uses node-pty when available; falls back to stdio pipes so tests and installs without
|
|
8
|
+
* native deps still exercise the control protocol.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { createServer } from "node:net";
|
|
14
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { StringDecoder } from "node:string_decoder";
|
|
18
|
+
import { appendLine, readJson } from "../src/core/atomic.mjs";
|
|
19
|
+
import { appendDiagnostic } from "../src/core/diagnostics.mjs";
|
|
20
|
+
import { finalizeHostCrash } from "../src/core/host-crash.mjs";
|
|
21
|
+
import { ownsEndpoint, shouldYieldRunner } from "../src/core/host-coordination.mjs";
|
|
22
|
+
import { lastVisibleLogLine } from "../src/core/heuristics.mjs";
|
|
23
|
+
import { acquireOwnedViewLock } from "../src/core/locks.mjs";
|
|
24
|
+
import * as P from "../src/core/paths.mjs";
|
|
25
|
+
import { captureStartToken } from "../src/core/pid.mjs";
|
|
26
|
+
import { readProcessStartToken } from "../src/core/process-identity-probe.mjs";
|
|
27
|
+
import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
|
|
28
|
+
import { createTerminalModel, feedOutput, resizeChildAndModel } from "../src/core/terminal-model.mjs";
|
|
29
|
+
import { createTerminalSubscription } from "../src/core/terminal-attach-protocol.mjs";
|
|
30
|
+
import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
|
|
31
|
+
import {
|
|
32
|
+
checkSeq,
|
|
33
|
+
CONTROL_COMMAND_TYPES,
|
|
34
|
+
createResizeTracker,
|
|
35
|
+
journalAppendRecord,
|
|
36
|
+
journalGc,
|
|
37
|
+
journalUnresolved,
|
|
38
|
+
JOURNAL_KEEP_DEFAULT,
|
|
39
|
+
validateCommandEnvelope,
|
|
40
|
+
} from "../src/core/control-protocol.mjs";
|
|
41
|
+
import { readHost, readState, updateOwnedHost, writeHost } from "../src/core/store.mjs";
|
|
42
|
+
import { sendStateCommand } from "../src/core/coordinator-client.mjs";
|
|
43
|
+
import { classifyClientHello, helloBookkeeping, CLIENT_ID_FRAME_REPORTER } from "../src/core/host-protocol.mjs";
|
|
44
|
+
import { createFrameStreamParser } from "../src/core/attach-frame-stream.mjs";
|
|
45
|
+
import { createViewProofTracker } from "../src/core/attach-view-proof.mjs";
|
|
46
|
+
import { createFrameWitnessSummaryScheduler } from "../src/core/frame-witness-update.mjs";
|
|
47
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
48
|
+
|
|
49
|
+
// Only there to bound what a bad message can make us allocate; a big screen at a small font must still fit.
|
|
50
|
+
const MAX_COLS = 1000;
|
|
51
|
+
const MAX_ROWS = 500;
|
|
52
|
+
import { markRowFailedDirect } from "./pty-runner-legacy.mjs";
|
|
53
|
+
import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
|
|
54
|
+
|
|
55
|
+
const requireForPty = createRequire(import.meta.url);
|
|
56
|
+
|
|
57
|
+
/** @type {any|null} */
|
|
58
|
+
let pty = null;
|
|
59
|
+
try {
|
|
60
|
+
pty = await import("node-pty");
|
|
61
|
+
} catch {
|
|
62
|
+
pty = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const HEARTBEAT_MS = 1000;
|
|
66
|
+
/** Service-input ack dedup bound (issue #70 Task 8): requestId → true, FIFO evict. */
|
|
67
|
+
const HOST_ACK_DEDUP_MAX = 1000;
|
|
68
|
+
/** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
|
|
69
|
+
const HOST_RUNNER_LOCK_WAIT_MS = 5_000;
|
|
70
|
+
/** Boot identity for the control lifecycle (issue #91 phase 5, spec D4 generation
|
|
71
|
+
* token): one runner process = one generation. Clients detect a runner
|
|
72
|
+
* replacement by the generation changing, which structurally disambiguates a
|
|
73
|
+
* reconnect ring-replay from a fresh child (phase 4 epoch ambiguity). */
|
|
74
|
+
const GENERATION = randomUUID();
|
|
75
|
+
/** GC trigger: rewrite the journal when records exceed 2× the keep bound.
|
|
76
|
+
* Append-only between rewrites, so the common path stays O(1) per command. */
|
|
77
|
+
const JOURNAL_GC_TRIGGER = JOURNAL_KEEP_DEFAULT * 2;
|
|
78
|
+
/** How much of the screen.log tail to scan when attributing an abnormal child
|
|
79
|
+
* exit (issue #90). Tail-only: the log can be 100MB+. */
|
|
80
|
+
const EXIT_LOG_TAIL_BYTES = 8_192;
|
|
81
|
+
|
|
82
|
+
/** Tail-read the last `maxBytes` of a file without loading it whole.
|
|
83
|
+
* @param {string} path
|
|
84
|
+
* @param {number} maxBytes
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function readScreenLogTail(path, maxBytes) {
|
|
88
|
+
let fd;
|
|
89
|
+
try {
|
|
90
|
+
fd = openSync(path, "r");
|
|
91
|
+
const size = fstatSync(fd).size;
|
|
92
|
+
const start = Math.max(0, size - maxBytes);
|
|
93
|
+
const length = size - start;
|
|
94
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
95
|
+
readSync(fd, buffer, 0, length, start);
|
|
96
|
+
return buffer.toString("utf8");
|
|
97
|
+
} finally {
|
|
98
|
+
try { if (fd != null) closeSync(fd); } catch { /* best effort */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Best-effort error attribution for an abnormal child exit (issue #90): the
|
|
104
|
+
* child's failure reason (e.g. "Model X not found") exists only in the raw
|
|
105
|
+
* screen log, so surface its last visible line in host.json.error. Never
|
|
106
|
+
* throws — this runs on the exit path.
|
|
107
|
+
* @param {number} exitCode
|
|
108
|
+
* @param {string} screenLogPath
|
|
109
|
+
* @returns {{} | { error: string }}
|
|
110
|
+
*/
|
|
111
|
+
/**
|
|
112
|
+
* Issue #222 FW-03/FW-04 runner-side frame-witness plumbing.
|
|
113
|
+
*
|
|
114
|
+
* A frame reporter socket (`hello` with `clientId:"frame-reporter"` and
|
|
115
|
+
* `frameWitness:{protocol:1}`) receives a per-instance random >=128-bit base64url token and
|
|
116
|
+
* the owned DCS parser is installed for that child. Old/no hello keeps byte-identical
|
|
117
|
+
* behaviour: `splitFrameChunk` passes the chunk through untouched.
|
|
118
|
+
*/
|
|
119
|
+
function enableFrameReporter(socket, msg, send) {
|
|
120
|
+
const protocol = msg && msg.frameWitness && Number(msg.frameWitness.protocol);
|
|
121
|
+
if (msg?.clientId !== CLIENT_ID_FRAME_REPORTER || protocol !== 1) return null;
|
|
122
|
+
const token = randomBytes(32).toString("base64url");
|
|
123
|
+
// F4: the identity is the runner's ACTUAL generation (one per runner process), not a
|
|
124
|
+
// per-connection UUID. A reporter replacement gets a new token but the same generation.
|
|
125
|
+
const instanceId = GENERATION;
|
|
126
|
+
// The reporter reports its actual extension session epoch (1, then 2+ after a real
|
|
127
|
+
// /new). Absence keeps the legacy epoch-1 compatibility default; present values must
|
|
128
|
+
// be positive integers or the enable is rejected without installing proof.
|
|
129
|
+
const reportedEpoch = msg.frameWitness?.sessionEpoch;
|
|
130
|
+
let sessionEpoch = 1;
|
|
131
|
+
if (reportedEpoch !== undefined) {
|
|
132
|
+
if (!Number.isInteger(reportedEpoch) || reportedEpoch < 1) return null;
|
|
133
|
+
sessionEpoch = reportedEpoch;
|
|
134
|
+
}
|
|
135
|
+
const state = {
|
|
136
|
+
socket,
|
|
137
|
+
token,
|
|
138
|
+
instanceId,
|
|
139
|
+
sessionEpoch,
|
|
140
|
+
parser: createFrameStreamParser({ token, instanceId, sessionEpoch }),
|
|
141
|
+
viewProof: createViewProofTracker(),
|
|
142
|
+
viewProofBaselineSeeded: false,
|
|
143
|
+
viewProofFailures: 0,
|
|
144
|
+
viewProofLastFailure: null,
|
|
145
|
+
geometryRevision: 0,
|
|
146
|
+
requestSeq: 0,
|
|
147
|
+
intents: [],
|
|
148
|
+
};
|
|
149
|
+
send(socket, { type: "frame_witness_enable", protocol: 1, token, instanceId, sessionEpoch });
|
|
150
|
+
return state;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Split one child chunk into ordinary segments + proofs (marker bytes are stripped). */
|
|
154
|
+
function splitFrameChunk(data, frameReporter) {
|
|
155
|
+
if (!frameReporter) return [{ kind: "output", data }];
|
|
156
|
+
const parts = frameReporter.parser.push(data);
|
|
157
|
+
return parts.length > 0 ? parts : [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Announce a frame-witness intent after an ACTUAL differing-size resize reached applied.
|
|
162
|
+
* Same-size requests do not call this at all (zero resize, no new revision). Each intent
|
|
163
|
+
* carries the current instance, a unique requestId, a monotonically increasing
|
|
164
|
+
* geometryRevision and the exact target cols/rows.
|
|
165
|
+
*/
|
|
166
|
+
function announceFrameIntent(frameReporter, send, cols, rows) {
|
|
167
|
+
if (!frameReporter) return null;
|
|
168
|
+
frameReporter.geometryRevision += 1;
|
|
169
|
+
frameReporter.requestSeq += 1;
|
|
170
|
+
const intent = {
|
|
171
|
+
type: "frame_witness_intent",
|
|
172
|
+
protocol: 1,
|
|
173
|
+
instanceId: frameReporter.instanceId,
|
|
174
|
+
requestId: `fw-${frameReporter.requestSeq}`,
|
|
175
|
+
geometryRevision: frameReporter.geometryRevision,
|
|
176
|
+
cols,
|
|
177
|
+
rows,
|
|
178
|
+
};
|
|
179
|
+
frameReporter.intents.push({ requestId: intent.requestId, geometryRevision: intent.geometryRevision, cols, rows });
|
|
180
|
+
send(frameReporter.socket, intent);
|
|
181
|
+
return intent;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* F7: ONE cleanup path for every lifecycle end (socket close/error, child exit, reporter
|
|
186
|
+
* replacement). Finishes the parser, forwards any trailing ORDINARY byte to the normal
|
|
187
|
+
* output path, and clears the state so no stale enabled reporter survives.
|
|
188
|
+
*/
|
|
189
|
+
function cleanupFrameReporter(frameReporter, reason, forward) {
|
|
190
|
+
if (!frameReporter) return null;
|
|
191
|
+
const finished = frameReporter.parser.finish(reason);
|
|
192
|
+
if (typeof forward === "function" && typeof finished.trailing === "string" && finished.trailing.length > 0) forward(finished.trailing);
|
|
193
|
+
return finished;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Bounded host-facing summary (never the token). */
|
|
197
|
+
function frameWitnessSummary(frameReporter) {
|
|
198
|
+
if (!frameReporter) return { enabled: false };
|
|
199
|
+
const snapshot = frameReporter.parser.snapshot();
|
|
200
|
+
return {
|
|
201
|
+
enabled: true,
|
|
202
|
+
instanceId: frameReporter.instanceId,
|
|
203
|
+
sessionEpoch: frameReporter.sessionEpoch,
|
|
204
|
+
tokenFingerprint: createHash("sha256").update(frameReporter.token).digest("hex").slice(0, 16),
|
|
205
|
+
proofs: snapshot.proofs.length,
|
|
206
|
+
droppedProofs: snapshot.droppedProofs,
|
|
207
|
+
droppedFinishedIds: snapshot.droppedFinishedIds,
|
|
208
|
+
diagnostics: snapshot.diagnostics.length,
|
|
209
|
+
lastFrameId: snapshot.lastFrameId,
|
|
210
|
+
openFrameId: snapshot.openFrameId,
|
|
211
|
+
pendingBytes: snapshot.pendingBytes,
|
|
212
|
+
geometryRevision: frameReporter.geometryRevision,
|
|
213
|
+
intents: frameReporter.intents.length,
|
|
214
|
+
viewProofFailures: frameReporter.viewProofFailures ?? 0,
|
|
215
|
+
...(frameReporter.viewProofLastFailure === null ? {} : { viewProofLastFailure: frameReporter.viewProofLastFailure }),
|
|
216
|
+
// Bounded, user-text-free view of the newest proof so a real-topology test can
|
|
217
|
+
// assert target geometry/mode/seq without parsing host-private internals.
|
|
218
|
+
lastProof: (() => {
|
|
219
|
+
const proofs = snapshot.proofs;
|
|
220
|
+
const last = proofs.length === 0 ? null : proofs[proofs.length - 1];
|
|
221
|
+
if (last === null) return null;
|
|
222
|
+
return {
|
|
223
|
+
marker: last.marker,
|
|
224
|
+
frameId: last.frameId,
|
|
225
|
+
seq: last.seq,
|
|
226
|
+
sessionEpoch: last.sessionEpoch,
|
|
227
|
+
...(last.metadata === undefined
|
|
228
|
+
? { reason: last.reason }
|
|
229
|
+
: {
|
|
230
|
+
cols: last.metadata.cols,
|
|
231
|
+
rows: last.metadata.rows,
|
|
232
|
+
mode: last.metadata.mode,
|
|
233
|
+
rendererEpoch: last.metadata.rendererEpoch,
|
|
234
|
+
intentId: last.metadata.intentId,
|
|
235
|
+
geometryRevision: last.metadata.geometryRevision,
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
})(),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** One per-enabled-reporter tracker source; reads the CURRENT reporter on every call. */
|
|
243
|
+
function createViewProofSource(getReporter) {
|
|
244
|
+
return {
|
|
245
|
+
coverage(seq) {
|
|
246
|
+
try {
|
|
247
|
+
return getReporter()?.viewProof?.coverageFor(seq) ?? null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
replay(fromSeq, throughSeq) {
|
|
253
|
+
try {
|
|
254
|
+
return getReporter()?.viewProof?.replay(fromSeq, throughSeq) ?? null;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function noteViewProofFailure(frameReporter, reason) {
|
|
263
|
+
if (!frameReporter) return;
|
|
264
|
+
frameReporter.viewProofFailures = (frameReporter.viewProofFailures ?? 0) + 1;
|
|
265
|
+
frameReporter.viewProofLastFailure = String(reason).slice(0, 64);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Feed the canonical segment seq into the tracker BEFORE subscriptions see it. */
|
|
269
|
+
function feedViewProofOutput(frameReporter, seq, data) {
|
|
270
|
+
if (!frameReporter?.viewProof) return;
|
|
271
|
+
try {
|
|
272
|
+
const result = frameReporter.viewProof.feedOutput(seq, data);
|
|
273
|
+
if (result.status === "protocol-error") noteViewProofFailure(frameReporter, result.reason);
|
|
274
|
+
else frameReporter.viewProofBaselineSeeded = true;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
noteViewProofFailure(frameReporter, err instanceof Error ? err.message : String(err));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Route one additive S candidate or F/A proof into the accepted tracker. Returns the
|
|
282
|
+
* accepted wire record (seq already assigned) or null. Never calls completeProof for an
|
|
283
|
+
* S/A record.
|
|
284
|
+
*/
|
|
285
|
+
function applyViewProofPart(frameReporter, part, seq) {
|
|
286
|
+
if (!frameReporter?.viewProof) return null;
|
|
287
|
+
const record = part.kind === "candidate" ? part.candidate : part.kind === "proof" ? part.proof : null;
|
|
288
|
+
if (record === null) return null;
|
|
289
|
+
record.seq = seq;
|
|
290
|
+
// A tracker created at reporter-enable time may see its first S before any ordinary
|
|
291
|
+
// segment was fed after enable. Adopt the runner's existing output seq as the baseline
|
|
292
|
+
// (zero bytes retained) so the real first frame is not dropped.
|
|
293
|
+
if (record.marker === "S" && frameReporter.viewProofBaselineSeeded === false && seq >= 1) {
|
|
294
|
+
try {
|
|
295
|
+
const baseline = frameReporter.viewProof.feedOutput(seq, "");
|
|
296
|
+
if (baseline.status === "protocol-error") noteViewProofFailure(frameReporter, baseline.reason);
|
|
297
|
+
else frameReporter.viewProofBaselineSeeded = true;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
noteViewProofFailure(frameReporter, err instanceof Error ? err.message : String(err));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
let result;
|
|
303
|
+
try {
|
|
304
|
+
if (record.marker === "S") result = frameReporter.viewProof.openCandidate(record);
|
|
305
|
+
else if (record.marker === "F") result = frameReporter.viewProof.completeProof(record);
|
|
306
|
+
else if (record.marker === "A") result = frameReporter.viewProof.abortCandidate(record);
|
|
307
|
+
else result = { status: "protocol-error", reason: "unknown-record-marker" };
|
|
308
|
+
} catch (err) {
|
|
309
|
+
noteViewProofFailure(frameReporter, err instanceof Error ? err.message : String(err));
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
if (result.status !== "ok") {
|
|
313
|
+
noteViewProofFailure(frameReporter, result.reason);
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
return record;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function resetViewProofSubscribers(terminalSubscriptions, reason) {
|
|
320
|
+
for (const sub of terminalSubscriptions.values()) sub.onViewProofReset?.(reason);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function attributedExitError(exitCode, screenLogPath) {
|
|
324
|
+
if (exitCode === 0) return {};
|
|
325
|
+
try {
|
|
326
|
+
const line = lastVisibleLogLine(readScreenLogTail(screenLogPath, EXIT_LOG_TAIL_BYTES));
|
|
327
|
+
return line ? { error: line } : {};
|
|
328
|
+
} catch {
|
|
329
|
+
return {};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function main() {
|
|
334
|
+
const configPath = process.argv[2];
|
|
335
|
+
if (!configPath) failEarly("pty-runner: missing config path");
|
|
336
|
+
/** @type {import("../src/core/types.mjs").HostConfig|null} */
|
|
337
|
+
const config = readJson(configPath, null);
|
|
338
|
+
if (!config) failEarly(`pty-runner: cannot read config ${configPath}`);
|
|
339
|
+
// New ownership protocol (issue #70): instance-scoped config selects the fenced
|
|
340
|
+
// path. Legacy configs (no instanceId) keep the historical behavior unchanged.
|
|
341
|
+
if (config.instanceId) {
|
|
342
|
+
// A mid-flight throw in ownedMain must still terminate the process — the
|
|
343
|
+
// runner is detached with ignored stdio; an unhandled rejection would hang it.
|
|
344
|
+
ownedMain(config).catch(() => process.exit(1));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
legacyMain(config);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function legacyMain(config) {
|
|
351
|
+
const socketPath = P.controlSocketPath(config.root, config.viewId);
|
|
352
|
+
const screenLog = P.screenLogPath(config.root, config.viewId);
|
|
353
|
+
// Optional per-install cap override from launch prefs (screenLogMaxSize).
|
|
354
|
+
// undefined → screen-log.mjs falls back to its built-in default.
|
|
355
|
+
const screenLogMaxBytes =
|
|
356
|
+
Number.isFinite(config.screenLogMaxBytes) && config.screenLogMaxBytes > 0
|
|
357
|
+
? Math.floor(config.screenLogMaxBytes)
|
|
358
|
+
: undefined;
|
|
359
|
+
const screenLogLimits = { maxBytes: screenLogMaxBytes };
|
|
360
|
+
let screenLogBytes = reconcileScreenLog(screenLog, screenLogLimits);
|
|
361
|
+
try {
|
|
362
|
+
if (existsSync(socketPath)) unlinkSync(socketPath);
|
|
363
|
+
} catch {}
|
|
364
|
+
|
|
365
|
+
/** @type {Set<import("node:net").Socket>} */
|
|
366
|
+
const clients = new Set();
|
|
367
|
+
/** Resident editor-state reporters: connected but never "attached" (#103). */
|
|
368
|
+
const editorReporters = new Set();
|
|
369
|
+
let frameReporter = null;
|
|
370
|
+
let lastOutputSeq = 0;
|
|
371
|
+
let childPid = null;
|
|
372
|
+
let child = null;
|
|
373
|
+
let exitCode = null;
|
|
374
|
+
let shutdownStarted = false;
|
|
375
|
+
let shutdownExitCode = null;
|
|
376
|
+
let childExited = false;
|
|
377
|
+
/** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
|
|
378
|
+
let boundSocketIdentity = null;
|
|
379
|
+
let resolveChildExit;
|
|
380
|
+
const childExitPromise = new Promise((resolve) => {
|
|
381
|
+
resolveChildExit = resolve;
|
|
382
|
+
});
|
|
383
|
+
// Set when the uncaughtException crash handler finalizes the host. Guards
|
|
384
|
+
// child.onExit against clobbering the persisted "failed" state with an
|
|
385
|
+
// "exited" update (the handler kills the child, so its exit callback fires
|
|
386
|
+
// inside the 50ms flush window — CR round-1, issue #48).
|
|
387
|
+
let crashed = false;
|
|
388
|
+
/** Authoritative child editor emptiness, pushed by the child Pi extension
|
|
389
|
+
* (issue #68). null = unknown (extension missing / not yet reported). */
|
|
390
|
+
let editorEmpty = null;
|
|
391
|
+
/** @type {import("../src/core/types.mjs").HostStatus} */
|
|
392
|
+
let host = {
|
|
393
|
+
version: 1,
|
|
394
|
+
viewId: config.viewId,
|
|
395
|
+
mode: "pty",
|
|
396
|
+
runnerPid: process.pid,
|
|
397
|
+
childPid: null,
|
|
398
|
+
socketPath,
|
|
399
|
+
state: "starting",
|
|
400
|
+
startedAt: Date.now(),
|
|
401
|
+
lastSeenAt: Date.now(),
|
|
402
|
+
endedAt: null,
|
|
403
|
+
exitCode: null,
|
|
404
|
+
error: null,
|
|
405
|
+
cols: config.cols || 120,
|
|
406
|
+
rows: config.rows || 36,
|
|
407
|
+
attachedClients: 0,
|
|
408
|
+
attachedEver: false,
|
|
409
|
+
revision: 0,
|
|
410
|
+
};
|
|
411
|
+
// Canonical terminal model + per-socket snapshot subscriptions (issue #91
|
|
412
|
+
// phase 3). Fed from child.onData alongside the legacy screen.log/broadcast
|
|
413
|
+
// path; legacy message semantics unchanged (the `seq` field is additive).
|
|
414
|
+
const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
|
|
415
|
+
/** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
|
|
416
|
+
const terminalSubscriptions = new Map();
|
|
417
|
+
// Control lifecycle runtime (issue #91 phase 5). instanceId is null in this
|
|
418
|
+
// legacy-mode main: no instance fence exists, so EVERY envelope claims a
|
|
419
|
+
// foreign instance and is rejected with `instance_mismatch` (documented
|
|
420
|
+
// contract — legacy-mode hosts serve only envelope-less clients).
|
|
421
|
+
// Shared by the control runtime and the plain resize message. Only an ACTUAL
|
|
422
|
+
// model geometry change announces an intent; same-size/no-op stays silent.
|
|
423
|
+
const applyResize = (cols, rows) => {
|
|
424
|
+
const before = { cols: terminalModel.cols, rows: terminalModel.rows };
|
|
425
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
426
|
+
update({ cols, rows });
|
|
427
|
+
const after = { cols: terminalModel.cols, rows: terminalModel.rows };
|
|
428
|
+
if (frameReporter !== null && (after.cols !== before.cols || after.rows !== before.rows)) {
|
|
429
|
+
announceFrameIntent(frameReporter, send, after.cols, after.rows);
|
|
430
|
+
frameWitnessSummaryUpdates.flush();
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
const control = createControlRuntime({
|
|
434
|
+
viewId: config.viewId,
|
|
435
|
+
root: config.root,
|
|
436
|
+
instanceId: null,
|
|
437
|
+
send,
|
|
438
|
+
diag: (code, message, details) => {
|
|
439
|
+
try {
|
|
440
|
+
appendDiagnostic(config.root, config.viewId, { source: "runner", code, message, ...(details ? { details } : {}) });
|
|
441
|
+
} catch {
|
|
442
|
+
/* diagnostics must never kill the host */
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
actions: {
|
|
446
|
+
childReady: () => Boolean(child),
|
|
447
|
+
writeInput: (data) => {
|
|
448
|
+
child.write(data);
|
|
449
|
+
},
|
|
450
|
+
hostCols: () => host.cols,
|
|
451
|
+
hostRows: () => host.rows,
|
|
452
|
+
applyResize,
|
|
453
|
+
currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
454
|
+
frameRevision: () => (frameReporter === null ? null : { geometryRevision: frameReporter.geometryRevision, intentId: frameReporter.requestSeq }),
|
|
455
|
+
applyInterrupt: () => {
|
|
456
|
+
if (child) child.write("\x1b");
|
|
457
|
+
},
|
|
458
|
+
applyTerminate: () => {
|
|
459
|
+
killChild(child, childPid, "SIGTERM");
|
|
460
|
+
setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
|
|
461
|
+
},
|
|
462
|
+
applyDetach: (s) => {
|
|
463
|
+
s.end();
|
|
464
|
+
},
|
|
465
|
+
hostRevision: () => host.revision ?? 0,
|
|
466
|
+
cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
467
|
+
stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
/** Persist host.json. A transient failure (e.g. Windows rename EPERM racing a
|
|
471
|
+
* reader) must degrade, not kill the host: record a diagnostic and let the
|
|
472
|
+
* next heartbeat tick retry. Socket protocol is the attach main channel, so
|
|
473
|
+
* host.json being briefly stale is acceptable. */
|
|
474
|
+
const persist = () => {
|
|
475
|
+
try {
|
|
476
|
+
writeHost(config.root, host);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
try {
|
|
479
|
+
appendDiagnostic(config.root, config.viewId, {
|
|
480
|
+
source: "runner",
|
|
481
|
+
level: "error",
|
|
482
|
+
code: "persist_error",
|
|
483
|
+
message: err instanceof Error ? err.message : String(err),
|
|
484
|
+
});
|
|
485
|
+
} catch { /* diagnostics must never kill the host either */ }
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
const broadcast = (msg) => {
|
|
489
|
+
const line = JSON.stringify(msg) + "\n";
|
|
490
|
+
for (const c of clients) c.write(line);
|
|
491
|
+
};
|
|
492
|
+
const update = (patch = {}) => {
|
|
493
|
+
host = { ...host, ...patch, revision: (host.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
494
|
+
persist();
|
|
495
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
496
|
+
};
|
|
497
|
+
const frameWitnessSummaryUpdates = createFrameWitnessSummaryScheduler({
|
|
498
|
+
publish: () => {
|
|
499
|
+
if (frameReporter !== null) update({ frameWitness: frameWitnessSummary(frameReporter) });
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
persist();
|
|
503
|
+
|
|
504
|
+
// Last-resort crash path (registered early, before spawnInteractive, so any
|
|
505
|
+
// early synchronous failure is also covered): the runner is launched detached
|
|
506
|
+
// with stdio ignored, so an uncaught exception is otherwise completely silent —
|
|
507
|
+
// no host.json finalize, no exit message, and the attach view reconnects
|
|
508
|
+
// forever. Record diagnostics, finalize the host as failed, and broadcast exit
|
|
509
|
+
// so attached clients can leave the view instead of looping.
|
|
510
|
+
process.on("uncaughtException", (err) => {
|
|
511
|
+
process.removeAllListeners("uncaughtException");
|
|
512
|
+
try {
|
|
513
|
+
appendDiagnostic(config.root, config.viewId, {
|
|
514
|
+
source: "runner",
|
|
515
|
+
level: "error",
|
|
516
|
+
code: "runner_crash",
|
|
517
|
+
message: err instanceof Error ? err.message : String(err),
|
|
518
|
+
details: { stack: err instanceof Error ? err.stack : undefined },
|
|
519
|
+
});
|
|
520
|
+
} catch { /* best effort */ }
|
|
521
|
+
crashed = true;
|
|
522
|
+
host = finalizeHostCrash(config.root, config.viewId, host, err);
|
|
523
|
+
try {
|
|
524
|
+
broadcast({ type: "exit", exitCode: 1 });
|
|
525
|
+
} catch { /* best effort */ }
|
|
526
|
+
try { if (child) killChild(child, childPid, "SIGTERM"); } catch { /* best effort */ }
|
|
527
|
+
setTimeout(() => process.exit(1), 50).unref?.();
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const args = [...config.piArgsPrefix, "--session", config.sessionFile];
|
|
531
|
+
if (config.model) args.push("--model", config.model);
|
|
532
|
+
if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
|
|
533
|
+
if (config.tools) args.push("--tools", config.tools);
|
|
534
|
+
if (config.initialPrompt) args.push(encodePromptForCliArg(config.initialPrompt));
|
|
535
|
+
|
|
536
|
+
const env = {
|
|
537
|
+
...process.env,
|
|
538
|
+
...(config.env || {}),
|
|
539
|
+
AGENT_BOARD_ROOT: config.root,
|
|
540
|
+
AGENT_BOARD_VIEW_ID: config.viewId,
|
|
541
|
+
AGENT_BOARD_CHILD: "1",
|
|
542
|
+
AGENT_BOARD_HOSTED: "pty",
|
|
543
|
+
// The endpoint this host actually bound: the child's editor-state reporter
|
|
544
|
+
// dials it instead of guessing the stable per-view address (issue #103).
|
|
545
|
+
AGENT_BOARD_CONTROL_SOCKET: socketPath,
|
|
546
|
+
// Legacy names are exported too so older child extension builds still behave.
|
|
547
|
+
AGENT_VIEW_ROOT: config.root,
|
|
548
|
+
AGENT_VIEW_VIEW_ID: config.viewId,
|
|
549
|
+
AGENT_VIEW_CHILD: "1",
|
|
550
|
+
AGENT_VIEW_HOSTED: "pty",
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
child = spawnInteractive(config.piCommand, args, {
|
|
555
|
+
cwd: config.cwd,
|
|
556
|
+
env,
|
|
557
|
+
cols: host.cols,
|
|
558
|
+
rows: host.rows,
|
|
559
|
+
allowPipeFallback: config.env?.AGENT_BOARD_ALLOW_PIPE_FALLBACK === "1" || config.env?.AGENT_VIEW_ALLOW_PIPE_FALLBACK === "1",
|
|
560
|
+
});
|
|
561
|
+
} catch (err) {
|
|
562
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
563
|
+
update({ state: "failed", endedAt: Date.now(), exitCode: 1, error: message });
|
|
564
|
+
// The command settles within the client's own timeout (never throws), so
|
|
565
|
+
// the exit stays bounded while the fenced write gets its chance. Return
|
|
566
|
+
// instead of falling through: child is null here and the rest of this
|
|
567
|
+
// function assumes a spawned child.
|
|
568
|
+
void markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`).finally(() => process.exit(1));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
childPid = child.pid ?? null;
|
|
572
|
+
update({ childPid });
|
|
573
|
+
|
|
574
|
+
child.onData((data) => {
|
|
575
|
+
// Issue #222 FW-03: split the ordered stream at exact DCS marker boundaries. With
|
|
576
|
+
// no frame reporter this is the byte-identical legacy path (one output part).
|
|
577
|
+
// Marker bytes never reach screenLog, the canonical model, subscribers or the
|
|
578
|
+
// legacy broadcast.
|
|
579
|
+
for (const part of splitFrameChunk(data, frameReporter)) {
|
|
580
|
+
if (part.kind !== "output") {
|
|
581
|
+
// S opens; F completes; A aborts. Never call completeProof for every record.
|
|
582
|
+
const record = applyViewProofPart(frameReporter, part, lastOutputSeq);
|
|
583
|
+
if (record !== null) {
|
|
584
|
+
for (const sub of terminalSubscriptions.values()) sub.onViewProofRecord?.(record);
|
|
585
|
+
}
|
|
586
|
+
if (frameReporter !== null) frameWitnessSummaryUpdates.schedule();
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const chunk = part.data;
|
|
590
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, chunk, screenLogBytes, screenLogLimits);
|
|
591
|
+
const outputSeq = feedOutput(terminalModel, chunk);
|
|
592
|
+
lastOutputSeq = outputSeq;
|
|
593
|
+
feedViewProofOutput(frameReporter, outputSeq, chunk);
|
|
594
|
+
// Per-socket output delivery (issue #91 phase 3): sockets that speak the
|
|
595
|
+
// subscribe_terminal protocol get exactly-once, gap-checked chunks from
|
|
596
|
+
// their subscription state machine; every other client keeps the legacy
|
|
597
|
+
// fire-and-forget broadcast (additive seq). Both streams to one socket
|
|
598
|
+
// would duplicate every chunk.
|
|
599
|
+
const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data: chunk }) + "\n";
|
|
600
|
+
for (const [socket, sub] of terminalSubscriptions) {
|
|
601
|
+
if (sub.subscribed()) sub.onOutput(outputSeq, chunk);
|
|
602
|
+
// Per-socket guard: a synchronous throw from a dead socket must never
|
|
603
|
+
// escape into the uncaughtException crash path (whole-branch review).
|
|
604
|
+
else { try { socket.write(outputLine); } catch { /* 'error' handler cleans up */ } }
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
child.onExit((code) => {
|
|
609
|
+
childExited = true;
|
|
610
|
+
resolveChildExit?.();
|
|
611
|
+
exitCode = code ?? 0;
|
|
612
|
+
if (frameReporter !== null) {
|
|
613
|
+
// F9: a trailing ordinary byte is forwarded through the normal output path.
|
|
614
|
+
cleanupFrameReporter(frameReporter, "child-exit", (trailing) => {
|
|
615
|
+
try {
|
|
616
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, trailing, screenLogBytes, screenLogLimits);
|
|
617
|
+
const seq = feedOutput(terminalModel, trailing);
|
|
618
|
+
feedViewProofOutput(frameReporter, seq, trailing);
|
|
619
|
+
for (const [socket, sub] of terminalSubscriptions) {
|
|
620
|
+
if (sub.subscribed()) sub.onOutput(seq, trailing);
|
|
621
|
+
}
|
|
622
|
+
} catch {
|
|
623
|
+
/* best effort during exit */
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
resetViewProofSubscribers(terminalSubscriptions, "child-exit");
|
|
627
|
+
try {
|
|
628
|
+
frameWitnessSummaryUpdates.flush();
|
|
629
|
+
} catch {
|
|
630
|
+
/* row may already be finalised */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// Terminate lifecycle (spec D4): a confirmed child exit is the observed
|
|
634
|
+
// evidence for any enveloped terminate still pending on a live socket.
|
|
635
|
+
control.flushTerminateObservations(exitCode, false);
|
|
636
|
+
// After a crash the handler already persisted "failed" and broadcast
|
|
637
|
+
// exit; this callback must not overwrite that state.
|
|
638
|
+
if (!crashed) {
|
|
639
|
+
// Attribute only a NATURAL abnormal exit (issue #90): after a deliberate
|
|
640
|
+
// stop (shutdownStarted) the child was killed by us — no error line.
|
|
641
|
+
update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null, ...(shutdownStarted ? {} : attributedExitError(exitCode, screenLog)) });
|
|
642
|
+
editorEmpty = null;
|
|
643
|
+
broadcast({ type: "editor_state", empty: null });
|
|
644
|
+
broadcast({ type: "exit", exitCode });
|
|
645
|
+
}
|
|
646
|
+
if (!shutdownStarted) setTimeout(() => process.exit(exitCode ?? 0), 50).unref?.();
|
|
647
|
+
});
|
|
648
|
+
child.onError((err) => {
|
|
649
|
+
update({ state: "failed", endedAt: Date.now(), exitCode: 1, error: err instanceof Error ? err.message : String(err) });
|
|
650
|
+
broadcast({ type: "error", message: host.error || "child error" });
|
|
651
|
+
void shutdown(1);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
let server;
|
|
655
|
+
server = createServer((socket) => {
|
|
656
|
+
clients.add(socket);
|
|
657
|
+
terminalSubscriptions.set(
|
|
658
|
+
socket,
|
|
659
|
+
createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION, viewProof: createViewProofSource(() => frameReporter) }),
|
|
660
|
+
);
|
|
661
|
+
socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
|
|
662
|
+
let buffer = "";
|
|
663
|
+
const decoder = new StringDecoder("utf8"); // A read can end halfway through a character.
|
|
664
|
+
socket.on("data", (chunk) => {
|
|
665
|
+
buffer += decoder.write(chunk);
|
|
666
|
+
const lines = buffer.split("\n");
|
|
667
|
+
buffer = lines.pop() ?? "";
|
|
668
|
+
for (const line of lines) handleClientLine(line, socket);
|
|
669
|
+
});
|
|
670
|
+
socket.on("close", () => {
|
|
671
|
+
clients.delete(socket);
|
|
672
|
+
editorReporters.delete(socket);
|
|
673
|
+
terminalSubscriptions.delete(socket);
|
|
674
|
+
control.closeSocket(socket);
|
|
675
|
+
const reporterLost = frameReporter !== null && frameReporter.socket === socket;
|
|
676
|
+
if (reporterLost) {
|
|
677
|
+
frameWitnessSummaryUpdates.cancel();
|
|
678
|
+
// P2: a trailing ORDINARY byte must not be lost when the reporter goes away.
|
|
679
|
+
cleanupFrameReporter(frameReporter, "reporter-close", (trailing) => {
|
|
680
|
+
try {
|
|
681
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, trailing, screenLogBytes, screenLogLimits);
|
|
682
|
+
const seq = feedOutput(terminalModel, trailing);
|
|
683
|
+
lastOutputSeq = seq;
|
|
684
|
+
feedViewProofOutput(frameReporter, seq, trailing);
|
|
685
|
+
for (const [peer, sub] of terminalSubscriptions) if (sub.subscribed()) sub.onOutput(seq, trailing);
|
|
686
|
+
} catch {
|
|
687
|
+
/* best effort */
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
frameReporter = null;
|
|
691
|
+
resetViewProofSubscribers(terminalSubscriptions, "reporter-close");
|
|
692
|
+
}
|
|
693
|
+
update(reporterLost ? { frameWitness: { enabled: false } } : {});
|
|
694
|
+
});
|
|
695
|
+
socket.on("error", () => {
|
|
696
|
+
clients.delete(socket);
|
|
697
|
+
editorReporters.delete(socket);
|
|
698
|
+
terminalSubscriptions.delete(socket);
|
|
699
|
+
control.closeSocket(socket);
|
|
700
|
+
const reporterLost = frameReporter !== null && frameReporter.socket === socket;
|
|
701
|
+
if (reporterLost) {
|
|
702
|
+
frameWitnessSummaryUpdates.cancel();
|
|
703
|
+
cleanupFrameReporter(frameReporter, "reporter-error", (trailing) => {
|
|
704
|
+
try {
|
|
705
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, trailing, screenLogBytes, screenLogLimits);
|
|
706
|
+
const seq = feedOutput(terminalModel, trailing);
|
|
707
|
+
lastOutputSeq = seq;
|
|
708
|
+
feedViewProofOutput(frameReporter, seq, trailing);
|
|
709
|
+
for (const [peer, sub] of terminalSubscriptions) if (sub.subscribed()) sub.onOutput(seq, trailing);
|
|
710
|
+
} catch {
|
|
711
|
+
/* best effort */
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
frameReporter = null;
|
|
715
|
+
resetViewProofSubscribers(terminalSubscriptions, "reporter-error");
|
|
716
|
+
}
|
|
717
|
+
update(reporterLost ? { frameWitness: { enabled: false } } : {});
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
server.on("error", (err) => {
|
|
721
|
+
update({ state: "failed", endedAt: Date.now(), error: err instanceof Error ? err.message : String(err), exitCode: 1 });
|
|
722
|
+
void shutdown(1);
|
|
723
|
+
});
|
|
724
|
+
server.listen(socketPath, () => {
|
|
725
|
+
// Capture the identity BEFORE publishing readiness: "host ready" must imply
|
|
726
|
+
// "identity recorded", or a shutdown could run with a stale/null identity.
|
|
727
|
+
if (process.platform !== "win32") {
|
|
728
|
+
try {
|
|
729
|
+
const st = statSync(socketPath);
|
|
730
|
+
boundSocketIdentity = { dev: st.dev, ino: st.ino };
|
|
731
|
+
} catch { /* without the identity, cleanup degrades to a no-op */ }
|
|
732
|
+
}
|
|
733
|
+
update({ socketPath, state: "alive" });
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
function handleClientLine(line, socket) {
|
|
737
|
+
if (!line.trim()) return;
|
|
738
|
+
let msg;
|
|
739
|
+
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
740
|
+
// Enveloped control commands take the phase-5 lifecycle path; everything
|
|
741
|
+
// else (no commandId, or a non-control type carrying one) falls through to
|
|
742
|
+
// the legacy switch byte-identically.
|
|
743
|
+
const envelope = validateCommandEnvelope(msg);
|
|
744
|
+
if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
|
|
745
|
+
control.handle(msg, socket, envelope);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
switch (msg.type) {
|
|
749
|
+
case "hello": {
|
|
750
|
+
// Bookkeeping-only clients must never pin the host against warm-host
|
|
751
|
+
// reclaim (issue #103 §C / #130): probes are read-only and transient,
|
|
752
|
+
// the editor reporter is resident. Both leave `clients` — the sole
|
|
753
|
+
// source of `attachedClients` — while their socket stays writable so
|
|
754
|
+
// probe replies and editor_state keep flowing. The policy lives in
|
|
755
|
+
// `helloBookkeeping`, so this path and the owned one cannot drift.
|
|
756
|
+
const book = helloBookkeeping(classifyClientHello(msg));
|
|
757
|
+
if (!book.keepInClients) {
|
|
758
|
+
clients.delete(socket);
|
|
759
|
+
terminalSubscriptions.delete(socket);
|
|
760
|
+
}
|
|
761
|
+
if (book.registerReporter) editorReporters.add(socket);
|
|
762
|
+
// Issue #222 FW-04: a frame reporter gets a per-instance random token; only a
|
|
763
|
+
// valid hello + frameWitness protocol 1 enables anything. Old/no hello is
|
|
764
|
+
// unchanged (no new messages).
|
|
765
|
+
{
|
|
766
|
+
const enabled = enableFrameReporter(socket, msg, send);
|
|
767
|
+
if (enabled !== null) {
|
|
768
|
+
const replacingReporter = frameReporter !== null && frameReporter.socket !== socket;
|
|
769
|
+
if (replacingReporter) {
|
|
770
|
+
// Same ordinary-trailing forwarding path as close/error/child-exit: a
|
|
771
|
+
// pending ordinary tail must not be lost when the reporter is replaced.
|
|
772
|
+
cleanupFrameReporter(frameReporter, "reporter-replaced", (trailing) => {
|
|
773
|
+
try {
|
|
774
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, trailing, screenLogBytes, screenLogLimits);
|
|
775
|
+
const seq = feedOutput(terminalModel, trailing);
|
|
776
|
+
lastOutputSeq = seq;
|
|
777
|
+
feedViewProofOutput(frameReporter, seq, trailing);
|
|
778
|
+
for (const [peer, sub] of terminalSubscriptions) if (sub.subscribed()) sub.onOutput(seq, trailing);
|
|
779
|
+
} catch {
|
|
780
|
+
/* best effort during replacement */
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
frameReporter = enabled;
|
|
785
|
+
if (replacingReporter) resetViewProofSubscribers(terminalSubscriptions, "reporter-replaced");
|
|
786
|
+
try { frameWitnessSummaryUpdates.flush(); } catch { /* row may be finalised */ }
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// A real client writes via flipAttachedEver alone (hence the `else
|
|
790
|
+
// if`); `persist` is the write flag for rows that write as-is (reporter).
|
|
791
|
+
if (book.flipAttachedEver) update({ attachedEver: true });
|
|
792
|
+
else if (book.persist) update();
|
|
793
|
+
send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
case "input":
|
|
797
|
+
if (typeof msg.data === "string") child.write(msg.data);
|
|
798
|
+
break;
|
|
799
|
+
case "resize": {
|
|
800
|
+
const cols = clampInt(msg.cols, 20, MAX_COLS, host.cols);
|
|
801
|
+
const rows = clampInt(msg.rows, 5, MAX_ROWS, host.rows);
|
|
802
|
+
applyResize(cols, rows);
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
case "interrupt":
|
|
806
|
+
child.write("\x1b");
|
|
807
|
+
break;
|
|
808
|
+
case "subscribe_terminal":
|
|
809
|
+
terminalSubscriptions.get(socket)?.handleMessage(msg);
|
|
810
|
+
break;
|
|
811
|
+
case "terminate": {
|
|
812
|
+
killChild(child, childPid, "SIGTERM");
|
|
813
|
+
setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
case "detach":
|
|
817
|
+
socket.end();
|
|
818
|
+
break;
|
|
819
|
+
case "get_status":
|
|
820
|
+
send(socket, { type: "status", status: host, generation: GENERATION });
|
|
821
|
+
break;
|
|
822
|
+
case "editor_state": {
|
|
823
|
+
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
824
|
+
broadcast({ type: "editor_state", empty: editorEmpty });
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const heartbeat = setInterval(() => {
|
|
831
|
+
if (host.state === "alive") update();
|
|
832
|
+
}, HEARTBEAT_MS);
|
|
833
|
+
heartbeat.unref?.();
|
|
834
|
+
|
|
835
|
+
function waitForChildExit(timeoutMs) {
|
|
836
|
+
if (childExited) return Promise.resolve(true);
|
|
837
|
+
return new Promise((resolve) => {
|
|
838
|
+
let settled = false;
|
|
839
|
+
let timer;
|
|
840
|
+
const finish = (exited) => {
|
|
841
|
+
if (settled) return;
|
|
842
|
+
settled = true;
|
|
843
|
+
clearTimeout(timer);
|
|
844
|
+
resolve(exited);
|
|
845
|
+
};
|
|
846
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
847
|
+
childExitPromise.then(() => finish(true));
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function shutdown(requestedExitCode = null) {
|
|
852
|
+
if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
|
|
853
|
+
if (shutdownStarted) return;
|
|
854
|
+
shutdownStarted = true;
|
|
855
|
+
// Terminate lifecycle (spec D4): pending enveloped terminates get the
|
|
856
|
+
// runner-finalizing evidence before sockets end (exit-confirm flush in
|
|
857
|
+
// child.onExit covers the natural path; this covers deliberate shutdown).
|
|
858
|
+
control.flushTerminateObservations(null, true);
|
|
859
|
+
// Do NOT call server.close() (issue #257, nodejs/node#19729): libuv unlinks the
|
|
860
|
+
// unix-socket bind path when the listening handle closes, so a shutdown after a
|
|
861
|
+
// successor bound would delete the successor's endpoint. With no close there is
|
|
862
|
+
// no implicit unlink. Cleanup is explicit and identity-gated below — the legacy
|
|
863
|
+
// path has no lease, so dev+ino is its only ownership proof.
|
|
864
|
+
// Windows has no close-time unlink (named pipes are kernel objects), so keep the
|
|
865
|
+
// historical prompt refusal of new connections there.
|
|
866
|
+
if (process.platform === "win32") {
|
|
867
|
+
try { server?.close(); } catch { /* already closed */ }
|
|
868
|
+
}
|
|
869
|
+
if (process.platform !== "win32" && boundSocketIdentity) {
|
|
870
|
+
try {
|
|
871
|
+
const st = statSync(socketPath);
|
|
872
|
+
if (ownsEndpoint(boundSocketIdentity, { dev: st.dev, ino: st.ino })) {
|
|
873
|
+
try { unlinkSync(socketPath); } catch { /* best effort */ }
|
|
874
|
+
}
|
|
875
|
+
} catch { /* path gone — nothing to clean */ }
|
|
876
|
+
}
|
|
877
|
+
for (const client of clients) {
|
|
878
|
+
try { client.end(); } catch {}
|
|
879
|
+
}
|
|
880
|
+
for (const reporter of editorReporters) {
|
|
881
|
+
try { reporter.end(); } catch { /* best effort */ }
|
|
882
|
+
}
|
|
883
|
+
editorReporters.clear();
|
|
884
|
+
killChild(child, childPid, "SIGTERM");
|
|
885
|
+
if (!(await waitForChildExit(4000)) && !childExited) {
|
|
886
|
+
killChild(child, childPid, "SIGKILL");
|
|
887
|
+
if (!(await waitForChildExit(1000)) && !childExited) {
|
|
888
|
+
// The child abstraction has no portable liveness probe. Exit only
|
|
889
|
+
// after the escalation window so normal children are always awaited;
|
|
890
|
+
// an unkillable platform child is left to the OS.
|
|
891
|
+
process.exit(1);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
process.exit(shutdownExitCode ?? exitCode ?? 0);
|
|
895
|
+
}
|
|
896
|
+
process.on("SIGTERM", () => { void shutdown(); });
|
|
897
|
+
process.on("SIGINT", () => { void shutdown(); });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* New-protocol host runner (issue #70).
|
|
902
|
+
*
|
|
903
|
+
* Runs only for instance-scoped configs. The lifecycle is fenced by an
|
|
904
|
+
* `instanceId` owner token and the per-view `host-start` lease:
|
|
905
|
+
* lease → ownership decision → bind UNIQUE endpoint → publish runner identity
|
|
906
|
+
* → spawn child → publish ready → release lease → heartbeat.
|
|
907
|
+
* All exits funnel through finishHost(), which writes only while the instance
|
|
908
|
+
* still owns host.json and unlinks only the exact socket inode it bound.
|
|
909
|
+
* @param {import("../src/core/types.mjs").HostConfig & { instanceId: string }} config
|
|
910
|
+
*/
|
|
911
|
+
async function ownedMain(config) {
|
|
912
|
+
// Self token for the published runner identity (issue #238): started before
|
|
913
|
+
// the start lease so the win32 creation-time probe (~390 ms) overlaps the
|
|
914
|
+
// lease, the yield check and endpoint binding. Never awaited here.
|
|
915
|
+
const selfTokenPromise = readProcessStartToken(process.pid);
|
|
916
|
+
if (!config.socketPath) failEarly("pty-runner: owned config missing socketPath");
|
|
917
|
+
const socketPath = config.socketPath;
|
|
918
|
+
const screenLog = P.screenLogPath(config.root, config.viewId);
|
|
919
|
+
const screenLogMaxBytes =
|
|
920
|
+
Number.isFinite(config.screenLogMaxBytes) && config.screenLogMaxBytes > 0
|
|
921
|
+
? Math.floor(config.screenLogMaxBytes)
|
|
922
|
+
: undefined;
|
|
923
|
+
const screenLogLimits = { maxBytes: screenLogMaxBytes };
|
|
924
|
+
let screenLogBytes = reconcileScreenLog(screenLog, screenLogLimits);
|
|
925
|
+
|
|
926
|
+
/** @type {Set<import("node:net").Socket>} */
|
|
927
|
+
const clients = new Set();
|
|
928
|
+
/** Resident editor-state reporters: connected but never "attached" (#103). */
|
|
929
|
+
const editorReporters = new Set();
|
|
930
|
+
let frameReporter = null;
|
|
931
|
+
let lastOutputSeq = 0;
|
|
932
|
+
let childPid = null;
|
|
933
|
+
let child = null;
|
|
934
|
+
let exitCode = null;
|
|
935
|
+
let childExited = false;
|
|
936
|
+
let resolveChildExit;
|
|
937
|
+
const childExitPromise = new Promise((resolve) => {
|
|
938
|
+
resolveChildExit = resolve;
|
|
939
|
+
});
|
|
940
|
+
/** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
|
|
941
|
+
let boundSocketIdentity = null;
|
|
942
|
+
/** Set once THIS process published its runner identity into the record —
|
|
943
|
+
* only then may finishHost write host state. A same-instance duplicate that
|
|
944
|
+
* dies at listen (EADDRINUSE) shares the fencing token but never took the
|
|
945
|
+
* record, so it must leave the winner's state untouched (issue #70). */
|
|
946
|
+
let claimedRecord = false;
|
|
947
|
+
let shutdownStarted = false;
|
|
948
|
+
/** Authoritative child editor emptiness (issue #68); null = unknown. */
|
|
949
|
+
let editorEmpty = null;
|
|
950
|
+
/** In-memory snapshot of the owned host record, refreshed on every owned write. */
|
|
951
|
+
let host = readHost(config.root, config.viewId);
|
|
952
|
+
/** @type {{ token: string, touch(): boolean, isOwner(): boolean, release(): boolean } | null} */
|
|
953
|
+
let startLease = null;
|
|
954
|
+
let startTouchTimer = null;
|
|
955
|
+
let heartbeatTimer = null;
|
|
956
|
+
let server = null;
|
|
957
|
+
/** Service-input ack dedup: requestId → true. FIFO-capped at
|
|
958
|
+
* HOST_ACK_DEDUP_MAX. Interactive UI keystrokes (no requestId) bypass it. */
|
|
959
|
+
const ackedRequestIds = new Map();
|
|
960
|
+
/** Last {cols, rows} held while the child does not exist yet; applied when
|
|
961
|
+
* ready is published (issue #70 §6.3 starting protocol). */
|
|
962
|
+
let cachedResize = null;
|
|
963
|
+
|
|
964
|
+
const diag = (code, message, details) => {
|
|
965
|
+
try {
|
|
966
|
+
appendDiagnostic(config.root, config.viewId, {
|
|
967
|
+
source: "runner",
|
|
968
|
+
code,
|
|
969
|
+
message,
|
|
970
|
+
...(details ? { details } : {}),
|
|
971
|
+
});
|
|
972
|
+
} catch { /* best effort */ }
|
|
973
|
+
};
|
|
974
|
+
const broadcast = (msg) => {
|
|
975
|
+
const line = JSON.stringify(msg) + "\n";
|
|
976
|
+
for (const c of clients) {
|
|
977
|
+
try { c.write(line); } catch { /* best effort */ }
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
/** Owner-fenced host write; refreshes the in-memory snapshot. */
|
|
981
|
+
const ownedUpdate = (mutate) => {
|
|
982
|
+
const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
|
|
983
|
+
const next = mutate(cur);
|
|
984
|
+
// Record revision (phase 5 reconcile baseline): every committed fenced
|
|
985
|
+
// write bumps it, heartbeats included — a client comparing revisions
|
|
986
|
+
// sees any host-record movement, which is exactly the contract.
|
|
987
|
+
return { ...next, revision: (cur.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
988
|
+
});
|
|
989
|
+
if (result.updated && result.host) host = result.host;
|
|
990
|
+
return result;
|
|
991
|
+
};
|
|
992
|
+
const frameWitnessSummaryUpdates = createFrameWitnessSummaryScheduler({
|
|
993
|
+
publish: () => {
|
|
994
|
+
if (frameReporter !== null) ownedUpdate((cur) => ({ ...cur, frameWitness: frameWitnessSummary(frameReporter) }));
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
const isOwnerNow = () => {
|
|
998
|
+
const h = readHost(config.root, config.viewId);
|
|
999
|
+
return Boolean(h && h.instanceId === config.instanceId);
|
|
1000
|
+
};
|
|
1001
|
+
const releaseStartLease = () => {
|
|
1002
|
+
if (startTouchTimer) {
|
|
1003
|
+
clearInterval(startTouchTimer);
|
|
1004
|
+
startTouchTimer = null;
|
|
1005
|
+
}
|
|
1006
|
+
const lease = startLease;
|
|
1007
|
+
startLease = null;
|
|
1008
|
+
try { lease?.release(); } catch { /* best effort */ }
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
function waitForChildExit(timeoutMs) {
|
|
1012
|
+
if (childExited) return Promise.resolve(true);
|
|
1013
|
+
return new Promise((resolve) => {
|
|
1014
|
+
let settled = false;
|
|
1015
|
+
let timer;
|
|
1016
|
+
const finish = (exited) => {
|
|
1017
|
+
if (settled) return;
|
|
1018
|
+
settled = true;
|
|
1019
|
+
clearTimeout(timer);
|
|
1020
|
+
resolve(exited);
|
|
1021
|
+
};
|
|
1022
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
1023
|
+
childExitPromise.then(() => finish(true));
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Reasons that finalize as `failed` rather than `exited`. */
|
|
1028
|
+
const FAILED_REASONS = new Set(["server_error", "child_error", "crash", "child_spawn_failed", "endpoint_busy"]);
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Idempotent unified finish: fenced terminal write, bounded server close,
|
|
1032
|
+
* child exit with escalation, inode-owned socket cleanup, config cleanup.
|
|
1033
|
+
* @param {string} reason
|
|
1034
|
+
* @param {number|null} [requestedExitCode]
|
|
1035
|
+
*/
|
|
1036
|
+
async function finishHost(reason, requestedExitCode = null) {
|
|
1037
|
+
if (shutdownStarted) return;
|
|
1038
|
+
shutdownStarted = true;
|
|
1039
|
+
// Terminate lifecycle (spec D4): pending enveloped terminates get the
|
|
1040
|
+
// runner-finalizing evidence before teardown ends sockets. The child-exit
|
|
1041
|
+
// flush (exitConfirmed) normally wins the race; whichever fires first
|
|
1042
|
+
// clears the pending set, so exactly one observed ack goes out.
|
|
1043
|
+
control.flushTerminateObservations(null, true);
|
|
1044
|
+
if (heartbeatTimer) {
|
|
1045
|
+
clearInterval(heartbeatTimer);
|
|
1046
|
+
heartbeatTimer = null;
|
|
1047
|
+
}
|
|
1048
|
+
// Only the runner that actually claimed the record writes terminal state; a
|
|
1049
|
+
// superseded owner (or a duplicate that never claimed) never writes.
|
|
1050
|
+
if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
|
|
1051
|
+
ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
|
|
1052
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
1053
|
+
}
|
|
1054
|
+
try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
|
|
1055
|
+
for (const c of clients) {
|
|
1056
|
+
try { c.destroy(); } catch { /* best effort */ }
|
|
1057
|
+
}
|
|
1058
|
+
clients.clear();
|
|
1059
|
+
for (const reporter of editorReporters) {
|
|
1060
|
+
try { reporter.destroy(); } catch { /* best effort */ }
|
|
1061
|
+
}
|
|
1062
|
+
editorReporters.clear();
|
|
1063
|
+
// Bounded server close (1s): never let a stuck client block cleanup.
|
|
1064
|
+
await new Promise((resolve) => {
|
|
1065
|
+
if (!server) return resolve();
|
|
1066
|
+
let settled = false;
|
|
1067
|
+
const done = () => {
|
|
1068
|
+
if (!settled) {
|
|
1069
|
+
settled = true;
|
|
1070
|
+
resolve();
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
const timer = setTimeout(done, 1000);
|
|
1074
|
+
server.close(() => {
|
|
1075
|
+
clearTimeout(timer);
|
|
1076
|
+
done();
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
if (child && !childExited) {
|
|
1080
|
+
killChild(child, childPid, "SIGTERM");
|
|
1081
|
+
if (!(await waitForChildExit(4000)) && !childExited) {
|
|
1082
|
+
killChild(child, childPid, "SIGKILL");
|
|
1083
|
+
await waitForChildExit(1000);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
|
|
1087
|
+
const failed = FAILED_REASONS.has(reason);
|
|
1088
|
+
ownedUpdate((cur) => ({
|
|
1089
|
+
...cur,
|
|
1090
|
+
state: failed ? "failed" : "exited",
|
|
1091
|
+
endedAt: Date.now(),
|
|
1092
|
+
exitCode: requestedExitCode ?? exitCode ?? 0,
|
|
1093
|
+
childPid: null,
|
|
1094
|
+
readyAt: null,
|
|
1095
|
+
stopRequestedAt: null,
|
|
1096
|
+
stopReason: reason,
|
|
1097
|
+
// Attribute abnormal NATURAL child exits only (issue #90): reason
|
|
1098
|
+
// "child_exit" with a non-zero code. Stops ("signal") and crashes
|
|
1099
|
+
// have their own attribution (stopReason / crash finalize), and a
|
|
1100
|
+
// SIGTERM'd healthy child must not capture a junk error line.
|
|
1101
|
+
...(reason === "child_exit" && cur.error == null
|
|
1102
|
+
? attributedExitError(requestedExitCode ?? exitCode ?? 0, screenLog)
|
|
1103
|
+
: {}),
|
|
1104
|
+
}));
|
|
1105
|
+
}
|
|
1106
|
+
// Endpoint cleanup: only the exact inode this instance bound.
|
|
1107
|
+
if (process.platform !== "win32" && boundSocketIdentity) {
|
|
1108
|
+
try {
|
|
1109
|
+
const st = statSync(socketPath);
|
|
1110
|
+
if (ownsEndpoint(boundSocketIdentity, { dev: st.dev, ino: st.ino })) {
|
|
1111
|
+
try { unlinkSync(socketPath); } catch { /* best effort */ }
|
|
1112
|
+
}
|
|
1113
|
+
} catch { /* path gone — nothing to clean */ }
|
|
1114
|
+
}
|
|
1115
|
+
// Best-effort config cleanup (instance-scoped file only).
|
|
1116
|
+
try {
|
|
1117
|
+
if (config.configPath && config.instanceId && config.configPath.includes(config.instanceId)) {
|
|
1118
|
+
unlinkSync(config.configPath);
|
|
1119
|
+
}
|
|
1120
|
+
} catch { /* best effort */ }
|
|
1121
|
+
releaseStartLease();
|
|
1122
|
+
process.exit(requestedExitCode ?? exitCode ?? 0);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Bounded finish: a mid-finish throw must still exit the process (the runner
|
|
1126
|
+
// is detached; an unhandled rejection inside finishHost would otherwise hang).
|
|
1127
|
+
const finish = (reason, requestedExitCode = null) => {
|
|
1128
|
+
finishHost(reason, requestedExitCode).catch(() => process.exit(1));
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
process.on("SIGTERM", () => { finish("signal"); });
|
|
1132
|
+
process.on("SIGINT", () => { finish("signal"); });
|
|
1133
|
+
process.on("uncaughtException", (err) => {
|
|
1134
|
+
process.removeAllListeners("uncaughtException");
|
|
1135
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1136
|
+
diag("runner_crash", message, { stack: err instanceof Error ? err.stack : undefined });
|
|
1137
|
+
// Crash finalize is owner-fenced via the expected-instance path in
|
|
1138
|
+
// finalizeHostCrash: a superseded owner must not clobber the replacement's
|
|
1139
|
+
// record (it records host_crash_owner_changed instead of writing).
|
|
1140
|
+
host = finalizeHostCrash(config.root, config.viewId, host, err, { expectedInstanceId: config.instanceId });
|
|
1141
|
+
finish("crash", 1);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// 1. Take the per-view host-start lease — the launch transaction boundary.
|
|
1145
|
+
try {
|
|
1146
|
+
startLease = acquireOwnedViewLock(config.root, config.viewId, "host-start", {
|
|
1147
|
+
waitMs: HOST_RUNNER_LOCK_WAIT_MS,
|
|
1148
|
+
identity: { pid: process.pid, startToken: captureStartToken(process.pid) },
|
|
1149
|
+
});
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
diag("host_start_lock_timeout", err instanceof Error ? err.message : String(err));
|
|
1152
|
+
process.exit(1);
|
|
1153
|
+
}
|
|
1154
|
+
startTouchTimer = setInterval(() => {
|
|
1155
|
+
try { startLease?.touch(); } catch { /* best effort */ }
|
|
1156
|
+
}, HEARTBEAT_MS);
|
|
1157
|
+
startTouchTimer.unref?.();
|
|
1158
|
+
|
|
1159
|
+
// 2. Ownership decision inside the lease: never write host.json on the yield paths.
|
|
1160
|
+
const current = readHost(config.root, config.viewId);
|
|
1161
|
+
if (shouldYieldRunner({ host: current, instanceId: config.instanceId })) {
|
|
1162
|
+
diag("host_start_yielded", "host record belongs to another active instance", { recordInstance: current?.instanceId ?? null });
|
|
1163
|
+
releaseStartLease();
|
|
1164
|
+
process.exit(0);
|
|
1165
|
+
}
|
|
1166
|
+
if (!current || current.instanceId !== config.instanceId) {
|
|
1167
|
+
diag("host_start_stale_record", "no matching host claim for this instance", { recordInstance: current?.instanceId ?? null });
|
|
1168
|
+
releaseStartLease();
|
|
1169
|
+
process.exit(0);
|
|
1170
|
+
}
|
|
1171
|
+
// Own-terminal record: this instance already finished once (e.g. the prior
|
|
1172
|
+
// runner of the SAME instance exited after claiming). Continuing would
|
|
1173
|
+
// re-publish exited→starting and re-deliver initialPrompt — exit instead.
|
|
1174
|
+
if (current.state === "exited" || current.state === "failed") {
|
|
1175
|
+
diag("host_start_own_terminal", "host record for this instance is already terminal");
|
|
1176
|
+
releaseStartLease();
|
|
1177
|
+
process.exit(0);
|
|
1178
|
+
}
|
|
1179
|
+
if (current.state === "stopping" || current.stopRequestedAt != null) {
|
|
1180
|
+
diag("host_start_revoked", "host claim was revoked before this runner started");
|
|
1181
|
+
releaseStartLease();
|
|
1182
|
+
process.exit(0);
|
|
1183
|
+
}
|
|
1184
|
+
host = current;
|
|
1185
|
+
|
|
1186
|
+
// Canonical terminal model + per-socket snapshot subscriptions (issue #91
|
|
1187
|
+
// phase 3). Created from the owned host record before the endpoint binds,
|
|
1188
|
+
// so every connect (including probes) can carry a subscription. Legacy
|
|
1189
|
+
// message semantics unchanged (the `seq` field on output is additive).
|
|
1190
|
+
const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
|
|
1191
|
+
/** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
|
|
1192
|
+
const terminalSubscriptions = new Map();
|
|
1193
|
+
// Control lifecycle runtime (issue #91 phase 5). Created after the terminal
|
|
1194
|
+
// model (actions close over it) and BEFORE the endpoint binds, so the durable
|
|
1195
|
+
// journal is loaded from disk before any client can reconcile. The action
|
|
1196
|
+
// closures read `child`/`host` at call time (both are let-bound above).
|
|
1197
|
+
// Shared by the control runtime and the plain resize message against a live
|
|
1198
|
+
// child. The raw no-child branch below retains its cachedResize behavior.
|
|
1199
|
+
const applyResize = (cols, rows) => {
|
|
1200
|
+
const before = { cols: terminalModel.cols, rows: terminalModel.rows };
|
|
1201
|
+
resizeChildAndModel(child, terminalModel, cols, rows);
|
|
1202
|
+
notifyChildResize(childPid);
|
|
1203
|
+
cachedResize = null;
|
|
1204
|
+
ownedUpdate((cur) => ({ ...cur, cols, rows }));
|
|
1205
|
+
const after = { cols: terminalModel.cols, rows: terminalModel.rows };
|
|
1206
|
+
if (frameReporter !== null && (after.cols !== before.cols || after.rows !== before.rows)) {
|
|
1207
|
+
announceFrameIntent(frameReporter, send, after.cols, after.rows);
|
|
1208
|
+
frameWitnessSummaryUpdates.flush();
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
const control = createControlRuntime({
|
|
1212
|
+
viewId: config.viewId,
|
|
1213
|
+
root: config.root,
|
|
1214
|
+
instanceId: config.instanceId,
|
|
1215
|
+
send,
|
|
1216
|
+
diag,
|
|
1217
|
+
actions: {
|
|
1218
|
+
childReady: () => Boolean(child),
|
|
1219
|
+
writeInput: (data) => {
|
|
1220
|
+
child.write(data);
|
|
1221
|
+
},
|
|
1222
|
+
hostCols: () => host.cols,
|
|
1223
|
+
hostRows: () => host.rows,
|
|
1224
|
+
applyResize,
|
|
1225
|
+
currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
1226
|
+
frameRevision: () => (frameReporter === null ? null : { geometryRevision: frameReporter.geometryRevision, intentId: frameReporter.requestSeq }),
|
|
1227
|
+
applyInterrupt: () => {
|
|
1228
|
+
if (child) child.write("\x1b");
|
|
1229
|
+
},
|
|
1230
|
+
applyTerminate: () => {
|
|
1231
|
+
// Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
|
|
1232
|
+
// so a SIGTERM-immune child still terminates within a bounded window.
|
|
1233
|
+
// The applied ack was already sent by the runtime BEFORE this hook.
|
|
1234
|
+
finish("terminated", 0);
|
|
1235
|
+
},
|
|
1236
|
+
applyDetach: (s) => {
|
|
1237
|
+
s.end();
|
|
1238
|
+
},
|
|
1239
|
+
hostRevision: () => host.revision ?? 0,
|
|
1240
|
+
cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
|
|
1241
|
+
stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
// 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
|
|
1246
|
+
// instance; an occupied path means someone else owns it.
|
|
1247
|
+
const listenOutcome = await new Promise((resolveListen) => {
|
|
1248
|
+
const onError = (err) => resolveListen({ ok: false, error: err });
|
|
1249
|
+
// Probe connections (clientId:"probe" hello) must not write host.json: the
|
|
1250
|
+
// attach resolver's 150ms probe loop would amplify fenced writes and flip
|
|
1251
|
+
// attachedEver with no client ever attached (CR round-1 finding 3). The
|
|
1252
|
+
// "not counted" half is handled at hello time instead (#130): the socket
|
|
1253
|
+
// leaves `clients` there, so this WeakSet only suppresses the write.
|
|
1254
|
+
const probeSockets = new WeakSet();
|
|
1255
|
+
server = createServer((socket) => {
|
|
1256
|
+
clients.add(socket);
|
|
1257
|
+
terminalSubscriptions.set(
|
|
1258
|
+
socket,
|
|
1259
|
+
createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION, viewProof: createViewProofSource(() => frameReporter) }),
|
|
1260
|
+
);
|
|
1261
|
+
socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
|
|
1262
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
1263
|
+
let buffer = "";
|
|
1264
|
+
const decoder = new StringDecoder("utf8"); // A read can end halfway through a character.
|
|
1265
|
+
socket.on("data", (chunk) => {
|
|
1266
|
+
buffer += decoder.write(chunk);
|
|
1267
|
+
const lines = buffer.split("\n");
|
|
1268
|
+
buffer = lines.pop() ?? "";
|
|
1269
|
+
for (const line of lines) handleClientLine(line, socket);
|
|
1270
|
+
});
|
|
1271
|
+
socket.on("close", () => {
|
|
1272
|
+
clients.delete(socket);
|
|
1273
|
+
editorReporters.delete(socket);
|
|
1274
|
+
terminalSubscriptions.delete(socket);
|
|
1275
|
+
control.closeSocket(socket);
|
|
1276
|
+
const reporterLost = frameReporter !== null && frameReporter.socket === socket;
|
|
1277
|
+
if (reporterLost) {
|
|
1278
|
+
frameWitnessSummaryUpdates.cancel();
|
|
1279
|
+
cleanupFrameReporter(frameReporter, "reporter-close", (trailing) => {
|
|
1280
|
+
try {
|
|
1281
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, trailing, screenLogBytes, screenLogLimits);
|
|
1282
|
+
const seq = feedOutput(terminalModel, trailing);
|
|
1283
|
+
lastOutputSeq = seq;
|
|
1284
|
+
feedViewProofOutput(frameReporter, seq, trailing);
|
|
1285
|
+
for (const [peer, sub] of terminalSubscriptions) if (sub.subscribed()) sub.onOutput(seq, trailing);
|
|
1286
|
+
} catch {
|
|
1287
|
+
/* best effort */
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
frameReporter = null;
|
|
1291
|
+
resetViewProofSubscribers(terminalSubscriptions, "reporter-close");
|
|
1292
|
+
}
|
|
1293
|
+
if (probeSockets.has(socket)) return;
|
|
1294
|
+
// Merge into the live record (a stale closure spread here erases a
|
|
1295
|
+
// concurrent revoke — final review finding 2).
|
|
1296
|
+
const closeUpdate = ownedUpdate((cur) => (reporterLost ? { ...cur, frameWitness: { enabled: false } } : { ...cur }));
|
|
1297
|
+
if (reporterLost && closeUpdate.updated) broadcast({ type: "status", status: host, generation: GENERATION });
|
|
1298
|
+
});
|
|
1299
|
+
socket.on("error", () => {
|
|
1300
|
+
clients.delete(socket);
|
|
1301
|
+
editorReporters.delete(socket);
|
|
1302
|
+
terminalSubscriptions.delete(socket);
|
|
1303
|
+
control.closeSocket(socket);
|
|
1304
|
+
const reporterLost = frameReporter !== null && frameReporter.socket === socket;
|
|
1305
|
+
if (reporterLost) {
|
|
1306
|
+
frameWitnessSummaryUpdates.cancel();
|
|
1307
|
+
cleanupFrameReporter(frameReporter, "reporter-error", (trailing) => {
|
|
1308
|
+
try {
|
|
1309
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, trailing, screenLogBytes, screenLogLimits);
|
|
1310
|
+
const seq = feedOutput(terminalModel, trailing);
|
|
1311
|
+
lastOutputSeq = seq;
|
|
1312
|
+
feedViewProofOutput(frameReporter, seq, trailing);
|
|
1313
|
+
for (const [peer, sub] of terminalSubscriptions) if (sub.subscribed()) sub.onOutput(seq, trailing);
|
|
1314
|
+
} catch {
|
|
1315
|
+
/* best effort */
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
frameReporter = null;
|
|
1319
|
+
resetViewProofSubscribers(terminalSubscriptions, "reporter-error");
|
|
1320
|
+
}
|
|
1321
|
+
if (probeSockets.has(socket)) return;
|
|
1322
|
+
const errorUpdate = ownedUpdate((cur) => (reporterLost ? { ...cur, frameWitness: { enabled: false } } : { ...cur }));
|
|
1323
|
+
if (reporterLost && errorUpdate.updated) broadcast({ type: "status", status: host, generation: GENERATION });
|
|
1324
|
+
});
|
|
1325
|
+
socket.markProbe = () => probeSockets.add(socket);
|
|
1326
|
+
});
|
|
1327
|
+
server.once("error", onError);
|
|
1328
|
+
server.listen(socketPath, () => {
|
|
1329
|
+
server?.removeListener("error", onError);
|
|
1330
|
+
resolveListen({ ok: true });
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
if (!listenOutcome.ok) {
|
|
1334
|
+
diag("host_endpoint_busy", listenOutcome.error instanceof Error ? listenOutcome.error.message : String(listenOutcome.error));
|
|
1335
|
+
await finishHost("endpoint_busy", 1);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
server.on("error", (err) => {
|
|
1339
|
+
diag("server_error", err instanceof Error ? err.message : String(err));
|
|
1340
|
+
finish("server_error", 1);
|
|
1341
|
+
});
|
|
1342
|
+
// POSIX: record the exact inode we bound — cleanup matches dev+ino.
|
|
1343
|
+
if (process.platform !== "win32") {
|
|
1344
|
+
try {
|
|
1345
|
+
const st = statSync(socketPath);
|
|
1346
|
+
boundSocketIdentity = { dev: st.dev, ino: st.ino };
|
|
1347
|
+
} catch { /* without the identity, cleanup degrades to no-op */ }
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// 4. Publish runner identity while still holding the lease.
|
|
1351
|
+
const selfToken = await selfTokenPromise;
|
|
1352
|
+
const claimedSelf = ownedUpdate((cur) => ({
|
|
1353
|
+
...cur,
|
|
1354
|
+
runnerPid: process.pid,
|
|
1355
|
+
runnerIdentity: { pid: process.pid, startToken: selfToken },
|
|
1356
|
+
runnerSpawnedAt: cur.runnerSpawnedAt ?? Date.now(),
|
|
1357
|
+
state: cur.state === "alive" ? "alive" : "starting",
|
|
1358
|
+
}));
|
|
1359
|
+
if (!claimedSelf.updated) {
|
|
1360
|
+
await finishHost("owner_lost", 0);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
claimedRecord = true;
|
|
1364
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
1365
|
+
|
|
1366
|
+
// Test seam (issue #70 Task 8): a config-level delay between publishing the
|
|
1367
|
+
// runner identity and spawning the child creates a REAL starting window
|
|
1368
|
+
// (endpoint accepting, child null) so the starting protocol is testable.
|
|
1369
|
+
// Only host configs written by tests set this env; the service never does.
|
|
1370
|
+
const spawnDelayMs = Number(config.env?.AGENT_BOARD_TEST_SPAWN_DELAY_MS || 0);
|
|
1371
|
+
if (spawnDelayMs > 0) await new Promise((r) => { setTimeout(r, spawnDelayMs).unref?.(); });
|
|
1372
|
+
|
|
1373
|
+
// 5. Spawn the child — only after the endpoint is bound and owned.
|
|
1374
|
+
const args = [...config.piArgsPrefix, "--session", config.sessionFile];
|
|
1375
|
+
if (config.model) args.push("--model", config.model);
|
|
1376
|
+
if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
|
|
1377
|
+
if (config.tools) args.push("--tools", config.tools);
|
|
1378
|
+
if (config.initialPrompt) args.push(encodePromptForCliArg(config.initialPrompt));
|
|
1379
|
+
const env = {
|
|
1380
|
+
...process.env,
|
|
1381
|
+
...(config.env || {}),
|
|
1382
|
+
AGENT_BOARD_ROOT: config.root,
|
|
1383
|
+
AGENT_BOARD_VIEW_ID: config.viewId,
|
|
1384
|
+
AGENT_BOARD_CHILD: "1",
|
|
1385
|
+
AGENT_BOARD_HOSTED: "pty",
|
|
1386
|
+
// The endpoint this host actually bound: the child's editor-state reporter
|
|
1387
|
+
// dials it instead of guessing the stable per-view address (issue #103).
|
|
1388
|
+
AGENT_BOARD_CONTROL_SOCKET: socketPath,
|
|
1389
|
+
// Legacy names are exported too so older child extension builds still behave.
|
|
1390
|
+
AGENT_VIEW_ROOT: config.root,
|
|
1391
|
+
AGENT_VIEW_VIEW_ID: config.viewId,
|
|
1392
|
+
AGENT_VIEW_CHILD: "1",
|
|
1393
|
+
AGENT_VIEW_HOSTED: "pty",
|
|
1394
|
+
};
|
|
1395
|
+
try {
|
|
1396
|
+
child = spawnInteractive(config.piCommand, args, {
|
|
1397
|
+
cwd: config.cwd,
|
|
1398
|
+
env,
|
|
1399
|
+
cols: host.cols,
|
|
1400
|
+
rows: host.rows,
|
|
1401
|
+
allowPipeFallback: config.env?.AGENT_BOARD_ALLOW_PIPE_FALLBACK === "1" || config.env?.AGENT_VIEW_ALLOW_PIPE_FALLBACK === "1",
|
|
1402
|
+
});
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1405
|
+
diag("child_spawn_failed", message);
|
|
1406
|
+
await markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
|
|
1407
|
+
await finishHost("child_spawn_failed", 1);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
childPid = child.pid ?? null;
|
|
1411
|
+
child.onData((data) => {
|
|
1412
|
+
// Issue #222 FW-03 (owned host path): marker bytes are stripped before every
|
|
1413
|
+
// consumer and each ordinary segment gets its own output sequence; a proof takes
|
|
1414
|
+
// the sequence of the preceding ordinary segment (never a child frameId, never
|
|
1415
|
+
// socket timing) and several proofs at one seq keep production order.
|
|
1416
|
+
for (const part of splitFrameChunk(data, frameReporter)) {
|
|
1417
|
+
if (part.kind !== "output") {
|
|
1418
|
+
// S opens; F completes; A aborts. Never call completeProof for every record.
|
|
1419
|
+
const record = applyViewProofPart(frameReporter, part, lastOutputSeq);
|
|
1420
|
+
if (record !== null) {
|
|
1421
|
+
for (const sub of terminalSubscriptions.values()) sub.onViewProofRecord?.(record);
|
|
1422
|
+
}
|
|
1423
|
+
frameWitnessSummaryUpdates.schedule();
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
const chunk = part.data;
|
|
1427
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, chunk, screenLogBytes, screenLogLimits);
|
|
1428
|
+
const outputSeq = feedOutput(terminalModel, chunk);
|
|
1429
|
+
lastOutputSeq = outputSeq;
|
|
1430
|
+
feedViewProofOutput(frameReporter, outputSeq, chunk);
|
|
1431
|
+
// Per-socket output delivery — same contract as legacyMain above.
|
|
1432
|
+
const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data: chunk }) + "\n";
|
|
1433
|
+
for (const [socket, sub] of terminalSubscriptions) {
|
|
1434
|
+
if (sub.subscribed()) sub.onOutput(outputSeq, chunk);
|
|
1435
|
+
// Same per-socket guard the legacy broadcast had: a synchronous throw
|
|
1436
|
+
// from a dead socket must never escape into the uncaughtException
|
|
1437
|
+
// crash path (whole-branch review finding).
|
|
1438
|
+
else { try { socket.write(outputLine); } catch { /* socket 'error' handler cleans up */ } }
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
child.onExit((code) => {
|
|
1443
|
+
childExited = true;
|
|
1444
|
+
resolveChildExit?.();
|
|
1445
|
+
exitCode = code ?? 0;
|
|
1446
|
+
if (frameReporter !== null) {
|
|
1447
|
+
// F9: a trailing ordinary byte is forwarded through the normal output path.
|
|
1448
|
+
cleanupFrameReporter(frameReporter, "child-exit", (trailing) => {
|
|
1449
|
+
try {
|
|
1450
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, trailing, screenLogBytes, screenLogLimits);
|
|
1451
|
+
const seq = feedOutput(terminalModel, trailing);
|
|
1452
|
+
feedViewProofOutput(frameReporter, seq, trailing);
|
|
1453
|
+
for (const [socket, sub] of terminalSubscriptions) {
|
|
1454
|
+
if (sub.subscribed()) sub.onOutput(seq, trailing);
|
|
1455
|
+
}
|
|
1456
|
+
} catch {
|
|
1457
|
+
/* best effort during exit */
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
1460
|
+
resetViewProofSubscribers(terminalSubscriptions, "child-exit");
|
|
1461
|
+
try {
|
|
1462
|
+
frameWitnessSummaryUpdates.flush();
|
|
1463
|
+
} catch {
|
|
1464
|
+
/* row may already be finalised */
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
// Terminate lifecycle (spec D4): a confirmed child exit is the observed
|
|
1468
|
+
// evidence for any enveloped terminate still pending — flush before the
|
|
1469
|
+
// finish teardown ends the client sockets.
|
|
1470
|
+
control.flushTerminateObservations(exitCode, false);
|
|
1471
|
+
editorEmpty = null;
|
|
1472
|
+
broadcast({ type: "editor_state", empty: null });
|
|
1473
|
+
finish("child_exit", exitCode);
|
|
1474
|
+
});
|
|
1475
|
+
child.onError((err) => {
|
|
1476
|
+
diag("child_error", err instanceof Error ? err.message : String(err));
|
|
1477
|
+
finish("child_error", 1);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
// 6. Re-check ownership after the child exists, then publish ready.
|
|
1481
|
+
// The child's token is read asynchronously (win32 probe, issue #238); a
|
|
1482
|
+
// failed read yields null, the same shape the record had before #238.
|
|
1483
|
+
const childToken = await readProcessStartToken(childPid);
|
|
1484
|
+
// The child may exit while the token probe is in flight; its terminal record
|
|
1485
|
+
// must not be overwritten by a later "alive" publish (issue #238 review).
|
|
1486
|
+
if (childExited || shutdownStarted) {
|
|
1487
|
+
await finishHost("child_exit", exitCode ?? 0);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
const ready = ownedUpdate((cur) => ({
|
|
1491
|
+
...cur,
|
|
1492
|
+
childPid,
|
|
1493
|
+
childIdentity: { pid: childPid, startToken: childToken },
|
|
1494
|
+
childSpawnedAt: Date.now(),
|
|
1495
|
+
state: "alive",
|
|
1496
|
+
readyAt: Date.now(),
|
|
1497
|
+
}));
|
|
1498
|
+
if (!ready.updated) {
|
|
1499
|
+
// Superseded between spawn and ready: never publish; take the child down.
|
|
1500
|
+
killChild(child, childPid, "SIGTERM");
|
|
1501
|
+
const revoked = readHost(config.root, config.viewId)?.stopRequestedAt != null;
|
|
1502
|
+
await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
broadcast({ type: "status", status: host, generation: GENERATION });
|
|
1506
|
+
|
|
1507
|
+
// A starting-window resize cannot be applied at spawn-return nor at first
|
|
1508
|
+
// output: the child node process is still bootstrapping and a SIGWINCH
|
|
1509
|
+
// landing in that window is silently lost (pending-signal race, observed
|
|
1510
|
+
// empirically). Defer the apply until the child has had time to finish
|
|
1511
|
+
// bootstrapping; one-shot, best-effort, superseded by any newer direct
|
|
1512
|
+
// resize (which clears the cache).
|
|
1513
|
+
if (cachedResize) {
|
|
1514
|
+
const applyHeld = setTimeout(() => {
|
|
1515
|
+
if (!cachedResize || !child || shutdownStarted) return;
|
|
1516
|
+
// Paired step (CR R1 advisory): model follows the real PTY, never leads.
|
|
1517
|
+
resizeChildAndModel(child, terminalModel, cachedResize.cols, cachedResize.rows);
|
|
1518
|
+
notifyChildResize(childPid);
|
|
1519
|
+
cachedResize = null;
|
|
1520
|
+
}, 500);
|
|
1521
|
+
applyHeld.unref?.();
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// 7. Launch transaction complete — hand the start lease back.
|
|
1525
|
+
releaseStartLease();
|
|
1526
|
+
|
|
1527
|
+
// 8. Heartbeat: watch the owner record; loss or revoke routes to finishHost.
|
|
1528
|
+
heartbeatTimer = setInterval(() => {
|
|
1529
|
+
const h = readHost(config.root, config.viewId);
|
|
1530
|
+
if (!h || h.instanceId !== config.instanceId) {
|
|
1531
|
+
finish("owner_lost", 0);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (h.stopRequestedAt != null) {
|
|
1535
|
+
finish("revoked", 0);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
const hb = ownedUpdate((cur) => ({ ...cur }));
|
|
1539
|
+
// Plain host-meta lease contention returns {updated:false, ownerChanged:false}
|
|
1540
|
+
// — transient, retry next tick (store.mjs contract). Only a confirmed
|
|
1541
|
+
// ownership change tears the session down (CR round-1 finding 1).
|
|
1542
|
+
if (hb.ownerChanged) finish("owner_lost", 0);
|
|
1543
|
+
}, HEARTBEAT_MS);
|
|
1544
|
+
heartbeatTimer.unref?.();
|
|
1545
|
+
|
|
1546
|
+
/** Client commands with the starting protocol (issue #70 §6.3): while the
|
|
1547
|
+
* child does not exist, service inputs answer host_starting, resizes are
|
|
1548
|
+
* held, and interactive keystrokes are dropped safely; once ready,
|
|
1549
|
+
* requestId-tagged inputs ack with an in-process dedup table. */
|
|
1550
|
+
function handleClientLine(line, socket) {
|
|
1551
|
+
if (!line.trim()) return;
|
|
1552
|
+
let msg;
|
|
1553
|
+
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
1554
|
+
// Enveloped control commands take the phase-5 lifecycle path; everything
|
|
1555
|
+
// else (no commandId, or a non-control type carrying one — e.g.
|
|
1556
|
+
// subscribe_terminal with correlation metadata) falls through to the
|
|
1557
|
+
// legacy switch byte-identically.
|
|
1558
|
+
const envelope = validateCommandEnvelope(msg);
|
|
1559
|
+
if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
|
|
1560
|
+
control.handle(msg, socket, envelope);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
switch (msg.type) {
|
|
1564
|
+
case "hello": {
|
|
1565
|
+
// Same bookkeeping contract as the legacy path (issue #103 §C /
|
|
1566
|
+
// #130): probes and the resident editor reporter are
|
|
1567
|
+
// bookkeeping-only — neither may flip attachedEver nor keep
|
|
1568
|
+
// attachedClients non-zero, or warm-host reclaim never fires and
|
|
1569
|
+
// hosts leak. The policy lives in `helloBookkeeping`.
|
|
1570
|
+
const book = helloBookkeeping(classifyClientHello(msg));
|
|
1571
|
+
if (!book.keepInClients) {
|
|
1572
|
+
clients.delete(socket);
|
|
1573
|
+
terminalSubscriptions.delete(socket);
|
|
1574
|
+
}
|
|
1575
|
+
if (book.registerReporter) editorReporters.add(socket);
|
|
1576
|
+
// Issue #222 FW-04: a frame reporter gets a per-instance random token; only a
|
|
1577
|
+
// valid hello + frameWitness protocol 1 enables anything. Old/no hello is
|
|
1578
|
+
// unchanged (no new messages).
|
|
1579
|
+
{
|
|
1580
|
+
const enabled = enableFrameReporter(socket, msg, send);
|
|
1581
|
+
if (enabled !== null) {
|
|
1582
|
+
const replacingReporter = frameReporter !== null && frameReporter.socket !== socket;
|
|
1583
|
+
if (replacingReporter) {
|
|
1584
|
+
// Same ordinary-trailing forwarding path as close/error/child-exit: a
|
|
1585
|
+
// pending ordinary tail must not be lost when the reporter is replaced.
|
|
1586
|
+
cleanupFrameReporter(frameReporter, "reporter-replaced", (trailing) => {
|
|
1587
|
+
try {
|
|
1588
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, trailing, screenLogBytes, screenLogLimits);
|
|
1589
|
+
const seq = feedOutput(terminalModel, trailing);
|
|
1590
|
+
lastOutputSeq = seq;
|
|
1591
|
+
feedViewProofOutput(frameReporter, seq, trailing);
|
|
1592
|
+
for (const [peer, sub] of terminalSubscriptions) if (sub.subscribed()) sub.onOutput(seq, trailing);
|
|
1593
|
+
} catch {
|
|
1594
|
+
/* best effort during replacement */
|
|
1595
|
+
}
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
frameReporter = enabled;
|
|
1599
|
+
if (replacingReporter) resetViewProofSubscribers(terminalSubscriptions, "reporter-replaced");
|
|
1600
|
+
try { frameWitnessSummaryUpdates.flush(); } catch { /* row may be finalised */ }
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
// The probe's close-time host.json refresh stays suppressed: the
|
|
1604
|
+
// resolver probes every HOST_PROBE_RETRY_MS, so flushing there
|
|
1605
|
+
// would amplify fenced writes (see the probeSockets comment at the
|
|
1606
|
+
// listen block). Being out of `clients` is what keeps the count
|
|
1607
|
+
// right — this only avoids a pointless write.
|
|
1608
|
+
if (book.suppressCloseWrite) socket.markProbe?.();
|
|
1609
|
+
// A real client writes via flipAttachedEver alone (hence the `else
|
|
1610
|
+
// if`); `persist` is the write flag for rows that write as-is (reporter).
|
|
1611
|
+
if (book.flipAttachedEver) ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
|
|
1612
|
+
else if (book.persist) ownedUpdate((cur) => ({ ...cur }));
|
|
1613
|
+
send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
|
|
1614
|
+
break;
|
|
1615
|
+
}
|
|
1616
|
+
case "input": {
|
|
1617
|
+
if (typeof msg.data !== "string") break;
|
|
1618
|
+
if (typeof msg.requestId !== "string" || !msg.requestId) {
|
|
1619
|
+
// Interactive UI keystrokes: fire-and-forget, no ack protocol.
|
|
1620
|
+
if (child) child.write(msg.data);
|
|
1621
|
+
break;
|
|
1622
|
+
}
|
|
1623
|
+
if (!child) {
|
|
1624
|
+
send(socket, { type: "error", code: "host_starting", requestId: msg.requestId });
|
|
1625
|
+
break;
|
|
1626
|
+
}
|
|
1627
|
+
if (ackedRequestIds.has(msg.requestId)) {
|
|
1628
|
+
send(socket, { type: "input_ack", requestId: msg.requestId });
|
|
1629
|
+
break;
|
|
1630
|
+
}
|
|
1631
|
+
child.write(msg.data);
|
|
1632
|
+
ackedRequestIds.set(msg.requestId, true);
|
|
1633
|
+
if (ackedRequestIds.size > HOST_ACK_DEDUP_MAX) {
|
|
1634
|
+
ackedRequestIds.delete(ackedRequestIds.keys().next().value);
|
|
1635
|
+
}
|
|
1636
|
+
send(socket, { type: "input_ack", requestId: msg.requestId });
|
|
1637
|
+
break;
|
|
1638
|
+
}
|
|
1639
|
+
case "resize": {
|
|
1640
|
+
const cols = clampInt(msg.cols, 20, MAX_COLS, host.cols);
|
|
1641
|
+
const rows = clampInt(msg.rows, 5, MAX_ROWS, host.rows);
|
|
1642
|
+
if (child) applyResize(cols, rows);
|
|
1643
|
+
else {
|
|
1644
|
+
cachedResize = { cols, rows };
|
|
1645
|
+
ownedUpdate((cur) => ({ ...cur, cols, rows }));
|
|
1646
|
+
}
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
case "interrupt":
|
|
1650
|
+
if (child) child.write("\x1b");
|
|
1651
|
+
break;
|
|
1652
|
+
case "subscribe_terminal":
|
|
1653
|
+
terminalSubscriptions.get(socket)?.handleMessage(msg);
|
|
1654
|
+
break;
|
|
1655
|
+
case "terminate":
|
|
1656
|
+
// Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
|
|
1657
|
+
// so a SIGTERM-immune child still terminates within a bounded window.
|
|
1658
|
+
finish("terminated", 0);
|
|
1659
|
+
break;
|
|
1660
|
+
case "detach":
|
|
1661
|
+
socket.end();
|
|
1662
|
+
break;
|
|
1663
|
+
case "get_status":
|
|
1664
|
+
send(socket, { type: "status", status: host, generation: GENERATION });
|
|
1665
|
+
break;
|
|
1666
|
+
case "editor_state": {
|
|
1667
|
+
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
1668
|
+
broadcast({ type: "editor_state", empty: editorEmpty });
|
|
1669
|
+
break;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function spawnInteractive(command, args, opts) {
|
|
1676
|
+
ensureNodePtySpawnHelperExecutable(requireForPty);
|
|
1677
|
+
if (pty?.spawn) {
|
|
1678
|
+
try {
|
|
1679
|
+
const proc = pty.spawn(command, args, {
|
|
1680
|
+
name: "xterm-256color",
|
|
1681
|
+
cols: opts.cols,
|
|
1682
|
+
rows: opts.rows,
|
|
1683
|
+
cwd: opts.cwd,
|
|
1684
|
+
env: opts.env,
|
|
1685
|
+
});
|
|
1686
|
+
return {
|
|
1687
|
+
pid: proc.pid ?? null,
|
|
1688
|
+
write: (s) => proc.write(s),
|
|
1689
|
+
resize: (cols, rows) => proc.resize(cols, rows),
|
|
1690
|
+
kill: (signal) => proc.kill(signal),
|
|
1691
|
+
onData: (fn) => proc.onData(fn),
|
|
1692
|
+
onExit: (fn) => proc.onExit((e) => fn(e.exitCode ?? 0)),
|
|
1693
|
+
onError: () => {},
|
|
1694
|
+
};
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
if (!opts.allowPipeFallback) throw err;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
if (!opts.allowPipeFallback) throw new Error("node-pty is unavailable");
|
|
1700
|
+
|
|
1701
|
+
const proc = spawn(command, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
1702
|
+
return {
|
|
1703
|
+
pid: proc.pid ?? null,
|
|
1704
|
+
write: (s) => proc.stdin.write(s),
|
|
1705
|
+
resize: () => {},
|
|
1706
|
+
kill: (signal) => proc.kill(signal),
|
|
1707
|
+
onData: (fn) => {
|
|
1708
|
+
proc.stdout.on("data", (c) => fn(c.toString()));
|
|
1709
|
+
proc.stderr.on("data", (c) => fn(c.toString()));
|
|
1710
|
+
},
|
|
1711
|
+
onExit: (fn) => proc.on("close", (code) => fn(code ?? 0)),
|
|
1712
|
+
onError: (fn) => proc.on("error", fn),
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function send(socket, msg) {
|
|
1717
|
+
socket.write(JSON.stringify(msg) + "\n");
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Terminate the hosted child on all platforms. node-pty's kill() throws
|
|
1722
|
+
* "Signals not supported on windows", so on win32 we TerminateProcess via
|
|
1723
|
+
* process.kill; on unix keep the graceful SIGTERM/SIGKILL path through the pty.
|
|
1724
|
+
* @param {{ kill: (signal: string) => void }} child
|
|
1725
|
+
* @param {number|null} pid
|
|
1726
|
+
* @param {"SIGTERM"|"SIGKILL"} [signal]
|
|
1727
|
+
*/
|
|
1728
|
+
function killChild(child, pid, signal = "SIGTERM") {
|
|
1729
|
+
if (process.platform === "win32") {
|
|
1730
|
+
if (pid) {
|
|
1731
|
+
try {
|
|
1732
|
+
process.kill(pid, "SIGKILL");
|
|
1733
|
+
return;
|
|
1734
|
+
} catch {}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
try { child.kill(signal); } catch {}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* node-pty's TIOCSWINSZ updates the kernel tty size, but the automatic
|
|
1742
|
+
* SIGWINCH to the pty's foreground group is NOT reliably delivered when the
|
|
1743
|
+
* runner itself is a detached session (empirically verified on Linux: the
|
|
1744
|
+
* child's kernel size is correct, its signal handler never fires, and a
|
|
1745
|
+
* direct kill delivers instantly). Real TUI children poll or handle SIGWINCH
|
|
1746
|
+
* — an explicit best-effort signal is harmless for them and makes resize
|
|
1747
|
+
* deterministic for every child. POSIX only.
|
|
1748
|
+
* @param {number|null} pid
|
|
1749
|
+
*/
|
|
1750
|
+
function notifyChildResize(pid) {
|
|
1751
|
+
if (process.platform === "win32" || !pid) return;
|
|
1752
|
+
try { process.kill(pid, "SIGWINCH"); } catch { /* best effort */ }
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function clampInt(value, min, max, fallback) {
|
|
1756
|
+
const n = Number(value);
|
|
1757
|
+
if (!Number.isFinite(n)) return fallback;
|
|
1758
|
+
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// ---------------------------------------------------------------------------
|
|
1762
|
+
// Control-command lifecycle runtime (issue #91 phase 5, spec D4). Pure
|
|
1763
|
+
// decision logic lives in src/core/control-protocol.mjs; this is the runner's
|
|
1764
|
+
// serial side-effect shell: per-socket seq tracking, the durable command
|
|
1765
|
+
// journal, resize latest-wins routing, and staged ack emission. Messages
|
|
1766
|
+
// WITHOUT an envelope (no commandId) never reach this code — the legacy
|
|
1767
|
+
// switch keeps byte-identical behavior.
|
|
1768
|
+
// ---------------------------------------------------------------------------
|
|
1769
|
+
|
|
1770
|
+
/** Load journal records, dropping torn tail lines from a crash mid-append. */
|
|
1771
|
+
function loadJournalRecords(path) {
|
|
1772
|
+
try {
|
|
1773
|
+
const records = [];
|
|
1774
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1775
|
+
if (!line.trim()) continue;
|
|
1776
|
+
try {
|
|
1777
|
+
records.push(JSON.parse(line));
|
|
1778
|
+
} catch {
|
|
1779
|
+
/* torn tail: the accepted/applied truth for that line is unknown —
|
|
1780
|
+
dropping it can only widen the accepted_unknown set, never hide one */
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return records;
|
|
1784
|
+
} catch {
|
|
1785
|
+
return [];
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* One control runtime per main. `actions` bundles the main-specific side
|
|
1791
|
+
* effects (child shape differs between the legacy and owned lifecycles); the
|
|
1792
|
+
* decision order here is shared and binding:
|
|
1793
|
+
* envelope validity → instance fence → seq monotonicity → per-type dispatch.
|
|
1794
|
+
* @param {{
|
|
1795
|
+
* viewId: string, root: string, instanceId: string|null,
|
|
1796
|
+
* send: (socket: import("node:net").Socket, msg: object) => void,
|
|
1797
|
+
* diag: (code: string, message: string, details?: object) => void,
|
|
1798
|
+
* actions: {
|
|
1799
|
+
* childReady(): boolean,
|
|
1800
|
+
* writeInput(data: string): void,
|
|
1801
|
+
* hostCols(): number, hostRows(): number,
|
|
1802
|
+
* applyResize(cols: number, rows: number): void, // runtime pre-checks childReady — never called without a child
|
|
1803
|
+
* currentDims(): { cols: number, rows: number },
|
|
1804
|
+
* applyInterrupt(): void,
|
|
1805
|
+
* applyTerminate(): void,
|
|
1806
|
+
* applyDetach(socket: import("node:net").Socket): void,
|
|
1807
|
+
* hostRevision(): number,
|
|
1808
|
+
* cursor(): { lastSeq: number, cols: number, rows: number },
|
|
1809
|
+
* stateStamp(): number | null,
|
|
1810
|
+
* },
|
|
1811
|
+
* }} opts
|
|
1812
|
+
*/
|
|
1813
|
+
function createControlRuntime({ viewId, root, instanceId, send, diag, actions }) {
|
|
1814
|
+
/** Per-connection seq watermark (ordering aid only — never dedup). */
|
|
1815
|
+
const connectionSeq = new Map();
|
|
1816
|
+
const journalPath = P.controlJournalPath(root, viewId);
|
|
1817
|
+
const journalRecords = loadJournalRecords(journalPath);
|
|
1818
|
+
/** O(1) journal dedup — rebuilt after GC so it mirrors the retained records. */
|
|
1819
|
+
const journalIds = new Set(journalRecords.map((r) => r.commandId));
|
|
1820
|
+
const resizes = createResizeTracker();
|
|
1821
|
+
/** commandId → socket awaiting the resize ack (superseded/applied routing). */
|
|
1822
|
+
const pendingResizeSockets = new Map();
|
|
1823
|
+
/** commandId → socket that requested terminate (observed routing). */
|
|
1824
|
+
const pendingTerminates = new Map();
|
|
1825
|
+
|
|
1826
|
+
const rawAppend = (record) => {
|
|
1827
|
+
appendFileSync(journalPath, JSON.stringify(record) + "\n");
|
|
1828
|
+
};
|
|
1829
|
+
const maybeGcJournal = () => {
|
|
1830
|
+
if (journalRecords.length <= JOURNAL_GC_TRIGGER) return;
|
|
1831
|
+
const kept = journalGc(journalRecords, JOURNAL_KEEP_DEFAULT);
|
|
1832
|
+
journalRecords.length = 0;
|
|
1833
|
+
journalRecords.push(...kept);
|
|
1834
|
+
journalIds.clear();
|
|
1835
|
+
for (const record of kept) journalIds.add(record.commandId);
|
|
1836
|
+
try {
|
|
1837
|
+
writeFileSync(journalPath, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
diag("control_journal_rewrite_failed", err instanceof Error ? err.message : String(err));
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
/** @returns {boolean} false when the append failed — the command is NOT accepted. */
|
|
1843
|
+
const journalAccept = (commandId, command, now) => {
|
|
1844
|
+
// Ruling (b): a journaled commandId is NEVER re-appended — the dedup
|
|
1845
|
+
// check is the caller's, and this guard is the last line of defense.
|
|
1846
|
+
if (journalIds.has(commandId)) return true;
|
|
1847
|
+
const record = { kind: "accepted", commandId, command, acceptedAt: now };
|
|
1848
|
+
journalAppendRecord(journalRecords, record); // shape validation; throws on a runner bug
|
|
1849
|
+
try {
|
|
1850
|
+
rawAppend(record);
|
|
1851
|
+
} catch (err) {
|
|
1852
|
+
// Nothing was pushed into journalRecords for this record (pure
|
|
1853
|
+
// validate + manual push on success), so there is nothing to unwind —
|
|
1854
|
+
// the command is simply not accepted.
|
|
1855
|
+
diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
|
|
1856
|
+
return false;
|
|
1857
|
+
}
|
|
1858
|
+
journalRecords.push(record);
|
|
1859
|
+
journalIds.add(commandId);
|
|
1860
|
+
maybeGcJournal();
|
|
1861
|
+
return true;
|
|
1862
|
+
};
|
|
1863
|
+
const journalApply = (commandId, now) => {
|
|
1864
|
+
const record = { kind: "applied", commandId, appliedAt: now };
|
|
1865
|
+
try {
|
|
1866
|
+
rawAppend(record);
|
|
1867
|
+
} catch (err) {
|
|
1868
|
+
diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
journalRecords.push(record);
|
|
1872
|
+
maybeGcJournal();
|
|
1873
|
+
};
|
|
1874
|
+
|
|
1875
|
+
/** Socket-death semantics for every ack: a dead client must never turn a
|
|
1876
|
+
* control reply into a runner crash (same invariant as the broadcast guard). */
|
|
1877
|
+
const reply = (socket, msg) => {
|
|
1878
|
+
try {
|
|
1879
|
+
send(socket, msg);
|
|
1880
|
+
} catch {
|
|
1881
|
+
/* socket 'error'/'close' handler owns cleanup */
|
|
1882
|
+
}
|
|
1883
|
+
};
|
|
1884
|
+
|
|
1885
|
+
const flushTerminateObservations = (exitCode, runnerFinalizing) => {
|
|
1886
|
+
for (const [commandId, termSocket] of pendingTerminates) {
|
|
1887
|
+
reply(termSocket, {
|
|
1888
|
+
type: "cmd_ack",
|
|
1889
|
+
commandId,
|
|
1890
|
+
stage: "observed",
|
|
1891
|
+
...(exitCode != null ? { exitConfirmed: true, exitCode } : {}),
|
|
1892
|
+
...(runnerFinalizing ? { runnerFinalizing: true } : {}),
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
pendingTerminates.clear();
|
|
1896
|
+
};
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Dispatch an enveloped control command. Caller guarantees
|
|
1900
|
+
* `env.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)`.
|
|
1901
|
+
*
|
|
1902
|
+
* Crash containment: a throwing action (e.g. child.write on a pty that died
|
|
1903
|
+
* mid-command) must degrade to an error reply + diagnostic, never escape
|
|
1904
|
+
* into the runner's uncaughtException path — the legacy switch predates this
|
|
1905
|
+
* invariant, but new protocol code must not inherit that crash class.
|
|
1906
|
+
*/
|
|
1907
|
+
const handle = (msg, socket, env) => {
|
|
1908
|
+
// (d) errors are only meaningful for enveloped messages — this function is
|
|
1909
|
+
// only entered for enveloped control types, so every error below is legal.
|
|
1910
|
+
if (env.errors.length > 0) {
|
|
1911
|
+
reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: env.errors });
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
try {
|
|
1915
|
+
handleChecked(msg, socket, env);
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
diag("control_command_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId, type: msg.type });
|
|
1918
|
+
reply(socket, { type: "error", code: "command_failed", commandId: msg.commandId });
|
|
1919
|
+
}
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
const handleChecked = (msg, socket, env) => {
|
|
1923
|
+
// (a) instance fencing, reject-with-current: a client naming a DIFFERENT
|
|
1924
|
+
// instance is talking across a runner replacement (stale socket race);
|
|
1925
|
+
// silently applying its commands would resurrect phantom controls — the
|
|
1926
|
+
// exact class #70 fencing exists to prevent. The error carries the current
|
|
1927
|
+
// instanceId so a well-behaved client re-hellos and recovers. Legacy-mode
|
|
1928
|
+
// hosts (instanceId null) fence EVERY envelope: an envelope claims an
|
|
1929
|
+
// instance a legacy-mode runner never had.
|
|
1930
|
+
if (msg.instanceId !== instanceId) {
|
|
1931
|
+
diag("instance_mismatch", "control command carries a foreign instanceId", {
|
|
1932
|
+
commandId: msg.commandId,
|
|
1933
|
+
got: msg.instanceId,
|
|
1934
|
+
current: instanceId,
|
|
1935
|
+
});
|
|
1936
|
+
reply(socket, { type: "error", code: "instance_mismatch", commandId: msg.commandId, currentInstanceId: instanceId });
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
// (c) per-connection monotonic seq: an ordering aid. Repeats/regressions
|
|
1940
|
+
// are dropped with a diagnostic — NOT a dedup signal (dedup keys on
|
|
1941
|
+
// commandId only); a legitimate re-send after reconnect uses a new socket.
|
|
1942
|
+
const lastSeq = connectionSeq.get(socket) ?? 0;
|
|
1943
|
+
const seqVerdict = checkSeq(lastSeq, msg.seq);
|
|
1944
|
+
if (!seqVerdict.ok) {
|
|
1945
|
+
diag("seq_out_of_order", "control command dropped for non-monotonic seq", {
|
|
1946
|
+
commandId: msg.commandId,
|
|
1947
|
+
seq: msg.seq,
|
|
1948
|
+
lastSeq,
|
|
1949
|
+
});
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
connectionSeq.set(socket, msg.seq);
|
|
1953
|
+
|
|
1954
|
+
switch (msg.type) {
|
|
1955
|
+
case "input": {
|
|
1956
|
+
if (typeof msg.data !== "string") {
|
|
1957
|
+
reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: ["input_data_missing"] });
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (msg.durable !== true) {
|
|
1961
|
+
// Keystroke with a correlation id: fire-and-forget preserved — no
|
|
1962
|
+
// ack, no journal (journal writes must never sit on the keystroke
|
|
1963
|
+
// path). The envelope is correlation/debugging metadata only.
|
|
1964
|
+
if (actions.childReady()) actions.writeInput(msg.data);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
// Durable follow-up: accepted (journaled) → applied (written). A
|
|
1968
|
+
// re-send of a journaled commandId returns the cached final stage —
|
|
1969
|
+
// never re-appended (ruling b), never re-written (commandId dedup).
|
|
1970
|
+
if (journalIds.has(msg.commandId)) {
|
|
1971
|
+
const wasApplied = journalRecords.some((r) => r.kind === "applied" && r.commandId === msg.commandId);
|
|
1972
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: wasApplied ? "applied" : "accepted", durable: true });
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
if (!actions.childReady()) {
|
|
1976
|
+
// Starting window: nothing accepted (matches the requestId path's
|
|
1977
|
+
// host_starting contract; the service retries with its own policy).
|
|
1978
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
if (!journalAccept(msg.commandId, msg.data, Date.now())) {
|
|
1982
|
+
// accepted means JOURNALED — without the journal entry the stage
|
|
1983
|
+
// would be a lie, so the command is refused, not silently applied.
|
|
1984
|
+
reply(socket, { type: "error", code: "journal_unavailable", commandId: msg.commandId });
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "accepted", durable: true });
|
|
1988
|
+
try {
|
|
1989
|
+
actions.writeInput(msg.data);
|
|
1990
|
+
} catch (err) {
|
|
1991
|
+
// §10 honesty: accepted but the write failed — the command stays
|
|
1992
|
+
// accepted_unknown; reconcile reports it; never auto-replayed.
|
|
1993
|
+
diag("input_apply_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId });
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
journalApply(msg.commandId, Date.now());
|
|
1997
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
case "resize": {
|
|
2001
|
+
// Uniform starting-window contract with input: without a child there
|
|
2002
|
+
// is nothing to resize and no honest applied value — the client
|
|
2003
|
+
// retries (new commandId). This also keeps latest-wins tracking off
|
|
2004
|
+
// the books for commands that never applied.
|
|
2005
|
+
if (!actions.childReady()) {
|
|
2006
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
const cols = clampInt(msg.cols, 20, MAX_COLS, actions.hostCols());
|
|
2010
|
+
const rows = clampInt(msg.rows, 5, MAX_ROWS, actions.hostRows());
|
|
2011
|
+
const tracked = resizes.track({ commandId: msg.commandId, clientId: msg.clientId, cols, rows });
|
|
2012
|
+
for (const sup of tracked.superseded) {
|
|
2013
|
+
const oldSocket = pendingResizeSockets.get(sup.commandId) ?? socket;
|
|
2014
|
+
pendingResizeSockets.delete(sup.commandId);
|
|
2015
|
+
reply(oldSocket, { type: "cmd_ack", commandId: sup.commandId, stage: "superseded", byCommandId: sup.byCommandId });
|
|
2016
|
+
}
|
|
2017
|
+
if (tracked.duplicate) {
|
|
2018
|
+
// Same commandId re-request: applyResize is synchronous, so a
|
|
2019
|
+
// duplicate always finds the command already applied — return the
|
|
2020
|
+
// cached result. (CR R1 advisory: the pendingResizeSockets entry is
|
|
2021
|
+
// deliberately NOT set here — the original resolved synchronously,
|
|
2022
|
+
// so a re-pointed entry would be a stale leak.)
|
|
2023
|
+
const cached = resizes.resultFor(msg.commandId);
|
|
2024
|
+
if (cached) reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: cached.cols, rows: cached.rows });
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
pendingResizeSockets.set(msg.commandId, socket);
|
|
2028
|
+
const dimsBefore = actions.currentDims();
|
|
2029
|
+
actions.applyResize(cols, rows);
|
|
2030
|
+
// The paired resize step keeps the model mirroring the REAL PTY
|
|
2031
|
+
// (reflow only on successful child.resize), so the model's dims are
|
|
2032
|
+
// the honest applied value — even when the PTY kept its old geometry.
|
|
2033
|
+
const dims = actions.currentDims();
|
|
2034
|
+
resizes.applied(msg.commandId, dims.cols, dims.rows);
|
|
2035
|
+
pendingResizeSockets.delete(msg.commandId);
|
|
2036
|
+
// A client numbers its resizes from zero at each attach, so it matches frame proofs against these.
|
|
2037
|
+
const revision = actions.frameRevision?.() ?? null;
|
|
2038
|
+
reply(socket, {
|
|
2039
|
+
type: "cmd_ack",
|
|
2040
|
+
commandId: msg.commandId,
|
|
2041
|
+
stage: "applied",
|
|
2042
|
+
cols: dims.cols,
|
|
2043
|
+
rows: dims.rows,
|
|
2044
|
+
...(revision === null ? {} : { ...revision, resized: dims.cols !== dimsBefore.cols || dims.rows !== dimsBefore.rows }),
|
|
2045
|
+
});
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
case "interrupt": {
|
|
2049
|
+
// Starting-window honesty (CR R1 blocking): applyInterrupt no-ops
|
|
2050
|
+
// without a child, so an unconditional applied ack would be a lie —
|
|
2051
|
+
// same contract as input/resize. The client consumes host_starting
|
|
2052
|
+
// as a cmdAck error (no retry chain: interrupt is transient and
|
|
2053
|
+
// user-timed; a re-send carries a fresh commandId).
|
|
2054
|
+
if (!actions.childReady()) {
|
|
2055
|
+
reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
actions.applyInterrupt();
|
|
2059
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
case "terminate": {
|
|
2063
|
+
// Idempotent: a repeat while the termination is in flight returns the
|
|
2064
|
+
// current stage instead of claiming a fresh start.
|
|
2065
|
+
if (pendingTerminates.has(msg.commandId)) {
|
|
2066
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
pendingTerminates.set(msg.commandId, socket);
|
|
2070
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
|
|
2071
|
+
actions.applyTerminate();
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
case "detach": {
|
|
2075
|
+
reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "detach_accepted" });
|
|
2076
|
+
actions.applyDetach(socket);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
case "reconcile": {
|
|
2080
|
+
reply(socket, {
|
|
2081
|
+
type: "reconcile_result",
|
|
2082
|
+
commandId: msg.commandId,
|
|
2083
|
+
generation: GENERATION,
|
|
2084
|
+
hostRevision: actions.hostRevision(),
|
|
2085
|
+
terminalCursor: actions.cursor(),
|
|
2086
|
+
stateMaterializedRevision: actions.stateStamp(),
|
|
2087
|
+
unresolved: journalUnresolved(journalRecords).map((u) => ({ ...u, status: "accepted_unknown" })),
|
|
2088
|
+
});
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
/** Socket teardown: drop the seq watermark and any ack-routing entries. */
|
|
2095
|
+
const closeSocket = (socket) => {
|
|
2096
|
+
connectionSeq.delete(socket);
|
|
2097
|
+
for (const [commandId, sock] of pendingResizeSockets) if (sock === socket) pendingResizeSockets.delete(commandId);
|
|
2098
|
+
for (const [commandId, sock] of pendingTerminates) if (sock === socket) pendingTerminates.delete(commandId);
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
// (completeHeldResize was removed as dead code: the starting-window resize
|
|
2102
|
+
// path rejects with host_starting instead of holding, and applyResize is
|
|
2103
|
+
// synchronous — there is no held-resize completion path. Task 2 review P2-C.)
|
|
2104
|
+
return { handle, closeSocket, flushTerminateObservations, journalIds };
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* Finalize the view row as failed when the PTY host itself fails (child spawn
|
|
2109
|
+
* failure). Routed through the View State Coordinator as a fenced
|
|
2110
|
+
* `host_run_failed` command (issue #91, PR #1 residual risk #2): a manually
|
|
2111
|
+
* completed row rejects with manual_fence and stays completed, and a row
|
|
2112
|
+
* re-pointed to a newer run rejects with stale_run.
|
|
2113
|
+
*
|
|
2114
|
+
* The runId is sourced from the row's current state.json (null = the row had
|
|
2115
|
+
* no run) so the coordinator's stale_run guard stays effective; the
|
|
2116
|
+
* coordinator cannot enforce this from its side because it has no visibility
|
|
2117
|
+
* into what the host knows.
|
|
2118
|
+
*
|
|
2119
|
+
* Never throws: every client outcome is handled so the crash path cannot hang.
|
|
2120
|
+
* - applied → done (journaled + materialized by the coordinator).
|
|
2121
|
+
* - coordinator_disabled → legacy direct write (documented escape hatch).
|
|
2122
|
+
* - decided rejections (manual_fence / stale_run / no_change) → info
|
|
2123
|
+
* diagnostic; the coordinator's verdict governs.
|
|
2124
|
+
* - everything else (timeout / connection reset / unavailable) → warn
|
|
2125
|
+
* diagnostic: the outcome is unknown or the row could not be marked — the
|
|
2126
|
+
* runner still shuts down.
|
|
2127
|
+
* @param {string} root
|
|
2128
|
+
* @param {string} viewId
|
|
2129
|
+
* @param {string} message
|
|
2130
|
+
*/
|
|
2131
|
+
async function markRowFailed(root, viewId, message) {
|
|
2132
|
+
const knownRunId = readState(root, viewId)?.currentRunId ?? null;
|
|
2133
|
+
const result = await sendStateCommand(root, {
|
|
2134
|
+
type: "state_command",
|
|
2135
|
+
viewId,
|
|
2136
|
+
runId: knownRunId,
|
|
2137
|
+
source: "pty-runner",
|
|
2138
|
+
kind: "host_run_failed",
|
|
2139
|
+
payload: { error: message },
|
|
2140
|
+
});
|
|
2141
|
+
if (result.status === "applied") return;
|
|
2142
|
+
if (result.reason === "coordinator_disabled") {
|
|
2143
|
+
markRowFailedDirect(root, viewId, message);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (result.reason === "manual_fence" || result.reason === "stale_run" || result.reason === "no_change") {
|
|
2147
|
+
try {
|
|
2148
|
+
appendDiagnostic(root, viewId, {
|
|
2149
|
+
source: "runner",
|
|
2150
|
+
level: "info",
|
|
2151
|
+
code: "host_run_failed_skipped",
|
|
2152
|
+
message: `Host failure not applied to the row (${result.reason})`,
|
|
2153
|
+
details: { reason: result.reason },
|
|
2154
|
+
});
|
|
2155
|
+
} catch { /* best effort */ }
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
try {
|
|
2159
|
+
appendDiagnostic(root, viewId, {
|
|
2160
|
+
source: "runner",
|
|
2161
|
+
level: "warn",
|
|
2162
|
+
code: "host_run_failed_ambiguous",
|
|
2163
|
+
message: `Host failure outcome unknown (${result.reason}); the row may not reflect the failed host`,
|
|
2164
|
+
details: { reason: result.reason },
|
|
2165
|
+
});
|
|
2166
|
+
} catch { /* best effort */ }
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function failEarly(message) {
|
|
2170
|
+
try { appendLine(join(tmpdir(), "pi-agent-board-pty-runner.err"), message); } catch {}
|
|
2171
|
+
process.stderr.write(`${message}\n`);
|
|
2172
|
+
process.exit(2);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
main();
|