@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
@@ -5,13 +5,18 @@
5
5
  * Usage: node job-runner.mjs <configPath>
6
6
  *
7
7
  * Owns one run: spawns a headless Pi worker (`pi --mode json -p --session <file> <prompt>`),
8
- * streams its JSON events into events.jsonl, reduces them into status.json + the row's
9
- * state.json, and finalizes on exit. Survives the parent Pi process exiting/reloading.
8
+ * streams its JSON events into events.jsonl, and routes every semantic-state
9
+ * mutation through the View State Coordinator as commands (issue #91): boot
10
+ * run_started, transient run_progress beats on the throttled hot path,
11
+ * auto-state classifications, run_finalized on exit, and the post-exit
12
+ * patch_fields (evidence mirrors / model summary). Evidence artifacts
13
+ * (events/stdout/stderr logs, evidence files, code-refs) stay runner-owned and
14
+ * are written directly. Survives the parent Pi process exiting/reloading.
10
15
  */
11
16
  import { spawn } from "node:child_process";
12
17
  import { fileURLToPath } from "node:url";
13
18
  import { appendLine, readJson } from "../src/core/atomic.mjs";
14
- import { createRunStatus, finalizeRun, projectViewState, reduceEvent } from "../src/core/events.mjs";
19
+ import { createRunStatus, finalizeRun, reduceEvent } from "../src/core/events.mjs";
15
20
  import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
16
21
  import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualCompletion } from "../src/core/auto-state.mjs";
17
22
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
@@ -21,8 +26,11 @@ import { claimNextFollowUp, completeFollowUp, releaseFollowUp } from "../src/cor
21
26
  import { newRunId } from "../src/core/ids.mjs";
22
27
  import { launchRun } from "../src/core/launch.mjs";
23
28
  import * as P from "../src/core/paths.mjs";
24
- import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
29
+ import { readState, readStatus, readMeta } from "../src/core/store.mjs";
25
30
  import { readSteering, recordPlanReady } from "../src/core/steering.mjs";
31
+ import { sendStateCommand, coordinatorDisabled } from "../src/core/coordinator-client.mjs";
32
+ import { commandRejectDiagnostic } from "../src/core/state-commands.mjs";
33
+ import { legacyFollowupBootstrap, legacyPersistState, legacyPlanReadyStateWrite, legacyWriteState, legacyWriteStatus } from "./job-runner-legacy.mjs";
26
34
  import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../src/core/steering-prompts.mjs";
27
35
 
28
36
  const WRITE_THROTTLE_MS = 250;
@@ -59,13 +67,70 @@ function main() {
59
67
  const meta = readMeta(root, viewId);
60
68
 
61
69
  let status = createRunStatus(config, null, Date.now());
70
+ // The run starts working the moment the runner is up; seeding the boot status
71
+ // with "working" (instead of createRunStatus's "queued") avoids the stale
72
+ // queued frame flickering through run_started's first materialization.
73
+ status.semanticState = "working";
62
74
  let evidence = emptyEvidenceSnapshot({ viewId, runId, source: "json-runner" });
63
75
  appendDiagnostic(root, viewId, { source: "runner", runId, code: "runner_start", message: "Runner started", details: { kind: config.kind, cwd: config.cwd, model: config.model } });
64
- writeStatus(root, status);
65
76
  writeRunEvidence(root, evidence);
66
77
  writeEvidence(root, evidence);
67
78
  updateCodeRefsFromEvidence(root, viewId, evidence, meta);
68
- writeState(root, projectViewState(status, Date.now(), readState(root, viewId)));
79
+ if (coordinatorDisabled()) {
80
+ legacyPersistState({ root, viewId, runId, status });
81
+ }
82
+ return bootstrapRun({ root, viewId, runId, config, status, meta, evidence, stdoutLog, stderrLog, eventsLog });
83
+ }
84
+
85
+ /**
86
+ * Async continuation of main: route the run_started bootstrap through the
87
+ * coordinator (the status file it creates is what every later run_progress
88
+ * beat patches onto), then spawn the worker and wire the event handlers.
89
+ * @param {{ root: string, viewId: string, runId: string, config: object, status: object, meta: object, evidence: object, stdoutLog: string, stderrLog: string, eventsLog: string }} ctx
90
+ */
91
+ async function bootstrapRun({ root, viewId, runId, config, status, meta, evidence, stdoutLog, stderrLog, eventsLog }) {
92
+ if (!coordinatorDisabled()) {
93
+ // Journaled bootstrap: creates the run's status.json (STATUS_BOOTSTRAP_KINDS)
94
+ // and pins the row to working/alive/currentRunId. Ambiguous outcomes never
95
+ // block the run: if the command was journaled, coordinator replay recovers
96
+ // it; otherwise dashboard reconcile converges the row.
97
+ const started = await sendStateCommand(root, {
98
+ type: "state_command",
99
+ viewId,
100
+ runId,
101
+ source: "job-runner",
102
+ kind: "run_started",
103
+ expectedRevision: null,
104
+ payload: { status: { ...status } },
105
+ });
106
+ if (started.status !== "applied" && started.reason !== "coordinator_disabled") {
107
+ appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("run_started", "Run bootstrap", started.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: started.reason } });
108
+ }
109
+ }
110
+
111
+ /**
112
+ * One transient progress beat: ship the full in-memory status as a sparse
113
+ * patch — the coordinator merges it onto the materialized status and lets
114
+ * projectViewState recompute the row state (delegation, not copied rules).
115
+ *
116
+ * Fire-and-forget by design (the ONLY such command in the runner): the hot
117
+ * path is a periodic self-healing snapshot (~4/sec), a lost beat is
118
+ * superseded by the next one, and failures are silently ignored — logging
119
+ * them would spam diagnostics at beat frequency. Transient commands are
120
+ * never journaled, so an ambiguous outcome has no replay to await.
121
+ */
122
+ const sendProgressBeat = () => {
123
+ const { materializedRevision: _fileStamp, ...patch } = status;
124
+ void sendStateCommand(root, {
125
+ type: "state_command",
126
+ viewId,
127
+ runId,
128
+ source: "job-runner",
129
+ kind: "run_progress",
130
+ expectedRevision: null,
131
+ payload: { statusPatch: patch },
132
+ }).catch(() => {});
133
+ };
69
134
 
70
135
  // Build worker args: pi --mode json -p --session <file> [--model m] [--thinking l] [--tools t] <prompt>
71
136
  const args = [
@@ -91,7 +156,9 @@ function main() {
91
156
 
92
157
  status.pid = worker.pid ?? null;
93
158
  appendDiagnostic(root, viewId, { source: "runner", runId, code: "worker_pid", message: "Worker pid recorded", details: { pid: status.pid } });
94
- writeStatus(root, status);
159
+ // The pid lands on disk via a transient progress beat (the worker's first
160
+ // events would carry it too, but a silent worker must still be observable).
161
+ sendProgressBeat();
95
162
 
96
163
  let stoppedByUser = false;
97
164
  let dirty = false;
@@ -99,15 +166,12 @@ function main() {
99
166
 
100
167
  const persist = (force = false) => {
101
168
  void force;
102
- const now = Date.now();
103
169
  status.evidenceSummary = summarizeEvidence(evidence);
104
- writeStatus(root, status);
105
- writeRunEvidence(root, evidence);
106
- writeEvidence(root, evidence);
107
- writeState(root, projectViewState(status, now, readState(root, viewId)));
170
+ persistEvidenceArtifacts();
171
+ if (coordinatorDisabled()) legacyPersistState({ root, viewId, runId, status });
172
+ else sendProgressBeat();
108
173
  // Best-effort code-refs extraction shells out to git and can take hundreds of
109
- // ms; run it after the state write so endedAt-visible state converges first.
110
- // The extraction only depends on evidence + git, never on state.json.
174
+ // ms; it only depends on evidence + git, never on state.json.
111
175
  updateCodeRefsFromEvidence(root, viewId, evidence, meta);
112
176
  dirty = false;
113
177
  };
@@ -125,6 +189,116 @@ function main() {
125
189
  return true;
126
190
  };
127
191
 
192
+ /**
193
+ * Runner-owned evidence artifacts (evidence files + code-refs). Written
194
+ * directly — the coordinator owns state.json/status.json, not these.
195
+ */
196
+ const persistEvidenceArtifacts = () => {
197
+ status.evidenceSummary = summarizeEvidence(evidence);
198
+ writeRunEvidence(root, evidence);
199
+ writeEvidence(root, evidence);
200
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
201
+ };
202
+
203
+ /**
204
+ * Refresh the evidence mirrors (status.evidenceSummary / state.review)
205
+ * through the coordinator: `patch_fields` carries only whitelisted mirror
206
+ * fields, and the coordinator's generic manual fence (source != user on a
207
+ * manually-completed row) is the authoritative guard — the old fresh-read
208
+ * + isManualCompletion pre-checks are no longer needed. Designed fences
209
+ * (manual_fence / no_change) are informational; ambiguous outcomes never
210
+ * fall back to a direct write.
211
+ */
212
+ const refreshEvidenceMirrors = async () => {
213
+ status.evidenceSummary = summarizeEvidence(evidence);
214
+ const result = await sendStateCommand(root, {
215
+ type: "state_command",
216
+ viewId,
217
+ runId: null,
218
+ source: "job-runner",
219
+ kind: "patch_fields",
220
+ expectedRevision: null,
221
+ payload: {
222
+ state: { review: status.evidenceSummary },
223
+ status: { evidenceSummary: status.evidenceSummary },
224
+ },
225
+ });
226
+ if (result.status === "applied") {
227
+ const fresh = readStatus(root, viewId, runId);
228
+ if (fresh) Object.assign(status, fresh);
229
+ return true;
230
+ }
231
+ if (result.reason === "manual_fence" || result.reason === "no_change") return false;
232
+ if (result.reason === "coordinator_disabled") {
233
+ // Legacy escape hatch: fresh-read + fence, pre-coordinator semantics.
234
+ const freshStatus = readStatus(root, viewId, runId);
235
+ if (freshStatus && !isManualCompletion(freshStatus)) {
236
+ freshStatus.evidenceSummary = status.evidenceSummary;
237
+ legacyWriteStatus(root, viewId, runId, freshStatus);
238
+ }
239
+ const freshState = readState(root, viewId);
240
+ if (freshState && !isManualCompletion(freshState)) {
241
+ freshState.review = status.evidenceSummary;
242
+ legacyWriteState(root, viewId, freshState);
243
+ }
244
+ return true;
245
+ }
246
+ appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("evidence_mirror", "Evidence mirror", result.reason, "otherwise the next mirror refresh will converge"), details: { reason: result.reason } });
247
+ return false;
248
+ };
249
+
250
+ /**
251
+ * Materialize the run's terminal state through the View State Coordinator
252
+ * (issue #91, A8 path 3): the runner submits minimal facts and the
253
+ * coordinator computes terminal semantics via finalizeRun, so a manual
254
+ * completion landing before the command is fenced by the coordinator
255
+ * (manual_fence), not by a file re-read (#46 class). Evidence artifacts stay
256
+ * direct (runner-owned). Ambiguous outcomes (timeout / connection_reset)
257
+ * NEVER fall back to a direct write: the command may already be journaled,
258
+ * and the coordinator's boot replay is the recovery path.
259
+ * coordinator_disabled keeps the pre-coordinator direct persist.
260
+ * @param {{ exitCode: number|null, stoppedByUser: boolean }} facts
261
+ * @returns {Promise<boolean>} whether the final state is known materialized
262
+ */
263
+ const finalizeThroughCoordinator = async ({ exitCode, stoppedByUser: stopped }) => {
264
+ const payload = { exitCode, stoppedByUser: stopped };
265
+ if (status.endedAt != null) payload.endedAt = status.endedAt;
266
+ // The close path cancels the pending throttled flush after the final
267
+ // buffer flush, so a stopReason observed in the last burst (reduceEvent
268
+ // sets it in memory only) never reached disk. Overlay it onto the payload:
269
+ // finalizeSemanticState keys on stopReason alone for exit-0 exits, and the
270
+ // coordinator already supports the payload overlay (issue #91).
271
+ if (status.stopReason != null) payload.stopReason = status.stopReason;
272
+ if (status.latestAssistantPreview) payload.latestAssistantPreview = status.latestAssistantPreview;
273
+ if (status.lastAgentActivityAt != null) payload.lastAgentActivityAt = status.lastAgentActivityAt;
274
+ const result = await sendStateCommand(root, {
275
+ type: "state_command",
276
+ viewId,
277
+ runId,
278
+ source: "job-runner",
279
+ kind: "run_finalized",
280
+ expectedRevision: null,
281
+ payload,
282
+ });
283
+ if (result.status === "applied") {
284
+ const fresh = readStatus(root, viewId, runId);
285
+ if (fresh) Object.assign(status, fresh);
286
+ await refreshEvidenceMirrors();
287
+ return true;
288
+ }
289
+ if (result.reason === "coordinator_disabled") {
290
+ persistEvidenceArtifacts();
291
+ legacyPersistState({ root, viewId, runId, status });
292
+ return true;
293
+ }
294
+ if (result.reason === "stale_run") {
295
+ // Duplicate finalize or the run was already superseded — nothing to do.
296
+ return false;
297
+ }
298
+ appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("run_finalize", "Run finalization", result.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: result.reason } });
299
+ return false;
300
+ };
301
+
128
302
  const scheduleFlush = () => {
129
303
  if (flushTimer) {
130
304
  dirty = true;
@@ -196,16 +370,26 @@ function main() {
196
370
  process.on("SIGTERM", stop);
197
371
  process.on("SIGINT", stop);
198
372
 
199
- worker.on("error", (err) => {
373
+ worker.on("error", async (err) => {
374
+ // Cancel any in-flight throttled flush before finalizing: if the timer
375
+ // callback lands during the sendStateCommand await below, the stale
376
+ // persist() would overwrite the coordinator-materialized terminal state
377
+ // and the close-path run_finalized would then bounce as stale_run
378
+ // (symmetric with the close path's cancel; issue #91 fix round 1).
379
+ if (flushTimer) {
380
+ clearTimeout(flushTimer);
381
+ flushTimer = null;
382
+ }
200
383
  status.error = `Failed to launch worker: ${err instanceof Error ? err.message : String(err)}`;
201
384
  appendDiagnostic(root, viewId, { source: "runner", runId, level: "error", code: "worker_error", message: status.error, details: {} });
202
385
  finalizeRun(status, { exitCode: 1, stoppedByUser }, Date.now());
203
386
  finalizeEvidence(evidence, status, Date.now());
204
- persist(true);
387
+ persistEvidenceArtifacts();
388
+ await finalizeThroughCoordinator({ exitCode: 1, stoppedByUser });
205
389
  process.exit(1);
206
390
  });
207
391
 
208
- worker.on("close", (code) => {
392
+ worker.on("close", async (code) => {
209
393
  if (buffer.trim()) onLine(buffer);
210
394
  if (flushTimer) {
211
395
  clearTimeout(flushTimer);
@@ -218,45 +402,67 @@ function main() {
218
402
  // dashboard flips to its final state at once. Then try to classify the final
219
403
  // bucket and upgrade the summary with cheap model passes. Slow/unreachable
220
404
  // model calls must never stall the row indefinitely.
221
- persist(true);
222
- if (applyHeuristicAutoState(config, status, evidence)) {
223
- finalizeEvidence(evidence, status, Date.now());
224
- status.evidenceSummary = summarizeEvidence(evidence);
225
- persistUnlessManual(true);
226
- }
227
- maybeModelAutoState(config, status, evidence)
405
+ //
406
+ // Issue #91 (A8 path 3): the terminal status/state materialize through the
407
+ // View State Coordinator (finalizeThroughCoordinator) — only evidence
408
+ // artifacts are written directly here. The in-flight hot-path flush was
409
+ // cancelled above, so no throttled write can race the coordinator's patch.
410
+ persistEvidenceArtifacts();
411
+ await finalizeThroughCoordinator({ exitCode: code ?? 0, stoppedByUser });
412
+ applyHeuristicAutoState(config, status, evidence)
228
413
  .then((changed) => {
229
414
  if (changed) {
230
415
  finalizeEvidence(evidence, status, Date.now());
231
- status.evidenceSummary = summarizeEvidence(evidence);
232
- persistUnlessManual(true);
416
+ if (coordinatorDisabled()) persistUnlessManual(true);
417
+ else refreshEvidenceMirrors();
233
418
  }
234
- return maybeModelSummary(config, status);
419
+ return maybeModelAutoState(config, status, evidence);
235
420
  })
236
421
  .then((changed) => {
237
- if (changed) persistUnlessManual(true);
422
+ if (changed) {
423
+ finalizeEvidence(evidence, status, Date.now());
424
+ if (coordinatorDisabled()) persistUnlessManual(true);
425
+ else refreshEvidenceMirrors();
426
+ }
427
+ return maybeModelSummary(config, status);
428
+ })
429
+ .then(async (changed) => {
430
+ if (!changed) return;
431
+ if (coordinatorDisabled()) persistUnlessManual(true);
432
+ else await patchSummaryThroughCoordinator(config, status);
238
433
  })
239
434
  .catch(() => {})
240
- .finally(() => {
435
+ .then(async () => {
241
436
  // The finalize chain must never prevent process.exit: a lock/fs failure
242
- // here used to pin the runner as a 100% CPU zombie (issue #33).
437
+ // here used to pin the runner as a 100% CPU zombie (issue #33). Both
438
+ // steps now await coordinator commands, so they run before the exit.
243
439
  try {
244
- finalizeSteeringIfNeeded(config, status, evidence);
440
+ await finalizeSteeringIfNeeded(config, status, evidence);
245
441
  } catch (err) {
246
442
  tryAppendDiagnostic(config, "finalize_steering_failed", err);
247
443
  }
248
444
  try {
249
- drainQueuedFollowUp(config, status);
445
+ await drainQueuedFollowUp(config, status);
250
446
  } catch (err) {
251
447
  tryAppendDiagnostic(config, "follow_up_drain_failed", err);
252
448
  }
449
+ })
450
+ .finally(() => {
253
451
  process.exit(stoppedByUser ? 0 : (code ?? 0));
254
452
  });
255
453
  });
256
454
  }
257
455
 
258
- /** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
259
- function finalizeSteeringIfNeeded(config, status, evidence) {
456
+ /**
457
+ * Route the plan-ready row flip through the coordinator (`plan_ready`): the
458
+ * decision layer carries the exact legacy patch (needs_input/exited/"Approve
459
+ * this plan?") and its manual fence replaces the old file re-read guard.
460
+ * recordPlanReady's steering.json write STAYS direct — steering is not a
461
+ * coordinator artifact. The cheap pre-check remains as an optimization; the
462
+ * coordinator's manual_fence is authoritative. Async because the command must
463
+ * land before the exit-chain process.exit.
464
+ * @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
465
+ async function finalizeSteeringIfNeeded(config, status, evidence) {
260
466
  if (config.kind !== "plan" && config.kind !== "plan_change") return;
261
467
  if (status.semanticState === "failed" || status.semanticState === "stopped") return;
262
468
  // A manual completion racing the exit chain must not be resurrected for
@@ -267,21 +473,26 @@ function finalizeSteeringIfNeeded(config, status, evidence) {
267
473
  runId: config.runId,
268
474
  planText: latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "Plan ready",
269
475
  });
270
- const prev = readState(config.root, config.viewId);
271
- if (prev) {
272
- prev.semanticState = "needs_input";
273
- prev.processState = "exited";
274
- prev.needsInput = true;
275
- prev.question = "Approve this plan?";
276
- prev.summary = "Plan ready for approval";
277
- prev.currentRunId = config.runId;
278
- prev.updatedAt = Date.now();
279
- writeState(config.root, prev);
476
+ if (coordinatorDisabled()) {
477
+ legacyPlanReadyStateWrite(config.root, config.viewId, config.runId);
478
+ return;
280
479
  }
480
+ const result = await sendStateCommand(config.root, {
481
+ type: "state_command",
482
+ viewId: config.viewId,
483
+ runId: config.runId,
484
+ source: "job-runner",
485
+ kind: "plan_ready",
486
+ expectedRevision: null,
487
+ payload: { runId: config.runId },
488
+ });
489
+ if (result.status === "applied") return;
490
+ if (result.reason === "manual_fence" || result.reason === "no_change") return;
491
+ appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, ...commandRejectDiagnostic("plan_ready", "Plan-ready", result.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: result.reason } });
281
492
  }
282
493
 
283
494
  /** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status */
284
- function drainQueuedFollowUp(config, status) {
495
+ async function drainQueuedFollowUp(config, status) {
285
496
  if (status.semanticState !== "idle" && status.semanticState !== "completed") return;
286
497
  // A manual completion racing the exit chain must never be followed up: the
287
498
  // user just finished this row, so don't launch a new run over it. The
@@ -302,8 +513,35 @@ function drainQueuedFollowUp(config, status) {
302
513
  try {
303
514
  const { pid } = launchRun(config.root, nextConfig, { runnerScript: fileURLToPath(import.meta.url) });
304
515
  const nextStatus = createRunStatus(nextConfig, pid ?? null, Date.now());
305
- writeStatus(config.root, nextStatus);
306
- writeState(config.root, projectViewState(nextStatus, Date.now(), readState(config.root, config.viewId)));
516
+ // Bootstrap the follow-up run through the coordinator: command.runId is
517
+ // deliberately omitted (a parent-run runId would trip the generic stale-run
518
+ // guard against the just-finalized parent); payload.newRunId governs the
519
+ // state-side currentRunId. The new runner's own run_started then lands on
520
+ // top of this bootstrap with the real pid.
521
+ if (coordinatorDisabled()) {
522
+ legacyFollowupBootstrap(config.root, config.viewId, nextStatus);
523
+ } else {
524
+ const result = await sendStateCommand(config.root, {
525
+ type: "state_command",
526
+ viewId: config.viewId,
527
+ source: "job-runner",
528
+ kind: "followup_started",
529
+ expectedRevision: null,
530
+ payload: { newRunId: nextRunId, statusPatch: { ...nextStatus } },
531
+ });
532
+ if (result.reason === "manual_fence") {
533
+ // The user completed the row between the pre-check and this command.
534
+ // The fence preserved their verdict (legacy clobbered it); the child
535
+ // is already launched, so complete the item to avoid a double fire
536
+ // and surface the lost follow-up.
537
+ appendDiagnostic(config.root, config.viewId, { source: "queue", runId: nextRunId, level: "warn", code: "follow_up_fenced", message: "Manual completion fenced the follow-up bootstrap; the launched run continues but the row keeps its manual verdict", details: { kind: item.kind } });
538
+ completeFollowUp(config.root, config.viewId, item.id, { runId: nextRunId });
539
+ return;
540
+ }
541
+ if (result.status !== "applied" && result.reason !== "no_change" && result.reason !== "stale_run") {
542
+ appendDiagnostic(config.root, config.viewId, { source: "queue", runId: nextRunId, ...commandRejectDiagnostic("follow_up_bootstrap", "Follow-up bootstrap", result.reason, "otherwise the launched runner's own run_started converges the row"), details: { reason: result.reason } });
543
+ }
544
+ }
307
545
  completeFollowUp(config.root, config.viewId, item.id, { runId: nextRunId });
308
546
  appendDiagnostic(config.root, config.viewId, { source: "queue", runId: nextRunId, code: "follow_up_started", message: "Queued follow-up started by JSON runner", details: { kind: item.kind } });
309
547
  } catch (err) {
@@ -369,22 +607,59 @@ function canAutoState(config, status, evidence) {
369
607
  return Boolean((latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "").trim());
370
608
  }
371
609
 
372
- function applyHeuristicAutoState(config, status, evidence) {
610
+ /**
611
+ * Submit one classification to the View State Coordinator (issue #91, A8 path 2).
612
+ * The coordinator owns semantic state: applied patches are materialized by it and
613
+ * this runner only refreshes its in-memory status from disk so any remaining
614
+ * direct persist (PR #1 hot path) starts from authoritative fields. Designed
615
+ * fences (manual_fence / no_change / stale_run) are informational, not errors.
616
+ * Ambiguous transport outcomes (timeout / connection_reset) never fall back to a
617
+ * direct write — the command may already be journaled, and the coordinator's
618
+ * boot replay is the recovery path.
619
+ * @param {import("../src/core/types.mjs").RunConfig} config
620
+ * @param {import("../src/core/types.mjs").RunStatus} status mutated in place on apply (fresh coordinator fields)
621
+ * @param {import("../src/core/types.mjs").AutoStateClassification} classification
622
+ * @returns {Promise<boolean>} whether the classification was applied
623
+ */
624
+ async function classifyThroughCoordinator(config, status, classification) {
625
+ const result = await sendStateCommand(config.root, {
626
+ type: "state_command",
627
+ viewId: config.viewId,
628
+ runId: config.runId,
629
+ source: "job-runner",
630
+ kind: "auto_state_classified",
631
+ expectedRevision: null,
632
+ payload: { classification },
633
+ });
634
+ if (result.status === "applied") {
635
+ appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated terminal state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
636
+ const fresh = readStatus(config.root, config.viewId, config.runId);
637
+ if (fresh) Object.assign(status, fresh);
638
+ return true;
639
+ }
640
+ if (result.reason === "coordinator_disabled") {
641
+ // Legacy escape hatch (AGENT_BOARD_COORDINATOR=off): apply locally; the
642
+ // caller's persistUnlessManual keeps the pre-coordinator fence for this path.
643
+ return applyAutoStateToStatus(status, classification, Date.now());
644
+ }
645
+ if (result.reason === "manual_fence" || result.reason === "no_change" || result.reason === "stale_run") {
646
+ // Designed fences — informational, not errors.
647
+ return false;
648
+ }
649
+ appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
650
+ return false;
651
+ }
652
+
653
+ async function applyHeuristicAutoState(config, status, evidence) {
373
654
  if (!canAutoState(config, status, evidence)) return false;
374
- // Fresh read of state.json (not status.json): completeView writes the manual
375
- // completion signal (semanticState "completed" + autoState null) to state.json
376
- // and only clears autoState in status.json, so status.json can never carry
377
- // the completed+null pair. If the user marked the row done while the worker
378
- // was exiting, skip classification so the persist path can't clobber it.
655
+ // Cheap pre-check kept as an optimization (avoids a pointless command);
656
+ // correctness no longer depends on it — the coordinator fences manual
657
+ // completions authoritatively (manual_fence).
379
658
  const latestState = readState(config.root, config.viewId);
380
659
  if (isManualCompletion(latestState)) return false;
381
660
  const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
382
661
  const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
383
- const changed = applyAutoStateToStatus(status, classification, Date.now());
384
- if (changed) {
385
- appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated terminal state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
386
- }
387
- return changed;
662
+ return classifyThroughCoordinator(config, status, classification);
388
663
  }
389
664
 
390
665
  async function maybeModelAutoState(config, status, evidence) {
@@ -398,19 +673,14 @@ async function maybeModelAutoState(config, status, evidence) {
398
673
  [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
399
674
  15000,
400
675
  );
401
- // Fresh read: the user may have marked the row done manually during the model
402
- // call. completeView clears autoState in both state.json and status.json, so a
403
- // manual completion is detectable here; applying the classification to the stale
404
- // in-memory status would clobber the user's verdict.
676
+ // The user may have marked the row done manually during the model call. The
677
+ // cheap pre-check avoids a pointless command; the coordinator's manual_fence
678
+ // is the authoritative guard for races after this read.
405
679
  const fresh = readStatus(config.root, config.viewId, config.runId);
406
680
  if (!fresh || isManualCompletion(fresh)) return false;
407
681
  Object.assign(status, fresh);
408
682
  const classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
409
- const changed = applyAutoStateToStatus(status, classification, Date.now());
410
- if (changed) {
411
- appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier refined terminal state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
412
- }
413
- return changed;
683
+ return classifyThroughCoordinator(config, status, classification);
414
684
  }
415
685
 
416
686
  /** Default cheap model for terminal summaries. Override/disable via $AGENT_BOARD_SUMMARY_MODEL. */
@@ -450,6 +720,40 @@ async function maybeModelSummary(config, status) {
450
720
  return false;
451
721
  }
452
722
 
723
+ /**
724
+ * Route the post-exit model-summary upgrade through the coordinator as a
725
+ * `patch_fields` command (summary + latestAssistantPreview are whitelisted for
726
+ * the job-runner source). The generic manual fence replaces the old
727
+ * persistUnlessManual file re-read. runId stays null: the finished run's
728
+ * currentRunId still points at it, so the coordinator binds the status patch
729
+ * to the right file without tripping the stale-run guard.
730
+ * @param {import("../src/core/types.mjs").RunConfig} config
731
+ * @param {import("../src/core/types.mjs").RunStatus} status mutated in place on apply
732
+ * @returns {Promise<boolean>} whether the summary patch was applied
733
+ */
734
+ async function patchSummaryThroughCoordinator(config, status) {
735
+ const result = await sendStateCommand(config.root, {
736
+ type: "state_command",
737
+ viewId: config.viewId,
738
+ runId: null,
739
+ source: "job-runner",
740
+ kind: "patch_fields",
741
+ expectedRevision: null,
742
+ payload: {
743
+ state: { summary: status.summary, latestAssistantPreview: status.latestAssistantPreview },
744
+ status: { summary: status.summary },
745
+ },
746
+ });
747
+ if (result.status === "applied") {
748
+ const fresh = readStatus(config.root, config.viewId, config.runId);
749
+ if (fresh) Object.assign(status, fresh);
750
+ return true;
751
+ }
752
+ if (result.reason === "manual_fence" || result.reason === "no_change") return false;
753
+ appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, ...commandRejectDiagnostic("summary_patch", "Summary patch", result.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: result.reason } });
754
+ return false;
755
+ }
756
+
453
757
  /**
454
758
  * Run a pi one-shot and return the concatenated assistant text from message_end events.
455
759
  * @param {string} command
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pre-coordinator direct write for host-failure row finalization (issue #91).
3
+ *
4
+ * Only reachable via `AGENT_BOARD_COORDINATOR=off` — the documented escape
5
+ * hatch. The normal path routes `host_run_failed` through the View State
6
+ * Coordinator (`runner/state-coordinator.mjs`), whose manual_fence / stale_run
7
+ * guards own the decision; this direct write has NO manual-completion fence,
8
+ * which is exactly why it must stay unreachable in the default configuration.
9
+ *
10
+ * Lives in its own module so `runner/pty-runner.mjs` itself never imports the
11
+ * state materializers (writer-boundary test, spec D3). `writeHost` is a
12
+ * different artifact: host.json is owned by the pty-runner per spec D3.
13
+ */
14
+ import { readState, writeState } from "../src/core/store.mjs";
15
+
16
+ /**
17
+ * Legacy direct write of the view-failed row (pre-coordinator markRowFailed).
18
+ * @param {string} root
19
+ * @param {string} viewId
20
+ * @param {string} message
21
+ */
22
+ export function markRowFailedDirect(root, viewId, message) {
23
+ const now = Date.now();
24
+ const state = readState(root, viewId) ?? {
25
+ version: 1,
26
+ viewId,
27
+ currentRunId: null,
28
+ semanticState: "queued",
29
+ processState: "exited",
30
+ summary: "Queued",
31
+ lastActivityAt: now,
32
+ updatedAt: now,
33
+ needsInput: false,
34
+ hasError: false,
35
+ latestAssistantPreview: "",
36
+ latestTool: null,
37
+ question: null,
38
+ pendingQuestions: [],
39
+ error: null,
40
+ };
41
+ state.semanticState = "failed";
42
+ state.processState = "exited";
43
+ state.summary = message;
44
+ state.hasError = true;
45
+ state.needsInput = false;
46
+ state.error = message;
47
+ state.updatedAt = now;
48
+ state.lastActivityAt = now;
49
+ writeState(root, state);
50
+ }