@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
@@ -9,13 +9,14 @@ import { randomBytes } from "node:crypto";
9
9
  import { createRequire } from "node:module";
10
10
  import { createConnection } from "node:net";
11
11
  import { resolve } from "node:path";
12
- import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState } from "../core/auto-state.mjs";
12
+ import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState, isManualCompletion } from "../core/auto-state.mjs";
13
13
  import { appendLine, atomicWriteJson, removeFile } from "../core/atomic.mjs";
14
14
  import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
15
15
  import { clearDiagnostics, appendDiagnostic, tailDiagnostics } from "../core/diagnostics.mjs";
16
16
  import { emptyEvidenceSnapshot, finalizeEvidence, readEvidence, reduceEvidence, summarizeEvidence, writeEvidence } from "../core/evidence.mjs";
17
17
  import { updateCodeRefsFromEvidence } from "../core/code-refs-store.mjs";
18
- import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
18
+ import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, failFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
19
+ import { foregroundPreviewCache } from "../core/foreground-preview-cache.mjs";
19
20
  import { approvePlan as approvePlanState, markExecutingApprovedPlan, readSteering, recordPlanReady, requestPlan as requestPlanState, requestPlanChanges as requestPlanChangesState, summarizeSteering } from "../core/steering.mjs";
20
21
  import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../core/steering-prompts.mjs";
21
22
  import { isGenericStatusText } from "../core/derive.mjs";
@@ -27,6 +28,9 @@ import { isAlive, killProcess } from "../core/pid.mjs";
27
28
  import { acquireOwnedViewLock, tryAcquireOwnedViewLock } from "../core/locks.mjs";
28
29
  import { canFinalizeLegacyHost, canReplaceHost } from "../core/host-coordination.mjs";
29
30
  import { modelRefAvailable } from "../core/launch-options.mjs";
31
+ import { ensureCoordinator, sendStateCommand, coordinatorDisabled } from "../core/coordinator-client.mjs";
32
+ import { createDesyncEpisodeThrottle, rereadPair, statusRevisionDesynced } from "../core/status-consistency.mjs";
33
+ import { commandRejectDiagnostic } from "../core/state-commands.mjs";
30
34
  import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
31
35
  import * as P from "../core/paths.mjs";
32
36
  import {
@@ -88,6 +92,7 @@ const ATTACH_RESOLVE_TIMEOUT_MS = 120_000;
88
92
  * signalOwnedProcess?: (identity: {pid: number, startToken: string|null}, signal: string) => void,
89
93
  * probeHostFn?: typeof probeHost,
90
94
  * sleepFn?: (ms: number) => Promise<void>,
95
+ * sendStateCommand?: typeof sendStateCommand,
91
96
  * availableModels?: () => Array<{ provider: string, id: string }> | undefined,
92
97
  * }} opts
93
98
  */
@@ -104,6 +109,11 @@ export function createService(opts) {
104
109
  const nowImpl = opts.now ?? Date.now;
105
110
  const acquireLockImpl = opts.acquireLock ?? acquireOwnedViewLock;
106
111
  const tryAcquireLockImpl = opts.tryAcquireLock ?? tryAcquireOwnedViewLock;
112
+ const sendStateCommandImpl = opts.sendStateCommand ?? sendStateCommand;
113
+ const ensureCoordinatorImpl = opts.ensureCoordinator ?? ensureCoordinator;
114
+ // Desync episode throttle: a stuck (stateRev:statusRev) pair logs once per
115
+ // service, not once per reconcile pass — see createDesyncEpisodeThrottle().
116
+ const desyncThrottle = createDesyncEpisodeThrottle();
107
117
  // Identity-aware observation/signalling for host recovery (issue #70). Callers must
108
118
  // only signal after observeProcess returned "owned" for that exact identity.
109
119
  const observeProcessImpl = opts.observeProcess ?? defaultObserveProcess;
@@ -144,9 +154,11 @@ export function createService(opts) {
144
154
  * @param {import("../core/types.mjs").ViewMeta} meta
145
155
  * @param {string} prompt
146
156
  * @param {RunKind} kind
147
- * @returns {{ runId: string, pid: number|null }}
157
+ * @returns {Promise<{ runId: string, pid: number|null }>} resolves after
158
+ * mark_queued has landed and the detached runner is spawned (ordering
159
+ * contract: the row must be queued before the runner can boot, final-review F2).
148
160
  */
149
- function launchForView(meta, prompt, kind) {
161
+ async function launchForView(meta, prompt, kind) {
150
162
  const runId = newRunId();
151
163
  /** @type {import("../core/types.mjs").RunConfig} */
152
164
  const config = {
@@ -163,9 +175,17 @@ export function createService(opts) {
163
175
  thinkingLevel: meta.defaultThinking ?? null,
164
176
  tools: null,
165
177
  };
178
+ // Final-review F2 residual (fix round 2): mark_queued must LAND before the
179
+ // runner can boot. A detached runner that boots faster than this round-trip
180
+ // used to send run_started while the row was still manual-completed → the
181
+ // command hit the manual fence → row stuck queued/alive with no status file
182
+ // → beats and run_finalized reject stale_run forever → permanent zombie.
183
+ // Spawn regardless of the result: with the coordinator unreachable the
184
+ // runner's own commands fail the same way either way — launch availability
185
+ // beats strict ordering.
186
+ await markQueued(meta.id, runId);
166
187
  const { pid } = launch(root, config, { runnerScript: opts.runnerScript });
167
188
  appendDiagnostic(root, meta.id, { source: "service", runId, code: "launch_run", message: "Detached runner launched", details: { kind, pid } });
168
- markQueued(meta.id, runId);
169
189
  return { runId, pid };
170
190
  }
171
191
 
@@ -176,9 +196,9 @@ export function createService(opts) {
176
196
  * can never stack a second host on top of a live claim.
177
197
  * @param {import("../core/types.mjs").ViewMeta} meta
178
198
  * @param {string|null} initialPrompt
179
- * @returns {{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
199
+ * @returns {Promise<{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }>}
180
200
  */
181
- function launchHost(meta, initialPrompt, launchOpts = {}) {
201
+ async function launchHost(meta, initialPrompt, launchOpts = {}) {
182
202
  let lease;
183
203
  try {
184
204
  lease = acquireLockImpl(root, meta.id, "host-start", { waitMs: HOST_START_LOCK_WAIT_MS, identity: serviceIdentity() });
@@ -190,7 +210,12 @@ export function createService(opts) {
190
210
  return pendingLaunchResult(existing);
191
211
  }
192
212
  try {
193
- return startHostUnderLease(meta, initialPrompt, launchOpts);
213
+ // Same ordering contract as launchForView (final-review F2 residual):
214
+ // the row must leave completed/fenced before the host can start work.
215
+ // `return await` (not `return`) keeps the host-start lease held until
216
+ // the two-phase claim transaction settles.
217
+ if (launchOpts.markQueued !== false) await markQueued(meta.id, null);
218
+ return await startHostUnderLease(meta, initialPrompt, launchOpts);
194
219
  } finally {
195
220
  try { lease.release(); } catch { /* best effort */ }
196
221
  }
@@ -228,7 +253,7 @@ export function createService(opts) {
228
253
  * lease; this function never acquires or releases it.
229
254
  * @param {import("../core/types.mjs").ViewMeta} meta
230
255
  * @param {string|null} initialPrompt
231
- * @returns {ReturnType<typeof launchHost>}
256
+ * @returns {{ ok: true, status: "reused"|"pending"|"started", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
232
257
  */
233
258
  function startHostUnderLease(meta, initialPrompt, launchOpts = {}) {
234
259
  const existing = readHost(root, meta.id);
@@ -304,7 +329,6 @@ export function createService(opts) {
304
329
  }
305
330
  updateOwnedHost(root, meta.id, instanceId, (h) => ({ ...h, runnerPid: pid, runnerSpawnedAt: nowImpl() }));
306
331
  appendDiagnostic(root, meta.id, { source: "service", code: "launch_host", message: "PTY host launched", details: { pid, instanceId, hasInitialPrompt: Boolean(initialPrompt) } });
307
- if (launchOpts.markQueued !== false) markQueued(meta.id, null);
308
332
  return { ok: true, status: "started", pid, socketPath, instanceId };
309
333
  }
310
334
 
@@ -337,17 +361,36 @@ export function createService(opts) {
337
361
  /**
338
362
  * Apply immediate heuristic auto-state and, when configured, queue a detached
339
363
  * model pass to refine the row without blocking the live Pi child.
364
+ *
365
+ * Issue #91 (A8, service source): the classification lands through the View
366
+ * State Coordinator — the single writer of state.json/status.json. On apply,
367
+ * projection-relevant fields are refreshed from the materialized state so the
368
+ * caller's foreground projection (writeForegroundState) cannot clobber the
369
+ * patch with stale in-memory values. Ambiguous transport outcomes (timeout /
370
+ * connection_reset) never fall back to a direct write — the command may
371
+ * already be journaled, and the coordinator's boot replay is the recovery
372
+ * path. coordinator_disabled keeps the pre-coordinator in-memory apply.
340
373
  * @param {import("../core/types.mjs").ViewMeta} meta
341
- * @param {import("../core/types.mjs").RunStatus} status
374
+ * @param {import("../core/types.mjs").RunStatus} status refreshed in place on apply
342
375
  * @param {import("../core/types.mjs").EvidenceSnapshot} evidence
343
- * @returns {boolean}
376
+ * @returns {Promise<boolean>} whether the classification was applied
344
377
  */
345
- function queueAutoState(meta, status, evidence) {
378
+ async function queueAutoState(meta, status, evidence) {
346
379
  if (!autoStateEnabled()) return false;
347
380
  if (status.processState === "alive" || status.semanticState === "failed" || status.semanticState === "stopped") return false;
348
381
  const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
349
382
  if (!latest.trim()) return false;
350
- const changed = applyAutoStateToStatus(status, heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null }), Date.now());
383
+ const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
384
+ const commandRunId = status.runId === "foreground" ? null : status.runId;
385
+ const result = await sendStateCommandImpl(root, {
386
+ type: "state_command",
387
+ viewId: meta.id,
388
+ runId: commandRunId,
389
+ source: "service",
390
+ kind: "auto_state_classified",
391
+ expectedRevision: null,
392
+ payload: { classification },
393
+ });
351
394
  if (opts.autoStateRunnerScript) {
352
395
  try {
353
396
  launchAutoStateImpl(root, {
@@ -362,11 +405,47 @@ export function createService(opts) {
362
405
  appendDiagnostic(root, meta.id, { source: "service", level: "warn", code: "auto_state_launch_failed", message: "Auto-state classifier could not be launched", details: { error: err instanceof Error ? err.message : String(err) } });
363
406
  }
364
407
  }
365
- return changed;
408
+ if (result.status === "applied") {
409
+ const fresh = readState(root, meta.id);
410
+ if (fresh) {
411
+ status.semanticState = fresh.semanticState;
412
+ status.summary = fresh.summary;
413
+ status.question = fresh.question;
414
+ status.autoState = fresh.autoState ?? null;
415
+ }
416
+ return true;
417
+ }
418
+ if (result.reason === "coordinator_disabled") {
419
+ return applyAutoStateToStatus(status, classification, Date.now());
420
+ }
421
+ // Unified decided/ambiguous classification (CR r2 issue-3): decided
422
+ // rejects (manual_fence/stale_run/…) log as info *_skipped — the
423
+ // coordinator's verdict is authoritative; only genuinely ambiguous
424
+ // outcomes (timeout/connection_reset) keep the recovery-path warn.
425
+ appendDiagnostic(root, meta.id, { source: "service", runId: commandRunId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
426
+ return false;
366
427
  }
367
428
 
368
- /** @param {string} viewId @param {string|null} runId */
369
- function markQueued(viewId, runId) {
429
+ /**
430
+ * Fire a state command for a non-run lifecycle/metadata mutation and surface
431
+ * non-applied outcomes. Ambiguity contract (issue #91): timeout /
432
+ * connection_reset mean the command MAY already be journaled — never fall
433
+ * back to a direct write on those; only the explicit
434
+ * AGENT_BOARD_COORDINATOR=off escape hatch takes the legacy branch (the
435
+ * caller checks `reason === "coordinator_disabled"`).
436
+ * @param {object} command
437
+ * @param {string} [viewId] defaults to command.viewId
438
+ */
439
+ async function sendLifecycleCommand(command, viewId = command.viewId) {
440
+ const result = await sendStateCommandImpl(root, command);
441
+ if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
442
+ appendDiagnostic(root, viewId, { source: "service", level: "warn", code: "state_command_not_applied", message: `${command.kind} ${result.status} (${result.reason})`, details: { kind: command.kind, reason: result.reason } });
443
+ }
444
+ return result;
445
+ }
446
+
447
+ /** Legacy direct write for mark_queued — coordinator_disabled escape hatch only. */
448
+ function markQueuedDirect(viewId, runId) {
370
449
  const state = readState(root, viewId) ?? blankState(viewId);
371
450
  state.currentRunId = runId;
372
451
  state.semanticState = "queued";
@@ -383,23 +462,111 @@ export function createService(opts) {
383
462
  writeState(root, state);
384
463
  }
385
464
 
386
- /** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
387
- function markVisited(viewId) {
388
- const row = loadRow(root, viewId);
389
- if (!row) return { ok: false, error: "Unknown session" };
390
- const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
465
+ /** @param {string} viewId @param {string|null} runId @returns {Promise<{status: string, reason: string|null, materializedRevision?: number}>} */
466
+ function markQueued(viewId, runId) {
467
+ if (coordinatorDisabled()) {
468
+ markQueuedDirect(viewId, runId);
469
+ return Promise.resolve({ status: "applied", reason: "coordinator_disabled" });
470
+ }
471
+ // Fire-and-forget: legacy issued this write right after launch() without
472
+ // waiting on the runner, and the coordinator serializes arrival order the
473
+ // same way. N1 (Task 1): command.runId stays null — the new run's id rides
474
+ // in the payload and the decision branch pins currentRunId, so the generic
475
+ // stale-run guard cannot fire against the PREVIOUS run.
476
+ //
477
+ // source is "dashboard-user" on purpose (final-review F1): launching a run
478
+ // from these flows is ALWAYS user intent (reply / dispatch / attach), and
479
+ // the manual fence exists to protect the user's verdict from AUTOMATED late
480
+ // writes — a user-initiated re-launch IS the user changing the verdict, so
481
+ // mark_queued must lift the fence for the rest of the new run (otherwise a
482
+ // reply on a done row would execute invisibly while the row stays
483
+ // completed). Automated re-launch stays guarded upstream: the job-runner's
484
+ // post-exit follow-up claim refuses manually-completed rows
485
+ // (drainQueuedFollowUp: `if (isManualCompletion(readState(...))) return;`),
486
+ // and the service-side drain path delivers already-queued user follow-ups
487
+ // with no fence by design (prompt-not-lost, issue #70 — its live-host input
488
+ // path never fenced either).
489
+ // Fix round 2 (launch ordering): this promise is now AWAITED by launchForView
490
+ // / launchHost before they spawn — see the ordering notes there.
491
+ return sendLifecycleCommand({
492
+ type: "state_command",
493
+ viewId,
494
+ runId: null,
495
+ source: "dashboard-user",
496
+ kind: "mark_queued",
497
+ expectedRevision: null,
498
+ payload: { runId },
499
+ }).catch(() => ({ status: "rejected", reason: "connection_reset" }));
500
+ }
501
+
502
+ /** Legacy direct write for markVisited — coordinator_disabled escape hatch only. */
503
+ function markVisitedDirect(row) {
504
+ const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
391
505
  state.lastVisitedAt = Date.now();
392
506
  state.updatedAt = Date.now();
393
507
  writeState(root, state);
394
- return { ok: true };
395
508
  }
396
509
 
397
- /** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
398
- function completeView(viewId) {
510
+ /**
511
+ * @param {string} viewId
512
+ * @returns {Promise<{ ok: boolean, error?: string }>}
513
+ */
514
+ async function markVisited(viewId) {
399
515
  const row = loadRow(root, viewId);
400
516
  if (!row) return { ok: false, error: "Unknown session" };
401
- if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before marking done" };
402
- const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
517
+ if (coordinatorDisabled()) {
518
+ markVisitedDirect(row);
519
+ return { ok: true };
520
+ }
521
+ // dashboard-user (not "service"): visiting is a user action, and the
522
+ // manual fence must not stop visit-recency tracking on completed rows —
523
+ // legacy stamped lastVisitedAt unconditionally. Whitelist: lastVisitedAt only.
524
+ const result = await sendLifecycleCommand({
525
+ type: "state_command",
526
+ viewId,
527
+ source: "dashboard-user",
528
+ kind: "patch_fields",
529
+ expectedRevision: null,
530
+ payload: { state: { lastVisitedAt: Date.now() } },
531
+ }, viewId);
532
+ if (result.reason === "coordinator_disabled") markVisitedDirect(row);
533
+ // Visit tracking is best-effort metadata — never fail the UI action.
534
+ return { ok: true };
535
+ }
536
+
537
+ /** Legacy direct write for adoptSession — coordinator_disabled escape hatch only. */
538
+ function adoptStateDirect(viewId, state) {
539
+ const target = state ?? readState(root, viewId) ?? blankState(viewId);
540
+ target.semanticState = "idle";
541
+ target.processState = "exited";
542
+ target.needsInput = false;
543
+ target.hasError = false;
544
+ target.question = null;
545
+ target.pendingQuestions = [];
546
+ target.error = null;
547
+ target.summary = "Backgrounded session";
548
+ target.updatedAt = Date.now();
549
+ target.lastActivityAt = Date.now();
550
+ writeState(root, target);
551
+ }
552
+
553
+ /** Route the adopt-idle transition through the coordinator (fenced, journaled). */
554
+ async function adoptIdleState(viewId, state) {
555
+ const result = await sendLifecycleCommand({
556
+ type: "state_command",
557
+ viewId,
558
+ runId: null,
559
+ source: "dashboard-user",
560
+ kind: "adopt_session",
561
+ expectedRevision: null,
562
+ payload: {},
563
+ }, viewId);
564
+ if (result.reason === "coordinator_disabled") adoptStateDirect(viewId, state);
565
+ }
566
+
567
+ /** Legacy direct-write completion — only reachable when the coordinator is
568
+ * explicitly disabled (AGENT_BOARD_COORDINATOR=off tests/escape hatch). */
569
+ function completeViewDirect(state) {
403
570
  state.semanticState = "completed";
404
571
  state.processState = "exited";
405
572
  state.needsInput = false;
@@ -414,21 +581,72 @@ export function createService(opts) {
414
581
  // Also clear autoState in the run status so in-flight model passes
415
582
  // (job-runner / state-runner) see the manual completion and skip refinement.
416
583
  if (state.currentRunId) {
417
- const status = readStatus(root, viewId, state.currentRunId);
584
+ const status = readStatus(root, state.viewId, state.currentRunId);
418
585
  if (status) {
419
586
  status.autoState = null;
420
587
  writeStatus(root, status);
421
588
  }
422
589
  }
423
590
  writeState(root, state);
424
- return { ok: true };
591
+ }
592
+
593
+ /**
594
+ * Explicitly mark an inactive session as done via the View State Coordinator
595
+ * (issue #91): the command is journaled and materialized by the single owner,
596
+ * which also rejects stale-run and fenced manual-completion overwrites.
597
+ * @param {string} viewId
598
+ * @returns {Promise<{ ok: boolean, error?: string }>}
599
+ */
600
+ async function completeView(viewId) {
601
+ const row = loadRow(root, viewId);
602
+ if (!row) return { ok: false, error: "Unknown session" };
603
+ // Fast local pre-check for instant UI feedback; the coordinator's decision
604
+ // stays authoritative (its "busy" rejection maps to the same wording).
605
+ if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before marking done" };
606
+ const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
607
+ const result = await sendStateCommandImpl(root, {
608
+ type: "state_command",
609
+ viewId,
610
+ runId: state.currentRunId ?? null,
611
+ source: "dashboard-user",
612
+ kind: "mark_completed",
613
+ expectedRevision: null,
614
+ payload: {},
615
+ });
616
+ if (result.status === "applied") return { ok: true };
617
+ if (result.reason === "busy") return { ok: false, error: "Wait for the active run to finish before marking done" };
618
+ if (result.reason === "coordinator_disabled") {
619
+ completeViewDirect(state);
620
+ return { ok: true };
621
+ }
622
+ // Ambiguous outcomes (timeout / connection_reset: the command MAY already be
623
+ // journaled) and real rejections surface verbatim — never fall back to a
624
+ // direct write here, it would bypass the single-writer fence.
625
+ return { ok: false, error: result.reason ?? "state_command_failed" };
626
+ }
627
+
628
+ /** Legacy direct write for archiveView's busy-row stop — coordinator_disabled escape hatch only. */
629
+ function archiveStateDirect(row) {
630
+ const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
631
+ state.semanticState = "stopped";
632
+ state.processState = "exited";
633
+ state.needsInput = false;
634
+ state.hasError = false;
635
+ state.question = null;
636
+ state.pendingQuestions = [];
637
+ state.error = null;
638
+ state.autoState = null;
639
+ state.summary = "Stopped";
640
+ state.lastActivityAt = Date.now();
641
+ state.updatedAt = Date.now();
642
+ writeState(root, state);
425
643
  }
426
644
 
427
645
  /**
428
646
  * @param {string} viewId
429
- * @returns {{ ok: boolean, error?: string }}
647
+ * @returns {Promise<{ ok: boolean, error?: string }>}
430
648
  */
431
- function archiveView(viewId) {
649
+ async function archiveView(viewId) {
432
650
  const row = loadRow(root, viewId);
433
651
  if (!row) return { ok: false, error: "Unknown session" };
434
652
  if (row.host?.instanceId) stopHostRow(row, "archive");
@@ -438,20 +656,19 @@ export function createService(opts) {
438
656
  if (pid) killProcess(pid);
439
657
  }
440
658
  if (isAgentBusy(row)) {
441
- const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
442
- state.semanticState = "stopped";
443
- state.processState = "exited";
444
- state.needsInput = false;
445
- state.hasError = false;
446
- state.question = null;
447
- state.pendingQuestions = [];
448
- state.error = null;
449
- state.autoState = null;
450
- state.summary = "Stopped";
451
- state.lastActivityAt = Date.now();
452
- state.updatedAt = Date.now();
453
- writeState(root, state);
659
+ // dashboard-user: archiving is a user action. Busy rows are allowed by
660
+ // the decision kind (archiving a working row stops it — legacy parity).
661
+ const result = await sendLifecycleCommand({
662
+ type: "state_command",
663
+ viewId,
664
+ source: "dashboard-user",
665
+ kind: "archive_view",
666
+ expectedRevision: null,
667
+ payload: {},
668
+ }, viewId);
669
+ if (result.reason === "coordinator_disabled") archiveStateDirect(row);
454
670
  }
671
+ foregroundPreviewCache.forget(viewId);
455
672
  row.meta.archived = true;
456
673
  writeMeta(root, row.meta);
457
674
  return { ok: true };
@@ -525,14 +742,34 @@ export function createService(opts) {
525
742
  /**
526
743
  * @param {import("../core/store.mjs").Row} row
527
744
  * @param {import("../core/types.mjs").RunStatus} status
745
+ * @returns {Promise<void>}
528
746
  */
529
- function writeForegroundState(row, status) {
747
+ async function writeForegroundState(row, status) {
530
748
  const projected = projectViewState(status, Date.now(), readState(root, row.meta.id) ?? row.state ?? null);
531
749
  // Foreground turns are driven by the interactive Pi process, not a detached
532
750
  // runner, so keep currentRunId null. This prevents reconcile()/stop() from
533
- // treating a foreground turn as a managed background runner pid.
751
+ // treating a foreground turn as a managed background runner pid. (The
752
+ // decision kind force-nulls it too; the caller-side null keeps the
753
+ // coordinator_disabled direct write at legacy parity.)
534
754
  projected.currentRunId = null;
535
- writeState(root, projected);
755
+ // Issue #113: remember the freshest known foreground fields before the
756
+ // coordinator round trip — syncRowEvent rebuilds from disk on the next
757
+ // event and must not read an older projection than this one.
758
+ foregroundPreviewCache.remember(row.meta.id, projected);
759
+ if (coordinatorDisabled()) {
760
+ writeState(root, projected);
761
+ return;
762
+ }
763
+ const result = await sendLifecycleCommand({
764
+ type: "state_command",
765
+ viewId: row.meta.id,
766
+ runId: null,
767
+ source: "service",
768
+ kind: "sync_foreground",
769
+ expectedRevision: null,
770
+ payload: { projection: projected },
771
+ }, row.meta.id);
772
+ if (result.reason === "coordinator_disabled") writeState(root, projected);
536
773
  }
537
774
 
538
775
  /** @param {string} sessionFile */
@@ -1086,7 +1323,7 @@ export function createService(opts) {
1086
1323
  * async: the item is only completed on input_ack; any failure releases it
1087
1324
  * back to queued for a later retry (issue #70 A13).
1088
1325
  * @param {string} viewId
1089
- * @returns {Promise<{ ok: boolean, error?: string, sent?: boolean, started?: boolean, pending?: boolean, item?: any }>}
1326
+ * @returns {Promise<{ ok: boolean, error?: string, sent?: boolean, started?: boolean, pending?: boolean, failed?: boolean, item?: any }>} (failed=true: §10 accepted_unknown — marked failed, never re-sent)
1090
1327
  */
1091
1328
  async function drainNextFollowUp(viewId) {
1092
1329
  const row = loadRow(root, viewId);
@@ -1116,13 +1353,27 @@ export function createService(opts) {
1116
1353
  return { ok: true, pending: true, item };
1117
1354
  }
1118
1355
  const sent = socketPath
1119
- ? await sendHostInput(socketPath, `${prompt}\r`, { requestId: item.id })
1356
+ ? await sendHostInput(socketPath, `${prompt}\r`, {
1357
+ requestId: item.id,
1358
+ commandId: item.id,
1359
+ viewId,
1360
+ instanceId: row.host.instanceId,
1361
+ })
1120
1362
  : { ok: false, error: "No host socket", retryable: true };
1121
1363
  if (sent.ok) {
1122
1364
  completeFollowUp(root, viewId, item.id);
1123
1365
  appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind } });
1124
1366
  return { ok: true, sent: true, item };
1125
1367
  }
1368
+ if (sent.ambiguous) {
1369
+ // §10: the prompt was accepted by a host that restarted before
1370
+ // delivering it — a re-send can only return cached stages, so the
1371
+ // honest terminal state is failed-with-reason (mirrors the queue's
1372
+ // existing failure marking; the item keeps its text for re-send).
1373
+ failFollowUp(root, viewId, item.id, "accepted_unknown: accepted but never applied; outcome unknowable (§10); not re-sent");
1374
+ appendDiagnostic(root, viewId, { source: "queue", level: "error", code: "follow_up_ambiguous", message: "Queued follow-up accepted_unknown after host restart — marked failed, not re-sent", details: { kind: item.kind, commandId: item.id } });
1375
+ return { ok: true, failed: true, item };
1376
+ }
1126
1377
  releaseFollowUp(root, viewId, item.id);
1127
1378
  appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_send_failed", message: "Queued follow-up could not be delivered to the host", details: { kind: item.kind, error: sent.error ?? "unknown", retryable: sent.retryable !== false } });
1128
1379
  return { ok: true, pending: true, item };
@@ -1135,7 +1386,7 @@ export function createService(opts) {
1135
1386
  let hostMode = "pty";
1136
1387
  if (!pty.ok) hostMode = "json-runner";
1137
1388
  if (pty.ok) {
1138
- const launched = launchHost(row.meta, prompt);
1389
+ const launched = await launchHost(row.meta, prompt);
1139
1390
  if (launched.ok && launched.status !== "started") {
1140
1391
  // An existing claim owns the view; the item stays queued and retries
1141
1392
  // after that host becomes ready (issue #70 prompt-not-lost invariant).
@@ -1152,7 +1403,7 @@ export function createService(opts) {
1152
1403
  return { ok: false, error: "PTY is required to drain adopted session follow-ups safely" };
1153
1404
  }
1154
1405
  if (hostMode === "json-runner") {
1155
- runId = launchForView(row.meta, prompt, runKindForFollowUp(item)).runId;
1406
+ ({ runId } = await launchForView(row.meta, prompt, runKindForFollowUp(item)));
1156
1407
  }
1157
1408
  completeFollowUp(root, viewId, item.id, { runId });
1158
1409
  appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_started", message: "Queued follow-up started", details: { kind: item.kind, hostMode } });
@@ -1164,10 +1415,22 @@ export function createService(opts) {
1164
1415
  }
1165
1416
  }
1166
1417
 
1167
- /** @param {import("../core/store.mjs").Row} row @param {any} event */
1168
- function syncRowEvent(row, event) {
1418
+ /**
1419
+ * Mirror a foreground/hosted session event into the row state. Async since
1420
+ * issue #91: the terminal classification routes through the View State
1421
+ * Coordinator command socket (fire-and-forget callers are fine — the returned
1422
+ * promise resolves after the classification outcome is known).
1423
+ * @param {import("../core/store.mjs").Row} row @param {any} event
1424
+ * @returns {Promise<boolean>} whether a managed row was updated
1425
+ */
1426
+ async function syncRowEvent(row, event) {
1169
1427
  const now = Date.now();
1170
1428
  const status = statusFromRow(row);
1429
+ // Issue #113: the previous event's projection may still be in flight (the
1430
+ // coordinator journals + fsyncs before materializing), so a disk rebuild
1431
+ // can lag the freshest known preview/activity. Restore read-your-writes
1432
+ // before any event branch touches the status.
1433
+ foregroundPreviewCache.backfill(row.meta.id, status);
1171
1434
  let evidence = readEvidence(root, row.meta.id);
1172
1435
  if (!evidence.viewId) evidence = emptyEvidenceSnapshot({ viewId: row.meta.id, source: "hosted" });
1173
1436
  try {
@@ -1187,26 +1450,55 @@ export function createService(opts) {
1187
1450
  status.error = null;
1188
1451
  status.summary = "Running…";
1189
1452
  status.lastActivityAt = now;
1190
- writeForegroundState(row, status);
1453
+ // Throughput path: periodic self-healing mirror, fire-and-forget.
1454
+ void writeForegroundState(row, status);
1191
1455
  return true;
1192
1456
  }
1193
1457
 
1194
1458
  if (event.type === "agent_end") {
1459
+ // #46-class fence (issue #91): a manual completion is a user verdict that
1460
+ // outlives the turn. A late/duplicate agent_end after markCompleted must
1461
+ // not resurrect the row via finalizeRun + projection — skip all semantic
1462
+ // writes (baseline, steering, classification) and keep evidence only.
1463
+ // The fence read is repeated after the classification await below; the
1464
+ // window between the two reads is covered by the coordinator's own
1465
+ // manual_fence rejection plus the guarded tail write.
1466
+ if (isManualCompletion(readState(root, row.meta.id))) {
1467
+ status.evidenceSummary = summarizeEvidence(evidence);
1468
+ writeEvidence(root, evidence);
1469
+ return false;
1470
+ }
1195
1471
  finalizeRun(status, { exitCode: 0 }, now);
1196
1472
  finalizeEvidence(evidence, status, now);
1197
1473
  const steering = readSteering(root, row.meta.id);
1474
+ let classificationQueued = false;
1198
1475
  if (steering.status === "plan_requested" || steering.status === "changes_requested") {
1199
1476
  recordPlanReady(root, row.meta.id, { planText: latestEvidenceText(evidence) || status.latestAssistantPreview || evidence.summary || "Plan ready", runId: status.runId });
1200
1477
  status.semanticState = "needs_input";
1201
1478
  status.question = "Approve this plan?";
1202
1479
  status.summary = "Plan ready for approval";
1203
- } else if (queueAutoState(row.meta, status, evidence)) {
1204
- finalizeEvidence(evidence, status, now);
1480
+ } else {
1481
+ // Baseline the exited projection BEFORE the classification command:
1482
+ // the coordinator's delegated auto-state rules key on processState,
1483
+ // and its materialized patch lands after this write and wins. When
1484
+ // the coordinator applies, the projection write below is skipped so
1485
+ // the stale in-memory fields cannot clobber the patch (#46 class).
1486
+ // The legacy coordinator_disabled path applies in-memory instead and
1487
+ // still needs the tail projection write (pre-coordinator behavior).
1488
+ // Awaited: this ordering IS the fence — the classification command
1489
+ // must observe processState "exited" or its delegated rules skip.
1490
+ await writeForegroundState(row, status);
1491
+ classificationQueued = await queueAutoState(row.meta, status, evidence);
1492
+ if (classificationQueued) finalizeEvidence(evidence, status, now);
1205
1493
  }
1206
1494
  status.evidenceSummary = summarizeEvidence(evidence);
1207
1495
  writeEvidence(root, evidence);
1208
1496
  updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
1209
- writeForegroundState(row, status);
1497
+ // Re-read the fence after the classification await: a completion landing
1498
+ // between the baseline write and the coordinator's decision read gets
1499
+ // manual_fence back (classificationQueued=false), and writing the stale
1500
+ // in-memory projection here would clobber it (#46 class).
1501
+ if (!isManualCompletion(readState(root, row.meta.id)) && !(classificationQueued && !coordinatorDisabled())) await writeForegroundState(row, status);
1210
1502
  pruneWarmHosts({ keepViewId: row.meta.id });
1211
1503
  // Async delivery (ack-gated, issue #70 A13): fire-and-forget here — the
1212
1504
  // queue item's own state records the outcome, ordering is preserved by
@@ -1217,7 +1509,8 @@ export function createService(opts) {
1217
1509
 
1218
1510
  if (reduceEvent(status, event, now, { interactive: true })) {
1219
1511
  status.processState = "alive";
1220
- writeForegroundState(row, status);
1512
+ // Throughput path: periodic self-healing mirror, fire-and-forget.
1513
+ void writeForegroundState(row, status);
1221
1514
  return true;
1222
1515
  }
1223
1516
  return false;
@@ -1237,9 +1530,9 @@ export function createService(opts) {
1237
1530
  * model?: string|null,
1238
1531
  * thinkingLevel?: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max"|null,
1239
1532
  * }} [dispatchOpts]
1240
- * @returns {{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }}
1533
+ * @returns {Promise<{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }>}
1241
1534
  */
1242
- dispatch(text, dispatchOpts = {}) {
1535
+ async dispatch(text, dispatchOpts = {}) {
1243
1536
  const prompt = String(text || "").trim();
1244
1537
  if (!prompt) return { ok: false, error: "Empty task" };
1245
1538
 
@@ -1271,7 +1564,7 @@ export function createService(opts) {
1271
1564
  let fallbackReason;
1272
1565
  let queued;
1273
1566
  if (pty.ok) {
1274
- const launched = launchHost(meta, prompt);
1567
+ const launched = await launchHost(meta, prompt);
1275
1568
  if (launched.ok) {
1276
1569
  hostMode = "pty";
1277
1570
  if (launched.status !== "started") {
@@ -1283,11 +1576,11 @@ export function createService(opts) {
1283
1576
  }
1284
1577
  } else {
1285
1578
  fallbackReason = launched.fallbackReason ?? launched.error;
1286
- launchForView(meta, prompt, "dispatch");
1579
+ await launchForView(meta, prompt, "dispatch");
1287
1580
  }
1288
1581
  } else {
1289
1582
  fallbackReason = nodePtyFallbackMessage(pty);
1290
- launchForView(meta, prompt, "dispatch");
1583
+ await launchForView(meta, prompt, "dispatch");
1291
1584
  }
1292
1585
  queueGeneratedTitle(meta, prompt);
1293
1586
  return {
@@ -1340,13 +1633,24 @@ export function createService(opts) {
1340
1633
  }
1341
1634
  const socketPath = row.host?.socketPath;
1342
1635
  const sent = socketPath
1343
- ? await sendHostInput(socketPath, `${prompt}\r`, { requestId: queued.item.id })
1636
+ ? await sendHostInput(socketPath, `${prompt}\r`, {
1637
+ requestId: queued.item.id,
1638
+ commandId: queued.item.id,
1639
+ viewId,
1640
+ instanceId: row.host.instanceId,
1641
+ })
1344
1642
  : { ok: false, error: "No host socket", retryable: true };
1345
1643
  if (sent.ok) {
1346
1644
  completeFollowUp(root, viewId, queued.item.id);
1347
1645
  appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind } });
1348
1646
  return { ok: true, sent: true, item: queued.item };
1349
1647
  }
1648
+ if (sent.ambiguous) {
1649
+ // §10: see drainNextFollowUp — never re-queue, never complete.
1650
+ failFollowUp(root, viewId, queued.item.id, "accepted_unknown: accepted but never applied; outcome unknowable (§10); not re-sent");
1651
+ appendDiagnostic(root, viewId, { source: "queue", level: "error", code: "follow_up_ambiguous", message: "Queued follow-up accepted_unknown after host restart — marked failed, not re-sent", details: { kind, commandId: queued.item.id } });
1652
+ return { ok: true, failed: true, item: queued.item };
1653
+ }
1350
1654
  // Not an error: starting host, busy socket or lost ack — the drain
1351
1655
  // loop retries once the host acks (issue #70 prompt-not-lost).
1352
1656
  return { ok: true, queued: true, summary: queued.summary };
@@ -1356,7 +1660,7 @@ export function createService(opts) {
1356
1660
  let hostMode = null;
1357
1661
  let fallbackReason;
1358
1662
  if (pty.ok) {
1359
- const launched = launchHost(row.meta, prompt);
1663
+ const launched = await launchHost(row.meta, prompt);
1360
1664
  if (launched.ok) {
1361
1665
  if (launched.status !== "started") {
1362
1666
  // The existing claim will consume the prompt later — keep it queued
@@ -1377,7 +1681,7 @@ export function createService(opts) {
1377
1681
  }
1378
1682
  if (!hostMode) {
1379
1683
  if (isExternalSession(row.meta)) return { ok: false, error: "PTY is required to continue an adopted foreground session safely" };
1380
- launchForView(row.meta, prompt, runKindForKind(kind));
1684
+ await launchForView(row.meta, prompt, runKindForKind(kind));
1381
1685
  hostMode = "json-runner";
1382
1686
  }
1383
1687
  return { ok: true, hostMode, ...(hostMode === "json-runner" ? { fallbackReason } : {}) };
@@ -1475,7 +1779,7 @@ export function createService(opts) {
1475
1779
  return { kind: "session", sessionFile: row.meta.sessionFile };
1476
1780
  },
1477
1781
 
1478
- adoptSession(adoptOpts = {}) {
1782
+ async adoptSession(adoptOpts = {}) {
1479
1783
  const sessionFile = String(adoptOpts.sessionFile || "").trim();
1480
1784
  if (!sessionFile) return { ok: false, error: "No session file to adopt" };
1481
1785
  const existing = rowForSession(sessionFile);
@@ -1484,18 +1788,7 @@ export function createService(opts) {
1484
1788
  if (adoptOpts.name) existing.meta.name = String(adoptOpts.name).trim() || existing.meta.name;
1485
1789
  writeMeta(root, existing.meta);
1486
1790
  if (!isAgentBusy(existing)) {
1487
- const state = readState(root, existing.meta.id) ?? existing.state ?? blankState(existing.meta.id);
1488
- state.semanticState = "idle";
1489
- state.processState = "exited";
1490
- state.needsInput = false;
1491
- state.hasError = false;
1492
- state.question = null;
1493
- state.pendingQuestions = [];
1494
- state.error = null;
1495
- state.summary = "Backgrounded session";
1496
- state.updatedAt = Date.now();
1497
- state.lastActivityAt = Date.now();
1498
- writeState(root, state);
1791
+ await adoptIdleState(existing.meta.id, readState(root, existing.meta.id) ?? existing.state ?? blankState(existing.meta.id));
1499
1792
  }
1500
1793
  appendDiagnostic(root, existing.meta.id, { source: "service", code: "session_adopted", message: "Existing session adopted into Agent Board", details: { reused: true } });
1501
1794
  return { ok: true, viewId: existing.meta.id, reused: true };
@@ -1517,12 +1810,7 @@ export function createService(opts) {
1517
1810
  sessionFile,
1518
1811
  });
1519
1812
  const state = readState(root, id) ?? blankState(id);
1520
- state.semanticState = "idle";
1521
- state.processState = "exited";
1522
- state.summary = "Backgrounded session";
1523
- state.updatedAt = Date.now();
1524
- state.lastActivityAt = Date.now();
1525
- writeState(root, state);
1813
+ await adoptIdleState(id, state);
1526
1814
  appendDiagnostic(root, id, { source: "service", code: "session_adopted", message: "Current session adopted into Agent Board", details: { reused: false } });
1527
1815
  return { ok: true, viewId: meta.id, reused: false };
1528
1816
  },
@@ -1560,7 +1848,7 @@ export function createService(opts) {
1560
1848
  return { ok: true };
1561
1849
  },
1562
1850
 
1563
- /** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
1851
+ /** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
1564
1852
  markVisited(viewId) {
1565
1853
  return markVisited(viewId);
1566
1854
  },
@@ -1569,7 +1857,7 @@ export function createService(opts) {
1569
1857
  * Explicitly mark an inactive session as done. Successful runs settle as
1570
1858
  * `idle` until the user reviews and confirms this action from the dashboard.
1571
1859
  * @param {string} viewId
1572
- * @returns {{ ok: boolean, error?: string }}
1860
+ * @returns {Promise<{ ok: boolean, error?: string }>}
1573
1861
  */
1574
1862
  markCompleted(viewId) {
1575
1863
  return completeView(viewId);
@@ -1640,7 +1928,12 @@ export function createService(opts) {
1640
1928
  return { state, summary: summarizeSteering(state) };
1641
1929
  },
1642
1930
 
1643
- markCompletedMany(viewIds) {
1931
+ /**
1932
+ * Bulk mark sessions done, skipping live/already-done rows.
1933
+ * @param {string[]} viewIds
1934
+ * @returns {Promise<{ ok: boolean, completed: number, skipped: number, completedIds: string[] }>}
1935
+ */
1936
+ async markCompletedMany(viewIds) {
1644
1937
  const ids = [...new Set((viewIds ?? []).filter(Boolean))];
1645
1938
  let completed = 0;
1646
1939
  let skipped = 0;
@@ -1651,7 +1944,7 @@ export function createService(opts) {
1651
1944
  skipped += 1;
1652
1945
  continue;
1653
1946
  }
1654
- const res = completeView(viewId);
1947
+ const res = await completeView(viewId);
1655
1948
  if (res.ok) {
1656
1949
  completed += 1;
1657
1950
  completedIds.push(viewId);
@@ -1672,9 +1965,9 @@ export function createService(opts) {
1672
1965
  /**
1673
1966
  * Bulk archive explicit row ids, skipping live/missing rows.
1674
1967
  * @param {string[]} viewIds
1675
- * @returns {{ ok: boolean, archived: number, skipped: number }}
1968
+ * @returns {Promise<{ ok: boolean, archived: number, skipped: number }>}
1676
1969
  */
1677
- archiveMany(viewIds) {
1970
+ async archiveMany(viewIds) {
1678
1971
  const ids = [...new Set((viewIds ?? []).filter(Boolean))];
1679
1972
  let archived = 0;
1680
1973
  let skipped = 0;
@@ -1684,7 +1977,7 @@ export function createService(opts) {
1684
1977
  skipped += 1;
1685
1978
  continue;
1686
1979
  }
1687
- const res = archiveView(viewId);
1980
+ const res = await archiveView(viewId);
1688
1981
  if (res.ok) archived += 1;
1689
1982
  else skipped += 1;
1690
1983
  }
@@ -1708,6 +2001,7 @@ export function createService(opts) {
1708
2001
  }
1709
2002
  if (row.host?.instanceId) stopHostRow(row, "archive");
1710
2003
  else if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
2004
+ foregroundPreviewCache.forget(row.meta.id);
1711
2005
  row.meta.archived = true;
1712
2006
  writeMeta(root, row.meta);
1713
2007
  archived += 1;
@@ -1719,9 +2013,9 @@ export function createService(opts) {
1719
2013
  * Recovery: reconcile rows whose runner died without finalizing (e.g. machine crash
1720
2014
  * or the runner was killed). If a terminal status exists, project it; otherwise mark
1721
2015
  * the row failed/stale. Safe to call on every dashboard open and on session_start.
1722
- * @returns {number} number of rows reconciled.
2016
+ * @returns {Promise<number>} number of rows reconciled.
1723
2017
  */
1724
- reconcile() {
2018
+ async reconcile() {
1725
2019
  const now = Date.now();
1726
2020
  let fixed = 0;
1727
2021
  for (const row of listRows(root)) {
@@ -1734,34 +2028,131 @@ export function createService(opts) {
1734
2028
  // its runner pid may legitimately be absent — never finalize it to failed yet.
1735
2029
  if (row.host.state === "starting" && now - (row.host.claimAt ?? row.host.startedAt ?? 0) < HOST_START_GRACE_MS) continue;
1736
2030
  const failed = row.host.state === "starting" || row.host.state === "alive" || row.host.state === "failed" || Boolean(row.host.error) || (row.host.exitCode !== null && row.host.exitCode !== 0);
1737
- s.semanticState = failed ? "failed" : "idle";
1738
- s.processState = "exited";
1739
- s.hasError = failed;
1740
- s.needsInput = false;
1741
- s.question = null;
1742
- s.pendingQuestions = [];
1743
- s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
1744
- s.summary = failed ? "Failed (PTY host exited)" : "Needs instructions";
1745
- s.updatedAt = now;
1746
- writeState(root, s);
2031
+ // F6: failed verdicts must carry a reason (legacy guaranteed an
2032
+ // error string). The row has no currentRunId here, so only the
2033
+ // state half participates (no status.json to sync).
2034
+ const result = await sendLifecycleCommand({
2035
+ type: "state_command",
2036
+ viewId: row.meta.id,
2037
+ runId: null,
2038
+ source: "service",
2039
+ kind: "reconcile_finalize",
2040
+ expectedRevision: null,
2041
+ payload: {
2042
+ semanticState: failed ? "failed" : "idle",
2043
+ summary: failed ? "Failed (PTY host exited)" : "Needs instructions",
2044
+ reason: failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null,
2045
+ },
2046
+ }, row.meta.id);
2047
+ if (result.status !== "applied") {
2048
+ if (result.reason === "coordinator_disabled") {
2049
+ s.semanticState = failed ? "failed" : "idle";
2050
+ s.processState = "exited";
2051
+ s.hasError = failed;
2052
+ s.needsInput = false;
2053
+ s.question = null;
2054
+ s.pendingQuestions = [];
2055
+ s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
2056
+ s.summary = failed ? "Failed (PTY host exited)" : "Needs instructions";
2057
+ s.updatedAt = now;
2058
+ writeState(root, s);
2059
+ } else continue; // rejected/ambiguous: nothing mutated, not fixed
2060
+ }
1747
2061
  appendDiagnostic(root, row.meta.id, { source: "service", level: failed ? "error" : "info", code: "host_reconciled", message: failed ? "PTY host exited before final event" : "PTY host finalized without final event", details: { hostState: row.host.state, exitCode: row.host.exitCode } });
1748
2062
  fixed += 1;
1749
2063
  }
1750
2064
  continue;
1751
2065
  }
1752
2066
  if (row.alive) continue;
1753
- const status = readStatus(root, row.meta.id, s.currentRunId);
2067
+ let status = readStatus(root, row.meta.id, s.currentRunId);
2068
+ if (statusRevisionDesynced(s, status)) {
2069
+ // TOCTOU guard: `s` is a listRows snapshot, and earlier rows'
2070
+ // awaited commands may have let the on-disk pair advance past
2071
+ // it — re-read BOTH files fresh and act only on a pair that
2072
+ // still disagrees. (The residual window between the two fresh
2073
+ // reads is sub-ms under the coordinator's view-lock pairing
2074
+ // invariant.)
2075
+ const reread = rereadPair(root, row.meta.id, s.currentRunId);
2076
+ if (reread.desynced) {
2077
+ // Half-materialized pair (a coordinator crashed between its paired
2078
+ // writes): don't combine the mismatched halves into one decision —
2079
+ // record it and kick a coordinator so boot replay repairs the pair.
2080
+ // The row is skipped this pass (not fixed); the next pass sees the
2081
+ // repaired stamps and proceeds normally.
2082
+ // Episode throttle: a stuck pair logs once, not once per 700ms
2083
+ // reconcile pass — diagnostics must stay bounded for unrepairable
2084
+ // pairs (torn transient beat has no journal record; coordinator off).
2085
+ // Episode throttle gates the DIAGNOSTIC (a stuck pair logs once —
2086
+ // CR r1); the repair KICK is retried independently while it keeps
2087
+ // failing (CR r2 issue-4): a transient ensureCoordinator failure
2088
+ // (spawn window exhausted) must not consume the recovery path —
2089
+ // an idle dashboard would otherwise never run boot replay. A
2090
+ // successful kick stops the retries: the coordinator is up, boot
2091
+ // replay already repaired a repairable pair, and an
2092
+ // unrepairable-torn pair gains nothing from re-kicking.
2093
+ // (shouldLog records the episode — evaluate it exactly once.)
2094
+ const logEpisode = desyncThrottle.shouldLog(row.meta.id, reread.state.materializedRevision, reread.status.materializedRevision);
2095
+ if (logEpisode) {
2096
+ appendDiagnostic(root, row.meta.id, { source: "service", level: "warn", code: "state_status_revision_desync", message: "state.json and status.json revisions disagree; skipping reconcile projection and requesting coordinator repair", details: { stateRevision: reread.state.materializedRevision, statusRevision: reread.status.materializedRevision, runId: s.currentRunId } });
2097
+ }
2098
+ if (logEpisode || desyncThrottle.shouldRetryKick(row.meta.id)) {
2099
+ void ensureCoordinatorImpl(root).then((res) => {
2100
+ if (res?.ok) desyncThrottle.clearKickFailed(row.meta.id);
2101
+ else desyncThrottle.markKickFailed(row.meta.id);
2102
+ }).catch(() => desyncThrottle.markKickFailed(row.meta.id));
2103
+ }
2104
+ continue;
2105
+ }
2106
+ // Snapshot was stale but the fresh pair agrees — project from it.
2107
+ status = reread.status;
2108
+ }
1754
2109
  if (status?.endedAt) {
1755
- writeState(root, projectViewState(status, now, readState(root, row.meta.id) ?? row.state ?? null));
2110
+ // The run's terminal status exists but the row was never materialized
2111
+ // from it (crash between the two writes, or a pre-coordinator row).
2112
+ // Project mode lets the status's own verdict govern (completed rows
2113
+ // must not be forced to failed/idle).
2114
+ const result = await sendLifecycleCommand({
2115
+ type: "state_command",
2116
+ viewId: row.meta.id,
2117
+ runId: s.currentRunId,
2118
+ source: "service",
2119
+ kind: "reconcile_finalize",
2120
+ expectedRevision: null,
2121
+ payload: { project: true },
2122
+ }, row.meta.id);
2123
+ if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
2124
+ // Fix round 2 (F5 leftover): a rejected/ambiguous project-mode
2125
+ // row (manual fence, no_change, stale_run, timeout) mutated
2126
+ // nothing — it must not count toward the fixed tally.
2127
+ continue;
2128
+ }
2129
+ if (result.reason === "coordinator_disabled") writeState(root, projectViewState(status, now, readState(root, row.meta.id) ?? row.state ?? null));
1756
2130
  } else {
1757
- s.semanticState = "failed";
1758
- s.processState = "exited";
1759
- s.hasError = true;
1760
- s.needsInput = false;
1761
- s.error = s.error ?? "Runner exited unexpectedly";
1762
- s.summary = "Failed (runner exited)";
1763
- s.updatedAt = now;
1764
- writeState(root, s);
2131
+ const result = await sendLifecycleCommand({
2132
+ type: "state_command",
2133
+ viewId: row.meta.id,
2134
+ runId: s.currentRunId,
2135
+ source: "service",
2136
+ kind: "reconcile_finalize",
2137
+ expectedRevision: null,
2138
+ payload: {
2139
+ semanticState: "failed",
2140
+ summary: "Failed (runner exited)",
2141
+ reason: s.error ?? "Runner exited unexpectedly",
2142
+ },
2143
+ }, row.meta.id);
2144
+ if (result.status !== "applied") {
2145
+ if (result.reason === "coordinator_disabled") {
2146
+ s.semanticState = "failed";
2147
+ s.processState = "exited";
2148
+ s.hasError = true;
2149
+ s.needsInput = false;
2150
+ s.error = s.error ?? "Runner exited unexpectedly";
2151
+ s.summary = "Failed (runner exited)";
2152
+ s.updatedAt = now;
2153
+ writeState(root, s);
2154
+ } else continue;
2155
+ }
1765
2156
  }
1766
2157
  fixed += 1;
1767
2158
  }
@@ -1782,20 +2173,20 @@ export function createService(opts) {
1782
2173
  * looking stale after the user types a follow-up in the real Pi session.
1783
2174
  * @param {string|undefined} sessionFile
1784
2175
  * @param {any} event
1785
- * @returns {boolean} whether a managed row was updated
2176
+ * @returns {Promise<boolean>} whether a managed row was updated
1786
2177
  */
1787
2178
  syncForegroundEvent(sessionFile, event) {
1788
- if (!sessionFile || !event?.type) return false;
2179
+ if (!sessionFile || !event?.type) return Promise.resolve(false);
1789
2180
  const row = rowForSession(sessionFile);
1790
- if (!row) return false;
2181
+ if (!row) return Promise.resolve(false);
1791
2182
  return syncRowEvent(row, event);
1792
2183
  },
1793
2184
 
1794
2185
  /** @param {string|undefined} viewId @param {any} event */
1795
2186
  syncHostedEvent(viewId, event) {
1796
- if (!viewId || !event?.type) return false;
2187
+ if (!viewId || !event?.type) return Promise.resolve(false);
1797
2188
  const row = loadRow(root, viewId);
1798
- if (!row) return false;
2189
+ if (!row) return Promise.resolve(false);
1799
2190
  return syncRowEvent(row, event);
1800
2191
  },
1801
2192
 
@@ -2040,11 +2431,184 @@ function sendHostMessage(row, message) {
2040
2431
  }
2041
2432
 
2042
2433
  /**
2043
- * Default window service waits for the runner's input_ack before treating a
2044
- * host input as undelivered (issue #70 A13).
2434
+ * Default window service waits for the runner's input_ack (or, on the staged
2435
+ * path, `cmd_ack applied`) before treating a host input as undelivered
2436
+ * (issue #70 A13; phase 5 D4).
2045
2437
  */
2046
2438
  const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
2047
2439
 
2440
+ /**
2441
+ * Reconcile-query one commandId against a host control socket (§10 discipline:
2442
+ * a timeout must be resolved by query, never by a blind retry).
2443
+ * @returns {Promise<"accepted_unknown"|"reconciled_clean"|"unavailable">}
2444
+ */
2445
+ function reconcileQueryCommand(socketPath, commandId, opts = {}) {
2446
+ const { viewId, instanceId, clientId = "service", connect = createConnection, timeoutMs = HOST_INPUT_ACK_TIMEOUT_MS } = opts;
2447
+ return new Promise((resolve) => {
2448
+ let settled = false;
2449
+ let buffer = "";
2450
+ /** @type {import("node:net").Socket|null} */
2451
+ let socket = null;
2452
+ const finish = (verdict) => {
2453
+ if (settled) return;
2454
+ settled = true;
2455
+ clearTimeout(timer);
2456
+ try { socket?.destroy(); } catch { /* best effort */ }
2457
+ resolve(verdict);
2458
+ };
2459
+ const timer = setTimeout(() => finish("unavailable"), timeoutMs);
2460
+ timer.unref?.();
2461
+ try {
2462
+ socket = connect(socketPath);
2463
+ } catch {
2464
+ finish("unavailable");
2465
+ return;
2466
+ }
2467
+ socket.on("connect", () => {
2468
+ try {
2469
+ socket.write(JSON.stringify({ type: "reconcile", commandId: `reconcile-${commandId}`, clientId, seq: 1, viewId, instanceId }) + "\n");
2470
+ } catch {
2471
+ finish("unavailable");
2472
+ }
2473
+ });
2474
+ socket.on("data", (chunk) => {
2475
+ buffer += chunk.toString("utf8");
2476
+ const lines = buffer.split("\n");
2477
+ buffer = lines.pop() ?? "";
2478
+ for (const line of lines) {
2479
+ if (!line.trim()) continue;
2480
+ let msg;
2481
+ try { msg = JSON.parse(line); } catch { continue; }
2482
+ if (msg?.type !== "reconcile_result") continue;
2483
+ const unresolved = Array.isArray(msg.unresolved) ? msg.unresolved : [];
2484
+ finish(unresolved.some((u) => u?.commandId === commandId) ? "accepted_unknown" : "reconciled_clean");
2485
+ return;
2486
+ }
2487
+ });
2488
+ socket.on("error", () => finish("unavailable"));
2489
+ socket.on("close", () => finish("unavailable"));
2490
+ });
2491
+ }
2492
+
2493
+ /**
2494
+ * Staged durable-input delivery (phase 5 D4): enveloped `{durable:true}` input
2495
+ * expecting `cmd_ack accepted → applied` against a generation-capable runner,
2496
+ * with the §10 reconcile-query on timeout. Runners whose hello lacks a
2497
+ * `generation` token predate the control lifecycle: the SAME connection falls
2498
+ * back to the legacy requestId/input_ack contract (byte-identical bytes), so
2499
+ * phase-4 owned hosts (instanceId known, envelope-ignorant) keep their dedup
2500
+ * instead of double-writing.
2501
+ *
2502
+ * Result shapes (never rejects):
2503
+ * - `{ok:true, applied:true}` — the runner wrote the child.
2504
+ * - `{ok:false, ambiguous:true, error:"accepted_unknown", retryable:false}` —
2505
+ * accepted but never applied (restart/write-failure window); the caller must
2506
+ * NOT re-send (§10) and must NOT treat the prompt as delivered.
2507
+ * - `{ok:false, error, retryable}` — everything else (starting window, dead
2508
+ * socket, legacy timeout); re-send is safe (journal dedup or requestId dedup).
2509
+ * @param {string} socketPath
2510
+ * @param {string} text
2511
+ * @param {{ requestId?: string|null, commandId?: string|null, viewId?: string, instanceId?: string|null, clientId?: string, timeoutMs?: number, connect?: (path: string) => import("node:net").Socket }} [opts]
2512
+ */
2513
+ function sendHostInputStaged(socketPath, text, opts = {}) {
2514
+ const commandId = opts.commandId ?? opts.requestId;
2515
+ const clientId = opts.clientId ?? "service";
2516
+ const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
2517
+ const connect = opts.connect ?? createConnection;
2518
+ const { viewId, instanceId } = opts;
2519
+ return new Promise((resolve) => {
2520
+ let settled = false;
2521
+ let buffer = "";
2522
+ let helloSeen = false;
2523
+ let sawAccepted = false;
2524
+ /** @type {import("node:net").Socket|null} */
2525
+ let socket = null;
2526
+ const finish = (result) => {
2527
+ if (settled) return;
2528
+ settled = true;
2529
+ clearTimeout(timer);
2530
+ try { socket?.destroy(); } catch { /* best effort */ }
2531
+ resolve(result);
2532
+ };
2533
+ /** §10: resolve the deadline by query, never by a blind retry. */
2534
+ const resolveByReconcile = (fallbackError) => {
2535
+ if (settled) return;
2536
+ reconcileQueryCommand(socketPath, commandId, { viewId, instanceId, clientId, connect, timeoutMs }).then((verdict) => {
2537
+ if (verdict === "accepted_unknown") finish({ ok: false, ambiguous: true, error: "accepted_unknown", retryable: false });
2538
+ else if (verdict === "reconciled_clean") finish({ ok: false, error: fallbackError, retryable: true });
2539
+ else finish({ ok: false, error: fallbackError, retryable: true });
2540
+ });
2541
+ };
2542
+ const timer = setTimeout(() => resolveByReconcile("timeout"), timeoutMs);
2543
+ timer.unref?.();
2544
+ try {
2545
+ socket = connect(socketPath);
2546
+ } catch (err) {
2547
+ finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
2548
+ return;
2549
+ }
2550
+ socket.on("data", (chunk) => {
2551
+ buffer += chunk.toString("utf8");
2552
+ const lines = buffer.split("\n");
2553
+ buffer = lines.pop() ?? "";
2554
+ for (const line of lines) {
2555
+ if (!line.trim()) continue;
2556
+ let msg;
2557
+ try { msg = JSON.parse(line); } catch { continue; }
2558
+ if (!helloSeen) {
2559
+ if (msg?.type !== "hello") continue; // wait for the capability answer
2560
+ helloSeen = true;
2561
+ try {
2562
+ if (typeof msg.generation === "string" && msg.generation) {
2563
+ socket.write(JSON.stringify({ type: "input", durable: true, commandId, clientId, seq: 1, viewId, instanceId, data: text }) + "\n");
2564
+ } else {
2565
+ // Pre-lifecycle runner: legacy requestId contract, same bytes.
2566
+ socket.write(JSON.stringify({ type: "input", requestId: commandId, data: text }) + "\n");
2567
+ }
2568
+ } catch (err) {
2569
+ finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
2570
+ }
2571
+ continue;
2572
+ }
2573
+ if (msg?.type === "cmd_ack" && msg.commandId === commandId) {
2574
+ if (msg.stage === "applied") finish({ ok: true, applied: true });
2575
+ if (msg.stage === "accepted") {
2576
+ sawAccepted = true;
2577
+ // accepted → applied is synchronous runner-side (Task 2 ruling 4);
2578
+ // a dropped socket between them is resolved by the deadline's query.
2579
+ }
2580
+ continue;
2581
+ }
2582
+ if (msg?.type === "input_ack" && msg.requestId === commandId) {
2583
+ // Legacy fallback answer on this same connection.
2584
+ finish({ ok: true });
2585
+ return;
2586
+ }
2587
+ if (msg?.type === "error") {
2588
+ const code = msg.code ?? msg.message ?? "host_error";
2589
+ if (code === "command_failed") {
2590
+ // Taxonomy: reconcile by commandId before any retry decision.
2591
+ resolveByReconcile(code);
2592
+ return;
2593
+ }
2594
+ finish({ ok: false, error: code, retryable: code !== "envelope_invalid" });
2595
+ return;
2596
+ }
2597
+ // hello/status/output lines are ignored — keep waiting.
2598
+ }
2599
+ });
2600
+ socket.on("error", (err) => {
2601
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
2602
+ finish({ ok: false, error: code ?? (err instanceof Error ? err.message : String(err)), retryable: true });
2603
+ });
2604
+ socket.on("close", () => {
2605
+ // A close after an accepted ack is the §10 window — resolve by query.
2606
+ if (sawAccepted) resolveByReconcile("socket_closed");
2607
+ else finish({ ok: false, error: "socket_closed", retryable: true });
2608
+ });
2609
+ });
2610
+ }
2611
+
2048
2612
  /**
2049
2613
  * Send one input line to a host control socket and wait for the runner's
2050
2614
  * input_ack (issue #70 A13). Fire-and-forget "connected" no longer counts as
@@ -2067,6 +2631,15 @@ const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
2067
2631
  * @returns {Promise<{ ok: boolean, error?: string, retryable?: boolean }>}
2068
2632
  */
2069
2633
  export function sendHostInput(socketPath, text, opts = {}) {
2634
+ // Staged durable path (phase 5 D4): the caller knows the host fence
2635
+ // (viewId + instanceId) and a stable commandId — envelope it and expect
2636
+ // accepted → applied with the §10 reconcile-query on timeout. Runners whose
2637
+ // hello lacks a generation fall back to the legacy requestId contract on
2638
+ // the same connection, so the legacy branch below stays for callers that
2639
+ // have no fence (pre-instanceId hosts).
2640
+ if (opts.viewId && opts.instanceId != null && (opts.commandId || opts.requestId)) {
2641
+ return sendHostInputStaged(socketPath, text, opts);
2642
+ }
2070
2643
  const requestId = opts.requestId ?? null;
2071
2644
  const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
2072
2645
  const connect = opts.connect ?? createConnection;