@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Detached job-runner shim (plain ESM — must not depend on Pi's jiti loader).
|
|
4
|
+
*
|
|
5
|
+
* Usage: node job-runner.mjs <configPath>
|
|
6
|
+
*
|
|
7
|
+
* Owns one run: spawns a headless Pi worker (`pi --mode json -p --session <file> <prompt>`),
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { appendLine, readJson } from "../src/core/atomic.mjs";
|
|
19
|
+
import { createRunStatus, finalizeRun, reduceEvent } from "../src/core/events.mjs";
|
|
20
|
+
import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
|
|
21
|
+
import { createStopLatch } from "../src/core/stop-latch.mjs";
|
|
22
|
+
import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualVerdict } from "../src/core/auto-state.mjs";
|
|
23
|
+
import { appendDiagnostic } from "../src/core/diagnostics.mjs";
|
|
24
|
+
import { emptyEvidenceSnapshot, finalizeEvidence, reduceEvidence, summarizeEvidence, writeEvidence, writeRunEvidence } from "../src/core/evidence.mjs";
|
|
25
|
+
import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
|
|
26
|
+
import { claimNextFollowUp, completeFollowUp, releaseFollowUp } from "../src/core/follow-up-queue.mjs";
|
|
27
|
+
import { newRunId } from "../src/core/ids.mjs";
|
|
28
|
+
import { launchRun } from "../src/core/launch.mjs";
|
|
29
|
+
import * as P from "../src/core/paths.mjs";
|
|
30
|
+
import { readState, readStatus, readMeta } from "../src/core/store.mjs";
|
|
31
|
+
import { readSteering, recordPlanReady } from "../src/core/steering.mjs";
|
|
32
|
+
import { sendStateCommand, coordinatorDisabled } from "../src/core/coordinator-client.mjs";
|
|
33
|
+
import { commandRejectDiagnostic } from "../src/core/state-commands.mjs";
|
|
34
|
+
import { legacyFollowupBootstrap, legacyPersistState, legacyPlanReadyStateWrite, legacyWriteState, legacyWriteStatus } from "./job-runner-legacy.mjs";
|
|
35
|
+
import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../src/core/steering-prompts.mjs";
|
|
36
|
+
|
|
37
|
+
const WRITE_THROTTLE_MS = 250;
|
|
38
|
+
|
|
39
|
+
// Stop-intent latch (issue #153): a stop signal that arrives before the real
|
|
40
|
+
// stop path exists (config read → run_started → worker spawn → handler
|
|
41
|
+
// wiring) must not kill this process via Node's default action — the run
|
|
42
|
+
// would never reach a terminal state. The latch records it; bootstrapRun
|
|
43
|
+
// replays it into stop() once the handlers are wired. A signal during module
|
|
44
|
+
// import (before these handlers exist) still exits by default, but nothing
|
|
45
|
+
// has been published at that point, so no observable state is lost.
|
|
46
|
+
const stopLatch = createStopLatch();
|
|
47
|
+
process.on("SIGTERM", () => stopLatch.note("SIGTERM"));
|
|
48
|
+
process.on("SIGINT", () => stopLatch.note("SIGINT"));
|
|
49
|
+
|
|
50
|
+
/** @param {string[]} args */
|
|
51
|
+
function redactWorkerArgs(args) {
|
|
52
|
+
return args.map((arg, idx) => {
|
|
53
|
+
const prev = args[idx - 1];
|
|
54
|
+
if (["--api-key", "--token", "--password", "--secret"].includes(prev)) return "[redacted]";
|
|
55
|
+
if (/^(sk-|gho_|ghp_)/.test(arg)) return "[redacted]";
|
|
56
|
+
return arg;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function main() {
|
|
61
|
+
const configPath = process.argv[2];
|
|
62
|
+
if (!configPath) {
|
|
63
|
+
process.stderr.write("job-runner: missing config path\n");
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
/** @type {import("../src/core/types.mjs").RunConfig|null} */
|
|
67
|
+
const config = readJson(configPath, null);
|
|
68
|
+
if (!config) {
|
|
69
|
+
process.stderr.write(`job-runner: cannot read config ${configPath}\n`);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { root, viewId, runId } = config;
|
|
74
|
+
const stdoutLog = P.stdoutPath(root, viewId, runId);
|
|
75
|
+
const stderrLog = P.stderrPath(root, viewId, runId);
|
|
76
|
+
const eventsLog = P.eventsPath(root, viewId, runId);
|
|
77
|
+
|
|
78
|
+
// Read the view meta once so code-ref extraction reuses it across every evidence write.
|
|
79
|
+
const meta = readMeta(root, viewId);
|
|
80
|
+
|
|
81
|
+
let status = createRunStatus(config, null, Date.now());
|
|
82
|
+
// The run starts working the moment the runner is up; seeding the boot status
|
|
83
|
+
// with "working" (instead of createRunStatus's "queued") avoids the stale
|
|
84
|
+
// queued frame flickering through run_started's first materialization.
|
|
85
|
+
status.semanticState = "working";
|
|
86
|
+
let evidence = emptyEvidenceSnapshot({ viewId, runId, source: "json-runner" });
|
|
87
|
+
// First observable write BY THE RUNNER (issue #153, A3): the stop latch was
|
|
88
|
+
// armed at module scope, before the runner publishes anything of its own.
|
|
89
|
+
// (The parent's mark_queued/pid.json predate the child; that pre-module shape
|
|
90
|
+
// is converged by service.reconcile() — A10.) Pinned by the stop-window
|
|
91
|
+
// integration test asserting this is diagnostics.jsonl's first entry.
|
|
92
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "stop_latch_armed", message: "Stop latch armed before any observable state", details: { signals: ["SIGTERM", "SIGINT"] } });
|
|
93
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "runner_start", message: "Runner started", details: { kind: config.kind, cwd: config.cwd, model: config.model } });
|
|
94
|
+
writeRunEvidence(root, evidence);
|
|
95
|
+
writeEvidence(root, evidence);
|
|
96
|
+
updateCodeRefsFromEvidence(root, viewId, evidence, meta);
|
|
97
|
+
if (coordinatorDisabled()) {
|
|
98
|
+
legacyPersistState({ root, viewId, runId, status });
|
|
99
|
+
}
|
|
100
|
+
return bootstrapRun({ root, viewId, runId, config, status, meta, evidence, stdoutLog, stderrLog, eventsLog });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Async continuation of main: route the run_started bootstrap through the
|
|
105
|
+
* coordinator (the status file it creates is what every later run_progress
|
|
106
|
+
* beat patches onto), then spawn the worker and wire the event handlers.
|
|
107
|
+
* @param {{ root: string, viewId: string, runId: string, config: object, status: object, meta: object, evidence: object, stdoutLog: string, stderrLog: string, eventsLog: string }} ctx
|
|
108
|
+
*/
|
|
109
|
+
async function bootstrapRun({ root, viewId, runId, config, status, meta, evidence, stdoutLog, stderrLog, eventsLog }) {
|
|
110
|
+
if (!coordinatorDisabled()) {
|
|
111
|
+
// Journaled bootstrap: creates the run's status.json (STATUS_BOOTSTRAP_KINDS)
|
|
112
|
+
// and pins the row to working/alive/currentRunId. Ambiguous outcomes never
|
|
113
|
+
// block the run: if the command was journaled, coordinator replay recovers
|
|
114
|
+
// it; otherwise dashboard reconcile converges the row.
|
|
115
|
+
const started = await sendStateCommand(root, {
|
|
116
|
+
type: "state_command",
|
|
117
|
+
viewId,
|
|
118
|
+
runId,
|
|
119
|
+
source: "job-runner",
|
|
120
|
+
kind: "run_started",
|
|
121
|
+
expectedRevision: null,
|
|
122
|
+
payload: { status: { ...status } },
|
|
123
|
+
});
|
|
124
|
+
if (started.status !== "applied" && started.reason !== "coordinator_disabled") {
|
|
125
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("run_started", "Run bootstrap", started.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: started.reason } });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Test-only boot-window knob (issue #153): deterministically widen the gap
|
|
130
|
+
// between the observable run_started and the handler wiring so a stop can
|
|
131
|
+
// be delivered inside the window without timing luck. Inert when unset.
|
|
132
|
+
const bootWindowMs = Number(process.env.AGENT_BOARD_TEST_BOOT_WINDOW_MS || 0);
|
|
133
|
+
if (bootWindowMs > 0) await new Promise((resolveDelay) => setTimeout(resolveDelay, bootWindowMs));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One transient progress beat: ship the full in-memory status as a sparse
|
|
137
|
+
* patch — the coordinator merges it onto the materialized status and lets
|
|
138
|
+
* projectViewState recompute the row state (delegation, not copied rules).
|
|
139
|
+
*
|
|
140
|
+
* Fire-and-forget by design (the ONLY such command in the runner): the hot
|
|
141
|
+
* path is a periodic self-healing snapshot (~4/sec), a lost beat is
|
|
142
|
+
* superseded by the next one, and failures are silently ignored — logging
|
|
143
|
+
* them would spam diagnostics at beat frequency. Transient commands are
|
|
144
|
+
* never journaled, so an ambiguous outcome has no replay to await.
|
|
145
|
+
*/
|
|
146
|
+
const sendProgressBeat = () => {
|
|
147
|
+
const { materializedRevision: _fileStamp, ...patch } = status;
|
|
148
|
+
void sendStateCommand(root, {
|
|
149
|
+
type: "state_command",
|
|
150
|
+
viewId,
|
|
151
|
+
runId,
|
|
152
|
+
source: "job-runner",
|
|
153
|
+
kind: "run_progress",
|
|
154
|
+
expectedRevision: null,
|
|
155
|
+
payload: { statusPatch: patch },
|
|
156
|
+
}).catch(() => {});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Build worker args: pi --mode json -p --session <file> [--model m] [--thinking l] [--tools t] <prompt>
|
|
160
|
+
const args = [
|
|
161
|
+
...config.piArgsPrefix,
|
|
162
|
+
"--mode",
|
|
163
|
+
"json",
|
|
164
|
+
"-p",
|
|
165
|
+
"--session",
|
|
166
|
+
config.sessionFile,
|
|
167
|
+
];
|
|
168
|
+
if (config.model) args.push("--model", config.model);
|
|
169
|
+
if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
|
|
170
|
+
if (config.tools) args.push("--tools", config.tools);
|
|
171
|
+
args.push(encodePromptForCliArg(config.prompt));
|
|
172
|
+
|
|
173
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "worker_spawn", message: "Worker spawned", details: { command: config.piCommand, args: redactWorkerArgs(args) } });
|
|
174
|
+
const worker = spawn(config.piCommand, args, {
|
|
175
|
+
cwd: config.cwd,
|
|
176
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
177
|
+
windowsHide: true,
|
|
178
|
+
env: { ...process.env, AGENT_BOARD_NO_SWEEP: "1" },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
status.pid = worker.pid ?? null;
|
|
182
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "worker_pid", message: "Worker pid recorded", details: { pid: status.pid } });
|
|
183
|
+
// The pid lands on disk via a transient progress beat (the worker's first
|
|
184
|
+
// events would carry it too, but a silent worker must still be observable).
|
|
185
|
+
sendProgressBeat();
|
|
186
|
+
|
|
187
|
+
let stoppedByUser = false;
|
|
188
|
+
let dirty = false;
|
|
189
|
+
let flushTimer = null;
|
|
190
|
+
|
|
191
|
+
const persist = (force = false) => {
|
|
192
|
+
void force;
|
|
193
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
194
|
+
persistEvidenceArtifacts();
|
|
195
|
+
if (coordinatorDisabled()) legacyPersistState({ root, viewId, runId, status });
|
|
196
|
+
else sendProgressBeat();
|
|
197
|
+
// Best-effort code-refs extraction shells out to git and can take hundreds of
|
|
198
|
+
// ms; it only depends on evidence + git, never on state.json.
|
|
199
|
+
updateCodeRefsFromEvidence(root, viewId, evidence, meta);
|
|
200
|
+
dirty = false;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Persist only if the row hasn't taken a manual verdict since the last
|
|
205
|
+
* persist. projectViewState() overwrites the row state unconditionally, so a
|
|
206
|
+
* post-exit model pass must never persist its stale in-memory status over a
|
|
207
|
+
* fresh manual verdict.
|
|
208
|
+
*/
|
|
209
|
+
const persistUnlessManual = (force = false) => {
|
|
210
|
+
const latestView = readState(root, viewId);
|
|
211
|
+
if (isManualVerdict(latestView)) return false;
|
|
212
|
+
persist(force);
|
|
213
|
+
return true;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Runner-owned evidence artifacts (evidence files + code-refs). Written
|
|
218
|
+
* directly — the coordinator owns state.json/status.json, not these.
|
|
219
|
+
*/
|
|
220
|
+
const persistEvidenceArtifacts = () => {
|
|
221
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
222
|
+
writeRunEvidence(root, evidence);
|
|
223
|
+
writeEvidence(root, evidence);
|
|
224
|
+
updateCodeRefsFromEvidence(root, viewId, evidence, meta);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Refresh the evidence mirrors (status.evidenceSummary / state.review)
|
|
229
|
+
* through the coordinator: `patch_fields` carries only whitelisted mirror
|
|
230
|
+
* fields, and the coordinator's generic manual fence (source != user on a
|
|
231
|
+
* row carrying a manual verdict) is the authoritative guard — the old
|
|
232
|
+
* fresh-read pre-checks are no longer needed. Designed fences
|
|
233
|
+
* (manual_fence / no_change) are informational; ambiguous outcomes never
|
|
234
|
+
* fall back to a direct write.
|
|
235
|
+
*/
|
|
236
|
+
const refreshEvidenceMirrors = async () => {
|
|
237
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
238
|
+
const result = await sendStateCommand(root, {
|
|
239
|
+
type: "state_command",
|
|
240
|
+
viewId,
|
|
241
|
+
runId: null,
|
|
242
|
+
source: "job-runner",
|
|
243
|
+
kind: "patch_fields",
|
|
244
|
+
expectedRevision: null,
|
|
245
|
+
payload: {
|
|
246
|
+
state: { review: status.evidenceSummary },
|
|
247
|
+
status: { evidenceSummary: status.evidenceSummary },
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
if (result.status === "applied") {
|
|
251
|
+
const fresh = readStatus(root, viewId, runId);
|
|
252
|
+
if (fresh) Object.assign(status, fresh);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
if (result.reason === "manual_fence" || result.reason === "no_change") return false;
|
|
256
|
+
if (result.reason === "coordinator_disabled") {
|
|
257
|
+
// Legacy escape hatch: fresh-read + fence, pre-coordinator semantics.
|
|
258
|
+
const freshStatus = readStatus(root, viewId, runId);
|
|
259
|
+
if (freshStatus && !isManualVerdict(freshStatus)) {
|
|
260
|
+
freshStatus.evidenceSummary = status.evidenceSummary;
|
|
261
|
+
legacyWriteStatus(root, viewId, runId, freshStatus);
|
|
262
|
+
}
|
|
263
|
+
const freshState = readState(root, viewId);
|
|
264
|
+
if (freshState && !isManualVerdict(freshState)) {
|
|
265
|
+
freshState.review = status.evidenceSummary;
|
|
266
|
+
legacyWriteState(root, viewId, freshState);
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("evidence_mirror", "Evidence mirror", result.reason, "otherwise the next mirror refresh will converge"), details: { reason: result.reason } });
|
|
271
|
+
return false;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Materialize the run's terminal state through the View State Coordinator
|
|
276
|
+
* (issue #91, A8 path 3): the runner submits minimal facts and the
|
|
277
|
+
* coordinator computes terminal semantics via finalizeRun, so a manual
|
|
278
|
+
* completion landing before the command is fenced by the coordinator
|
|
279
|
+
* (manual_fence), not by a file re-read (#46 class). Evidence artifacts stay
|
|
280
|
+
* direct (runner-owned). Ambiguous outcomes (timeout / connection_reset)
|
|
281
|
+
* NEVER fall back to a direct write: the command may already be journaled,
|
|
282
|
+
* and the coordinator's boot replay is the recovery path.
|
|
283
|
+
* coordinator_disabled keeps the pre-coordinator direct persist.
|
|
284
|
+
* @param {{ exitCode: number|null, stoppedByUser: boolean }} facts
|
|
285
|
+
* @returns {Promise<boolean>} whether the final state is known materialized
|
|
286
|
+
*/
|
|
287
|
+
const finalizeThroughCoordinator = async ({ exitCode, stoppedByUser: stopped }) => {
|
|
288
|
+
const payload = { exitCode, stoppedByUser: stopped };
|
|
289
|
+
if (status.endedAt != null) payload.endedAt = status.endedAt;
|
|
290
|
+
// The close path cancels the pending throttled flush after the final
|
|
291
|
+
// buffer flush, so a stopReason observed in the last burst (reduceEvent
|
|
292
|
+
// sets it in memory only) never reached disk. Overlay it onto the payload:
|
|
293
|
+
// finalizeSemanticState keys on stopReason alone for exit-0 exits, and the
|
|
294
|
+
// coordinator already supports the payload overlay (issue #91).
|
|
295
|
+
if (status.stopReason != null) payload.stopReason = status.stopReason;
|
|
296
|
+
if (status.latestAssistantPreview) payload.latestAssistantPreview = status.latestAssistantPreview;
|
|
297
|
+
if (status.lastAgentActivityAt != null) payload.lastAgentActivityAt = status.lastAgentActivityAt;
|
|
298
|
+
const result = await sendStateCommand(root, {
|
|
299
|
+
type: "state_command",
|
|
300
|
+
viewId,
|
|
301
|
+
runId,
|
|
302
|
+
source: "job-runner",
|
|
303
|
+
kind: "run_finalized",
|
|
304
|
+
expectedRevision: null,
|
|
305
|
+
payload,
|
|
306
|
+
});
|
|
307
|
+
if (result.status === "applied") {
|
|
308
|
+
const fresh = readStatus(root, viewId, runId);
|
|
309
|
+
if (fresh) Object.assign(status, fresh);
|
|
310
|
+
await refreshEvidenceMirrors();
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
if (result.reason === "coordinator_disabled") {
|
|
314
|
+
persistEvidenceArtifacts();
|
|
315
|
+
legacyPersistState({ root, viewId, runId, status });
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
if (result.reason === "stale_run") {
|
|
319
|
+
// Duplicate finalize or the run was already superseded — nothing to do.
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("run_finalize", "Run finalization", result.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: result.reason } });
|
|
323
|
+
return false;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const scheduleFlush = () => {
|
|
327
|
+
if (flushTimer) {
|
|
328
|
+
dirty = true;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
persist();
|
|
332
|
+
flushTimer = setTimeout(() => {
|
|
333
|
+
flushTimer = null;
|
|
334
|
+
if (dirty) scheduleFlush();
|
|
335
|
+
}, WRITE_THROTTLE_MS);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// ---- stdout: JSON event stream -----------------------------------------
|
|
339
|
+
let buffer = "";
|
|
340
|
+
const onLine = (line) => {
|
|
341
|
+
const trimmed = line.trim();
|
|
342
|
+
if (!trimmed) return;
|
|
343
|
+
appendLine(eventsLog, trimmed);
|
|
344
|
+
let event;
|
|
345
|
+
try {
|
|
346
|
+
event = JSON.parse(trimmed);
|
|
347
|
+
} catch {
|
|
348
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, level: "warn", code: "malformed_event", message: "Worker emitted malformed JSON", details: { preview: trimmed.slice(0, 160) } });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// First line is the session header {type:"session",...}; nothing to reduce.
|
|
352
|
+
if (event?.type === "session") return;
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
const statusChanged = reduceEvent(status, event, now);
|
|
355
|
+
const evidenceChanged = reduceEvidence(evidence, event, now);
|
|
356
|
+
if (statusChanged || evidenceChanged) scheduleFlush();
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
worker.stdout.on("data", (chunk) => {
|
|
360
|
+
const text = chunk.toString();
|
|
361
|
+
try {
|
|
362
|
+
appendLine(stdoutLog, text.replace(/\n$/, ""));
|
|
363
|
+
} catch {
|
|
364
|
+
/* ignore raw-log failures */
|
|
365
|
+
}
|
|
366
|
+
buffer += text;
|
|
367
|
+
const lines = buffer.split("\n");
|
|
368
|
+
buffer = lines.pop() ?? "";
|
|
369
|
+
for (const line of lines) onLine(line);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
worker.stderr.on("data", (chunk) => {
|
|
373
|
+
const text = chunk.toString();
|
|
374
|
+
appendLine(stderrLog, text.replace(/\n$/, ""));
|
|
375
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, level: "warn", code: "worker_stderr", message: "Worker wrote to stderr", details: { preview: text.slice(0, 300) } });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ---- termination handling ----------------------------------------------
|
|
379
|
+
const stop = () => {
|
|
380
|
+
stoppedByUser = true;
|
|
381
|
+
try {
|
|
382
|
+
if (worker.pid && !worker.killed) worker.kill("SIGTERM");
|
|
383
|
+
} catch {
|
|
384
|
+
/* ignore */
|
|
385
|
+
}
|
|
386
|
+
setTimeout(() => {
|
|
387
|
+
try {
|
|
388
|
+
if (worker.pid && !worker.killed) worker.kill("SIGKILL");
|
|
389
|
+
} catch {
|
|
390
|
+
/* ignore */
|
|
391
|
+
}
|
|
392
|
+
}, 4000).unref?.();
|
|
393
|
+
};
|
|
394
|
+
process.on("SIGTERM", stop);
|
|
395
|
+
process.on("SIGINT", stop);
|
|
396
|
+
|
|
397
|
+
// Replay a stop observed before the real handlers existed (issue #153).
|
|
398
|
+
// take() clears, so this fires at most once and cannot double-fire with a
|
|
399
|
+
// later real signal; stop() itself is worker.killed-guarded. The diagnostic
|
|
400
|
+
// is emitted first so the latch path stays observable even if stop() no-ops.
|
|
401
|
+
if (stopLatch.take() != null) {
|
|
402
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "stop_latch_replayed", message: "Replaying a stop observed before the handlers were wired", details: {} });
|
|
403
|
+
stop();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
worker.on("error", async (err) => {
|
|
407
|
+
// Cancel any in-flight throttled flush before finalizing: if the timer
|
|
408
|
+
// callback lands during the sendStateCommand await below, the stale
|
|
409
|
+
// persist() would overwrite the coordinator-materialized terminal state
|
|
410
|
+
// and the close-path run_finalized would then bounce as stale_run
|
|
411
|
+
// (symmetric with the close path's cancel; issue #91 fix round 1).
|
|
412
|
+
if (flushTimer) {
|
|
413
|
+
clearTimeout(flushTimer);
|
|
414
|
+
flushTimer = null;
|
|
415
|
+
}
|
|
416
|
+
status.error = `Failed to launch worker: ${err instanceof Error ? err.message : String(err)}`;
|
|
417
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, level: "error", code: "worker_error", message: status.error, details: {} });
|
|
418
|
+
finalizeRun(status, { exitCode: 1, stoppedByUser }, Date.now());
|
|
419
|
+
finalizeEvidence(evidence, status, Date.now());
|
|
420
|
+
persistEvidenceArtifacts();
|
|
421
|
+
await finalizeThroughCoordinator({ exitCode: 1, stoppedByUser });
|
|
422
|
+
process.exit(1);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
worker.on("close", async (code) => {
|
|
426
|
+
if (buffer.trim()) onLine(buffer);
|
|
427
|
+
if (flushTimer) {
|
|
428
|
+
clearTimeout(flushTimer);
|
|
429
|
+
flushTimer = null;
|
|
430
|
+
}
|
|
431
|
+
finalizeRun(status, { exitCode: code ?? 0, stoppedByUser }, Date.now());
|
|
432
|
+
finalizeEvidence(evidence, status, Date.now());
|
|
433
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, level: code ? "error" : "info", code: "worker_exit", message: `Worker exited with code ${code ?? 0}`, details: { code, stoppedByUser } });
|
|
434
|
+
// Persist the terminal state (with the heuristic summary) IMMEDIATELY so the
|
|
435
|
+
// dashboard flips to its final state at once. Then try to classify the final
|
|
436
|
+
// bucket and upgrade the summary with cheap model passes. Slow/unreachable
|
|
437
|
+
// model calls must never stall the row indefinitely.
|
|
438
|
+
//
|
|
439
|
+
// Issue #91 (A8 path 3): the terminal status/state materialize through the
|
|
440
|
+
// View State Coordinator (finalizeThroughCoordinator) — only evidence
|
|
441
|
+
// artifacts are written directly here. The in-flight hot-path flush was
|
|
442
|
+
// cancelled above, so no throttled write can race the coordinator's patch.
|
|
443
|
+
persistEvidenceArtifacts();
|
|
444
|
+
await finalizeThroughCoordinator({ exitCode: code ?? 0, stoppedByUser });
|
|
445
|
+
applyHeuristicAutoState(config, status, evidence)
|
|
446
|
+
.then((changed) => {
|
|
447
|
+
if (changed) {
|
|
448
|
+
finalizeEvidence(evidence, status, Date.now());
|
|
449
|
+
if (coordinatorDisabled()) persistUnlessManual(true);
|
|
450
|
+
else refreshEvidenceMirrors();
|
|
451
|
+
}
|
|
452
|
+
return maybeModelAutoState(config, status, evidence);
|
|
453
|
+
})
|
|
454
|
+
.then((changed) => {
|
|
455
|
+
if (changed) {
|
|
456
|
+
finalizeEvidence(evidence, status, Date.now());
|
|
457
|
+
if (coordinatorDisabled()) persistUnlessManual(true);
|
|
458
|
+
else refreshEvidenceMirrors();
|
|
459
|
+
}
|
|
460
|
+
return maybeModelSummary(config, status);
|
|
461
|
+
})
|
|
462
|
+
.then(async (changed) => {
|
|
463
|
+
if (!changed) return;
|
|
464
|
+
if (coordinatorDisabled()) persistUnlessManual(true);
|
|
465
|
+
else await patchSummaryThroughCoordinator(config, status);
|
|
466
|
+
})
|
|
467
|
+
.catch(() => {})
|
|
468
|
+
.then(async () => {
|
|
469
|
+
// The finalize chain must never prevent process.exit: a lock/fs failure
|
|
470
|
+
// here used to pin the runner as a 100% CPU zombie (issue #33). Both
|
|
471
|
+
// steps now await coordinator commands, so they run before the exit.
|
|
472
|
+
try {
|
|
473
|
+
await finalizeSteeringIfNeeded(config, status, evidence);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
tryAppendDiagnostic(config, "finalize_steering_failed", err);
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
await drainQueuedFollowUp(config, status);
|
|
479
|
+
} catch (err) {
|
|
480
|
+
tryAppendDiagnostic(config, "follow_up_drain_failed", err);
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
.finally(() => {
|
|
484
|
+
process.exit(stoppedByUser ? 0 : (code ?? 0));
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Route the plan-ready row flip through the coordinator (`plan_ready`): the
|
|
491
|
+
* decision layer carries the exact legacy patch (needs_input/exited/"Approve
|
|
492
|
+
* this plan?") and its manual fence replaces the old file re-read guard.
|
|
493
|
+
* recordPlanReady's steering.json write STAYS direct — steering is not a
|
|
494
|
+
* coordinator artifact. The cheap pre-check remains as an optimization; the
|
|
495
|
+
* coordinator's manual_fence is authoritative. Async because the command must
|
|
496
|
+
* land before the exit-chain process.exit.
|
|
497
|
+
* @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
|
|
498
|
+
async function finalizeSteeringIfNeeded(config, status, evidence) {
|
|
499
|
+
if (config.kind !== "plan" && config.kind !== "plan_change") return;
|
|
500
|
+
if (status.semanticState === "failed" || status.semanticState === "stopped") return;
|
|
501
|
+
// A manual verdict racing the exit chain must not be resurrected for
|
|
502
|
+
// approval: the user already closed this row. Same signal as the other
|
|
503
|
+
// post-exit guards (mark_completed/mark_holding write the verdict state
|
|
504
|
+
// + autoState null to state.json).
|
|
505
|
+
if (isManualVerdict(readState(config.root, config.viewId))) return;
|
|
506
|
+
recordPlanReady(config.root, config.viewId, {
|
|
507
|
+
runId: config.runId,
|
|
508
|
+
planText: latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "Plan ready",
|
|
509
|
+
});
|
|
510
|
+
if (coordinatorDisabled()) {
|
|
511
|
+
legacyPlanReadyStateWrite(config.root, config.viewId, config.runId);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const result = await sendStateCommand(config.root, {
|
|
515
|
+
type: "state_command",
|
|
516
|
+
viewId: config.viewId,
|
|
517
|
+
runId: config.runId,
|
|
518
|
+
source: "job-runner",
|
|
519
|
+
kind: "plan_ready",
|
|
520
|
+
expectedRevision: null,
|
|
521
|
+
payload: { runId: config.runId },
|
|
522
|
+
});
|
|
523
|
+
if (result.status === "applied") return;
|
|
524
|
+
if (result.reason === "manual_fence" || result.reason === "no_change") return;
|
|
525
|
+
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 } });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status */
|
|
529
|
+
async function drainQueuedFollowUp(config, status) {
|
|
530
|
+
if (status.semanticState !== "idle" && status.semanticState !== "completed") return;
|
|
531
|
+
// A manual verdict racing the exit chain must never be followed up: the
|
|
532
|
+
// user just closed this row, so don't launch a new run over it. The
|
|
533
|
+
// in-memory status may be stale (fresh-read guards skip classification), so
|
|
534
|
+
// check the authoritative state.json signal.
|
|
535
|
+
if (isManualVerdict(readState(config.root, config.viewId))) return;
|
|
536
|
+
if (config.kind === "plan" || config.kind === "plan_change") return;
|
|
537
|
+
const claimed = claimNextFollowUp(config.root, config.viewId);
|
|
538
|
+
if (!claimed.ok || !claimed.item) return;
|
|
539
|
+
const item = claimed.item;
|
|
540
|
+
const nextRunId = newRunId();
|
|
541
|
+
const nextConfig = {
|
|
542
|
+
...config,
|
|
543
|
+
runId: nextRunId,
|
|
544
|
+
kind: runKindForFollowUp(item),
|
|
545
|
+
prompt: promptForFollowUp(config.root, config.viewId, item),
|
|
546
|
+
};
|
|
547
|
+
try {
|
|
548
|
+
const { pid } = launchRun(config.root, nextConfig, { runnerScript: fileURLToPath(import.meta.url) });
|
|
549
|
+
const nextStatus = createRunStatus(nextConfig, pid ?? null, Date.now());
|
|
550
|
+
// Bootstrap the follow-up run through the coordinator: command.runId is
|
|
551
|
+
// deliberately omitted (a parent-run runId would trip the generic stale-run
|
|
552
|
+
// guard against the just-finalized parent); payload.newRunId governs the
|
|
553
|
+
// state-side currentRunId. The new runner's own run_started then lands on
|
|
554
|
+
// top of this bootstrap with the real pid.
|
|
555
|
+
if (coordinatorDisabled()) {
|
|
556
|
+
legacyFollowupBootstrap(config.root, config.viewId, nextStatus);
|
|
557
|
+
} else {
|
|
558
|
+
const result = await sendStateCommand(config.root, {
|
|
559
|
+
type: "state_command",
|
|
560
|
+
viewId: config.viewId,
|
|
561
|
+
source: "job-runner",
|
|
562
|
+
kind: "followup_started",
|
|
563
|
+
expectedRevision: null,
|
|
564
|
+
payload: { newRunId: nextRunId, statusPatch: { ...nextStatus } },
|
|
565
|
+
});
|
|
566
|
+
if (result.reason === "manual_fence") {
|
|
567
|
+
// The user completed the row between the pre-check and this command.
|
|
568
|
+
// The fence preserved their verdict (legacy clobbered it); the child
|
|
569
|
+
// is already launched, so complete the item to avoid a double fire
|
|
570
|
+
// and surface the lost follow-up.
|
|
571
|
+
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 } });
|
|
572
|
+
completeFollowUp(config.root, config.viewId, item.id, { runId: nextRunId });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (result.status !== "applied" && result.reason !== "no_change" && result.reason !== "stale_run") {
|
|
576
|
+
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 } });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
completeFollowUp(config.root, config.viewId, item.id, { runId: nextRunId });
|
|
580
|
+
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 } });
|
|
581
|
+
} catch (err) {
|
|
582
|
+
releaseFollowUp(config.root, config.viewId, item.id);
|
|
583
|
+
appendDiagnostic(config.root, config.viewId, { source: "queue", level: "error", code: "follow_up_drain_failed", message: "JSON runner could not start queued follow-up", details: { error: err instanceof Error ? err.message : String(err) } });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** @param {import("../src/core/types.mjs").RunConfig} config @param {string} code @param {unknown} err */
|
|
588
|
+
function tryAppendDiagnostic(config, code, err) {
|
|
589
|
+
try {
|
|
590
|
+
appendDiagnostic(config.root, config.viewId, {
|
|
591
|
+
source: "runner",
|
|
592
|
+
runId: config.runId,
|
|
593
|
+
level: "error",
|
|
594
|
+
code,
|
|
595
|
+
message: "Finalize step failed",
|
|
596
|
+
details: { error: err instanceof Error ? err.message : String(err) },
|
|
597
|
+
});
|
|
598
|
+
} catch {
|
|
599
|
+
/* root may be deleted — nothing to persist, exit anyway */
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** @param {import("../src/core/types.mjs").FollowUpItem} item */
|
|
604
|
+
function runKindForFollowUp(item) {
|
|
605
|
+
switch (item.kind) {
|
|
606
|
+
case "plan_request":
|
|
607
|
+
return "plan";
|
|
608
|
+
case "plan_change":
|
|
609
|
+
return "plan_change";
|
|
610
|
+
case "plan_approval":
|
|
611
|
+
return "plan_approval";
|
|
612
|
+
default:
|
|
613
|
+
return "reply";
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
|
|
618
|
+
function latestEvidenceText(evidence) {
|
|
619
|
+
return evidence.assistantEvidence?.[evidence.assistantEvidence.length - 1]?.text ?? "";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** @param {string} root @param {string} viewId @param {import("../src/core/types.mjs").FollowUpItem} item */
|
|
623
|
+
function promptForFollowUp(root, viewId, item) {
|
|
624
|
+
const steering = readSteering(root, viewId);
|
|
625
|
+
switch (item.kind) {
|
|
626
|
+
case "plan_request":
|
|
627
|
+
return buildPlanRequestPrompt(item.text);
|
|
628
|
+
case "plan_change":
|
|
629
|
+
return buildPlanChangesPrompt(steering.planText, item.text);
|
|
630
|
+
case "plan_approval":
|
|
631
|
+
return buildApprovePlanPrompt(steering.planText);
|
|
632
|
+
default:
|
|
633
|
+
return item.text;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function canAutoState(config, status, evidence) {
|
|
638
|
+
if (!autoStateEnabled()) return false;
|
|
639
|
+
if (config.kind === "plan" || config.kind === "plan_change") return false;
|
|
640
|
+
if (status.semanticState === "failed" || status.semanticState === "stopped" || status.processState === "alive") return false;
|
|
641
|
+
return Boolean((latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "").trim());
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Submit one classification to the View State Coordinator (issue #91, A8 path 2).
|
|
646
|
+
* The coordinator owns semantic state: applied patches are materialized by it and
|
|
647
|
+
* this runner only refreshes its in-memory status from disk so any remaining
|
|
648
|
+
* direct persist (PR #1 hot path) starts from authoritative fields. Designed
|
|
649
|
+
* fences (manual_fence / no_change / stale_run) are informational, not errors.
|
|
650
|
+
* Ambiguous transport outcomes (timeout / connection_reset) never fall back to a
|
|
651
|
+
* direct write — the command may already be journaled, and the coordinator's
|
|
652
|
+
* boot replay is the recovery path.
|
|
653
|
+
* @param {import("../src/core/types.mjs").RunConfig} config
|
|
654
|
+
* @param {import("../src/core/types.mjs").RunStatus} status mutated in place on apply (fresh coordinator fields)
|
|
655
|
+
* @param {import("../src/core/types.mjs").AutoStateClassification} classification
|
|
656
|
+
* @returns {Promise<boolean>} whether the classification was applied
|
|
657
|
+
*/
|
|
658
|
+
async function classifyThroughCoordinator(config, status, classification) {
|
|
659
|
+
const result = await sendStateCommand(config.root, {
|
|
660
|
+
type: "state_command",
|
|
661
|
+
viewId: config.viewId,
|
|
662
|
+
runId: config.runId,
|
|
663
|
+
source: "job-runner",
|
|
664
|
+
kind: "auto_state_classified",
|
|
665
|
+
expectedRevision: null,
|
|
666
|
+
payload: { classification },
|
|
667
|
+
});
|
|
668
|
+
if (result.status === "applied") {
|
|
669
|
+
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 } });
|
|
670
|
+
const fresh = readStatus(config.root, config.viewId, config.runId);
|
|
671
|
+
if (fresh) Object.assign(status, fresh);
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
if (result.reason === "coordinator_disabled") {
|
|
675
|
+
// Legacy escape hatch (AGENT_BOARD_COORDINATOR=off): apply locally; the
|
|
676
|
+
// caller's persistUnlessManual keeps the pre-coordinator fence for this path.
|
|
677
|
+
return applyAutoStateToStatus(status, classification, Date.now());
|
|
678
|
+
}
|
|
679
|
+
if (result.reason === "manual_fence" || result.reason === "no_change" || result.reason === "stale_run") {
|
|
680
|
+
// Designed fences — informational, not errors.
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
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 } });
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function applyHeuristicAutoState(config, status, evidence) {
|
|
688
|
+
if (!canAutoState(config, status, evidence)) return false;
|
|
689
|
+
// Cheap pre-check kept as an optimization (avoids a pointless command);
|
|
690
|
+
// correctness no longer depends on it — the coordinator fences manual
|
|
691
|
+
// verdicts authoritatively (manual_fence).
|
|
692
|
+
const latestState = readState(config.root, config.viewId);
|
|
693
|
+
if (isManualVerdict(latestState)) return false;
|
|
694
|
+
const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
|
|
695
|
+
const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
696
|
+
return classifyThroughCoordinator(config, status, classification);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function maybeModelAutoState(config, status, evidence) {
|
|
700
|
+
if (!canAutoState(config, status, evidence)) return false;
|
|
701
|
+
const model = autoStateModel();
|
|
702
|
+
if (!model) return false;
|
|
703
|
+
const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
|
|
704
|
+
const prompt = buildAutoStatePrompt(latest);
|
|
705
|
+
const out = await runOneShot(
|
|
706
|
+
config.piCommand,
|
|
707
|
+
[...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
|
|
708
|
+
15000,
|
|
709
|
+
);
|
|
710
|
+
// The user may have placed a manual verdict on the row during the model call.
|
|
711
|
+
// The cheap pre-check avoids a pointless command; the coordinator's manual_fence
|
|
712
|
+
// is the authoritative guard for races after this read.
|
|
713
|
+
const fresh = readStatus(config.root, config.viewId, config.runId);
|
|
714
|
+
if (!fresh || isManualVerdict(fresh)) return false;
|
|
715
|
+
Object.assign(status, fresh);
|
|
716
|
+
const classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
717
|
+
return classifyThroughCoordinator(config, status, classification);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Default cheap model for terminal summaries. Override/disable via $AGENT_BOARD_SUMMARY_MODEL. */
|
|
721
|
+
const DEFAULT_SUMMARY_MODEL = "gpt-4o";
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Cheap-model summary, ON BY DEFAULT (uses {@link DEFAULT_SUMMARY_MODEL}; override with
|
|
725
|
+
* $AGENT_BOARD_SUMMARY_MODEL=<model>, disable with $AGENT_BOARD_SUMMARY_MODEL=off). Overrides
|
|
726
|
+
* status.summary with a short model-generated line. On any failure (no API key, model
|
|
727
|
+
* unavailable, timeout) it silently keeps the heuristic summary already in status.summary.
|
|
728
|
+
* @param {import("../src/core/types.mjs").RunConfig} config
|
|
729
|
+
* @param {import("../src/core/types.mjs").RunStatus} status
|
|
730
|
+
* @returns {Promise<boolean>} whether the summary was upgraded.
|
|
731
|
+
*/
|
|
732
|
+
async function maybeModelSummary(config, status) {
|
|
733
|
+
const configured = process.env.AGENT_BOARD_SUMMARY_MODEL ?? process.env.AGENT_VIEW_SUMMARY_MODEL;
|
|
734
|
+
if (configured === "off") return false;
|
|
735
|
+
const model = configured || DEFAULT_SUMMARY_MODEL;
|
|
736
|
+
if (status.semanticState === "failed" || status.semanticState === "stopped") return false;
|
|
737
|
+
const source = status.latestAssistantPreview || status.summary;
|
|
738
|
+
if (!source) return false;
|
|
739
|
+
const prompt = `In 8 words or fewer, summarize what this coding agent just did. No quotes.\n\n${source}`;
|
|
740
|
+
const out = await runOneShot(
|
|
741
|
+
config.piCommand,
|
|
742
|
+
[...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
|
|
743
|
+
15000,
|
|
744
|
+
);
|
|
745
|
+
// The user may have placed a manual verdict on the row during the summary call.
|
|
746
|
+
// Updating the stale status and letting the caller persist would clobber the
|
|
747
|
+
// manual verdict, so bail out before touching the in-memory status.
|
|
748
|
+
if (isManualVerdict(readState(config.root, config.viewId))) return false;
|
|
749
|
+
const text = out.trim().split("\n").slice(-1)[0]?.trim();
|
|
750
|
+
if (text) {
|
|
751
|
+
status.summary = text.replace(/^["']|["']$/g, "").slice(0, 80);
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Route the post-exit model-summary upgrade through the coordinator as a
|
|
759
|
+
* `patch_fields` command (summary + latestAssistantPreview are whitelisted for
|
|
760
|
+
* the job-runner source). The generic manual fence replaces the old
|
|
761
|
+
* persistUnlessManual file re-read. runId stays null: the finished run's
|
|
762
|
+
* currentRunId still points at it, so the coordinator binds the status patch
|
|
763
|
+
* to the right file without tripping the stale-run guard.
|
|
764
|
+
* @param {import("../src/core/types.mjs").RunConfig} config
|
|
765
|
+
* @param {import("../src/core/types.mjs").RunStatus} status mutated in place on apply
|
|
766
|
+
* @returns {Promise<boolean>} whether the summary patch was applied
|
|
767
|
+
*/
|
|
768
|
+
async function patchSummaryThroughCoordinator(config, status) {
|
|
769
|
+
const result = await sendStateCommand(config.root, {
|
|
770
|
+
type: "state_command",
|
|
771
|
+
viewId: config.viewId,
|
|
772
|
+
runId: null,
|
|
773
|
+
source: "job-runner",
|
|
774
|
+
kind: "patch_fields",
|
|
775
|
+
expectedRevision: null,
|
|
776
|
+
payload: {
|
|
777
|
+
state: { summary: status.summary, latestAssistantPreview: status.latestAssistantPreview },
|
|
778
|
+
status: { summary: status.summary },
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
if (result.status === "applied") {
|
|
782
|
+
const fresh = readStatus(config.root, config.viewId, config.runId);
|
|
783
|
+
if (fresh) Object.assign(status, fresh);
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
if (result.reason === "manual_fence" || result.reason === "no_change") return false;
|
|
787
|
+
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 } });
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Run a pi one-shot and return the concatenated assistant text from message_end events.
|
|
793
|
+
* @param {string} command
|
|
794
|
+
* @param {string[]} args
|
|
795
|
+
* @param {number} [timeoutMs]
|
|
796
|
+
* @returns {Promise<string>}
|
|
797
|
+
*/
|
|
798
|
+
function runOneShot(command, args, timeoutMs = 20000) {
|
|
799
|
+
return new Promise((resolve) => {
|
|
800
|
+
let out = "";
|
|
801
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, env: { ...process.env, AGENT_BOARD_NO_SWEEP: "1" } });
|
|
802
|
+
let buf = "";
|
|
803
|
+
child.stdout.on("data", (c) => {
|
|
804
|
+
buf += c.toString();
|
|
805
|
+
const lines = buf.split("\n");
|
|
806
|
+
buf = lines.pop() ?? "";
|
|
807
|
+
for (const line of lines) {
|
|
808
|
+
try {
|
|
809
|
+
const e = JSON.parse(line);
|
|
810
|
+
if (e?.type === "message_end" && e.message?.role === "assistant") {
|
|
811
|
+
for (const b of e.message.content ?? []) {
|
|
812
|
+
if (b.type === "text") out += b.text;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
} catch {
|
|
816
|
+
/* ignore */
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
child.on("close", () => resolve(out));
|
|
821
|
+
child.on("error", () => resolve(""));
|
|
822
|
+
// Safety timeout so a hung summarizer never blocks finalization forever.
|
|
823
|
+
setTimeout(() => {
|
|
824
|
+
try {
|
|
825
|
+
child.kill("SIGKILL");
|
|
826
|
+
} catch {
|
|
827
|
+
/* ignore */
|
|
828
|
+
}
|
|
829
|
+
resolve(out);
|
|
830
|
+
}, timeoutMs).unref?.();
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
main();
|