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