muse-crew 0.17.2 → 0.17.3
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/API.md +11 -0
- package/docs/decisions/composition-machinery.md +180 -0
- package/docs/decisions/publish-path.md +6 -0
- package/docs/decisions/workflow-core.md +5 -4
- package/lib/AGENTS.md +2 -1
- package/lib/bugfix/phases/build.js +168 -0
- package/lib/bugfix/phases/capture.js +170 -0
- package/lib/bugfix/phases/integrate.js +165 -0
- package/lib/bugfix/phases/map.js +129 -0
- package/lib/bugfix/phases/publish.js +592 -0
- package/lib/bugfix/phases/qa.js +356 -0
- package/lib/bugfix/phases/reproduce.js +254 -0
- package/lib/bugfix/phases/review.js +292 -0
- package/lib/bugfix/phases/triage.js +69 -0
- package/lib/chore/CONTRACT.md +181 -0
- package/lib/chore/DISPOSITION.md +98 -0
- package/lib/chore/extract.js +204 -0
- package/lib/chore/phase-lib.js +885 -0
- package/lib/chore/phases/build.js +110 -0
- package/lib/chore/phases/capture.js +82 -0
- package/lib/chore/phases/integrate.js +109 -0
- package/lib/chore/phases/map.js +81 -0
- package/lib/chore/phases/publish.js +543 -0
- package/lib/chore/phases/review.js +255 -0
- package/lib/chore/phases/triage.js +57 -0
- package/lib/chore/prompts/evidence-gatherer.js +41 -0
- package/lib/chore/prompts/evidence-gatherer.schema.json +1 -0
- package/lib/chore/prompts/tool-check.js +15 -0
- package/lib/chore/prompts/trailers.js +56 -0
- package/lib/chore/prompts/verdict-reask.js +28 -0
- package/lib/chore/prompts/verdict-reask.schema.json +1 -0
- package/lib/chore/prompts/work-agent.js +52 -0
- package/lib/chore/prompts/work-agent.schema.json +1 -0
- package/lib/chore/spawn-keys.js +44 -0
- package/lib/chore/spawn-vocab.js +87 -0
- package/lib/chore-run.js +538 -0
- package/lib/chore-tick.js +289 -0
- package/lib/crew-api.js +273 -0
- package/lib/crew-dispatch-worker.js +27 -7
- package/lib/crew-release.sh +7 -2
- package/lib/extract.js +252 -0
- package/lib/prompts/tool-check.js +18 -0
- package/lib/prompts/trailers.js +59 -0
- package/lib/prompts/verdict-reask.js +31 -0
- package/lib/prompts/verdict-reask.schema.json +1 -0
- package/lib/prompts/work-agent.js +56 -0
- package/lib/prompts/work-agent.schema.json +1 -0
- package/lib/reap-spawns.js +407 -0
- package/lib/schema.sql +12 -1
- package/lib/spawn-keys.js +47 -0
- package/lib/spawn-step.js +572 -0
- package/lib/standard/phases/build.js +120 -0
- package/lib/standard/phases/capture.js +163 -0
- package/lib/standard/phases/integrate.js +172 -0
- package/lib/standard/phases/map.js +119 -0
- package/lib/standard/phases/publish.js +565 -0
- package/lib/standard/phases/qa.js +399 -0
- package/lib/standard/phases/review.js +281 -0
- package/lib/standard/phases/triage.js +64 -0
- package/lib/test-detached-integrate.sh +47 -0
- package/lib/workflow-driver.js +605 -0
- package/lib/workflow-lib.js +1012 -0
- package/lib/workflow-spec.js +187 -0
- package/lib/worktree-lifecycle.sh +55 -3
- package/package.json +1 -1
- package/seed/cron-body-template.md +61 -9
- package/workflows/bugfix.js +17 -17
- package/workflows/chore.js +16 -16
- package/workflows/docs.js +14 -11
- package/workflows/standard.js +16 -16
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// lib/chore/spawn-vocab.js — closed vocabularies for the production spawn
|
|
2
|
+
// bridge (sandbox exit, chore pilot Piece 2, 2026-09-26).
|
|
3
|
+
//
|
|
4
|
+
// Import-safe: pure constants, no I/O, no clock, no randomness. Single
|
|
5
|
+
// source of truth shared by lib/spawn-step.js, lib/reap-spawns.js, and the
|
|
6
|
+
// spawn-ledger commands in lib/crew-api.js.
|
|
7
|
+
//
|
|
8
|
+
// The frozen creative spawn kinds (DESIGN-chore-pilot.md §1): work-agent,
|
|
9
|
+
// verdict-reask, evidence-gatherer. Unknown kinds fail closed at the bridge.
|
|
10
|
+
|
|
11
|
+
export const SPAWN_KINDS = Object.freeze([
|
|
12
|
+
"work-agent",
|
|
13
|
+
"verdict-reask",
|
|
14
|
+
"evidence-gatherer",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// Terminal outcome vocabulary for the worker_runs.result JSON. Both writers
|
|
18
|
+
// (spawn-step post, reap-spawns.js) enforce this closed set fail-closed: a
|
|
19
|
+
// row may only leave 'running' with one of these outcomes. The 3-state
|
|
20
|
+
// status CHECK (running|completed|failed) is unchanged — kill states live
|
|
21
|
+
// here, in the result JSON, never as new statuses.
|
|
22
|
+
export const CLOSED_OUTCOMES = Object.freeze([
|
|
23
|
+
"completed", // spawn returned; typed result in the payload
|
|
24
|
+
"synthetic", // no-spawn branch: workflow-authored prose, extracted identically
|
|
25
|
+
"transport_timeout", // agent did not return within timeout_ms
|
|
26
|
+
"transport_error", // the spawn itself failed
|
|
27
|
+
"transport_empty", // agent returned blank text
|
|
28
|
+
"transport_parse", // verdict unrecoverable after the task-domain reask budget
|
|
29
|
+
"killed_confirmed", // timeout + machine-verified dead
|
|
30
|
+
"killed_by_reaper", // reaper closed an already-dead child
|
|
31
|
+
"orphaned_unkillable", // child believed alive but unkillable
|
|
32
|
+
"orphaned_unverified", // child liveness unverifiable; no kill attempted
|
|
33
|
+
"transport_failed", // terminal transport failure (unrecoverable)
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export function isClosedOutcome(outcome) {
|
|
37
|
+
return typeof outcome === "string" && CLOSED_OUTCOMES.includes(outcome);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Outcomes that count toward the per-task consecutive transport-failure
|
|
41
|
+
// budget (DESIGN-chore-pilot.md §4): terminal TRANSPORT_TIMEOUT /
|
|
42
|
+
// TRANSPORT_ERROR / TRANSPORT_PARSE equivalents, orphaned_*,
|
|
43
|
+
// killed_by_reaper, and terminal reask failures. Reset by any completed
|
|
44
|
+
// non-transport row for the task.
|
|
45
|
+
export const TRANSPORT_FAILURE_OUTCOMES = Object.freeze([
|
|
46
|
+
"transport_timeout",
|
|
47
|
+
"transport_error",
|
|
48
|
+
"transport_empty",
|
|
49
|
+
"transport_parse",
|
|
50
|
+
"killed_confirmed",
|
|
51
|
+
"killed_by_reaper",
|
|
52
|
+
"orphaned_unkillable",
|
|
53
|
+
"orphaned_unverified",
|
|
54
|
+
"transport_failed",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
export function isTransportFailureOutcome(outcome) {
|
|
58
|
+
return typeof outcome === "string" && TRANSPORT_FAILURE_OUTCOMES.includes(outcome);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Per-kind attempt caps, mirroring the chore workflow's bounded loops:
|
|
62
|
+
// work-agent 3 attempts (t0-t2), verdict-reask 2 attempts (180s each).
|
|
63
|
+
// evidence-gatherer has no chore site; 3 is the work-agent analog —
|
|
64
|
+
// revisit when the kind gets its Piece 4 contract.
|
|
65
|
+
export const MAX_ATTEMPTS = Object.freeze({
|
|
66
|
+
"work-agent": 3,
|
|
67
|
+
"verdict-reask": 2,
|
|
68
|
+
"evidence-gatherer": 3,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Consecutive transport failures for one task before the bridge refuses
|
|
72
|
+
// new spawns (the task parks instead of burning attempts).
|
|
73
|
+
export const TRANSPORT_BUDGET_CAP = 5;
|
|
74
|
+
|
|
75
|
+
// Platform tick kill: the tick worker is killed at 90m. A spawn's timeout
|
|
76
|
+
// must leave headroom for post-processing and the driver loop, so the
|
|
77
|
+
// tick never dies mid-spawn-wait.
|
|
78
|
+
export const TICK_KILL_MS = 90 * 60 * 1000;
|
|
79
|
+
export const SPAWN_HEADROOM_MS = 10 * 60 * 1000;
|
|
80
|
+
export const MAX_TIMEOUT_MS = TICK_KILL_MS - SPAWN_HEADROOM_MS;
|
|
81
|
+
export const MIN_TIMEOUT_MS = 60 * 1000;
|
|
82
|
+
|
|
83
|
+
// Reaper: a running row is reapable once it has been past its timeout_ms
|
|
84
|
+
// for longer than this margin (slow-post / clock-skew grace). Unknown kill
|
|
85
|
+
// states keep the row running — it may only leave 'running' with a closed
|
|
86
|
+
// outcome.
|
|
87
|
+
export const REAP_MARGIN_MS = 5 * 60 * 1000;
|
package/lib/chore-run.js
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/chore-run.js — the Chore driver loop: the worker-layer port of
|
|
3
|
+
// workflows/chore.js (sandbox-exit Piece 3, 2026-09-26).
|
|
4
|
+
//
|
|
5
|
+
// The tick worker invokes this once per tick for a dispatched Chore task:
|
|
6
|
+
// node lib/chore-run.js --crew-home H --task-id T --tick-seq N [options]
|
|
7
|
+
//
|
|
8
|
+
// Per-boundary stateless invocation. Stdout carries EXACTLY ONE JSON line:
|
|
9
|
+
// {"type":"NEED_SPAWN","request":{...}} — post the request via the spawn
|
|
10
|
+
// bridge (spawn-step.js pre), then re-invoke when it lands.
|
|
11
|
+
// {"type":"WAIT","reason":..,"key":..,"phase":..} — a spawn is in flight;
|
|
12
|
+
// re-invoke when the spawn row goes terminal.
|
|
13
|
+
// {"type":"DONE","outcome":..,"task_id":..} — outcome is one of
|
|
14
|
+
// completed | failed | parked | park-failed | stood-down.
|
|
15
|
+
//
|
|
16
|
+
// PROTOCOL NOTE (2026-09-26): the frozen design named only NEED_SPAWN and
|
|
17
|
+
// DONE. WAIT is a deliberate third frame, not a silent expansion. When a
|
|
18
|
+
// spawn is already posted and running, re-emitting NEED_SPAWN would instruct
|
|
19
|
+
// the launcher to post a duplicate (record-spawn-open refuses with
|
|
20
|
+
// duplicate_running, forcing the launcher to interpret the refusal as
|
|
21
|
+
// "wait" — decision logic leaking out of the driver). Emitting DONE would
|
|
22
|
+
// lie about the run being over. WAIT keeps the single-decision-point
|
|
23
|
+
// contract: the driver decides, the launcher only posts and re-invokes.
|
|
24
|
+
// Flagged for cutover review; the design doc is amended only on approval.
|
|
25
|
+
// All logs go to stderr. All run state is derived from Crew API reads
|
|
26
|
+
// (tasks, sessions, events, spawn-ledger rows) — there is no run-state
|
|
27
|
+
// file anywhere. A derivation failure is a transport failure: the driver
|
|
28
|
+
// fails closed with DONE/failed, never a partial advance.
|
|
29
|
+
//
|
|
30
|
+
// Concurrency discipline (same as the old workflow): one invocation per
|
|
31
|
+
// task at a time. The tick worker must not invoke this for a task with a
|
|
32
|
+
// running session unless the previous invocation is known dead. A lost
|
|
33
|
+
// claim race stands down quietly via the claim-as-gate.
|
|
34
|
+
//
|
|
35
|
+
// No wall-clock reads and no randomness in decision code (G3). Timestamps
|
|
36
|
+
// are minted SQLite-side by crew-api.js.
|
|
37
|
+
|
|
38
|
+
// Spawn-pre refusal codes that are PERMANENT (2026-09-26): the refusal fires
|
|
39
|
+
// before any spawn-ledger row is opened, so re-deriving would emit an
|
|
40
|
+
// identical NEED_SPAWN forever — no ledger state advances. The ferry
|
|
41
|
+
// (chore-tick.js) reports these back via --pre-refusal and the driver
|
|
42
|
+
// terminates instead of re-emitting NEED_SPAWN. DUPLICATE_RUNNING is the
|
|
43
|
+
// legitimate re-drive case (a spawn is in flight; the next derivation
|
|
44
|
+
// resolves to WAIT). This list must match TERMINAL_REFUSAL_CODES in
|
|
45
|
+
// lib/chore-tick.js.
|
|
46
|
+
const TERMINAL_REFUSAL_CODES = [
|
|
47
|
+
"BOUNDS_REJECTED", // spawn bounds invalid (project misconfiguration) → PARK
|
|
48
|
+
"ATTEMPTS_EXHAUSTED", // spawn attempts exhausted → FAILED
|
|
49
|
+
"TRANSPORT_BUDGET_EXHAUSTED", // consecutive transport failures exhausted → FAILED
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
import { rmSync, readdirSync } from "node:fs";
|
|
53
|
+
import { execFile } from "node:child_process";
|
|
54
|
+
import { pathToFileURL } from "node:url";
|
|
55
|
+
|
|
56
|
+
import {
|
|
57
|
+
deriveRunEnv, pinLifecycle, parsePinListing, PIN_BASENAMES,
|
|
58
|
+
deriveReworkCount, projectGuard, parkTask,
|
|
59
|
+
} from "./chore/phase-lib.js";
|
|
60
|
+
import { PHASE as TRIAGE, runPhase as triageRun } from "./chore/phases/triage.js";
|
|
61
|
+
import { PHASE as CAPTURE, runPhase as captureRun } from "./chore/phases/capture.js";
|
|
62
|
+
import { PHASE as MAP, runPhase as mapRun } from "./chore/phases/map.js";
|
|
63
|
+
import { PHASE as BUILD, runPhase as buildRun } from "./chore/phases/build.js";
|
|
64
|
+
import { PHASE as REVIEW, runPhase as reviewRun } from "./chore/phases/review.js";
|
|
65
|
+
import { PHASE as INTEGRATE, runPhase as integrateRun } from "./chore/phases/integrate.js";
|
|
66
|
+
import { PHASE as PUBLISH, runPhase as publishRun } from "./chore/phases/publish.js";
|
|
67
|
+
|
|
68
|
+
var PHASES = {
|
|
69
|
+
Triage: { def: TRIAGE, run: triageRun },
|
|
70
|
+
Capture: { def: CAPTURE, run: captureRun },
|
|
71
|
+
Map: { def: MAP, run: mapRun },
|
|
72
|
+
Build: { def: BUILD, run: buildRun },
|
|
73
|
+
Review: { def: REVIEW, run: reviewRun },
|
|
74
|
+
Integrate: { def: INTEGRATE, run: integrateRun },
|
|
75
|
+
Publish: { def: PUBLISH, run: publishRun },
|
|
76
|
+
};
|
|
77
|
+
var PHASE_ORDER = ["Triage", "Capture", "Map", "Build", "Review", "Integrate", "Publish"];
|
|
78
|
+
|
|
79
|
+
// Runaway guard: REWIND is self-resolving (Map→Capture writes the evidence
|
|
80
|
+
// the gate needs; Review→Build is capped at 2 by the phase itself), so 20
|
|
81
|
+
// phase iterations per invocation is far beyond any legitimate run.
|
|
82
|
+
var MAX_PHASE_ITERATIONS = 20;
|
|
83
|
+
|
|
84
|
+
var USAGE = [
|
|
85
|
+
"Usage: node lib/chore-run.js --crew-home <dir> --task-id <id> --tick-seq <n> [options]",
|
|
86
|
+
"",
|
|
87
|
+
"Runs one Chore task through the seven worker-layer phases (Triage → Publish).",
|
|
88
|
+
"Stateless per invocation; stdout is exactly one JSON frame (NEED_SPAWN | WAIT | DONE).",
|
|
89
|
+
"",
|
|
90
|
+
"Required:",
|
|
91
|
+
" --crew-home <dir> crew home directory",
|
|
92
|
+
" --task-id <id> task to run",
|
|
93
|
+
" --tick-seq <n> owning tick sequence (positive integer; fills spawn bounds)",
|
|
94
|
+
"",
|
|
95
|
+
"Options:",
|
|
96
|
+
" --start-step <name> resume at this phase (Triage|Capture|Map|Build|Review|Integrate|Publish)",
|
|
97
|
+
" --project-id <id> expected project (mid-run project-change guard)",
|
|
98
|
+
" --visual-protocol visual protocol available (overrides project default)",
|
|
99
|
+
" --no-visual-protocol visual protocol unavailable (overrides project default)",
|
|
100
|
+
" --resolved-workflow <w> workflow name (defaults to the task's workflow)",
|
|
101
|
+
" --workflow-was-null the dispatcher did not specify a workflow",
|
|
102
|
+
" --help print this usage",
|
|
103
|
+
].join("\n");
|
|
104
|
+
|
|
105
|
+
// --help is answered on stdout with exit 0 BEFORE required-argument parsing
|
|
106
|
+
// (lib/AGENTS.md shebang⇔CLI contract) and before the stderr-log patch.
|
|
107
|
+
var RAW_ARGS = process.argv.slice(2);
|
|
108
|
+
if (RAW_ARGS.indexOf("--help") !== -1 || RAW_ARGS.indexOf("-h") !== -1) {
|
|
109
|
+
process.stdout.write(USAGE + "\n");
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// From here on, all logs go to stderr: stdout carries exactly one JSON
|
|
114
|
+
// frame. phase-lib's log() uses console.log, so it is rerouted here.
|
|
115
|
+
console.log = function () {
|
|
116
|
+
process.stderr.write(Array.prototype.map.call(arguments, String).join(" ") + "\n");
|
|
117
|
+
};
|
|
118
|
+
console.info = console.log;
|
|
119
|
+
|
|
120
|
+
function log(message) {
|
|
121
|
+
process.stderr.write(String(message) + "\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseArgs(argv) {
|
|
125
|
+
var o = {
|
|
126
|
+
crewHome: null, taskId: null, tickSeq: null, startStep: null,
|
|
127
|
+
projectId: null, visualProtocol: null, resolvedWorkflow: null,
|
|
128
|
+
workflowWasNull: false, preRefusal: null,
|
|
129
|
+
};
|
|
130
|
+
for (var i = 0; i < argv.length; i++) {
|
|
131
|
+
var a = argv[i];
|
|
132
|
+
if (a === "--crew-home") o.crewHome = argv[++i];
|
|
133
|
+
else if (a === "--task-id") o.taskId = argv[++i];
|
|
134
|
+
else if (a === "--tick-seq") o.tickSeq = argv[++i];
|
|
135
|
+
else if (a === "--start-step") o.startStep = argv[++i];
|
|
136
|
+
else if (a === "--project-id") o.projectId = argv[++i];
|
|
137
|
+
else if (a === "--visual-protocol") o.visualProtocol = true;
|
|
138
|
+
else if (a === "--no-visual-protocol") o.visualProtocol = false;
|
|
139
|
+
else if (a === "--resolved-workflow") o.resolvedWorkflow = argv[++i];
|
|
140
|
+
else if (a === "--workflow-was-null") o.workflowWasNull = true;
|
|
141
|
+
else if (a === "--pre-refusal") o.preRefusal = argv[++i];
|
|
142
|
+
else throw new Error("unknown argument: " + a);
|
|
143
|
+
}
|
|
144
|
+
if (!o.crewHome) throw new Error("--crew-home is required.");
|
|
145
|
+
if (!o.taskId) throw new Error("--task-id is required.");
|
|
146
|
+
if (o.tickSeq === null || o.tickSeq === undefined || o.tickSeq === "") throw new Error("--tick-seq is required.");
|
|
147
|
+
var n = Number(o.tickSeq);
|
|
148
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error("--tick-seq must be a positive integer.");
|
|
149
|
+
o.tickSeq = n;
|
|
150
|
+
if (o.startStep !== null && PHASE_ORDER.indexOf(o.startStep) === -1) {
|
|
151
|
+
throw new Error("--start-step must be one of: " + PHASE_ORDER.join("|") + ".");
|
|
152
|
+
}
|
|
153
|
+
if (o.preRefusal !== null && TERMINAL_REFUSAL_CODES.indexOf(o.preRefusal) === -1) {
|
|
154
|
+
throw new Error("--pre-refusal must be one of: " + TERMINAL_REFUSAL_CODES.join("|") + ".");
|
|
155
|
+
}
|
|
156
|
+
return o;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Bootstrap crew-api call: execFile argv only (no shell), against the
|
|
160
|
+
// release's own crew-api.js. Used only until the pins are verified; every
|
|
161
|
+
// decision-making call goes through the pinned copy.
|
|
162
|
+
function bootstrapApi(crewHome, command, args) {
|
|
163
|
+
return new Promise(function (resolve, reject) {
|
|
164
|
+
var argv = ["node", crewHome + "/current/lib/crew-api.js", "--crew-home", crewHome, command];
|
|
165
|
+
if (args !== undefined) argv.push("--json", JSON.stringify(args));
|
|
166
|
+
execFile(argv[0], argv.slice(1), { timeout: 30000 }, function (err, stdout, stderr) {
|
|
167
|
+
if (err) { reject(new Error("crew-api " + command + " failed: " + String(stderr || err.message).slice(0, 500))); return; }
|
|
168
|
+
try { resolve(JSON.parse(String(stdout || ""))); }
|
|
169
|
+
catch (e) { reject(new Error("crew-api " + command + " returned unparseable JSON")); }
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Pinned crew-api call (post-pin): same argv shape, against RUN_LIB.
|
|
175
|
+
function pinnedApi(env, command, args) {
|
|
176
|
+
return new Promise(function (resolve, reject) {
|
|
177
|
+
var argv = ["node", env.crewApiPinned, "--crew-home", env.crewHome, command];
|
|
178
|
+
if (args !== undefined) argv.push("--json", JSON.stringify(args));
|
|
179
|
+
execFile(argv[0], argv.slice(1), { timeout: 30000 }, function (err, stdout, stderr) {
|
|
180
|
+
if (err) { reject(new Error("crew-api " + command + " failed: " + String(stderr || err.message).slice(0, 500))); return; }
|
|
181
|
+
try { resolve(JSON.parse(String(stdout || ""))); }
|
|
182
|
+
catch (e) { reject(new Error("crew-api " + command + " returned unparseable JSON")); }
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function errText(e) {
|
|
188
|
+
return (e && e.message) ? String(e.message) : String(e);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Run telemetry (fire-and-forget: never crashes the invocation) ───────
|
|
192
|
+
// The worker layer's counterpart to the old record-run-start/end. One row
|
|
193
|
+
// per chore run (phase "chore", kind NULL); reused across invocations via
|
|
194
|
+
// get-worker-run so a tick's re-invocation doesn't open a row per pass.
|
|
195
|
+
async function openRunTelemetry(env) {
|
|
196
|
+
try {
|
|
197
|
+
var existing = await pinnedApi(env, "get-worker-run", { task_id: env.taskId, phase: "chore" });
|
|
198
|
+
if (existing && existing.row && existing.row.status === "running") return existing.row.id;
|
|
199
|
+
} catch (e) { /* no row yet — open one */ }
|
|
200
|
+
try {
|
|
201
|
+
var opened = await pinnedApi(env, "record-worker-run", { phase: "chore", task_id: env.taskId, status: "running" });
|
|
202
|
+
return (opened && opened.id) || null;
|
|
203
|
+
} catch (e) {
|
|
204
|
+
log("run telemetry open failed (non-fatal): " + errText(e));
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function closeRunTelemetry(env, runId, frame) {
|
|
210
|
+
if (runId === null || runId === undefined) return;
|
|
211
|
+
var status, error;
|
|
212
|
+
if (frame.outcome === "completed" || frame.outcome === "stood-down" || frame.outcome === "parked") {
|
|
213
|
+
status = "completed";
|
|
214
|
+
error = frame.outcome === "completed" ? null : frame.outcome + (frame.reason ? ": " + frame.reason : "");
|
|
215
|
+
} else {
|
|
216
|
+
status = "failed";
|
|
217
|
+
error = frame.reason || frame.outcome;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
await pinnedApi(env, "record-worker-run",
|
|
221
|
+
{ id: runId, phase: "chore", status: status, error: error ? String(error).slice(0, 1000) : null });
|
|
222
|
+
} catch (e) {
|
|
223
|
+
log("run telemetry close failed (non-fatal): " + errText(e));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Resume derivation ────────────────────────────────────────────────────
|
|
228
|
+
// All state comes from Crew API reads. A derivation failure throws — the
|
|
229
|
+
// caller fails closed (DONE/failed), never a partial advance.
|
|
230
|
+
function taskSessions(allSessions, taskId) {
|
|
231
|
+
var out = [];
|
|
232
|
+
for (var i = 0; i < allSessions.length; i++) {
|
|
233
|
+
if (allSessions[i] && allSessions[i].task_id === taskId) out.push(allSessions[i]);
|
|
234
|
+
}
|
|
235
|
+
out.sort(function (a, b) { return String(a.started_at || "") < String(b.started_at || "") ? -1 : 1; });
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Returns a phase name, or null when Publish already completed (closeout),
|
|
240
|
+
// or {done} for nothing-to-do. Throws on unresolvable state (fail closed).
|
|
241
|
+
function deriveStartPhase(task, sessions, args) {
|
|
242
|
+
if (task.next_phase) {
|
|
243
|
+
if (PHASE_ORDER.indexOf(task.next_phase) === -1) {
|
|
244
|
+
throw new Error("task next_phase \"" + task.next_phase + "\" is not a Chore phase — left set for human inspection.");
|
|
245
|
+
}
|
|
246
|
+
return task.next_phase;
|
|
247
|
+
}
|
|
248
|
+
if (args.startStep) return args.startStep;
|
|
249
|
+
if (sessions.length === 0) return "Triage";
|
|
250
|
+
var latest = sessions[sessions.length - 1];
|
|
251
|
+
var step = latest.step;
|
|
252
|
+
if (latest.status === "running") {
|
|
253
|
+
if (PHASE_ORDER.indexOf(step) === -1) throw new Error("running session has unknown step \"" + step + "\" — failing closed.");
|
|
254
|
+
return step;
|
|
255
|
+
}
|
|
256
|
+
if (latest.status === "completed") {
|
|
257
|
+
var idx = PHASE_ORDER.indexOf(step);
|
|
258
|
+
if (idx === -1) throw new Error("completed session has unknown step \"" + step + "\" — failing closed.");
|
|
259
|
+
if (idx + 1 >= PHASE_ORDER.length) return null; // Publish completed: closeout
|
|
260
|
+
return PHASE_ORDER[idx + 1];
|
|
261
|
+
}
|
|
262
|
+
if (latest.status === "rejected") return "Build"; // rework routing (dispatcher parity)
|
|
263
|
+
if (latest.status === "failed" || latest.status === "timed_out" || latest.status === "stalled") {
|
|
264
|
+
if (PHASE_ORDER.indexOf(step) === -1) throw new Error("failed session has unknown step \"" + step + "\" — failing closed.");
|
|
265
|
+
return step; // dispatcher retry resumes the failed phase
|
|
266
|
+
}
|
|
267
|
+
throw new Error("latest session has unresolvable status \"" + latest.status + "\" — failing closed.");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function doCloseout(env) {
|
|
271
|
+
var taskId = env.taskId;
|
|
272
|
+
// Source parity: mark the task done, log the completion event, clean up
|
|
273
|
+
// the pins. A failed mark fails closed (the task is not done); a failed
|
|
274
|
+
// completion event after a successful mark is logged but not fatal.
|
|
275
|
+
try {
|
|
276
|
+
await pinnedApi(env, "update-task", { id: taskId, state: "done" });
|
|
277
|
+
} catch (e) {
|
|
278
|
+
throw new Error("closeout update-task failed: " + errText(e));
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await pinnedApi(env, "log-event",
|
|
282
|
+
{ task_id: taskId, type: "completed", message: "All chore workflow steps complete." });
|
|
283
|
+
} catch (e) {
|
|
284
|
+
log("closeout log-event failed (non-fatal): " + errText(e));
|
|
285
|
+
}
|
|
286
|
+
log("Chore run complete for task " + taskId);
|
|
287
|
+
return { type: "DONE", outcome: "completed", task_id: taskId };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// cleanupPins — the run's last filesystem act on success: remove the
|
|
291
|
+
// per-task pin dir. Runs AFTER the telemetry close (the pinned crew-api.js
|
|
292
|
+
// lives in the pin dir). Best-effort, never fails the run.
|
|
293
|
+
async function cleanupPins(env) {
|
|
294
|
+
try {
|
|
295
|
+
rmSync(env.runLib, { recursive: true, force: true });
|
|
296
|
+
} catch (e) {
|
|
297
|
+
log("closeout pin cleanup failed (non-fatal): " + errText(e));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Handle one phase result. Returns a frame (NEED_SPAWN | WAIT | DONE), or
|
|
302
|
+
// null to continue the loop at result.next (ADVANCE/REWIND).
|
|
303
|
+
async function handlePhaseResult(env, state, args, phaseName, result) {
|
|
304
|
+
var taskId = env.taskId;
|
|
305
|
+
switch (result.type) {
|
|
306
|
+
case "NEED_SPAWN": {
|
|
307
|
+
// Permanent pre refusal (2026-09-26): the spawn bridge refused this
|
|
308
|
+
// spawn before opening a ledger row, so re-emitting NEED_SPAWN would
|
|
309
|
+
// loop forever — no ledger state advances between derivations.
|
|
310
|
+
// The ferry reports the refusal via --pre-refusal; terminate here
|
|
311
|
+
// through the normal closeout (pins released, terminal task state
|
|
312
|
+
// written) instead of burning frame budget.
|
|
313
|
+
if (args.preRefusal && TERMINAL_REFUSAL_CODES.indexOf(args.preRefusal) !== -1) {
|
|
314
|
+
var refusalReason = "spawn pre permanently refused (" + args.preRefusal + ")";
|
|
315
|
+
if (args.preRefusal === "BOUNDS_REJECTED") {
|
|
316
|
+
// Operational misconfiguration (project bounds), not a task
|
|
317
|
+
// defect: park for a human to fix the project config and recover.
|
|
318
|
+
// parkTask writes the parked state and runs terminal cleanup;
|
|
319
|
+
// convert to the DONE frame here (the PARK case below assumes a
|
|
320
|
+
// phase already parked, which did not happen on this path).
|
|
321
|
+
var parked = await parkTask(env, refusalReason + ": spawn bounds invalid — fix the project configuration, then recover the task.");
|
|
322
|
+
if (parked.type === "PARK_FAILED") {
|
|
323
|
+
return { type: "DONE", outcome: "park-failed", task_id: taskId, phase: phaseName, reason: parked.reason };
|
|
324
|
+
}
|
|
325
|
+
return { type: "DONE", outcome: "parked", task_id: taskId, phase: phaseName, reason: parked.reason };
|
|
326
|
+
}
|
|
327
|
+
// Budget/attempt exhaustion is a task-level terminal failure: convert
|
|
328
|
+
// to the DONE frame directly (the FAILED case below assumes a phase
|
|
329
|
+
// result, which did not happen on this path).
|
|
330
|
+
return { type: "DONE", outcome: "failed", task_id: taskId, phase: phaseName,
|
|
331
|
+
reason: refusalReason + ": the task exhausted its spawn budget." };
|
|
332
|
+
}
|
|
333
|
+
var request = result.request || {};
|
|
334
|
+
request.bounds = request.bounds || {};
|
|
335
|
+
request.bounds.owner_tick_seq = args.tickSeq;
|
|
336
|
+
request.owner_tick_seq = args.tickSeq;
|
|
337
|
+
return { type: "NEED_SPAWN", request: request };
|
|
338
|
+
}
|
|
339
|
+
case "STANDBY": {
|
|
340
|
+
var wait = { type: "WAIT", reason: result.reason, phase: phaseName, task_id: taskId };
|
|
341
|
+
if (result.key) wait.key = result.key;
|
|
342
|
+
return wait;
|
|
343
|
+
}
|
|
344
|
+
case "STAND_DOWN":
|
|
345
|
+
return { type: "DONE", outcome: "stood-down", task_id: taskId, phase: phaseName, reason: result.reason };
|
|
346
|
+
case "PARK":
|
|
347
|
+
// parkTask already parked the task and ran terminal cleanup.
|
|
348
|
+
return { type: "DONE", outcome: "parked", task_id: taskId, phase: phaseName, reason: result.reason };
|
|
349
|
+
case "PARK_FAILED":
|
|
350
|
+
return { type: "DONE", outcome: "park-failed", task_id: taskId, phase: phaseName, reason: result.reason };
|
|
351
|
+
case "FAILED":
|
|
352
|
+
return { type: "DONE", outcome: "failed", task_id: taskId, phase: phaseName,
|
|
353
|
+
reason: result.reason, detail: result.detail };
|
|
354
|
+
case "ADVANCE":
|
|
355
|
+
case "REWIND": {
|
|
356
|
+
// The phase's typed result seeds the driver's experiential state
|
|
357
|
+
// (Triage ADVANCE carries it; null leaves any prior value).
|
|
358
|
+
if (result.experiential === "yes") state.experiential = true;
|
|
359
|
+
else if (result.experiential === "no") state.experiential = false;
|
|
360
|
+
if (result.next === null || result.next === undefined) return await doCloseout(env);
|
|
361
|
+
if (PHASE_ORDER.indexOf(result.next) === -1) {
|
|
362
|
+
throw new Error("phase " + phaseName + " returned unknown next phase \"" + result.next + "\" — failing closed.");
|
|
363
|
+
}
|
|
364
|
+
// Fresh per-phase claim for the next phase (source parity: one
|
|
365
|
+
// session per phase). Re-derive the rework count — a REWIND just
|
|
366
|
+
// wrote the rejected event this result was computed from.
|
|
367
|
+
state.activeSessionId = null;
|
|
368
|
+
state.reworkCount = await deriveReworkCount(env);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
default:
|
|
372
|
+
throw new Error("phase " + phaseName + " returned unknown result type \"" + result.type + "\" — failing closed.");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function runDriver(args) {
|
|
377
|
+
var crewHome = args.crewHome, taskId = args.taskId;
|
|
378
|
+
|
|
379
|
+
// Pin lifecycle first (pure fs — no crew-api needed). Persistent disk,
|
|
380
|
+
// NOT /tmp (tmpfs is wiped on cell reboot — canary b5efd1b1).
|
|
381
|
+
var runLib = crewHome + "/.pins/" + taskId;
|
|
382
|
+
var pinsOk = false;
|
|
383
|
+
try {
|
|
384
|
+
pinLifecycle({ crewHome: crewHome, runLib: runLib });
|
|
385
|
+
var listing = parsePinListing({ listing: readdirSync(runLib).join("\n") });
|
|
386
|
+
pinsOk = PIN_BASENAMES.every(function (b) { return listing.indexOf(b) !== -1; });
|
|
387
|
+
} catch (e) {
|
|
388
|
+
throw new Error("pin lifecycle failed: " + errText(e));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Bootstrap reads (task + project) through the release's own crew-api.
|
|
392
|
+
var st = await bootstrapApi(crewHome, "get-state", { events_limit: 1 });
|
|
393
|
+
var task = null;
|
|
394
|
+
var tasks = (st && st.tasks) || [];
|
|
395
|
+
for (var i = 0; i < tasks.length; i++) if (tasks[i].id === taskId) { task = tasks[i]; break; }
|
|
396
|
+
if (!task) throw new Error("task " + taskId + " not found — failing closed.");
|
|
397
|
+
var project = await bootstrapApi(crewHome, "get-project", { id: task.project });
|
|
398
|
+
project = (project && project.project) || null;
|
|
399
|
+
if (!project || !project.id) throw new Error("project " + task.project + " not found — failing closed.");
|
|
400
|
+
var repoPath = project.repo_path || "";
|
|
401
|
+
if (!repoPath) throw new Error("Project '" + project.id + "' has no repo_path configured — failing closed.");
|
|
402
|
+
|
|
403
|
+
var visualProtocol = args.visualProtocol !== null ? args.visualProtocol : (project.visual_protocol === true);
|
|
404
|
+
var resolvedWorkflow = args.resolvedWorkflow || task.workflow || null;
|
|
405
|
+
var workflowWasNull = args.workflowWasNull || (!args.resolvedWorkflow && !task.workflow);
|
|
406
|
+
|
|
407
|
+
var env = deriveRunEnv({
|
|
408
|
+
crewHome: crewHome, taskId: taskId,
|
|
409
|
+
repoPath: repoPath, orchPath: crewHome + "/.orchestration", runLib: runLib,
|
|
410
|
+
lifecycle: runLib + "/worktree-lifecycle.sh",
|
|
411
|
+
mergeLock: runLib + "/merge-lock.sh",
|
|
412
|
+
publishNpm: runLib + "/publish-npm.sh",
|
|
413
|
+
computeDiff: runLib + "/compute-publish-diff.js",
|
|
414
|
+
crewApi: crewHome + "/current/lib/crew-api.js",
|
|
415
|
+
crewApiPinned: runLib + "/crew-api.js",
|
|
416
|
+
taskTitle: task.title, taskDescription: task.description,
|
|
417
|
+
projectId: task.project, projectConfig: project,
|
|
418
|
+
visualProtocol: visualProtocol,
|
|
419
|
+
workflowWasNull: workflowWasNull, resolvedWorkflow: resolvedWorkflow,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// The pins gate the run: an incomplete pin set parks the task for human
|
|
423
|
+
// attention (source parity), exactly like the source's pin-verification.
|
|
424
|
+
if (!pinsOk) {
|
|
425
|
+
var parked = await parkTask(env, "Pin verification failed — required lifecycle scripts missing from " + runLib);
|
|
426
|
+
if (parked.type === "PARK") return { type: "DONE", outcome: "parked", task_id: taskId, reason: parked.reason };
|
|
427
|
+
return { type: "DONE", outcome: "park-failed", task_id: taskId, reason: parked.reason };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Nothing to do for terminal tasks.
|
|
431
|
+
if (task.state === "done" || task.state === "cancelled" || task.state === "parked") {
|
|
432
|
+
return { type: "DONE", outcome: "stood-down", task_id: taskId, reason: "task already " + task.state };
|
|
433
|
+
}
|
|
434
|
+
// The driver only runs Chore tasks.
|
|
435
|
+
if (resolvedWorkflow && resolvedWorkflow !== "chore") {
|
|
436
|
+
throw new Error("task workflow is \"" + resolvedWorkflow + "\", not chore — refusing to run the chore driver.");
|
|
437
|
+
}
|
|
438
|
+
// Optional launch-project guard (the tick worker passes the dispatch
|
|
439
|
+
// claim's project; the per-phase projectGuard re-checks it).
|
|
440
|
+
if (args.projectId && args.projectId !== task.project) {
|
|
441
|
+
throw new Error("task project changed from '" + args.projectId + "' to '" + task.project + "' — failing closed.");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
var sessions = taskSessions((st && st.sessions) || [], taskId);
|
|
445
|
+
var running = null;
|
|
446
|
+
for (var s = 0; s < sessions.length; s++) {
|
|
447
|
+
if (sessions[s].status === "running") { running = sessions[s]; break; }
|
|
448
|
+
}
|
|
449
|
+
var state = {
|
|
450
|
+
activeSessionId: running ? running.id : null,
|
|
451
|
+
reworkCount: await deriveReworkCount(env),
|
|
452
|
+
isFirstClaimVisit: sessions.length === 0,
|
|
453
|
+
nextPhaseRouted: task.next_phase || null,
|
|
454
|
+
experiential: undefined,
|
|
455
|
+
memo: {},
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
var runId = await openRunTelemetry(env);
|
|
459
|
+
|
|
460
|
+
var frame;
|
|
461
|
+
try {
|
|
462
|
+
frame = await driveLoop(env, state, args, task, sessions);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
var msg = errText(e);
|
|
465
|
+
// A mid-run terminal transition (done/cancelled under us) is a
|
|
466
|
+
// stand-down, not a failure.
|
|
467
|
+
var terminalRace = /already done|cancelled/i.test(msg);
|
|
468
|
+
frame = {
|
|
469
|
+
type: "DONE",
|
|
470
|
+
outcome: terminalRace ? "stood-down" : "failed",
|
|
471
|
+
task_id: taskId,
|
|
472
|
+
reason: (terminalRace ? "stood down: " : "driver error: ") + msg,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (frame.type === "DONE") await closeRunTelemetry(env, runId, frame);
|
|
476
|
+
// Pin cleanup lands after the telemetry close: the pinned crew-api.js
|
|
477
|
+
// the close runs through lives inside the pin dir.
|
|
478
|
+
if (frame.type === "DONE" && frame.outcome === "completed") await cleanupPins(env);
|
|
479
|
+
return frame;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function driveLoop(env, state, args, task, sessions) {
|
|
483
|
+
var taskId = env.taskId;
|
|
484
|
+
var start = deriveStartPhase(task, sessions, args);
|
|
485
|
+
if (start === null) return await doCloseout(env);
|
|
486
|
+
|
|
487
|
+
var current = start;
|
|
488
|
+
for (var iter = 0; iter < MAX_PHASE_ITERATIONS; iter++) {
|
|
489
|
+
var phase = PHASES[current];
|
|
490
|
+
var guard = await projectGuard(env, state, phase.def);
|
|
491
|
+
if (!guard.ok) {
|
|
492
|
+
return { type: "DONE", outcome: "failed", task_id: taskId, phase: current, reason: guard.reason };
|
|
493
|
+
}
|
|
494
|
+
var result = await phase.run({ env: env, state: state });
|
|
495
|
+
var frame = await handlePhaseResult(env, state, args, current, result);
|
|
496
|
+
if (frame) return frame;
|
|
497
|
+
current = result.next;
|
|
498
|
+
}
|
|
499
|
+
throw new Error("runaway guard: exceeded " + MAX_PHASE_ITERATIONS + " phase iterations — failing closed.");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function main() {
|
|
503
|
+
var args;
|
|
504
|
+
try {
|
|
505
|
+
args = parseArgs(RAW_ARGS);
|
|
506
|
+
} catch (e) {
|
|
507
|
+
process.stderr.write(USAGE + "\n\nError: " + errText(e) + "\n");
|
|
508
|
+
process.exit(2);
|
|
509
|
+
}
|
|
510
|
+
var frame;
|
|
511
|
+
try {
|
|
512
|
+
frame = await runDriver(args);
|
|
513
|
+
} catch (e) {
|
|
514
|
+
// Even a catastrophic setup failure emits the one JSON frame.
|
|
515
|
+
frame = { type: "DONE", outcome: "failed", task_id: args.taskId || null, reason: "driver error: " + errText(e) };
|
|
516
|
+
}
|
|
517
|
+
process.stdout.write(JSON.stringify(frame) + "\n");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Import-safe: no side effects on import. main() runs only on direct
|
|
521
|
+
// execution; bare `node lib/chore-run.js` reports the usage error (exit 2),
|
|
522
|
+
// matching the other lib CLIs.
|
|
523
|
+
var isDirectExecution = false;
|
|
524
|
+
try {
|
|
525
|
+
isDirectExecution = !!process.argv[1] &&
|
|
526
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
527
|
+
} catch (e) { /* conservative: do not run */ }
|
|
528
|
+
if (isDirectExecution) {
|
|
529
|
+
main().catch(function (e) {
|
|
530
|
+
try {
|
|
531
|
+
process.stdout.write(JSON.stringify({
|
|
532
|
+
type: "DONE", outcome: "failed", task_id: null,
|
|
533
|
+
reason: "driver error: " + errText(e),
|
|
534
|
+
}) + "\n");
|
|
535
|
+
} catch (e2) { /* stdout itself failed */ }
|
|
536
|
+
process.exit(1);
|
|
537
|
+
});
|
|
538
|
+
}
|