@zhuxixi/pi-agent-board 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -0,0 +1,699 @@
1
+ /**
2
+ * Pure decision layer for View State Coordinator commands (issue #91, spec D3).
3
+ *
4
+ * This module is the single source of truth for "which semantic-state mutations
5
+ * are allowed". The coordinator process shell (runner/state-coordinator.mjs)
6
+ * owns all side effects — journal, socket, file writes — and calls into this
7
+ * module for every decision. No fs, no net, no Date.now(): every timestamp is
8
+ * taken from an explicit `now` argument or from command payload fields, so
9
+ * decisions are deterministic and exhaustively unit-testable (same pattern as
10
+ * host-coordination.mjs from issue #70).
11
+ *
12
+ * Delegation contract (rules are never copied here):
13
+ * - `auto_state_classified` → auto-state.mjs applyAutoStateToViewState/Status
14
+ * run on deep clones; changed fields are diffed into field patches.
15
+ * - `run_finalized` → events.mjs finalizeRun + projectViewState run on a deep
16
+ * clone of the on-disk status; diffs produce the status/state patches.
17
+ * - `run_progress` / `followup_started` → payload.statusPatch merges onto the
18
+ * status clone, then projectViewState recomputes the state side (shared
19
+ * applyStatusProjection helper).
20
+ * - `reconcile_finalize` → stamps finalizeRun's exact field names on the
21
+ * status (endedAt/exitCode/processState/pid) but does NOT run finalizeRun:
22
+ * the reconciler's explicitly observed semanticState governs, and a full
23
+ * finalize would recompute it from a possibly stale preview.
24
+ *
25
+ * Reject reasons: "unknown_view" | "revision_conflict" | "stale_run" |
26
+ * "manual_fence" | "busy" | "no_change" | "field_not_allowed".
27
+ */
28
+ import {
29
+ applyAutoStateToStatus,
30
+ applyAutoStateToViewState,
31
+ isManualCompletion,
32
+ } from "./auto-state.mjs";
33
+ import { finalizeRun, projectViewState } from "./events.mjs";
34
+
35
+ /** Command kinds accepted by the View State Coordinator (issue #91 scope). */
36
+ export const STATE_COMMAND_KINDS = Object.freeze([
37
+ "mark_completed",
38
+ "auto_state_classified",
39
+ "run_finalized",
40
+ "mark_queued",
41
+ "run_started",
42
+ "run_progress",
43
+ "reconcile_finalize",
44
+ "host_run_failed",
45
+ "archive_view",
46
+ "adopt_session",
47
+ "sync_foreground",
48
+ "plan_ready",
49
+ "followup_started",
50
+ "patch_fields",
51
+ ]);
52
+
53
+ /**
54
+ * Kinds the coordinator applies WITHOUT journaling (plan D3/Task-3 decision:
55
+ * run_progress is a periodic self-healing snapshot — ~4 writes/sec/run would
56
+ * bloat the journal unboundedly; a lost beat is overwritten by the next one).
57
+ * Transient commands still materialize and bump materializedRevision, but they
58
+ * are not replayed and not deduped.
59
+ */
60
+ export const TRANSIENT_KINDS = Object.freeze(["run_progress"]);
61
+
62
+ /**
63
+ * Decision-layer reject reasons: journaled, authoritative coordinator verdicts
64
+ * with NO recovery semantics (a retry would be decided the same way). The
65
+ * complement — transport ambiguity ("timeout"/"connection_reset"/
66
+ * "connection_failed"/"coordinator_unavailable") — is the only class where
67
+ * "outcome unknown, replay will recover" diagnostics are honest (issue #91
68
+ * hygiene). validateCommand envelope errors are first-party programmer errors
69
+ * and are not in this set.
70
+ */
71
+ export const DECIDED_REJECT_REASONS = Object.freeze(new Set([
72
+ "manual_fence",
73
+ "stale_run",
74
+ "no_change",
75
+ "busy",
76
+ "revision_conflict",
77
+ "unknown_view",
78
+ "unknown_kind",
79
+ "field_not_allowed",
80
+ ]));
81
+
82
+ /**
83
+ * Diagnostic fields for a non-applied command result (issue #91 hygiene):
84
+ * decided rejects are info-level skips — the coordinator's verdict is
85
+ * authoritative, so "outcome unknown, replay will recover" would be a lie.
86
+ * Transport ambiguity keeps the warn with the caller's recovery hint.
87
+ * Pure: shapes only, no I/O.
88
+ * @param {string} code diagnostic code stem (e.g. "run_started")
89
+ * @param {string} label human phrase for the message (e.g. "Run bootstrap")
90
+ * @param {string|null} reason rejected reason from the command result
91
+ * @param {string} ambiguousTail recovery hint appended ONLY for ambiguous reasons
92
+ * @returns {{ level: "info"|"warn", code: string, message: string }}
93
+ */
94
+ export function commandRejectDiagnostic(code, label, reason, ambiguousTail) {
95
+ const decided = reason != null && DECIDED_REJECT_REASONS.has(reason);
96
+ return decided
97
+ ? { level: "info", code: `${code}_skipped`, message: `${label} skipped by the coordinator (${reason}); its decision is authoritative` }
98
+ : { level: "warn", code: `${code}_ambiguous`, message: `${label} outcome unknown (${reason}); if the command was journaled, coordinator replay will recover it; ${ambiguousTail}` };
99
+ }
100
+
101
+ /**
102
+ * Per-source whitelist for `patch_fields` (metadata/evidence-mirror merges).
103
+ * Anything not listed here is rejected with "field_not_allowed" — semantic
104
+ * fields must travel through their dedicated kinds so the guard table in the
105
+ * plan stays exhaustive.
106
+ * @typedef {{ state: string[], status: string[] }} PatchableFields
107
+ * @type {Record<string, PatchableFields>}
108
+ */
109
+ export const PATCHABLE_FIELDS = Object.freeze({
110
+ // summary/latestAssistantPreview: the post-exit model-summary persist routes
111
+ // through patch_fields (controller ruling R1 — plan oversight, Task 3).
112
+ "job-runner": Object.freeze({ state: Object.freeze(["review", "evidenceSummary", "summary", "latestAssistantPreview"]), status: Object.freeze(["evidenceSummary", "summary", "latestAssistantPreview"]) }),
113
+ "state-runner": Object.freeze({ state: Object.freeze(["review", "evidenceSummary"]), status: Object.freeze(["evidenceSummary"]) }),
114
+ "service": Object.freeze({ state: Object.freeze(["lastVisitedAt"]), status: Object.freeze([]) }),
115
+ // lastVisitedAt (markVisited): visiting is a user action, so it routes as
116
+ // dashboard-user — the manual fence only fences non-human sources, and
117
+ // legacy stamped lastVisitedAt unconditionally (visit-recency tracking
118
+ // must keep working on manually-completed rows).
119
+ "dashboard-user": Object.freeze({ state: Object.freeze(["lastVisitedAt"]), status: Object.freeze([]) }),
120
+ });
121
+
122
+ /** Who may originate a state command. Non-human sources are fenced by manual completions. */
123
+ export const COMMAND_SOURCES = Object.freeze([
124
+ "dashboard-user",
125
+ "service",
126
+ "job-runner",
127
+ "state-runner",
128
+ "pty-runner",
129
+ ]);
130
+
131
+ /**
132
+ * A semantic-state mutation request routed through the coordinator.
133
+ * @typedef {Object} StateCommand
134
+ * @property {"state_command"} type
135
+ * @property {string} commandId Stable id — journal replays return the original result.
136
+ * @property {string} viewId
137
+ * @property {string} [runId] Active run this command belongs to (stale-run fenced).
138
+ * @property {typeof COMMAND_SOURCES[number]} source
139
+ * @property {number|null} [expectedRevision] Optimistic concurrency on materializedRevision.
140
+ * @property {typeof STATE_COMMAND_KINDS[number]} kind
141
+ * @property {Record<string, unknown>} payload Kind-specific; validated per kind.
142
+ */
143
+
144
+ /**
145
+ * Validate the command envelope plus kind-specific payload presence. Pure.
146
+ * @param {any} raw
147
+ * @returns {{ ok: true, command: object } | { ok: false, error: string }}
148
+ */
149
+ export function validateCommand(raw) {
150
+ if (!raw || raw.type !== "state_command") return { ok: false, error: "bad_type" };
151
+ // Transient kinds have no idempotency semantics — the shell neither dedupes
152
+ // nor replays them, so commandId is optional there (echoed in the reply only).
153
+ if ((typeof raw.commandId !== "string" || !raw.commandId) && !TRANSIENT_KINDS.includes(raw.kind)) {
154
+ return { ok: false, error: "missing_commandId" };
155
+ }
156
+ if (typeof raw.viewId !== "string" || !raw.viewId) return { ok: false, error: "missing_viewId" };
157
+ if (!STATE_COMMAND_KINDS.includes(raw.kind)) return { ok: false, error: "unknown_kind" };
158
+ if (!COMMAND_SOURCES.includes(raw.source)) return { ok: false, error: "unknown_source" };
159
+ if (raw.expectedRevision != null && typeof raw.expectedRevision !== "number") return { ok: false, error: "bad_expectedRevision" };
160
+ if (raw.runId != null && typeof raw.runId !== "string") return { ok: false, error: "bad_runId" };
161
+ if (raw.kind === "auto_state_classified") {
162
+ const classification = raw.payload?.classification;
163
+ if (!classification || typeof classification !== "object") return { ok: false, error: "missing_classification" };
164
+ if (typeof classification.classifiedAt !== "number") return { ok: false, error: "bad_classification" };
165
+ }
166
+ if (raw.kind === "run_finalized") {
167
+ if (!raw.payload || typeof raw.payload !== "object") return { ok: false, error: "missing_payload" };
168
+ if (typeof raw.payload.exitCode !== "number" && raw.payload.exitCode !== null) return { ok: false, error: "missing_exitCode" };
169
+ if (raw.payload.endedAt != null && typeof raw.payload.endedAt !== "number") return { ok: false, error: "bad_endedAt" };
170
+ if (raw.payload.lastAgentActivityAt != null && typeof raw.payload.lastAgentActivityAt !== "number") return { ok: false, error: "bad_lastAgentActivityAt" };
171
+ if (raw.payload.stoppedByUser != null && typeof raw.payload.stoppedByUser !== "boolean") return { ok: false, error: "bad_stoppedByUser" };
172
+ if (raw.payload.stopReason != null && typeof raw.payload.stopReason !== "string") return { ok: false, error: "bad_stopReason" };
173
+ }
174
+ switch (raw.kind) {
175
+ case "mark_queued":
176
+ // runId may be null (PTY host launch pins no run — legacy
177
+ // markQueued(id, null)); the key must be present, and when non-null
178
+ // it must be a non-empty string.
179
+ if (!raw.payload || !("runId" in raw.payload)) return { ok: false, error: "missing_runId" };
180
+ if (raw.payload.runId != null && (typeof raw.payload.runId !== "string" || !raw.payload.runId)) return { ok: false, error: "missing_runId" };
181
+ break;
182
+ case "run_started": {
183
+ // Non-empty string (not just != null): a degenerate runId passes the
184
+ // null guard, then the statusRunId binding silently drops the status
185
+ // half while the ack still says "applied" (Task 2 review P2-A).
186
+ if (typeof raw.runId !== "string" || !raw.runId) return { ok: false, error: "missing_runId" };
187
+ const status = raw.payload?.status;
188
+ if (!status || typeof status !== "object") return { ok: false, error: "missing_status" };
189
+ if (typeof status.runId !== "string" || status.runId !== raw.runId) return { ok: false, error: "bad_status" };
190
+ break;
191
+ }
192
+ case "run_progress":
193
+ if (!raw.payload || typeof raw.payload.statusPatch !== "object" || raw.payload.statusPatch == null) return { ok: false, error: "missing_statusPatch" };
194
+ break;
195
+ case "followup_started":
196
+ // The command carries NO runId (a null runId skips the generic stale-run
197
+ // guard against the finished parent run); the NEW run's id travels here
198
+ // and governs the state-side currentRunId (ruling R2).
199
+ if (!raw.payload || typeof raw.payload.statusPatch !== "object" || raw.payload.statusPatch == null) return { ok: false, error: "missing_statusPatch" };
200
+ if (typeof raw.payload.newRunId !== "string" || !raw.payload.newRunId) return { ok: false, error: "missing_newRunId" };
201
+ break;
202
+ case "reconcile_finalize": {
203
+ // Project mode (service.mjs dead-runner path): the run's terminal status
204
+ // exists but the row was never materialized from it — the status itself
205
+ // is the verdict, so semanticState/summary are derived, not passed.
206
+ if (raw.payload?.project === true) {
207
+ if (raw.payload.reason != null && typeof raw.payload.reason !== "string") return { ok: false, error: "bad_reason" };
208
+ break;
209
+ }
210
+ const semanticState = raw.payload?.semanticState;
211
+ if (semanticState !== "failed" && semanticState !== "idle") return { ok: false, error: "bad_semanticState" };
212
+ // The reconciler's summary is caller-provided (legacy parity: both
213
+ // service reconcile sites always stamp a summary, ruling R4).
214
+ if (typeof raw.payload?.summary !== "string" || !raw.payload.summary) return { ok: false, error: "missing_summary" };
215
+ if (raw.payload.reason != null && typeof raw.payload.reason !== "string") return { ok: false, error: "bad_reason" };
216
+ if (raw.payload.exitCode != null && typeof raw.payload.exitCode !== "number") return { ok: false, error: "bad_exitCode" };
217
+ break;
218
+ }
219
+ case "host_run_failed":
220
+ if (raw.payload?.error != null && typeof raw.payload.error !== "string") return { ok: false, error: "bad_error" };
221
+ if (raw.payload?.exitCode != null && typeof raw.payload.exitCode !== "number") return { ok: false, error: "bad_exitCode" };
222
+ break;
223
+ case "sync_foreground":
224
+ if (!raw.payload?.projection || typeof raw.payload.projection !== "object") return { ok: false, error: "missing_projection" };
225
+ break;
226
+ case "plan_ready":
227
+ // The producer (job-runner's plan-ready pass) fires post-finalization and
228
+ // re-points the row at the plan-producing run (ruling R3).
229
+ if (raw.payload?.question != null && typeof raw.payload.question !== "string") return { ok: false, error: "bad_question" };
230
+ if (typeof raw.payload?.runId !== "string" || !raw.payload.runId) return { ok: false, error: "missing_runId" };
231
+ break;
232
+ case "patch_fields": {
233
+ const hasState = raw.payload?.state != null && typeof raw.payload.state === "object";
234
+ const hasStatus = raw.payload?.status != null && typeof raw.payload.status === "object";
235
+ if (!hasState && !hasStatus) return { ok: false, error: "missing_payload" };
236
+ break;
237
+ }
238
+ }
239
+ return { ok: true, command: raw };
240
+ }
241
+
242
+ /**
243
+ * Decide whether a command may mutate the view state, and which field patches
244
+ * to apply. Pure: deep-clones inputs before delegating, never mutates arguments,
245
+ * never touches the clock (pass `now` for wall-clock timestamps; falls back to
246
+ * payload-provided timestamps for determinism).
247
+ *
248
+ * @param {object} command
249
+ * @param {object|null} currentState ViewState as currently materialized (state.json).
250
+ * @param {object|null} currentStatus RunStatus for command.runId as currently
251
+ * materialized (status.json), when a status file exists; null otherwise.
252
+ * @param {number} [now] Wall-clock epoch ms supplied by the coordinator shell.
253
+ * @returns {{ action: "apply", mutate: { state?: object, status?: object }, reason: string }
254
+ * | { action: "reject", reason: string }}
255
+ * `mutate.state`/`mutate.status` are sparse field patches — only fields whose
256
+ * value actually changed. The coordinator merges them onto the materialized
257
+ * files and stamps the shared materializedRevision.
258
+ */
259
+ export function decideStateTransition(command, currentState, currentStatus, now = undefined) {
260
+ if (!currentState) return reject("unknown_view");
261
+ if (command.expectedRevision != null && command.expectedRevision !== (currentState.materializedRevision ?? 0)) {
262
+ return reject("revision_conflict");
263
+ }
264
+ // Generic stale-run guard: a command for a run other than the row's current
265
+ // run is stale. run_started is EXEMPT — it carries its own liveness-scoped
266
+ // guard below (launch-order inversion, cold-start race): the runner is
267
+ // authoritative that its run just started, so a bootstrap landing on a
268
+ // not-alive row applies and re-pins currentRunId. Only two SIMULTANEOUS live
269
+ // runs on one view are the hazard this guard exists for.
270
+ if (
271
+ command.kind !== "run_started" &&
272
+ command.runId &&
273
+ currentState.currentRunId &&
274
+ command.runId !== currentState.currentRunId
275
+ ) {
276
+ return reject("stale_run");
277
+ }
278
+ // Manual completions are user verdicts: only a human source may act on a
279
+ // fenced row (this is the #46 invariant — late classifications lose).
280
+ if (command.source !== "dashboard-user" && isManualCompletion(currentState)) {
281
+ return reject("manual_fence");
282
+ }
283
+ switch (command.kind) {
284
+ case "mark_completed": {
285
+ if (currentState.processState === "alive") return reject("busy");
286
+ // autoState: null on both artifacts is the manual-completion fence
287
+ // signal that later auto-state commands (and the auto-state rules
288
+ // themselves) key off — see isManualCompletion().
289
+ return {
290
+ action: "apply",
291
+ reason: "manual_completion",
292
+ mutate: {
293
+ state: {
294
+ semanticState: "completed",
295
+ processState: "exited",
296
+ needsInput: false,
297
+ hasError: false,
298
+ question: null,
299
+ pendingQuestions: [],
300
+ error: null,
301
+ autoState: null,
302
+ },
303
+ status: { autoState: null },
304
+ },
305
+ };
306
+ }
307
+ case "auto_state_classified": {
308
+ const classification = command.payload?.classification;
309
+ const at = now ?? classification?.classifiedAt;
310
+ const stateClone = cloneJson(currentState);
311
+ const statusClone = cloneJson(currentStatus);
312
+ const stateChanged = applyAutoStateToViewState(stateClone, classification, at);
313
+ const statusChanged = statusClone ? applyAutoStateToStatus(statusClone, classification, at) : false;
314
+ if (!stateChanged && !statusChanged) return reject("no_change");
315
+ return { action: "apply", reason: command.kind, mutate: buildPatches(currentState, stateClone, stateChanged, currentStatus, statusClone, statusChanged) };
316
+ }
317
+ case "run_finalized": {
318
+ const payload = command.payload ?? {};
319
+ // Only a live run record for this view's current run may be finalized;
320
+ // anything else is stale (duplicate finalize commands included).
321
+ if (!currentStatus) return reject("stale_run");
322
+ if (currentState.currentRunId !== command.runId) return reject("stale_run");
323
+ if (currentState.processState !== "alive") return reject("stale_run");
324
+ const at = now ?? payload.endedAt ?? 0;
325
+ const statusClone = cloneJson(currentStatus);
326
+ // Overlay fresher fields the throttled on-disk write may lag behind.
327
+ // stopReason must be overlaid onto the status itself: finalizeSemanticState
328
+ // reads status.stopReason (finalizeRun's opts have no stopReason slot), so
329
+ // a fresh "aborted" reported by the runner would otherwise be lost and the
330
+ // run would land on "idle" instead of "failed".
331
+ if (typeof payload.latestAssistantPreview === "string") statusClone.latestAssistantPreview = payload.latestAssistantPreview;
332
+ if (payload.lastAgentActivityAt != null) statusClone.lastAgentActivityAt = payload.lastAgentActivityAt;
333
+ if (payload.stopReason != null) statusClone.stopReason = payload.stopReason;
334
+ finalizeRun(statusClone, {
335
+ exitCode: payload.exitCode,
336
+ stoppedByUser: payload.stoppedByUser,
337
+ stopReason: payload.stopReason,
338
+ openEnded: payload.openEnded,
339
+ }, at);
340
+ const projectedState = projectViewState(statusClone, at, currentState);
341
+ return {
342
+ action: "apply",
343
+ reason: command.kind,
344
+ mutate: {
345
+ state: diffFields(currentState, projectedState),
346
+ status: diffFields(currentStatus, statusClone),
347
+ },
348
+ };
349
+ }
350
+ case "mark_queued": {
351
+ return {
352
+ action: "apply",
353
+ reason: command.kind,
354
+ mutate: {
355
+ state: {
356
+ currentRunId: command.payload.runId,
357
+ semanticState: "queued",
358
+ processState: "alive",
359
+ summary: "Queued",
360
+ needsInput: false,
361
+ hasError: false,
362
+ question: null,
363
+ pendingQuestions: [],
364
+ error: null,
365
+ autoState: null,
366
+ },
367
+ },
368
+ };
369
+ }
370
+ case "run_started": {
371
+ // Liveness-scoped stale guard (the generic guard above exempts this
372
+ // kind): only TWO LIVE RUNS on one view are the hazard — a bootstrap for
373
+ // a different runId while this row already runs something else is
374
+ // out-of-order and rejected. When the row is NOT alive, the runner is
375
+ // authoritative that its run just started: apply and re-pin
376
+ // currentRunId. This covers the cold-start race where run_started lands
377
+ // before mark_queued (the runner was spawned first), and re-launches of
378
+ // rows still pointing at the previous run.
379
+ if (
380
+ currentState.processState === "alive" &&
381
+ currentState.currentRunId &&
382
+ currentState.currentRunId !== command.runId
383
+ ) {
384
+ return reject("stale_run");
385
+ }
386
+ const statusAfter = cloneJson(command.payload.status);
387
+ return {
388
+ action: "apply",
389
+ reason: command.kind,
390
+ mutate: {
391
+ state: {
392
+ processState: "alive",
393
+ semanticState: "working",
394
+ currentRunId: command.runId,
395
+ },
396
+ status: diffFields(currentStatus, statusAfter),
397
+ },
398
+ };
399
+ }
400
+ case "run_progress": {
401
+ // Liveness semantics: only the row's current live run may move forward.
402
+ // Late/duplicate progress from a finished run is dropped (next run's
403
+ // progress supersedes it anyway — this kind is transient by design).
404
+ //
405
+ // A missing status file splits two ways (F2, split by the hard-down
406
+ // residual closure): a QUALIFIED beat from the row's current live run —
407
+ // full-shaped patch carrying processState/semanticState and the same
408
+ // runId — bootstraps the file, because the coordinator may have been
409
+ // down through the runner's boot window (mark_queued applied,
410
+ // run_started lost): without the bootstrap the row can never converge
411
+ // (beats and finalize both reject forever). Anything else — exited or
412
+ // re-pointed rows, sparse patches that would materialize an
413
+ // undefined-shaped status — stays stale_run (F2's original exposure).
414
+ if (!currentStatus) {
415
+ // P2 hardening: the bootstrap is keyed on command.runId (it becomes the
416
+ // status-file identity), so a degenerate null==null row match must never
417
+ // bootstrap — the shell would silently drop the status half. The check
418
+ // lives here beside its sibling liveness guards (runId semantics for
419
+ // run_progress are decision-layer; envelope validation stays minimal,
420
+ // matching the generic stale-run guard's decision-side read).
421
+ const rowMatches = typeof command.runId === "string" && command.runId.length > 0
422
+ && currentState.currentRunId === command.runId
423
+ && currentState.processState === "alive";
424
+ if (!rowMatches || !beatPatchQualifiesForBootstrap(command)) return reject("stale_run");
425
+ // The patch itself is the base: it is the runner's authoritative
426
+ // full in-memory status, so applyStatusProjection's merge-onto-empty
427
+ // materializes exactly the patch (validated full-shaped above).
428
+ return applyStatusProjection(command, currentState, null, now);
429
+ }
430
+ if (currentState.currentRunId !== command.runId || currentState.processState !== "alive") return reject("stale_run");
431
+ return applyStatusProjection(command, currentState, currentStatus, now);
432
+ }
433
+ case "followup_started": {
434
+ // No liveness guard and no command.runId: the follow-up starts from a
435
+ // just-finalized parent run, so the generic stale-run guard must not fire
436
+ // against it. The NEW run's identity (payload.newRunId) governs the
437
+ // state-side currentRunId regardless of what the status patch carries,
438
+ // and a follow-up starting is definitionally the row running again —
439
+ // pin processState so a sparse bootstrap patch can never materialize
440
+ // an undefined (key-dropping) processState on a re-pointed row.
441
+ const result = applyStatusProjection(command, currentState, currentStatus, now);
442
+ return {
443
+ ...result,
444
+ mutate: { ...result.mutate, state: { ...result.mutate.state, currentRunId: command.payload.newRunId, processState: "alive" } },
445
+ };
446
+ }
447
+ case "reconcile_finalize": {
448
+ if (currentState.processState !== "alive") return reject("no_change");
449
+ const at = now ?? 0;
450
+ if (command.payload.project === true) {
451
+ // Project mode: faithfully re-materialize the row from the run's
452
+ // terminal status (projectViewState delegation — never copied rules).
453
+ // A missing status means there is nothing to project — stale.
454
+ if (!currentStatus) return reject("stale_run");
455
+ const projected = projectViewState(cloneJson(currentStatus), at, currentState);
456
+ return { action: "apply", reason: command.kind, mutate: { state: diffFields(currentState, projected) } };
457
+ }
458
+ const failed = command.payload.semanticState === "failed";
459
+ const stateClone = cloneJson(currentState);
460
+ stateClone.semanticState = command.payload.semanticState;
461
+ stateClone.processState = "exited";
462
+ // Derived-field clearing at legacy parity (service.mjs reconcile sites,
463
+ // ruling R4): both legacy branches always stamp these fields.
464
+ stateClone.needsInput = false;
465
+ stateClone.hasError = failed;
466
+ stateClone.question = null;
467
+ stateClone.pendingQuestions = [];
468
+ stateClone.error = command.payload.reason ?? null;
469
+ stateClone.summary = command.payload.summary;
470
+ const mutate = { state: diffFields(currentState, stateClone) };
471
+ if (currentStatus) {
472
+ const statusClone = cloneJson(currentStatus);
473
+ // Same field names finalizeRun stamps, minus the semantic recomputation:
474
+ // the reconciler observed the outcome explicitly and its verdict governs.
475
+ statusClone.endedAt = at;
476
+ statusClone.exitCode = command.payload.exitCode ?? null;
477
+ statusClone.processState = "exited";
478
+ statusClone.pid = null;
479
+ statusClone.semanticState = command.payload.semanticState;
480
+ mutate.status = diffFields(currentStatus, statusClone);
481
+ }
482
+ return { action: "apply", reason: command.kind, mutate };
483
+ }
484
+ case "host_run_failed": {
485
+ // No kind-specific guard: the generic manual_fence above is exactly the
486
+ // PR #1 residual-risk closure — a late host crash must not flip a row
487
+ // the user already completed by hand.
488
+ const message = command.payload?.error ?? "PTY host failed";
489
+ const mutate = {
490
+ state: {
491
+ semanticState: "failed",
492
+ processState: "exited",
493
+ // Legacy parity with markRowFailedDirect (runner/pty-runner-legacy.mjs):
494
+ // both summary and error carry the message, hasError/needsInput are
495
+ // stamped so row rendering and warm-host eviction match the direct era.
496
+ summary: message,
497
+ hasError: true,
498
+ needsInput: false,
499
+ error: command.payload?.error ?? null,
500
+ },
501
+ };
502
+ if (currentStatus) {
503
+ mutate.status = {
504
+ semanticState: "failed",
505
+ processState: "exited",
506
+ error: command.payload?.error ?? null,
507
+ };
508
+ }
509
+ return { action: "apply", reason: command.kind, mutate };
510
+ }
511
+ case "archive_view": {
512
+ // Busy rows are allowed: archiving a working row stops it (matches the
513
+ // legacy archiveView behavior this kind replaces).
514
+ return {
515
+ action: "apply",
516
+ reason: command.kind,
517
+ mutate: {
518
+ state: {
519
+ semanticState: "stopped",
520
+ processState: "exited",
521
+ needsInput: false,
522
+ hasError: false,
523
+ question: null,
524
+ pendingQuestions: [],
525
+ error: null,
526
+ autoState: null,
527
+ summary: "Stopped",
528
+ },
529
+ },
530
+ };
531
+ }
532
+ case "adopt_session": {
533
+ if (currentState.processState === "alive") return reject("busy");
534
+ // Derived-field clearing at legacy parity (service.mjs adoptSession reuse
535
+ // path, ruling R4). autoState is intentionally untouched: the legacy
536
+ // adopt path leaves it alone.
537
+ return {
538
+ action: "apply",
539
+ reason: command.kind,
540
+ mutate: {
541
+ state: {
542
+ semanticState: "idle",
543
+ processState: "exited",
544
+ needsInput: false,
545
+ hasError: false,
546
+ question: null,
547
+ pendingQuestions: [],
548
+ error: null,
549
+ summary: "Backgrounded session",
550
+ },
551
+ },
552
+ };
553
+ }
554
+ case "sync_foreground": {
555
+ // Foreground mirrors never own a background run: the projection is the
556
+ // caller's, but currentRunId stays null regardless of what it carries.
557
+ const stateClone = cloneJson(currentState);
558
+ Object.assign(stateClone, command.payload.projection);
559
+ stateClone.currentRunId = null;
560
+ return { action: "apply", reason: command.kind, mutate: { state: diffFields(currentState, stateClone) } };
561
+ }
562
+ case "plan_ready": {
563
+ // The producer (job-runner's plan-ready pass) fires POST-finalization:
564
+ // the run has exited by the time a plan is ready for approval, so a live
565
+ // row means out-of-order delivery — drop it (ruling R3 inverts the guard).
566
+ if (currentState.processState === "alive") return reject("no_change");
567
+ // Exact legacy parity with runner/job-runner.mjs's plan-ready write.
568
+ return {
569
+ action: "apply",
570
+ reason: command.kind,
571
+ mutate: {
572
+ state: {
573
+ semanticState: "needs_input",
574
+ processState: "exited",
575
+ needsInput: true,
576
+ question: command.payload?.question ?? "Approve this plan?",
577
+ summary: "Plan ready for approval",
578
+ currentRunId: command.payload.runId,
579
+ },
580
+ },
581
+ };
582
+ }
583
+ case "patch_fields": {
584
+ const allowed = PATCHABLE_FIELDS[command.source];
585
+ if (!allowed) return reject("field_not_allowed");
586
+ const requestedState = command.payload.state ?? {};
587
+ const requestedStatus = command.payload.status ?? {};
588
+ for (const key of Object.keys(requestedState)) {
589
+ if (!allowed.state.includes(key)) return reject("field_not_allowed");
590
+ }
591
+ for (const key of Object.keys(requestedStatus)) {
592
+ if (!allowed.status.includes(key)) return reject("field_not_allowed");
593
+ }
594
+ const stateClone = cloneJson(currentState);
595
+ Object.assign(stateClone, requestedState);
596
+ const statePatch = diffFields(currentState, stateClone);
597
+ let statusPatch;
598
+ if (currentStatus && Object.keys(requestedStatus).length > 0) {
599
+ const statusClone = cloneJson(currentStatus);
600
+ Object.assign(statusClone, requestedStatus);
601
+ statusPatch = diffFields(currentStatus, statusClone);
602
+ }
603
+ if (Object.keys(statePatch).length === 0 && (!statusPatch || Object.keys(statusPatch).length === 0)) {
604
+ return reject("no_change");
605
+ }
606
+ return { action: "apply", reason: command.kind, mutate: statusPatch ? { state: statePatch, status: statusPatch } : { state: statePatch } };
607
+ }
608
+ default:
609
+ // validateCommand already rejects unknown kinds; defensive only.
610
+ return reject("unknown_kind");
611
+ }
612
+ }
613
+
614
+ /** @param {string} reason @returns {{ action: "reject", reason: string }} */
615
+ function reject(reason) {
616
+ return { action: "reject", reason };
617
+ }
618
+
619
+ /** JSON round-trip clone: patches stay JSON-serializable (they are written to disk). */
620
+ function cloneJson(value) {
621
+ return value == null ? value : JSON.parse(JSON.stringify(value));
622
+ }
623
+
624
+ /**
625
+ * Shallow top-level field diff (deep-equality per field via JSON form), so a
626
+ * cloned-but-unchanged nested object never lands in a patch.
627
+ * @param {object|null} before
628
+ * @param {object} after
629
+ */
630
+ function diffFields(before, after) {
631
+ const patch = {};
632
+ for (const key of Object.keys(after)) {
633
+ if (JSON.stringify(before?.[key]) !== JSON.stringify(after[key])) patch[key] = after[key];
634
+ }
635
+ return patch;
636
+ }
637
+
638
+ /** @param {boolean} stateChanged @param {boolean} statusChanged */
639
+ function buildPatches(currentState, stateClone, stateChanged, currentStatus, statusClone, statusChanged) {
640
+ const mutate = {};
641
+ if (stateChanged) mutate.state = diffFields(currentState, stateClone);
642
+ if (statusChanged) mutate.status = diffFields(currentStatus, statusClone);
643
+ return mutate;
644
+ }
645
+
646
+ /**
647
+ * Whether a run_progress beat may bootstrap a missing status file: the patch
648
+ * must be full-shaped — carrying processState and semanticState (so the
649
+ * materialized status never has an undefined shape) and pinned to the same
650
+ * runId as the command (so a foreign run's snapshot can never masquerade as
651
+ * this run's bootstrap). Sparse or mismatched patches stay stale_run.
652
+ * @param {object} command
653
+ */
654
+ function beatPatchQualifiesForBootstrap(command) {
655
+ const patch = command.payload?.statusPatch;
656
+ return Boolean(
657
+ patch &&
658
+ typeof patch === "object" &&
659
+ typeof patch.processState === "string" &&
660
+ typeof patch.semanticState === "string" &&
661
+ patch.runId === command.runId,
662
+ );
663
+ }
664
+
665
+ /**
666
+ * Shared body for the status-patch kinds (`run_progress`, `followup_started`):
667
+ * merge payload.statusPatch onto the materialized status, then let
668
+ * projectViewState recompute the state side — the projection rules live in
669
+ * events.mjs and are never copied here (same delegation contract as
670
+ * run_finalized).
671
+ *
672
+ * A missing status file is tolerated: the patch merges onto an empty object so
673
+ * the coordinator materializes a fresh status from the patch fields (this is
674
+ * the followup_started bootstrap path — a new run's status file does not exist
675
+ * yet). The caller should send enough fields to make that object meaningful.
676
+ *
677
+ * Pure; timestamp comes from `now`, falling back to the patch's
678
+ * lastActivityAt (payload-carried determinism, same rule as run_finalized).
679
+ *
680
+ * @param {object} command
681
+ * @param {object} currentState
682
+ * @param {object|null} currentStatus
683
+ * @param {number|undefined} now
684
+ */
685
+ function applyStatusProjection(command, currentState, currentStatus, now) {
686
+ const patch = command.payload.statusPatch;
687
+ const at = now ?? patch.lastActivityAt ?? 0;
688
+ const statusClone = cloneJson(currentStatus) ?? {};
689
+ Object.assign(statusClone, patch);
690
+ const projectedState = projectViewState(statusClone, at, currentState);
691
+ return {
692
+ action: "apply",
693
+ reason: command.kind,
694
+ mutate: {
695
+ state: diffFields(currentState, projectedState),
696
+ status: diffFields(currentStatus ?? {}, statusClone),
697
+ },
698
+ };
699
+ }