@zhuxixi/pi-agent-board 0.6.2 → 0.7.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.
@@ -9,7 +9,7 @@ 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";
@@ -27,6 +27,7 @@ import { isAlive, killProcess } from "../core/pid.mjs";
27
27
  import { acquireOwnedViewLock, tryAcquireOwnedViewLock } from "../core/locks.mjs";
28
28
  import { canFinalizeLegacyHost, canReplaceHost } from "../core/host-coordination.mjs";
29
29
  import { modelRefAvailable } from "../core/launch-options.mjs";
30
+ import { sendStateCommand, coordinatorDisabled } from "../core/coordinator-client.mjs";
30
31
  import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
31
32
  import * as P from "../core/paths.mjs";
32
33
  import {
@@ -88,6 +89,7 @@ const ATTACH_RESOLVE_TIMEOUT_MS = 120_000;
88
89
  * signalOwnedProcess?: (identity: {pid: number, startToken: string|null}, signal: string) => void,
89
90
  * probeHostFn?: typeof probeHost,
90
91
  * sleepFn?: (ms: number) => Promise<void>,
92
+ * sendStateCommand?: typeof sendStateCommand,
91
93
  * availableModels?: () => Array<{ provider: string, id: string }> | undefined,
92
94
  * }} opts
93
95
  */
@@ -104,6 +106,7 @@ export function createService(opts) {
104
106
  const nowImpl = opts.now ?? Date.now;
105
107
  const acquireLockImpl = opts.acquireLock ?? acquireOwnedViewLock;
106
108
  const tryAcquireLockImpl = opts.tryAcquireLock ?? tryAcquireOwnedViewLock;
109
+ const sendStateCommandImpl = opts.sendStateCommand ?? sendStateCommand;
107
110
  // Identity-aware observation/signalling for host recovery (issue #70). Callers must
108
111
  // only signal after observeProcess returned "owned" for that exact identity.
109
112
  const observeProcessImpl = opts.observeProcess ?? defaultObserveProcess;
@@ -146,7 +149,7 @@ export function createService(opts) {
146
149
  * @param {RunKind} kind
147
150
  * @returns {{ runId: string, pid: number|null }}
148
151
  */
149
- function launchForView(meta, prompt, kind) {
152
+ async function launchForView(meta, prompt, kind) {
150
153
  const runId = newRunId();
151
154
  /** @type {import("../core/types.mjs").RunConfig} */
152
155
  const config = {
@@ -163,9 +166,17 @@ export function createService(opts) {
163
166
  thinkingLevel: meta.defaultThinking ?? null,
164
167
  tools: null,
165
168
  };
169
+ // Final-review F2 residual (fix round 2): mark_queued must LAND before the
170
+ // runner can boot. A detached runner that boots faster than this round-trip
171
+ // used to send run_started while the row was still manual-completed → the
172
+ // command hit the manual fence → row stuck queued/alive with no status file
173
+ // → beats and run_finalized reject stale_run forever → permanent zombie.
174
+ // Spawn regardless of the result: with the coordinator unreachable the
175
+ // runner's own commands fail the same way either way — launch availability
176
+ // beats strict ordering.
177
+ await markQueued(meta.id, runId);
166
178
  const { pid } = launch(root, config, { runnerScript: opts.runnerScript });
167
179
  appendDiagnostic(root, meta.id, { source: "service", runId, code: "launch_run", message: "Detached runner launched", details: { kind, pid } });
168
- markQueued(meta.id, runId);
169
180
  return { runId, pid };
170
181
  }
171
182
 
@@ -176,9 +187,9 @@ export function createService(opts) {
176
187
  * can never stack a second host on top of a live claim.
177
188
  * @param {import("../core/types.mjs").ViewMeta} meta
178
189
  * @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 }}
190
+ * @returns {Promise<{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }>}
180
191
  */
181
- function launchHost(meta, initialPrompt, launchOpts = {}) {
192
+ async function launchHost(meta, initialPrompt, launchOpts = {}) {
182
193
  let lease;
183
194
  try {
184
195
  lease = acquireLockImpl(root, meta.id, "host-start", { waitMs: HOST_START_LOCK_WAIT_MS, identity: serviceIdentity() });
@@ -190,7 +201,12 @@ export function createService(opts) {
190
201
  return pendingLaunchResult(existing);
191
202
  }
192
203
  try {
193
- return startHostUnderLease(meta, initialPrompt, launchOpts);
204
+ // Same ordering contract as launchForView (final-review F2 residual):
205
+ // the row must leave completed/fenced before the host can start work.
206
+ // `return await` (not `return`) keeps the host-start lease held until
207
+ // the two-phase claim transaction settles.
208
+ if (launchOpts.markQueued !== false) await markQueued(meta.id, null);
209
+ return await startHostUnderLease(meta, initialPrompt, launchOpts);
194
210
  } finally {
195
211
  try { lease.release(); } catch { /* best effort */ }
196
212
  }
@@ -304,7 +320,6 @@ export function createService(opts) {
304
320
  }
305
321
  updateOwnedHost(root, meta.id, instanceId, (h) => ({ ...h, runnerPid: pid, runnerSpawnedAt: nowImpl() }));
306
322
  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
323
  return { ok: true, status: "started", pid, socketPath, instanceId };
309
324
  }
310
325
 
@@ -337,17 +352,36 @@ export function createService(opts) {
337
352
  /**
338
353
  * Apply immediate heuristic auto-state and, when configured, queue a detached
339
354
  * model pass to refine the row without blocking the live Pi child.
355
+ *
356
+ * Issue #91 (A8, service source): the classification lands through the View
357
+ * State Coordinator — the single writer of state.json/status.json. On apply,
358
+ * projection-relevant fields are refreshed from the materialized state so the
359
+ * caller's foreground projection (writeForegroundState) cannot clobber the
360
+ * patch with stale in-memory values. Ambiguous transport outcomes (timeout /
361
+ * connection_reset) never fall back to a direct write — the command may
362
+ * already be journaled, and the coordinator's boot replay is the recovery
363
+ * path. coordinator_disabled keeps the pre-coordinator in-memory apply.
340
364
  * @param {import("../core/types.mjs").ViewMeta} meta
341
- * @param {import("../core/types.mjs").RunStatus} status
365
+ * @param {import("../core/types.mjs").RunStatus} status refreshed in place on apply
342
366
  * @param {import("../core/types.mjs").EvidenceSnapshot} evidence
343
- * @returns {boolean}
367
+ * @returns {Promise<boolean>} whether the classification was applied
344
368
  */
345
- function queueAutoState(meta, status, evidence) {
369
+ async function queueAutoState(meta, status, evidence) {
346
370
  if (!autoStateEnabled()) return false;
347
371
  if (status.processState === "alive" || status.semanticState === "failed" || status.semanticState === "stopped") return false;
348
372
  const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
349
373
  if (!latest.trim()) return false;
350
- const changed = applyAutoStateToStatus(status, heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null }), Date.now());
374
+ const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
375
+ const commandRunId = status.runId === "foreground" ? null : status.runId;
376
+ const result = await sendStateCommandImpl(root, {
377
+ type: "state_command",
378
+ viewId: meta.id,
379
+ runId: commandRunId,
380
+ source: "service",
381
+ kind: "auto_state_classified",
382
+ expectedRevision: null,
383
+ payload: { classification },
384
+ });
351
385
  if (opts.autoStateRunnerScript) {
352
386
  try {
353
387
  launchAutoStateImpl(root, {
@@ -362,11 +396,47 @@ export function createService(opts) {
362
396
  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
397
  }
364
398
  }
365
- return changed;
399
+ if (result.status === "applied") {
400
+ const fresh = readState(root, meta.id);
401
+ if (fresh) {
402
+ status.semanticState = fresh.semanticState;
403
+ status.summary = fresh.summary;
404
+ status.question = fresh.question;
405
+ status.autoState = fresh.autoState ?? null;
406
+ }
407
+ return true;
408
+ }
409
+ if (result.reason === "coordinator_disabled") {
410
+ return applyAutoStateToStatus(status, classification, Date.now());
411
+ }
412
+ if (result.reason === "manual_fence" || result.reason === "no_change" || result.reason === "stale_run") {
413
+ // Designed fences — informational, not errors.
414
+ return false;
415
+ }
416
+ appendDiagnostic(root, meta.id, { source: "service", runId: commandRunId, level: "warn", code: "auto_state_command_ambiguous", message: `Auto-state classification outcome unknown (${result.reason}); if the command was journaled, coordinator replay will recover it; otherwise the next classification pass will converge the row`, details: { reason: result.reason } });
417
+ return false;
366
418
  }
367
419
 
368
- /** @param {string} viewId @param {string|null} runId */
369
- function markQueued(viewId, runId) {
420
+ /**
421
+ * Fire a state command for a non-run lifecycle/metadata mutation and surface
422
+ * non-applied outcomes. Ambiguity contract (issue #91): timeout /
423
+ * connection_reset mean the command MAY already be journaled — never fall
424
+ * back to a direct write on those; only the explicit
425
+ * AGENT_BOARD_COORDINATOR=off escape hatch takes the legacy branch (the
426
+ * caller checks `reason === "coordinator_disabled"`).
427
+ * @param {object} command
428
+ * @param {string} [viewId] defaults to command.viewId
429
+ */
430
+ async function sendLifecycleCommand(command, viewId = command.viewId) {
431
+ const result = await sendStateCommandImpl(root, command);
432
+ if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
433
+ 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 } });
434
+ }
435
+ return result;
436
+ }
437
+
438
+ /** Legacy direct write for mark_queued — coordinator_disabled escape hatch only. */
439
+ function markQueuedDirect(viewId, runId) {
370
440
  const state = readState(root, viewId) ?? blankState(viewId);
371
441
  state.currentRunId = runId;
372
442
  state.semanticState = "queued";
@@ -383,23 +453,111 @@ export function createService(opts) {
383
453
  writeState(root, state);
384
454
  }
385
455
 
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);
456
+ /** @param {string} viewId @param {string|null} runId @returns {Promise<{status: string, reason: string|null, materializedRevision?: number}>} */
457
+ function markQueued(viewId, runId) {
458
+ if (coordinatorDisabled()) {
459
+ markQueuedDirect(viewId, runId);
460
+ return Promise.resolve({ status: "applied", reason: "coordinator_disabled" });
461
+ }
462
+ // Fire-and-forget: legacy issued this write right after launch() without
463
+ // waiting on the runner, and the coordinator serializes arrival order the
464
+ // same way. N1 (Task 1): command.runId stays null — the new run's id rides
465
+ // in the payload and the decision branch pins currentRunId, so the generic
466
+ // stale-run guard cannot fire against the PREVIOUS run.
467
+ //
468
+ // source is "dashboard-user" on purpose (final-review F1): launching a run
469
+ // from these flows is ALWAYS user intent (reply / dispatch / attach), and
470
+ // the manual fence exists to protect the user's verdict from AUTOMATED late
471
+ // writes — a user-initiated re-launch IS the user changing the verdict, so
472
+ // mark_queued must lift the fence for the rest of the new run (otherwise a
473
+ // reply on a done row would execute invisibly while the row stays
474
+ // completed). Automated re-launch stays guarded upstream: the job-runner's
475
+ // post-exit follow-up claim refuses manually-completed rows
476
+ // (drainQueuedFollowUp: `if (isManualCompletion(readState(...))) return;`),
477
+ // and the service-side drain path delivers already-queued user follow-ups
478
+ // with no fence by design (prompt-not-lost, issue #70 — its live-host input
479
+ // path never fenced either).
480
+ // Fix round 2 (launch ordering): this promise is now AWAITED by launchForView
481
+ // / launchHost before they spawn — see the ordering notes there.
482
+ return sendLifecycleCommand({
483
+ type: "state_command",
484
+ viewId,
485
+ runId: null,
486
+ source: "dashboard-user",
487
+ kind: "mark_queued",
488
+ expectedRevision: null,
489
+ payload: { runId },
490
+ }).catch(() => ({ status: "rejected", reason: "connection_reset" }));
491
+ }
492
+
493
+ /** Legacy direct write for markVisited — coordinator_disabled escape hatch only. */
494
+ function markVisitedDirect(row) {
495
+ const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
391
496
  state.lastVisitedAt = Date.now();
392
497
  state.updatedAt = Date.now();
393
498
  writeState(root, state);
394
- return { ok: true };
395
499
  }
396
500
 
397
- /** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
398
- function completeView(viewId) {
501
+ /**
502
+ * @param {string} viewId
503
+ * @returns {Promise<{ ok: boolean, error?: string }>}
504
+ */
505
+ async function markVisited(viewId) {
399
506
  const row = loadRow(root, viewId);
400
507
  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);
508
+ if (coordinatorDisabled()) {
509
+ markVisitedDirect(row);
510
+ return { ok: true };
511
+ }
512
+ // dashboard-user (not "service"): visiting is a user action, and the
513
+ // manual fence must not stop visit-recency tracking on completed rows —
514
+ // legacy stamped lastVisitedAt unconditionally. Whitelist: lastVisitedAt only.
515
+ const result = await sendLifecycleCommand({
516
+ type: "state_command",
517
+ viewId,
518
+ source: "dashboard-user",
519
+ kind: "patch_fields",
520
+ expectedRevision: null,
521
+ payload: { state: { lastVisitedAt: Date.now() } },
522
+ }, viewId);
523
+ if (result.reason === "coordinator_disabled") markVisitedDirect(row);
524
+ // Visit tracking is best-effort metadata — never fail the UI action.
525
+ return { ok: true };
526
+ }
527
+
528
+ /** Legacy direct write for adoptSession — coordinator_disabled escape hatch only. */
529
+ function adoptStateDirect(viewId, state) {
530
+ const target = state ?? readState(root, viewId) ?? blankState(viewId);
531
+ target.semanticState = "idle";
532
+ target.processState = "exited";
533
+ target.needsInput = false;
534
+ target.hasError = false;
535
+ target.question = null;
536
+ target.pendingQuestions = [];
537
+ target.error = null;
538
+ target.summary = "Backgrounded session";
539
+ target.updatedAt = Date.now();
540
+ target.lastActivityAt = Date.now();
541
+ writeState(root, target);
542
+ }
543
+
544
+ /** Route the adopt-idle transition through the coordinator (fenced, journaled). */
545
+ async function adoptIdleState(viewId, state) {
546
+ const result = await sendLifecycleCommand({
547
+ type: "state_command",
548
+ viewId,
549
+ runId: null,
550
+ source: "dashboard-user",
551
+ kind: "adopt_session",
552
+ expectedRevision: null,
553
+ payload: {},
554
+ }, viewId);
555
+ if (result.reason === "coordinator_disabled") adoptStateDirect(viewId, state);
556
+ }
557
+
558
+ /** Legacy direct-write completion — only reachable when the coordinator is
559
+ * explicitly disabled (AGENT_BOARD_COORDINATOR=off tests/escape hatch). */
560
+ function completeViewDirect(state) {
403
561
  state.semanticState = "completed";
404
562
  state.processState = "exited";
405
563
  state.needsInput = false;
@@ -414,21 +572,72 @@ export function createService(opts) {
414
572
  // Also clear autoState in the run status so in-flight model passes
415
573
  // (job-runner / state-runner) see the manual completion and skip refinement.
416
574
  if (state.currentRunId) {
417
- const status = readStatus(root, viewId, state.currentRunId);
575
+ const status = readStatus(root, state.viewId, state.currentRunId);
418
576
  if (status) {
419
577
  status.autoState = null;
420
578
  writeStatus(root, status);
421
579
  }
422
580
  }
423
581
  writeState(root, state);
424
- return { ok: true };
425
582
  }
426
583
 
427
584
  /**
585
+ * Explicitly mark an inactive session as done via the View State Coordinator
586
+ * (issue #91): the command is journaled and materialized by the single owner,
587
+ * which also rejects stale-run and fenced manual-completion overwrites.
428
588
  * @param {string} viewId
429
- * @returns {{ ok: boolean, error?: string }}
589
+ * @returns {Promise<{ ok: boolean, error?: string }>}
430
590
  */
431
- function archiveView(viewId) {
591
+ async function completeView(viewId) {
592
+ const row = loadRow(root, viewId);
593
+ if (!row) return { ok: false, error: "Unknown session" };
594
+ // Fast local pre-check for instant UI feedback; the coordinator's decision
595
+ // stays authoritative (its "busy" rejection maps to the same wording).
596
+ if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before marking done" };
597
+ const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
598
+ const result = await sendStateCommandImpl(root, {
599
+ type: "state_command",
600
+ viewId,
601
+ runId: state.currentRunId ?? null,
602
+ source: "dashboard-user",
603
+ kind: "mark_completed",
604
+ expectedRevision: null,
605
+ payload: {},
606
+ });
607
+ if (result.status === "applied") return { ok: true };
608
+ if (result.reason === "busy") return { ok: false, error: "Wait for the active run to finish before marking done" };
609
+ if (result.reason === "coordinator_disabled") {
610
+ completeViewDirect(state);
611
+ return { ok: true };
612
+ }
613
+ // Ambiguous outcomes (timeout / connection_reset: the command MAY already be
614
+ // journaled) and real rejections surface verbatim — never fall back to a
615
+ // direct write here, it would bypass the single-writer fence.
616
+ return { ok: false, error: result.reason ?? "state_command_failed" };
617
+ }
618
+
619
+ /** Legacy direct write for archiveView's busy-row stop — coordinator_disabled escape hatch only. */
620
+ function archiveStateDirect(row) {
621
+ const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
622
+ state.semanticState = "stopped";
623
+ state.processState = "exited";
624
+ state.needsInput = false;
625
+ state.hasError = false;
626
+ state.question = null;
627
+ state.pendingQuestions = [];
628
+ state.error = null;
629
+ state.autoState = null;
630
+ state.summary = "Stopped";
631
+ state.lastActivityAt = Date.now();
632
+ state.updatedAt = Date.now();
633
+ writeState(root, state);
634
+ }
635
+
636
+ /**
637
+ * @param {string} viewId
638
+ * @returns {Promise<{ ok: boolean, error?: string }>}
639
+ */
640
+ async function archiveView(viewId) {
432
641
  const row = loadRow(root, viewId);
433
642
  if (!row) return { ok: false, error: "Unknown session" };
434
643
  if (row.host?.instanceId) stopHostRow(row, "archive");
@@ -438,19 +647,17 @@ export function createService(opts) {
438
647
  if (pid) killProcess(pid);
439
648
  }
440
649
  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);
650
+ // dashboard-user: archiving is a user action. Busy rows are allowed by
651
+ // the decision kind (archiving a working row stops it — legacy parity).
652
+ const result = await sendLifecycleCommand({
653
+ type: "state_command",
654
+ viewId,
655
+ source: "dashboard-user",
656
+ kind: "archive_view",
657
+ expectedRevision: null,
658
+ payload: {},
659
+ }, viewId);
660
+ if (result.reason === "coordinator_disabled") archiveStateDirect(row);
454
661
  }
455
662
  row.meta.archived = true;
456
663
  writeMeta(root, row.meta);
@@ -525,14 +732,30 @@ export function createService(opts) {
525
732
  /**
526
733
  * @param {import("../core/store.mjs").Row} row
527
734
  * @param {import("../core/types.mjs").RunStatus} status
735
+ * @returns {Promise<void>}
528
736
  */
529
- function writeForegroundState(row, status) {
737
+ async function writeForegroundState(row, status) {
530
738
  const projected = projectViewState(status, Date.now(), readState(root, row.meta.id) ?? row.state ?? null);
531
739
  // Foreground turns are driven by the interactive Pi process, not a detached
532
740
  // runner, so keep currentRunId null. This prevents reconcile()/stop() from
533
- // treating a foreground turn as a managed background runner pid.
741
+ // treating a foreground turn as a managed background runner pid. (The
742
+ // decision kind force-nulls it too; the caller-side null keeps the
743
+ // coordinator_disabled direct write at legacy parity.)
534
744
  projected.currentRunId = null;
535
- writeState(root, projected);
745
+ if (coordinatorDisabled()) {
746
+ writeState(root, projected);
747
+ return;
748
+ }
749
+ const result = await sendLifecycleCommand({
750
+ type: "state_command",
751
+ viewId: row.meta.id,
752
+ runId: null,
753
+ source: "service",
754
+ kind: "sync_foreground",
755
+ expectedRevision: null,
756
+ payload: { projection: projected },
757
+ }, row.meta.id);
758
+ if (result.reason === "coordinator_disabled") writeState(root, projected);
536
759
  }
537
760
 
538
761
  /** @param {string} sessionFile */
@@ -1135,7 +1358,7 @@ export function createService(opts) {
1135
1358
  let hostMode = "pty";
1136
1359
  if (!pty.ok) hostMode = "json-runner";
1137
1360
  if (pty.ok) {
1138
- const launched = launchHost(row.meta, prompt);
1361
+ const launched = await launchHost(row.meta, prompt);
1139
1362
  if (launched.ok && launched.status !== "started") {
1140
1363
  // An existing claim owns the view; the item stays queued and retries
1141
1364
  // after that host becomes ready (issue #70 prompt-not-lost invariant).
@@ -1152,7 +1375,7 @@ export function createService(opts) {
1152
1375
  return { ok: false, error: "PTY is required to drain adopted session follow-ups safely" };
1153
1376
  }
1154
1377
  if (hostMode === "json-runner") {
1155
- runId = launchForView(row.meta, prompt, runKindForFollowUp(item)).runId;
1378
+ ({ runId } = await launchForView(row.meta, prompt, runKindForFollowUp(item)));
1156
1379
  }
1157
1380
  completeFollowUp(root, viewId, item.id, { runId });
1158
1381
  appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_started", message: "Queued follow-up started", details: { kind: item.kind, hostMode } });
@@ -1164,8 +1387,15 @@ export function createService(opts) {
1164
1387
  }
1165
1388
  }
1166
1389
 
1167
- /** @param {import("../core/store.mjs").Row} row @param {any} event */
1168
- function syncRowEvent(row, event) {
1390
+ /**
1391
+ * Mirror a foreground/hosted session event into the row state. Async since
1392
+ * issue #91: the terminal classification routes through the View State
1393
+ * Coordinator command socket (fire-and-forget callers are fine — the returned
1394
+ * promise resolves after the classification outcome is known).
1395
+ * @param {import("../core/store.mjs").Row} row @param {any} event
1396
+ * @returns {Promise<boolean>} whether a managed row was updated
1397
+ */
1398
+ async function syncRowEvent(row, event) {
1169
1399
  const now = Date.now();
1170
1400
  const status = statusFromRow(row);
1171
1401
  let evidence = readEvidence(root, row.meta.id);
@@ -1187,26 +1417,55 @@ export function createService(opts) {
1187
1417
  status.error = null;
1188
1418
  status.summary = "Running…";
1189
1419
  status.lastActivityAt = now;
1190
- writeForegroundState(row, status);
1420
+ // Throughput path: periodic self-healing mirror, fire-and-forget.
1421
+ void writeForegroundState(row, status);
1191
1422
  return true;
1192
1423
  }
1193
1424
 
1194
1425
  if (event.type === "agent_end") {
1426
+ // #46-class fence (issue #91): a manual completion is a user verdict that
1427
+ // outlives the turn. A late/duplicate agent_end after markCompleted must
1428
+ // not resurrect the row via finalizeRun + projection — skip all semantic
1429
+ // writes (baseline, steering, classification) and keep evidence only.
1430
+ // The fence read is repeated after the classification await below; the
1431
+ // window between the two reads is covered by the coordinator's own
1432
+ // manual_fence rejection plus the guarded tail write.
1433
+ if (isManualCompletion(readState(root, row.meta.id))) {
1434
+ status.evidenceSummary = summarizeEvidence(evidence);
1435
+ writeEvidence(root, evidence);
1436
+ return false;
1437
+ }
1195
1438
  finalizeRun(status, { exitCode: 0 }, now);
1196
1439
  finalizeEvidence(evidence, status, now);
1197
1440
  const steering = readSteering(root, row.meta.id);
1441
+ let classificationQueued = false;
1198
1442
  if (steering.status === "plan_requested" || steering.status === "changes_requested") {
1199
1443
  recordPlanReady(root, row.meta.id, { planText: latestEvidenceText(evidence) || status.latestAssistantPreview || evidence.summary || "Plan ready", runId: status.runId });
1200
1444
  status.semanticState = "needs_input";
1201
1445
  status.question = "Approve this plan?";
1202
1446
  status.summary = "Plan ready for approval";
1203
- } else if (queueAutoState(row.meta, status, evidence)) {
1204
- finalizeEvidence(evidence, status, now);
1447
+ } else {
1448
+ // Baseline the exited projection BEFORE the classification command:
1449
+ // the coordinator's delegated auto-state rules key on processState,
1450
+ // and its materialized patch lands after this write and wins. When
1451
+ // the coordinator applies, the projection write below is skipped so
1452
+ // the stale in-memory fields cannot clobber the patch (#46 class).
1453
+ // The legacy coordinator_disabled path applies in-memory instead and
1454
+ // still needs the tail projection write (pre-coordinator behavior).
1455
+ // Awaited: this ordering IS the fence — the classification command
1456
+ // must observe processState "exited" or its delegated rules skip.
1457
+ await writeForegroundState(row, status);
1458
+ classificationQueued = await queueAutoState(row.meta, status, evidence);
1459
+ if (classificationQueued) finalizeEvidence(evidence, status, now);
1205
1460
  }
1206
1461
  status.evidenceSummary = summarizeEvidence(evidence);
1207
1462
  writeEvidence(root, evidence);
1208
1463
  updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
1209
- writeForegroundState(row, status);
1464
+ // Re-read the fence after the classification await: a completion landing
1465
+ // between the baseline write and the coordinator's decision read gets
1466
+ // manual_fence back (classificationQueued=false), and writing the stale
1467
+ // in-memory projection here would clobber it (#46 class).
1468
+ if (!isManualCompletion(readState(root, row.meta.id)) && !(classificationQueued && !coordinatorDisabled())) await writeForegroundState(row, status);
1210
1469
  pruneWarmHosts({ keepViewId: row.meta.id });
1211
1470
  // Async delivery (ack-gated, issue #70 A13): fire-and-forget here — the
1212
1471
  // queue item's own state records the outcome, ordering is preserved by
@@ -1217,7 +1476,8 @@ export function createService(opts) {
1217
1476
 
1218
1477
  if (reduceEvent(status, event, now, { interactive: true })) {
1219
1478
  status.processState = "alive";
1220
- writeForegroundState(row, status);
1479
+ // Throughput path: periodic self-healing mirror, fire-and-forget.
1480
+ void writeForegroundState(row, status);
1221
1481
  return true;
1222
1482
  }
1223
1483
  return false;
@@ -1237,9 +1497,9 @@ export function createService(opts) {
1237
1497
  * model?: string|null,
1238
1498
  * thinkingLevel?: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max"|null,
1239
1499
  * }} [dispatchOpts]
1240
- * @returns {{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }}
1500
+ * @returns {Promise<{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }>}
1241
1501
  */
1242
- dispatch(text, dispatchOpts = {}) {
1502
+ async dispatch(text, dispatchOpts = {}) {
1243
1503
  const prompt = String(text || "").trim();
1244
1504
  if (!prompt) return { ok: false, error: "Empty task" };
1245
1505
 
@@ -1271,7 +1531,7 @@ export function createService(opts) {
1271
1531
  let fallbackReason;
1272
1532
  let queued;
1273
1533
  if (pty.ok) {
1274
- const launched = launchHost(meta, prompt);
1534
+ const launched = await launchHost(meta, prompt);
1275
1535
  if (launched.ok) {
1276
1536
  hostMode = "pty";
1277
1537
  if (launched.status !== "started") {
@@ -1283,11 +1543,11 @@ export function createService(opts) {
1283
1543
  }
1284
1544
  } else {
1285
1545
  fallbackReason = launched.fallbackReason ?? launched.error;
1286
- launchForView(meta, prompt, "dispatch");
1546
+ await launchForView(meta, prompt, "dispatch");
1287
1547
  }
1288
1548
  } else {
1289
1549
  fallbackReason = nodePtyFallbackMessage(pty);
1290
- launchForView(meta, prompt, "dispatch");
1550
+ await launchForView(meta, prompt, "dispatch");
1291
1551
  }
1292
1552
  queueGeneratedTitle(meta, prompt);
1293
1553
  return {
@@ -1356,7 +1616,7 @@ export function createService(opts) {
1356
1616
  let hostMode = null;
1357
1617
  let fallbackReason;
1358
1618
  if (pty.ok) {
1359
- const launched = launchHost(row.meta, prompt);
1619
+ const launched = await launchHost(row.meta, prompt);
1360
1620
  if (launched.ok) {
1361
1621
  if (launched.status !== "started") {
1362
1622
  // The existing claim will consume the prompt later — keep it queued
@@ -1377,7 +1637,7 @@ export function createService(opts) {
1377
1637
  }
1378
1638
  if (!hostMode) {
1379
1639
  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));
1640
+ await launchForView(row.meta, prompt, runKindForKind(kind));
1381
1641
  hostMode = "json-runner";
1382
1642
  }
1383
1643
  return { ok: true, hostMode, ...(hostMode === "json-runner" ? { fallbackReason } : {}) };
@@ -1475,7 +1735,7 @@ export function createService(opts) {
1475
1735
  return { kind: "session", sessionFile: row.meta.sessionFile };
1476
1736
  },
1477
1737
 
1478
- adoptSession(adoptOpts = {}) {
1738
+ async adoptSession(adoptOpts = {}) {
1479
1739
  const sessionFile = String(adoptOpts.sessionFile || "").trim();
1480
1740
  if (!sessionFile) return { ok: false, error: "No session file to adopt" };
1481
1741
  const existing = rowForSession(sessionFile);
@@ -1484,18 +1744,7 @@ export function createService(opts) {
1484
1744
  if (adoptOpts.name) existing.meta.name = String(adoptOpts.name).trim() || existing.meta.name;
1485
1745
  writeMeta(root, existing.meta);
1486
1746
  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);
1747
+ await adoptIdleState(existing.meta.id, readState(root, existing.meta.id) ?? existing.state ?? blankState(existing.meta.id));
1499
1748
  }
1500
1749
  appendDiagnostic(root, existing.meta.id, { source: "service", code: "session_adopted", message: "Existing session adopted into Agent Board", details: { reused: true } });
1501
1750
  return { ok: true, viewId: existing.meta.id, reused: true };
@@ -1517,12 +1766,7 @@ export function createService(opts) {
1517
1766
  sessionFile,
1518
1767
  });
1519
1768
  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);
1769
+ await adoptIdleState(id, state);
1526
1770
  appendDiagnostic(root, id, { source: "service", code: "session_adopted", message: "Current session adopted into Agent Board", details: { reused: false } });
1527
1771
  return { ok: true, viewId: meta.id, reused: false };
1528
1772
  },
@@ -1569,7 +1813,7 @@ export function createService(opts) {
1569
1813
  * Explicitly mark an inactive session as done. Successful runs settle as
1570
1814
  * `idle` until the user reviews and confirms this action from the dashboard.
1571
1815
  * @param {string} viewId
1572
- * @returns {{ ok: boolean, error?: string }}
1816
+ * @returns {Promise<{ ok: boolean, error?: string }>}
1573
1817
  */
1574
1818
  markCompleted(viewId) {
1575
1819
  return completeView(viewId);
@@ -1640,7 +1884,12 @@ export function createService(opts) {
1640
1884
  return { state, summary: summarizeSteering(state) };
1641
1885
  },
1642
1886
 
1643
- markCompletedMany(viewIds) {
1887
+ /**
1888
+ * Bulk mark sessions done, skipping live/already-done rows.
1889
+ * @param {string[]} viewIds
1890
+ * @returns {Promise<{ ok: boolean, completed: number, skipped: number, completedIds: string[] }>}
1891
+ */
1892
+ async markCompletedMany(viewIds) {
1644
1893
  const ids = [...new Set((viewIds ?? []).filter(Boolean))];
1645
1894
  let completed = 0;
1646
1895
  let skipped = 0;
@@ -1651,7 +1900,7 @@ export function createService(opts) {
1651
1900
  skipped += 1;
1652
1901
  continue;
1653
1902
  }
1654
- const res = completeView(viewId);
1903
+ const res = await completeView(viewId);
1655
1904
  if (res.ok) {
1656
1905
  completed += 1;
1657
1906
  completedIds.push(viewId);
@@ -1672,9 +1921,9 @@ export function createService(opts) {
1672
1921
  /**
1673
1922
  * Bulk archive explicit row ids, skipping live/missing rows.
1674
1923
  * @param {string[]} viewIds
1675
- * @returns {{ ok: boolean, archived: number, skipped: number }}
1924
+ * @returns {Promise<{ ok: boolean, archived: number, skipped: number }>}
1676
1925
  */
1677
- archiveMany(viewIds) {
1926
+ async archiveMany(viewIds) {
1678
1927
  const ids = [...new Set((viewIds ?? []).filter(Boolean))];
1679
1928
  let archived = 0;
1680
1929
  let skipped = 0;
@@ -1684,7 +1933,7 @@ export function createService(opts) {
1684
1933
  skipped += 1;
1685
1934
  continue;
1686
1935
  }
1687
- const res = archiveView(viewId);
1936
+ const res = await archiveView(viewId);
1688
1937
  if (res.ok) archived += 1;
1689
1938
  else skipped += 1;
1690
1939
  }
@@ -1719,9 +1968,9 @@ export function createService(opts) {
1719
1968
  * Recovery: reconcile rows whose runner died without finalizing (e.g. machine crash
1720
1969
  * or the runner was killed). If a terminal status exists, project it; otherwise mark
1721
1970
  * the row failed/stale. Safe to call on every dashboard open and on session_start.
1722
- * @returns {number} number of rows reconciled.
1971
+ * @returns {Promise<number>} number of rows reconciled.
1723
1972
  */
1724
- reconcile() {
1973
+ async reconcile() {
1725
1974
  const now = Date.now();
1726
1975
  let fixed = 0;
1727
1976
  for (const row of listRows(root)) {
@@ -1734,16 +1983,36 @@ export function createService(opts) {
1734
1983
  // its runner pid may legitimately be absent — never finalize it to failed yet.
1735
1984
  if (row.host.state === "starting" && now - (row.host.claimAt ?? row.host.startedAt ?? 0) < HOST_START_GRACE_MS) continue;
1736
1985
  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);
1986
+ // F6: failed verdicts must carry a reason (legacy guaranteed an
1987
+ // error string). The row has no currentRunId here, so only the
1988
+ // state half participates (no status.json to sync).
1989
+ const result = await sendLifecycleCommand({
1990
+ type: "state_command",
1991
+ viewId: row.meta.id,
1992
+ runId: null,
1993
+ source: "service",
1994
+ kind: "reconcile_finalize",
1995
+ expectedRevision: null,
1996
+ payload: {
1997
+ semanticState: failed ? "failed" : "idle",
1998
+ summary: failed ? "Failed (PTY host exited)" : "Needs instructions",
1999
+ reason: failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null,
2000
+ },
2001
+ }, row.meta.id);
2002
+ if (result.status !== "applied") {
2003
+ if (result.reason === "coordinator_disabled") {
2004
+ s.semanticState = failed ? "failed" : "idle";
2005
+ s.processState = "exited";
2006
+ s.hasError = failed;
2007
+ s.needsInput = false;
2008
+ s.question = null;
2009
+ s.pendingQuestions = [];
2010
+ s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
2011
+ s.summary = failed ? "Failed (PTY host exited)" : "Needs instructions";
2012
+ s.updatedAt = now;
2013
+ writeState(root, s);
2014
+ } else continue; // rejected/ambiguous: nothing mutated, not fixed
2015
+ }
1747
2016
  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
2017
  fixed += 1;
1749
2018
  }
@@ -1752,16 +2021,52 @@ export function createService(opts) {
1752
2021
  if (row.alive) continue;
1753
2022
  const status = readStatus(root, row.meta.id, s.currentRunId);
1754
2023
  if (status?.endedAt) {
1755
- writeState(root, projectViewState(status, now, readState(root, row.meta.id) ?? row.state ?? null));
2024
+ // The run's terminal status exists but the row was never materialized
2025
+ // from it (crash between the two writes, or a pre-coordinator row).
2026
+ // Project mode lets the status's own verdict govern (completed rows
2027
+ // must not be forced to failed/idle).
2028
+ const result = await sendLifecycleCommand({
2029
+ type: "state_command",
2030
+ viewId: row.meta.id,
2031
+ runId: s.currentRunId,
2032
+ source: "service",
2033
+ kind: "reconcile_finalize",
2034
+ expectedRevision: null,
2035
+ payload: { project: true },
2036
+ }, row.meta.id);
2037
+ if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
2038
+ // Fix round 2 (F5 leftover): a rejected/ambiguous project-mode
2039
+ // row (manual fence, no_change, stale_run, timeout) mutated
2040
+ // nothing — it must not count toward the fixed tally.
2041
+ continue;
2042
+ }
2043
+ if (result.reason === "coordinator_disabled") writeState(root, projectViewState(status, now, readState(root, row.meta.id) ?? row.state ?? null));
1756
2044
  } 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);
2045
+ const result = await sendLifecycleCommand({
2046
+ type: "state_command",
2047
+ viewId: row.meta.id,
2048
+ runId: s.currentRunId,
2049
+ source: "service",
2050
+ kind: "reconcile_finalize",
2051
+ expectedRevision: null,
2052
+ payload: {
2053
+ semanticState: "failed",
2054
+ summary: "Failed (runner exited)",
2055
+ reason: s.error ?? "Runner exited unexpectedly",
2056
+ },
2057
+ }, row.meta.id);
2058
+ if (result.status !== "applied") {
2059
+ if (result.reason === "coordinator_disabled") {
2060
+ s.semanticState = "failed";
2061
+ s.processState = "exited";
2062
+ s.hasError = true;
2063
+ s.needsInput = false;
2064
+ s.error = s.error ?? "Runner exited unexpectedly";
2065
+ s.summary = "Failed (runner exited)";
2066
+ s.updatedAt = now;
2067
+ writeState(root, s);
2068
+ } else continue;
2069
+ }
1765
2070
  }
1766
2071
  fixed += 1;
1767
2072
  }
@@ -1782,20 +2087,20 @@ export function createService(opts) {
1782
2087
  * looking stale after the user types a follow-up in the real Pi session.
1783
2088
  * @param {string|undefined} sessionFile
1784
2089
  * @param {any} event
1785
- * @returns {boolean} whether a managed row was updated
2090
+ * @returns {Promise<boolean>} whether a managed row was updated
1786
2091
  */
1787
2092
  syncForegroundEvent(sessionFile, event) {
1788
- if (!sessionFile || !event?.type) return false;
2093
+ if (!sessionFile || !event?.type) return Promise.resolve(false);
1789
2094
  const row = rowForSession(sessionFile);
1790
- if (!row) return false;
2095
+ if (!row) return Promise.resolve(false);
1791
2096
  return syncRowEvent(row, event);
1792
2097
  },
1793
2098
 
1794
2099
  /** @param {string|undefined} viewId @param {any} event */
1795
2100
  syncHostedEvent(viewId, event) {
1796
- if (!viewId || !event?.type) return false;
2101
+ if (!viewId || !event?.type) return Promise.resolve(false);
1797
2102
  const row = loadRow(root, viewId);
1798
- if (!row) return false;
2103
+ if (!row) return Promise.resolve(false);
1799
2104
  return syncRowEvent(row, event);
1800
2105
  },
1801
2106