@zhuxixi/pi-agent-board 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached View State Coordinator (issue #91, spec D3) — the single logical
4
+ * writer of state.json/status.json for one board root.
5
+ *
6
+ * Usage: node state-coordinator.mjs <root>
7
+ *
8
+ * Design contract:
9
+ * - Exactly one instance per root: a token-fenced lease (acquireOwnedViewLock on
10
+ * the pseudo-view "_coordinator") is grabbed before anything else; a second
11
+ * instance exits 0 immediately ("busy" is an expected, idempotent outcome).
12
+ * - Every semantic mutation arrives as a `state_command` over the JSONL control
13
+ * socket and is decided by the pure layer (state-commands.mjs). This process
14
+ * owns ALL side effects: durable journal append (fsync BEFORE materialize, so
15
+ * boot replay can repair the crash window), materialization under a per-view
16
+ * file lock, checkpoint + journal GC, and socket lifecycle.
17
+ * - Transient kinds (TRANSIENT_KINDS, run_progress) bypass the durable
18
+ * machinery: validate → decide → materialize → reply, with no journal
19
+ * append, no dedupe, and no checkpoint trigger — a periodic snapshot whose
20
+ * next beat supersedes it. They still bump materializedRevision.
21
+ * - Idempotency: a processed commandId returns its original result forever —
22
+ * from the in-memory ring first (bounded, covers the post-GC window), then
23
+ * from the journal (findProcessedCommand). Duplicates never re-append.
24
+ * - Revisions: one global monotonic materializedRevision counter, initialized at
25
+ * boot to max(checkpoint, max journal revision, max revision across views'
26
+ * state.json), incremented once per applied command, and stamped identically
27
+ * on state.json and the command's run status.json (spec 根治条件 5 scope).
28
+ * - Boot replay re-materializes every applied journal record from its stored
29
+ * patches; per-file guards stamp only files strictly behind the recorded
30
+ * revision, so a half-materialized pair (state.json written, status.json not)
31
+ * repairs just the lagging half without moving the other one backwards. The
32
+ * decision-time status binding (statusRunId) is recorded in the journal, so
33
+ * replay is deterministic — no re-decision, no re-binding.
34
+ * - Legacy adoption: a view without materializedRevision is stamped as part of
35
+ * its first applied command's single materialization write (no extra write).
36
+ * - AGENT_BOARD_COORDINATOR=off makes the process exit 0 immediately (tests and
37
+ * the client's degradation path).
38
+ */
39
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
40
+ import { randomBytes } from "node:crypto";
41
+ import { createServer } from "node:net";
42
+ import { readJson } from "../src/core/atomic.mjs";
43
+ import {
44
+ appendCommand,
45
+ findProcessedCommand,
46
+ gcJournal,
47
+ readCheckpoint,
48
+ readJournal,
49
+ repairJournalTail,
50
+ writeCheckpoint,
51
+ } from "../src/core/coordinator-journal.mjs";
52
+ import { ownsEndpoint } from "../src/core/host-coordination.mjs";
53
+ import { acquireOwnedViewLock, withViewLockSync } from "../src/core/locks.mjs";
54
+ import * as P from "../src/core/paths.mjs";
55
+ import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
56
+ import { TRANSIENT_KINDS, decideStateTransition, validateCommand } from "../src/core/state-commands.mjs";
57
+ import { COORDINATOR_PROTOCOL_VERSION } from "../src/core/coordinator-protocol.mjs";
58
+
59
+ /** In-memory processed-command ring size (FIFO). Beyond the journal, this covers
60
+ * idempotency when the journal prefix has already been GC'd away. */
61
+ const PROCESSED_RING_MAX = 1000;
62
+ /** Journal growth (bytes) that triggers checkpoint + GC. Keeps boot replay bounded. */
63
+ const CHECKPOINT_THRESHOLD_BYTES = 262_144;
64
+ /** Lease heartbeat — matches pty-runner's host-start lease cadence. */
65
+ const HEARTBEAT_MS = 1000;
66
+ /** Lifecycle kinds whose legacy write sites stamped state.lastActivityAt on
67
+ * every persist (service markQueued/archive/adopt, job-runner plan-ready,
68
+ * reconcile terminal sites). The decision layer stays clock-free, so the
69
+ * shell adds the wall-clock stamp — into the patch BEFORE the journal
70
+ * append, so boot replay re-applies the exact same fields deterministically
71
+ * (there is no clock at replay time either). (Task 1 review F5.) */
72
+ const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt_session", "reconcile_finalize", "plan_ready", "mark_completed", "host_run_failed"]);
73
+ /** Kinds whose status patch may CREATE the run's status file: they carry the
74
+ * full status content for a run that has no materialized status yet. Every
75
+ * other kind keeps PR #1's "patch presence ≠ file requirement" semantics
76
+ * (e.g. mark_completed's `status: {autoState: null}` on a legacy row without
77
+ * a status file must not fabricate one).
78
+ *
79
+ * `run_progress` joins them (issue #91 hard-down residual closure): its beat
80
+ * patch IS the runner's full in-memory status, and when the coordinator was
81
+ * down through the runner's boot window (mark_queued applied, run_started
82
+ * lost) the first post-recovery beat is the only surviving source that can
83
+ * recreate the file — without it beats and finalize reject stale_run forever
84
+ * and the row can never converge. The decision layer gates this on a
85
+ * qualified live-run beat (full-shaped patch, matching string runId). */
86
+ const STATUS_BOOTSTRAP_KINDS = new Set(["run_started", "followup_started", "run_progress"]);
87
+
88
+ const root = process.argv[2];
89
+ if (!root) {
90
+ process.stderr.write("state-coordinator: missing root argument\n");
91
+ process.exit(2);
92
+ }
93
+ if (process.env.AGENT_BOARD_COORDINATOR === "off") process.exit(0);
94
+
95
+ main().catch((err) => {
96
+ process.stderr.write(`state-coordinator: fatal ${err?.stack || err}\n`);
97
+ process.exit(1);
98
+ });
99
+
100
+ async function main() {
101
+ const instanceId = randomBytes(8).toString("hex");
102
+ const startedAt = Date.now();
103
+
104
+ // 1. Lease first: a second coordinator is an expected, idempotent no-op.
105
+ // Identity carries a POSIX startToken (pty-runner pattern) so a SIGKILLed
106
+ // coordinator's lease can be reclaimed by the next instance after pid reuse.
107
+ /** @type {import("../src/core/locks.mjs").Lease} */
108
+ let lease;
109
+ try {
110
+ lease = acquireOwnedViewLock(root, "_coordinator", "state-coordinator", {
111
+ waitMs: 500,
112
+ identity: { pid: process.pid, startToken: captureStartToken(process.pid) },
113
+ });
114
+ } catch (err) {
115
+ process.stderr.write(`state-coordinator: lease unavailable (${err?.code ?? err?.message ?? "unknown"}); another instance may own it; exiting\n`);
116
+ process.exit(0);
117
+ }
118
+ const startTouchTimer = setInterval(() => {
119
+ try { lease?.touch(); } catch { /* best effort */ }
120
+ }, HEARTBEAT_MS);
121
+ startTouchTimer.unref?.();
122
+
123
+ // 2. Boot state: journal summary, processed-command ring, replay, counter init.
124
+ // Repair a crash-torn tail FIRST: without it the first post-restart append
125
+ // concatenates onto the torn line and that fsynced record becomes invisible
126
+ // to every readJournal / replay / revision scan below.
127
+ repairJournalTail(root);
128
+ const checkpoint = readCheckpoint(root) ?? { materializedRevision: 0, journalBytes: 0 };
129
+ const journal = readJournal(root);
130
+ /** @type {Map<string, { result: { status: string, reason: string|null }, materializedRevision: number }>} */
131
+ const processedCommands = new Map();
132
+ for (const record of journal) {
133
+ const commandId = record?.command?.commandId;
134
+ if (commandId && !processedCommands.has(commandId)) {
135
+ processedCommands.set(commandId, {
136
+ result: record.result ?? { status: "rejected", reason: "unknown" },
137
+ materializedRevision: record.materializedRevision ?? 0,
138
+ });
139
+ }
140
+ }
141
+
142
+ // Replay in journal order: re-materialize every applied record from its stored
143
+ // patches; materialize's per-file guards write only files strictly behind the
144
+ // recorded revision, so this repairs just the half that missed its write in a
145
+ // crash window and never moves the other half backwards.
146
+ for (const record of journal) {
147
+ if (record?.result?.status !== "applied") continue;
148
+ const viewId = record?.command?.viewId;
149
+ if (!viewId) continue;
150
+ materialize(viewId, record.command ?? {}, record.mutate ?? {}, record.materializedRevision ?? 0, record.statusRunId ?? null);
151
+ }
152
+
153
+ // Global revision counter: never reissue a revision that exists anywhere.
154
+ let revisionCounter = Math.max(checkpoint.materializedRevision ?? 0, 0);
155
+ for (const record of journal) revisionCounter = Math.max(revisionCounter, record.materializedRevision ?? 0);
156
+ if (existsSync(P.viewsDir(root))) {
157
+ for (const entry of readdirSync(P.viewsDir(root))) {
158
+ const state = readJson(P.statePath(root, entry), null);
159
+ revisionCounter = Math.max(revisionCounter, state?.materializedRevision ?? 0);
160
+ }
161
+ }
162
+ let lastCheckpointBytes = 0;
163
+
164
+ // 3. Control socket (JSONL, pty-runner's line-buffered server shape).
165
+ const socketPath = P.coordinatorEndpointPathFor(process.platform, root);
166
+ if (process.platform !== "win32" && existsSync(socketPath)) {
167
+ // Lease is ours: any leftover socket file belongs to a dead coordinator.
168
+ try { unlinkSync(socketPath); } catch { /* best effort */ }
169
+ }
170
+ /** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
171
+ let boundSocketIdentity = null;
172
+
173
+ const server = createServer((socket) => {
174
+ let buffer = "";
175
+ socket.on("data", (chunk) => {
176
+ buffer += chunk.toString("utf8");
177
+ const lines = buffer.split("\n");
178
+ buffer = lines.pop() ?? "";
179
+ for (const line of lines) handleClientLine(line, socket);
180
+ });
181
+ socket.on("error", () => {}); // client vanished mid-request; nothing to answer
182
+ });
183
+
184
+ server.on("error", (err) => {
185
+ process.stderr.write(`state-coordinator: server error ${err instanceof Error ? err.message : String(err)}\n`);
186
+ shutdown(1);
187
+ });
188
+
189
+ server.listen(socketPath, () => {
190
+ if (process.platform !== "win32") {
191
+ try {
192
+ const st = statSync(socketPath);
193
+ boundSocketIdentity = { dev: st.dev, ino: st.ino };
194
+ } catch { /* without the identity, cleanup degrades to no-op */ }
195
+ }
196
+ });
197
+
198
+ let shuttingDown = false;
199
+ process.on("SIGTERM", () => shutdown(0));
200
+ process.on("SIGINT", () => shutdown(0));
201
+
202
+ function shutdown(code) {
203
+ if (shuttingDown) return;
204
+ shuttingDown = true;
205
+ try { clearInterval(startTouchTimer); } catch { /* best effort */ }
206
+ try { server.close(); } catch { /* already closed */ }
207
+ if (process.platform !== "win32" && boundSocketIdentity) {
208
+ try {
209
+ const st = statSync(socketPath);
210
+ if (ownsEndpoint(boundSocketIdentity, { dev: st.dev, ino: st.ino })) {
211
+ try { unlinkSync(socketPath); } catch { /* best effort */ }
212
+ }
213
+ } catch { /* path gone — nothing to clean */ }
214
+ }
215
+ try { lease.release(); } catch { /* best effort */ }
216
+ process.exit(code);
217
+ }
218
+
219
+ function send(socket, msg) {
220
+ socket.write(JSON.stringify(msg) + "\n");
221
+ }
222
+
223
+ function handleClientLine(line, socket) {
224
+ if (!line.trim()) return;
225
+ let msg;
226
+ try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
227
+ switch (msg?.type) {
228
+ case "ping":
229
+ send(socket, { type: "pong", instanceId, startedAt, protocolVersion: COORDINATOR_PROTOCOL_VERSION });
230
+ break;
231
+ case "state_command":
232
+ send(socket, { type: "state_command_result", ...processStateCommand(msg) });
233
+ break;
234
+ default:
235
+ send(socket, { type: "error", message: "unknown type" });
236
+ }
237
+ }
238
+
239
+ /**
240
+ * The command loop: validate → dedupe → decide → journal (fsync) → materialize
241
+ * → remember → reply. Rejections are journaled too, so their exact reason is
242
+ * replayable; duplicates short-circuit before any append.
243
+ * @param {object} msg
244
+ * @returns {{ commandId: string|null, status: string, reason: string|null, materializedRevision: number }}
245
+ */
246
+ function processStateCommand(msg) {
247
+ const checked = validateCommand(msg);
248
+ if (!checked.ok) {
249
+ return {
250
+ commandId: typeof msg?.commandId === "string" ? msg.commandId : null,
251
+ status: "rejected",
252
+ reason: checked.error,
253
+ materializedRevision: 0,
254
+ };
255
+ }
256
+ const command = checked.command;
257
+ const transient = TRANSIENT_KINDS.includes(command.kind);
258
+
259
+ if (!transient) {
260
+ const known = lookupProcessed(command.commandId);
261
+ if (known) {
262
+ return { commandId: command.commandId, status: known.result.status, reason: known.result.reason, materializedRevision: known.materializedRevision };
263
+ }
264
+ }
265
+
266
+ const state = readState(root, command.viewId);
267
+ // Status consistency only binds the view's current run (spec 根治条件 5):
268
+ // no currentRunId → status.json plays no role in this command.
269
+ // Exception (Task 1 review F1): followup_started re-points the row at a
270
+ // NEW run carried in payload.newRunId (command.runId is deliberately null
271
+ // so the stale-run guard cannot fire against the finished parent run).
272
+ // Resolving the status from the parent here would overwrite the parent's
273
+ // status.json with the new run's bootstrap patch; resolving from newRunId
274
+ // finds no file and cleanly bootstraps the new run's status instead.
275
+ const statusRunId = command.kind === "followup_started"
276
+ ? (typeof command.payload?.newRunId === "string" ? command.payload.newRunId : null)
277
+ : (command.runId ?? state?.currentRunId ?? null);
278
+ const status = statusRunId ? readStatus(root, command.viewId, statusRunId) : null;
279
+ const now = Date.now();
280
+ const decision = stampLegacyTimestamps(command, decideStateTransition(command, state, status, now), now);
281
+ const currentRevision = state?.materializedRevision ?? 0;
282
+
283
+ if (decision.action === "reject") {
284
+ if (transient) {
285
+ // Transient rejections are not replayable either — nothing was
286
+ // mutated, and journaling them would grow the journal for noise.
287
+ return { commandId: command.commandId ?? null, status: "rejected", reason: decision.reason, materializedRevision: currentRevision };
288
+ }
289
+ const result = { status: "rejected", reason: decision.reason };
290
+ const record = { command, result, materializedRevision: currentRevision, at: now };
291
+ const journalBytes = appendCommand(root, record);
292
+ rememberProcessed(command.commandId, result, currentRevision);
293
+ maybeCheckpoint(journalBytes);
294
+ return { commandId: command.commandId, status: "rejected", reason: decision.reason, materializedRevision: currentRevision };
295
+ }
296
+
297
+ const newRevision = revisionCounter + 1;
298
+ const result = { status: "applied", reason: decision.reason };
299
+ if (transient) {
300
+ // No journal, no processed ring, no checkpoint: a periodic snapshot —
301
+ // the next beat supersedes it.
302
+ materialize(command.viewId, command, decision.mutate, newRevision, statusRunId);
303
+ revisionCounter = newRevision;
304
+ return { commandId: command.commandId ?? null, status: "applied", reason: decision.reason, materializedRevision: newRevision };
305
+ }
306
+ // Journal first (fsync inside), materialize second: boot replay repairs
307
+ // the window between the two using the stored patches.
308
+ const record = { command, result, materializedRevision: newRevision, at: now, mutate: decision.mutate, statusRunId };
309
+ const journalBytes = appendCommand(root, record);
310
+ materialize(command.viewId, command, decision.mutate, newRevision, statusRunId);
311
+ revisionCounter = newRevision;
312
+ rememberProcessed(command.commandId, result, newRevision);
313
+ maybeCheckpoint(journalBytes);
314
+ return { commandId: command.commandId, status: "applied", reason: decision.reason, materializedRevision: newRevision };
315
+ }
316
+
317
+ /**
318
+ * Merge patches onto state.json (and the run's status.json when it exists)
319
+ * under the view's materialize lock, stamping the shared revision. Per-file
320
+ * guards stamp only files strictly behind the record revision: the live path
321
+ * always qualifies (the global counter is monotonic), while replay repairs
322
+ * just the half that missed its write in a crash window and never moves the
323
+ * other half backwards.
324
+ *
325
+ * Pairing invariant (issue #91 final review): EVERY applied revision pairs
326
+ * BOTH files. A state-only patch (e.g. patch_fields markVisited) restamps
327
+ * the existing status file's revision with no content change, and a
328
+ * status-only patch restamps the state file the same way — a healthy
329
+ * coordinator can never produce the asymmetric pair the reader-side
330
+ * revision check (reconcile) treats as a crash signature. Journaled
331
+ * state-only records heal on replay through this shared path. Residual:
332
+ * a torn TRANSIENT beat (crash between the two writes, never journaled)
333
+ * stays half-paired — ms-window × adjacent-crash, documented in the spec.
334
+ *
335
+ * A status patch without a status file is skipped — patch presence ≠ file
336
+ * requirement (mark_completed always emits `status: {autoState: null}`).
337
+ * The status run binding is the decision-time one (`statusRunId`, recorded
338
+ * in the journal) so replay cannot re-bind a runId-less patch to whatever
339
+ * run is current on disk at replay time; `state.currentRunId` remains only
340
+ * as a legacy fallback for records written before statusRunId existed.
341
+ */
342
+ function materialize(viewId, command, mutate, revision, statusRunId) {
343
+ withViewLockSync(root, viewId, "state-materialize", () => {
344
+ const state = readState(root, viewId);
345
+ if (state && (state.materializedRevision ?? 0) < revision) {
346
+ writeState(root, mutate?.state
347
+ ? { ...state, ...mutate.state, materializedRevision: revision }
348
+ : { ...state, materializedRevision: revision });
349
+ }
350
+ const runId = command?.runId ?? statusRunId ?? state?.currentRunId ?? null;
351
+ if (!runId) return;
352
+ const status = readStatus(root, viewId, runId);
353
+ if (mutate?.status) {
354
+ if (!status && STATUS_BOOTSTRAP_KINDS.has(command?.kind)) {
355
+ // run_started / followup_started / (bootstrapping) run_progress
356
+ // carry the full status content for a run that has no status
357
+ // file yet — create it. Identity fields come from the command
358
+ // context; the patch carries everything meaningful.
359
+ writeStatus(root, { version: 1, runId, viewId, ...mutate.status, materializedRevision: revision });
360
+ } else if (status && (status.materializedRevision ?? 0) < revision) {
361
+ writeStatus(root, { ...status, ...mutate.status, materializedRevision: revision });
362
+ }
363
+ } else if (status && (status.materializedRevision ?? 0) < revision) {
364
+ // State-only patch: restamp the existing status file's revision with
365
+ // no content change (pairing invariant above).
366
+ writeStatus(root, { ...status, materializedRevision: revision });
367
+ }
368
+ });
369
+ }
370
+
371
+ /** Add the legacy wall-clock stamp to a decided patch (F5): see
372
+ * LAST_ACTIVITY_STAMP_KINDS. Applied before the journal append so the
373
+ * stored patches are exactly what replay re-materializes. */
374
+ function stampLegacyTimestamps(command, decision, now) {
375
+ if (decision.action !== "apply" || !LAST_ACTIVITY_STAMP_KINDS.has(command.kind)) return decision;
376
+ return { ...decision, mutate: { ...decision.mutate, state: { ...(decision.mutate.state ?? {}), lastActivityAt: now } } };
377
+ }
378
+
379
+ /** Memory ring first (covers the post-GC window), journal second. */
380
+ function lookupProcessed(commandId) {
381
+ const inMemory = processedCommands.get(commandId);
382
+ if (inMemory) return inMemory;
383
+ const result = findProcessedCommand(root, commandId);
384
+ if (!result) return null;
385
+ const entry = { result, materializedRevision: revisionOf(commandId) };
386
+ rememberProcessed(commandId, result, entry.materializedRevision);
387
+ return entry;
388
+ }
389
+
390
+ /** Revision recorded for a known commandId (journal scan; 0 if unrecorded). */
391
+ function revisionOf(commandId) {
392
+ for (const record of readJournal(root)) {
393
+ if (record?.command?.commandId === commandId) return record.materializedRevision ?? 0;
394
+ }
395
+ return 0;
396
+ }
397
+
398
+ function rememberProcessed(commandId, result, materializedRevision) {
399
+ processedCommands.set(commandId, { result, materializedRevision });
400
+ while (processedCommands.size > PROCESSED_RING_MAX) {
401
+ processedCommands.delete(processedCommands.keys().next().value);
402
+ }
403
+ }
404
+
405
+ /** Checkpoint + GC once the journal outgrows the threshold. GC only runs when
406
+ * the checkpoint write provably succeeded (journal module enforces this). */
407
+ function maybeCheckpoint(journalBytes) {
408
+ if (journalBytes - lastCheckpointBytes < CHECKPOINT_THRESHOLD_BYTES) return;
409
+ if (!writeCheckpoint(root, { materializedRevision: revisionCounter, journalBytes })) return;
410
+ lastCheckpointBytes = gcJournal(root);
411
+ }
412
+ }
413
+
414
+ /** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
415
+ * across exec(2). Same recipe as pty-runner: lets locks.mjs reclaim this
416
+ * coordinator's lease after a SIGKILL even if the pid was reused. null on
417
+ * failure or non-Linux platforms (reclaim degrades to "blocked" there — same
418
+ * platform parity as PTY hosts). */
419
+ function captureStartToken(pid) {
420
+ if (process.platform !== "linux" || !pid) return null;
421
+ try {
422
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
423
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trimStart();
424
+ const fields = afterComm.split(/\s+/);
425
+ return fields[19] ?? null;
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
@@ -9,10 +9,12 @@
9
9
  import { spawn } from "node:child_process";
10
10
  import { readJson } from "../src/core/atomic.mjs";
11
11
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
12
- import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState } from "../src/core/auto-state.mjs";
12
+ import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualCompletion } from "../src/core/auto-state.mjs";
13
13
  import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from "../src/core/evidence.mjs";
14
14
  import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
15
15
  import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
16
+ import { sendStateCommand } from "../src/core/coordinator-client.mjs";
17
+ import { commandRejectDiagnostic } from "../src/core/state-commands.mjs";
16
18
 
17
19
  async function main() {
18
20
  const configPath = process.argv[2];
@@ -26,6 +28,10 @@ async function main() {
26
28
 
27
29
  const state = readState(config.root, config.viewId);
28
30
  if (!state || state.processState === "alive" || state.semanticState === "failed" || state.semanticState === "stopped") process.exit(0);
31
+ // Cheap pre-check (optimization only): skip a pointless command when the
32
+ // manual verdict is already materialized. The coordinator's manual_fence
33
+ // stays the authoritative guard for races after this read.
34
+ if (isManualCompletion(state)) process.exit(0);
29
35
 
30
36
  const evidence = readEvidence(config.root, config.viewId);
31
37
  const latest = latestEvidenceText(evidence) || state.latestAssistantPreview || state.summary || "";
@@ -45,24 +51,93 @@ async function main() {
45
51
  classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: state.lastAgentActivityAt ?? null });
46
52
  }
47
53
 
48
- let changed = false;
49
- if (config.runId) {
50
- const status = readStatus(config.root, config.viewId, config.runId);
51
- if (status) {
52
- changed = applyAutoStateToStatus(status, classification, Date.now()) || changed;
53
- status.evidenceSummary = summarizeEvidence(finalizeEvidence(evidence, status, Date.now()));
54
- writeStatus(config.root, status);
54
+ // Issue #91 (A8 path 2): the classification lands through the View State
55
+ // Coordinator — the single writer of state.json/status.json. A manual
56
+ // completion is fenced by the coordinator (manual_fence), so this late pass
57
+ // can no longer clobber the user's verdict (#46 class). Ambiguous transport
58
+ // outcomes (timeout / connection_reset) NEVER fall back to a direct write:
59
+ // the command may already be journaled, and the coordinator's boot replay
60
+ // is the recovery path.
61
+ const result = await sendStateCommand(config.root, {
62
+ type: "state_command",
63
+ viewId: config.viewId,
64
+ runId: config.runId ?? null,
65
+ source: "state-runner",
66
+ kind: "auto_state_classified",
67
+ expectedRevision: null,
68
+ payload: { classification },
69
+ });
70
+
71
+ if (result.status === "applied") {
72
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
73
+ } else if (result.reason !== "coordinator_disabled") {
74
+ // Unified decided/ambiguous classification (CR r2 issue-3): decided
75
+ // rejects (manual_fence/stale_run/…) log as info *_skipped — the
76
+ // coordinator's verdict is authoritative; only genuinely ambiguous
77
+ // outcomes (timeout/connection_reset) keep the recovery-path warn.
78
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
79
+ }
80
+
81
+ if (result.reason === "coordinator_disabled") {
82
+ // Legacy escape hatch (AGENT_BOARD_COORDINATOR=off): pre-coordinator
83
+ // direct-write behavior, unchanged.
84
+ let changed = false;
85
+ if (config.runId) {
86
+ const status = readStatus(config.root, config.viewId, config.runId);
87
+ if (status) {
88
+ changed = applyAutoStateToStatus(status, classification, Date.now()) || changed;
89
+ status.evidenceSummary = summarizeEvidence(finalizeEvidence(evidence, status, Date.now()));
90
+ writeStatus(config.root, status);
91
+ }
55
92
  }
93
+ const latestState = readState(config.root, config.viewId) ?? state;
94
+ changed = applyAutoStateToViewState(latestState, classification, Date.now()) || changed;
95
+ finalizeEvidence(evidence, { semanticState: latestState.semanticState, usage: null }, Date.now());
96
+ latestState.review = summarizeEvidence(evidence);
97
+ writeEvidence(config.root, evidence);
98
+ updateCodeRefsFromEvidence(config.root, config.viewId, evidence, meta);
99
+ writeState(config.root, latestState);
100
+ if (changed) {
101
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
102
+ }
103
+ process.exit(0);
56
104
  }
57
- const latestState = readState(config.root, config.viewId) ?? state;
58
- changed = applyAutoStateToViewState(latestState, classification, Date.now()) || changed;
59
- finalizeEvidence(evidence, { semanticState: latestState.semanticState, usage: null }, Date.now());
60
- latestState.review = summarizeEvidence(evidence);
105
+
106
+ // Evidence pipeline (coordinator-independent) stays direct: finalize the
107
+ // evidence snapshot from a FRESH state read (reads are not writes — the
108
+ // coordinator owns state.json/status.json writes, not reads), then persist
109
+ // the evidence artifacts and code-refs.
110
+ const postState = readState(config.root, config.viewId);
111
+ finalizeEvidence(evidence, { semanticState: postState?.semanticState ?? state.semanticState, usage: null }, Date.now());
61
112
  writeEvidence(config.root, evidence);
62
113
  updateCodeRefsFromEvidence(config.root, config.viewId, evidence, meta);
63
- writeState(config.root, latestState);
64
- if (changed) {
65
- appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
114
+
115
+ // Issue #91 PR #2 (Task 4): the evidence mirrors move behind the coordinator
116
+ // too — one patch_fields command materializes review/evidenceSummary on both
117
+ // files under a shared materializedRevision (previously two separately
118
+ // fenced direct writes). The coordinator's generic manual_fence / stale_run
119
+ // guards are authoritative; ambiguous outcomes (timeout / connection_reset)
120
+ // NEVER fall back to a direct write — if the command was journaled, boot
121
+ // replay recovers it, and the next classification pass re-derives mirrors
122
+ // from the evidence files either way.
123
+ const mirrorSummary = summarizeEvidence(evidence);
124
+ const patch = await sendStateCommand(config.root, {
125
+ type: "state_command",
126
+ viewId: config.viewId,
127
+ runId: config.runId ?? null,
128
+ source: "state-runner",
129
+ kind: "patch_fields",
130
+ expectedRevision: null,
131
+ payload: { state: { review: mirrorSummary }, status: { evidenceSummary: mirrorSummary } },
132
+ });
133
+ if (patch.status === "applied") {
134
+ // Quiet success — mirrors materialized by the coordinator.
135
+ } else if (patch.reason === "manual_fence" || patch.reason === "stale_run" || patch.reason === "no_change") {
136
+ // Designed fences — informational: a manual verdict or a newer run owns
137
+ // the row, or the mirrors already match.
138
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "evidence_mirror_patch_skipped", message: `Evidence mirror patch not applied (${patch.reason})`, details: { reason: patch.reason } });
139
+ } else {
140
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, level: "warn", code: "evidence_mirror_patch_ambiguous", message: `Evidence mirror patch outcome unknown (${patch.reason}); if the command was journaled, coordinator replay will recover it; the next classification pass re-derives the mirrors from the evidence files`, details: { reason: patch.reason } });
66
141
  }
67
142
  }
68
143
 
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Authoritative entry for the A11 perf gate (issue #121).
4
+ *
5
+ * Loudly refuses under coverage instrumentation: c8 sets NODE_V8_COVERAGE
6
+ * for its whole process tree, and instrumented latency measurements are
7
+ * invalid (2.5–6× inflation). Silently skipping here would manufacture a
8
+ * fake-green gate, so refusal is a nonzero exit with an explicit message.
9
+ *
10
+ * This script is the side-effect layer only — no decision logic, no
11
+ * measurement. The pure gate decision lives in test-support/perf-gate.mjs
12
+ * and serves the test file; do not merge the layers (spec §3.2/§4).
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ if (process.env.NODE_V8_COVERAGE !== undefined) {
18
+ console.error(
19
+ "run-perf-gate: refusing to measure under coverage instrumentation " +
20
+ "(NODE_V8_COVERAGE is set). Perf assertions are only valid without c8; " +
21
+ "run `npm run test:perf` directly, outside any coverage wrapper.",
22
+ );
23
+ process.exit(1);
24
+ }
25
+
26
+ const perfTest = fileURLToPath(new URL("../test/terminal-model-perf.test.mjs", import.meta.url));
27
+ // No --test-concurrency=1: the flag requires Node 21+ but package.json
28
+ // declares engines >=20, and it is a no-op while the gate runs a single
29
+ // file (spec D3, revised 2026-09-20). Re-add it when a second perf file
30
+ // lands — parallel measurement across files must stay impossible.
31
+ const child = spawn(
32
+ process.execPath,
33
+ ["--test", perfTest],
34
+ { stdio: "inherit", env: { ...process.env, AGENT_BOARD_PERF_GATE: "1" } },
35
+ );
36
+ child.on("error", (err) => {
37
+ console.error(`run-perf-gate: failed to spawn the perf suite: ${err.message}`);
38
+ process.exit(1);
39
+ });
40
+ child.on("exit", (code) => process.exit(code ?? 1));
@@ -106,7 +106,7 @@ export async function openDashboard(
106
106
  currentThinkingLevel: options.currentThinkingLevel ?? "off",
107
107
  });
108
108
  interval = setInterval(() => {
109
- service.reconcile();
109
+ void service.reconcile().catch(() => {});
110
110
  comp.refresh();
111
111
  requestDashboardRender(tui);
112
112
  }, POLL_MS);
@@ -146,8 +146,8 @@ export async function dashboardAttachLoop(
146
146
  let again = true;
147
147
  while (again) {
148
148
  const currentId = currentViewId(ctx, service);
149
- if (currentId) service.markVisited?.(currentId);
150
- service.reconcile();
149
+ if (currentId) void service.markVisited?.(currentId)?.catch(() => {});
150
+ void service.reconcile().catch(() => {});
151
151
  const result = await openDashboard(ctx, service, {
152
152
  initialSelectedId: selectedId,
153
153
  currentThinkingLevel: getThinkingLevel?.(),
@@ -185,9 +185,9 @@ async function attach(
185
185
  // Single async resolver entry: real probe + bounded recovery decide readiness (issue #70).
186
186
  const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
187
187
  if (plan.plan === "open-pty") {
188
- service.markVisited?.(viewId);
188
+ void service.markVisited?.(viewId)?.catch(() => {});
189
189
  const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
190
- service.markVisited?.(viewId);
190
+ void service.markVisited?.(viewId)?.catch(() => {});
191
191
  return { action: result.action === "closed" ? "closed" : "detached" };
192
192
  }
193
193
  if (plan.plan === "session-switch") {
@@ -197,7 +197,7 @@ async function attach(
197
197
  return { action: "none" };
198
198
  }
199
199
  const name = latest.meta.name;
200
- service.markVisited?.(viewId);
200
+ void service.markVisited?.(viewId)?.catch(() => {});
201
201
  const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
202
202
  const result = await ctx.switchSession(latest.meta.sessionFile, {
203
203
  withSession: async (replaced) => {
@@ -296,8 +296,8 @@ function installBackToDashboard(ctx: ExtensionCommandContext, service: ReturnTyp
296
296
  try {
297
297
  let selectedId = currentViewId(ctx, service);
298
298
  while (true) {
299
- if (selectedId) service.markVisited?.(selectedId);
300
- service.reconcile();
299
+ if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
300
+ void service.reconcile().catch(() => {});
301
301
  const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
302
302
  if (result.action !== "attach") return;
303
303
  selectedId = result.viewId;