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,605 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/workflow-driver.js — the generic workflow driver (composition
|
|
3
|
+
// machinery, Phase B, 2026-09-26): a mechanical extraction of the driver
|
|
4
|
+
// core from lib/chore-run.js, parameterized by a workflow spec instead of
|
|
5
|
+
// hardcoded phases.
|
|
6
|
+
//
|
|
7
|
+
// The tick worker invokes this once per tick for a dispatched task:
|
|
8
|
+
// node lib/workflow-driver.js --spec <path> --crew-home H --task-id T --tick-seq N [options]
|
|
9
|
+
//
|
|
10
|
+
// Per-boundary stateless invocation. Stdout carries EXACTLY ONE JSON line:
|
|
11
|
+
// {"type":"NEED_SPAWN","request":{...}} — post the request via the spawn
|
|
12
|
+
// bridge (spawn-step.js pre), then re-invoke when it lands.
|
|
13
|
+
// {"type":"WAIT","reason":..,"key":..,"phase":..} — a spawn is in flight;
|
|
14
|
+
// re-invoke when the spawn row goes terminal.
|
|
15
|
+
// {"type":"DONE","outcome":..,"task_id":..} — outcome is one of
|
|
16
|
+
// completed | failed | parked | park-failed | stood-down.
|
|
17
|
+
//
|
|
18
|
+
// The spec (lib/workflow-spec.js) declares composition: ordered phases,
|
|
19
|
+
// named transitions, guard references. Phase modules are loaded by dynamic
|
|
20
|
+
// import from the spec's module refs and must export PHASE + runPhase.
|
|
21
|
+
// The driver never imports phase modules by name — module refs come from
|
|
22
|
+
// the spec. (Runtime helpers still come from chore's phase-lib;
|
|
23
|
+
// generalizing that library is Phase C/D work.)
|
|
24
|
+
//
|
|
25
|
+
// PROTOCOL NOTE (from chore-run.js, 2026-09-26): the frozen design named
|
|
26
|
+
// only NEED_SPAWN and DONE. WAIT is a deliberate third frame, not a silent
|
|
27
|
+
// expansion. When a spawn is already posted and running, re-emitting
|
|
28
|
+
// NEED_SPAWN would instruct the launcher to post a duplicate
|
|
29
|
+
// (record-spawn-open refuses with duplicate_running, forcing the launcher
|
|
30
|
+
// to interpret the refusal as "wait" — decision logic leaking out of the
|
|
31
|
+
// driver). Emitting DONE would lie about the run being over. WAIT keeps the
|
|
32
|
+
// single-decision-point contract: the driver decides, the launcher only
|
|
33
|
+
// posts and re-invokes. Flagged for cutover review; the design doc is
|
|
34
|
+
// amended only on approval. All logs go to stderr. All run state is derived
|
|
35
|
+
// from Crew API reads (tasks, sessions, events, spawn-ledger rows) — there
|
|
36
|
+
// is no run-state file anywhere. A derivation failure is a transport
|
|
37
|
+
// failure: the driver fails closed with DONE/failed, never a partial
|
|
38
|
+
// advance.
|
|
39
|
+
//
|
|
40
|
+
// Concurrency discipline (same as the old workflow): one invocation per
|
|
41
|
+
// task at a time. The tick worker must not invoke this for a task with a
|
|
42
|
+
// running session unless the previous invocation is known dead. A lost
|
|
43
|
+
// claim race stands down quietly via the claim-as-gate.
|
|
44
|
+
//
|
|
45
|
+
// No wall-clock reads and no randomness in decision code (G3). Timestamps
|
|
46
|
+
// are minted SQLite-side by crew-api.js.
|
|
47
|
+
|
|
48
|
+
// Spawn-pre refusal codes that are PERMANENT (2026-09-26): the refusal fires
|
|
49
|
+
// before any spawn-ledger row is opened, so re-deriving would emit an
|
|
50
|
+
// identical NEED_SPAWN forever — no ledger state advances. The ferry
|
|
51
|
+
// (chore-tick.js) reports these back via --pre-refusal and the driver
|
|
52
|
+
// terminates instead of re-emitting NEED_SPAWN. DUPLICATE_RUNNING is the
|
|
53
|
+
// legitimate re-drive case (a spawn is in flight; the next derivation
|
|
54
|
+
// resolves to WAIT). This list must match TERMINAL_REFUSAL_CODES in
|
|
55
|
+
// lib/chore-tick.js.
|
|
56
|
+
const TERMINAL_REFUSAL_CODES = [
|
|
57
|
+
"BOUNDS_REJECTED", // spawn bounds invalid (project misconfiguration) → PARK
|
|
58
|
+
"ATTEMPTS_EXHAUSTED", // spawn attempts exhausted → FAILED
|
|
59
|
+
"TRANSPORT_BUDGET_EXHAUSTED", // consecutive transport failures exhausted → FAILED
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
import { rmSync, readdirSync } from "node:fs";
|
|
63
|
+
import { execFile } from "node:child_process";
|
|
64
|
+
import { pathToFileURL } from "node:url";
|
|
65
|
+
import { dirname, resolve } from "node:path";
|
|
66
|
+
|
|
67
|
+
import { loadSpec, phaseModuleUrl } from "./workflow-spec.js";
|
|
68
|
+
// Runtime helpers (Phase B generalized, Phase D): the run environment, pin
|
|
69
|
+
// lifecycle, guards, and parking live in the workflow-neutral library.
|
|
70
|
+
// Workflow-specific runtime values (project description fallback, prompt
|
|
71
|
+
// template directory) arrive declaratively through the spec — the driver
|
|
72
|
+
// stays free of workflow-shaped conditionals.
|
|
73
|
+
import {
|
|
74
|
+
deriveRunEnv, pinLifecycle, parsePinListing, PIN_BASENAMES,
|
|
75
|
+
deriveReworkCount, projectGuard, parkTask,
|
|
76
|
+
} from "./workflow-lib.js";
|
|
77
|
+
|
|
78
|
+
// Runaway guard: REWIND is self-resolving (Map→Capture writes the evidence
|
|
79
|
+
// the gate needs; Review→Build is capped at 2 by the phase itself), so 20
|
|
80
|
+
// phase iterations per invocation is far beyond any legitimate run.
|
|
81
|
+
var MAX_PHASE_ITERATIONS = 20;
|
|
82
|
+
|
|
83
|
+
var USAGE = [
|
|
84
|
+
"Usage: node lib/workflow-driver.js --spec <path> --crew-home <dir> --task-id <id> --tick-seq <n> [options]",
|
|
85
|
+
"",
|
|
86
|
+
"Runs one task through the phases declared by a workflow spec JSON.",
|
|
87
|
+
"Stateless per invocation; stdout is exactly one JSON frame (NEED_SPAWN | WAIT | DONE).",
|
|
88
|
+
"",
|
|
89
|
+
"Required:",
|
|
90
|
+
" --spec <path> workflow spec JSON (see lib/workflow-spec.js)",
|
|
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 (must name a spec phase)",
|
|
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; must match the spec)",
|
|
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
|
+
spec: null, 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 === "--spec") o.spec = argv[++i];
|
|
133
|
+
else if (a === "--crew-home") o.crewHome = argv[++i];
|
|
134
|
+
else if (a === "--task-id") o.taskId = argv[++i];
|
|
135
|
+
else if (a === "--tick-seq") o.tickSeq = argv[++i];
|
|
136
|
+
else if (a === "--start-step") o.startStep = argv[++i];
|
|
137
|
+
else if (a === "--project-id") o.projectId = argv[++i];
|
|
138
|
+
else if (a === "--visual-protocol") o.visualProtocol = true;
|
|
139
|
+
else if (a === "--no-visual-protocol") o.visualProtocol = false;
|
|
140
|
+
else if (a === "--resolved-workflow") o.resolvedWorkflow = argv[++i];
|
|
141
|
+
else if (a === "--workflow-was-null") o.workflowWasNull = true;
|
|
142
|
+
else if (a === "--pre-refusal") o.preRefusal = argv[++i];
|
|
143
|
+
else throw new Error("unknown argument: " + a);
|
|
144
|
+
}
|
|
145
|
+
if (!o.spec) throw new Error("--spec is required.");
|
|
146
|
+
if (!o.crewHome) throw new Error("--crew-home is required.");
|
|
147
|
+
if (!o.taskId) throw new Error("--task-id is required.");
|
|
148
|
+
if (o.tickSeq === null || o.tickSeq === undefined || o.tickSeq === "") throw new Error("--tick-seq is required.");
|
|
149
|
+
var n = Number(o.tickSeq);
|
|
150
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error("--tick-seq must be a positive integer.");
|
|
151
|
+
o.tickSeq = n;
|
|
152
|
+
// --start-step values come from the spec: validated after spec load.
|
|
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
|
+
// Load the spec's phase modules by dynamic import. Each module must export
|
|
160
|
+
// PHASE (the phase definition, incl. identity for the project guard) and
|
|
161
|
+
// runPhase (the phase runner). Returns {name: {def, run}}.
|
|
162
|
+
async function loadPhases(spec, specPath) {
|
|
163
|
+
var phases = {};
|
|
164
|
+
for (var i = 0; i < spec.phases.length; i++) {
|
|
165
|
+
var entry = spec.phases[i];
|
|
166
|
+
var mod;
|
|
167
|
+
try {
|
|
168
|
+
mod = await import(phaseModuleUrl(specPath, entry.module));
|
|
169
|
+
} catch (e) {
|
|
170
|
+
throw new Error("phase \"" + entry.name + "\": cannot load module " + entry.module + ": " + errText(e));
|
|
171
|
+
}
|
|
172
|
+
if (!mod.PHASE || typeof mod.PHASE !== "object" || Array.isArray(mod.PHASE) || typeof mod.runPhase !== "function") {
|
|
173
|
+
throw new Error("phase \"" + entry.name + "\": module must export PHASE (object) and runPhase (function).");
|
|
174
|
+
}
|
|
175
|
+
phases[entry.name] = { def: mod.PHASE, run: mod.runPhase };
|
|
176
|
+
}
|
|
177
|
+
return phases;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Bootstrap crew-api call: execFile argv only (no shell), against the
|
|
181
|
+
// release's own crew-api.js. Used only until the pins are verified; every
|
|
182
|
+
// decision-making call goes through the pinned copy.
|
|
183
|
+
function bootstrapApi(crewHome, command, args) {
|
|
184
|
+
return new Promise(function (resolve, reject) {
|
|
185
|
+
var argv = ["node", crewHome + "/current/lib/crew-api.js", "--crew-home", crewHome, command];
|
|
186
|
+
if (args !== undefined) argv.push("--json", JSON.stringify(args));
|
|
187
|
+
execFile(argv[0], argv.slice(1), { timeout: 30000 }, function (err, stdout, stderr) {
|
|
188
|
+
if (err) { reject(new Error("crew-api " + command + " failed: " + String(stderr || err.message).slice(0, 500))); return; }
|
|
189
|
+
try { resolve(JSON.parse(String(stdout || ""))); }
|
|
190
|
+
catch (e) { reject(new Error("crew-api " + command + " returned unparseable JSON")); }
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Pinned crew-api call (post-pin): same argv shape, against RUN_LIB.
|
|
196
|
+
function pinnedApi(env, command, args) {
|
|
197
|
+
return new Promise(function (resolve, reject) {
|
|
198
|
+
var argv = ["node", env.crewApiPinned, "--crew-home", env.crewHome, command];
|
|
199
|
+
if (args !== undefined) argv.push("--json", JSON.stringify(args));
|
|
200
|
+
execFile(argv[0], argv.slice(1), { timeout: 30000 }, function (err, stdout, stderr) {
|
|
201
|
+
if (err) { reject(new Error("crew-api " + command + " failed: " + String(stderr || err.message).slice(0, 500))); return; }
|
|
202
|
+
try { resolve(JSON.parse(String(stdout || ""))); }
|
|
203
|
+
catch (e) { reject(new Error("crew-api " + command + " returned unparseable JSON")); }
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function errText(e) {
|
|
209
|
+
return (e && e.message) ? String(e.message) : String(e);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// displayName — the spec's workflow name with an initial capital, for
|
|
213
|
+
// human-facing messages (matches chore-run.js's "Chore" capitalization).
|
|
214
|
+
function displayName(spec) {
|
|
215
|
+
var w = spec.workflow || "";
|
|
216
|
+
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Run telemetry (fire-and-forget: never crashes the invocation) ───────
|
|
220
|
+
// The worker layer's counterpart to the old record-run-start/end. One row
|
|
221
|
+
// per run (phase = spec workflow name, kind NULL); reused across
|
|
222
|
+
// invocations via get-worker-run so a tick's re-invocation doesn't open a
|
|
223
|
+
// row per pass.
|
|
224
|
+
async function openRunTelemetry(env, spec) {
|
|
225
|
+
try {
|
|
226
|
+
var existing = await pinnedApi(env, "get-worker-run", { task_id: env.taskId, phase: spec.workflow });
|
|
227
|
+
if (existing && existing.row && existing.row.status === "running") return existing.row.id;
|
|
228
|
+
} catch (e) { /* no row yet — open one */ }
|
|
229
|
+
try {
|
|
230
|
+
var opened = await pinnedApi(env, "record-worker-run", { phase: spec.workflow, task_id: env.taskId, status: "running" });
|
|
231
|
+
return (opened && opened.id) || null;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
log("run telemetry open failed (non-fatal): " + errText(e));
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function closeRunTelemetry(env, spec, runId, frame) {
|
|
239
|
+
if (runId === null || runId === undefined) return;
|
|
240
|
+
var status, error;
|
|
241
|
+
if (frame.outcome === "completed" || frame.outcome === "stood-down" || frame.outcome === "parked") {
|
|
242
|
+
status = "completed";
|
|
243
|
+
error = frame.outcome === "completed" ? null : frame.outcome + (frame.reason ? ": " + frame.reason : "");
|
|
244
|
+
} else {
|
|
245
|
+
status = "failed";
|
|
246
|
+
error = frame.reason || frame.outcome;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await pinnedApi(env, "record-worker-run",
|
|
250
|
+
{ id: runId, phase: spec.workflow, status: status, error: error ? String(error).slice(0, 1000) : null });
|
|
251
|
+
} catch (e) {
|
|
252
|
+
log("run telemetry close failed (non-fatal): " + errText(e));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Resume derivation ────────────────────────────────────────────────────
|
|
257
|
+
// All state comes from Crew API reads. A derivation failure throws — the
|
|
258
|
+
// caller fails closed (DONE/failed), never a partial advance.
|
|
259
|
+
function taskSessions(allSessions, taskId) {
|
|
260
|
+
var out = [];
|
|
261
|
+
for (var i = 0; i < allSessions.length; i++) {
|
|
262
|
+
if (allSessions[i] && allSessions[i].task_id === taskId) out.push(allSessions[i]);
|
|
263
|
+
}
|
|
264
|
+
out.sort(function (a, b) { return String(a.started_at || "") < String(b.started_at || "") ? -1 : 1; });
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Returns a phase name, or null when the last spec phase already completed
|
|
269
|
+
// (closeout), or {done} for nothing-to-do. Throws on unresolvable state
|
|
270
|
+
// (fail closed).
|
|
271
|
+
function deriveStartPhase(task, sessions, args, spec) {
|
|
272
|
+
var order = spec.phaseNames;
|
|
273
|
+
if (task.next_phase) {
|
|
274
|
+
if (order.indexOf(task.next_phase) === -1) {
|
|
275
|
+
throw new Error("task next_phase \"" + task.next_phase + "\" is not a " + displayName(spec) + " phase — left set for human inspection.");
|
|
276
|
+
}
|
|
277
|
+
return task.next_phase;
|
|
278
|
+
}
|
|
279
|
+
if (args.startStep) return args.startStep;
|
|
280
|
+
if (sessions.length === 0) return order[0];
|
|
281
|
+
var latest = sessions[sessions.length - 1];
|
|
282
|
+
var step = latest.step;
|
|
283
|
+
if (latest.status === "running") {
|
|
284
|
+
if (order.indexOf(step) === -1) throw new Error("running session has unknown step \"" + step + "\" — failing closed.");
|
|
285
|
+
return step;
|
|
286
|
+
}
|
|
287
|
+
if (latest.status === "completed") {
|
|
288
|
+
var idx = order.indexOf(step);
|
|
289
|
+
if (idx === -1) throw new Error("completed session has unknown step \"" + step + "\" — failing closed.");
|
|
290
|
+
if (idx + 1 >= order.length) return null; // last phase completed: closeout
|
|
291
|
+
return order[idx + 1];
|
|
292
|
+
}
|
|
293
|
+
if (latest.status === "rejected") {
|
|
294
|
+
// Rework routing (dispatcher parity in the chore port): a rejected
|
|
295
|
+
// session resumes at the spec's declared rework entry point. The
|
|
296
|
+
// rework target is workflow knowledge, so it lives in the spec —
|
|
297
|
+
// undeclared is unresolvable, and unresolvable fails closed.
|
|
298
|
+
if (!spec.rejectedResume) {
|
|
299
|
+
throw new Error("latest session is rejected but the spec declares no rejectedResume — failing closed.");
|
|
300
|
+
}
|
|
301
|
+
return spec.rejectedResume;
|
|
302
|
+
}
|
|
303
|
+
if (latest.status === "failed" || latest.status === "timed_out" || latest.status === "stalled") {
|
|
304
|
+
if (order.indexOf(step) === -1) throw new Error("failed session has unknown step \"" + step + "\" — failing closed.");
|
|
305
|
+
return step; // dispatcher retry resumes the failed phase
|
|
306
|
+
}
|
|
307
|
+
throw new Error("latest session has unresolvable status \"" + latest.status + "\" — failing closed.");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function doCloseout(env, spec) {
|
|
311
|
+
var taskId = env.taskId;
|
|
312
|
+
// Source parity: mark the task done, log the completion event, clean up
|
|
313
|
+
// the pins. A failed mark fails closed (the task is not done); a failed
|
|
314
|
+
// completion event after a successful mark is logged but not fatal.
|
|
315
|
+
try {
|
|
316
|
+
await pinnedApi(env, "update-task", { id: taskId, state: "done" });
|
|
317
|
+
} catch (e) {
|
|
318
|
+
throw new Error("closeout update-task failed: " + errText(e));
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
await pinnedApi(env, "log-event",
|
|
322
|
+
{ task_id: taskId, type: "completed", message: "All " + spec.workflow + " workflow steps complete." });
|
|
323
|
+
} catch (e) {
|
|
324
|
+
log("closeout log-event failed (non-fatal): " + errText(e));
|
|
325
|
+
}
|
|
326
|
+
log(displayName(spec) + " run complete for task " + taskId);
|
|
327
|
+
return { type: "DONE", outcome: "completed", task_id: taskId };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// cleanupPins — the run's last filesystem act on success: remove the
|
|
331
|
+
// per-task pin dir. Runs AFTER the telemetry close (the pinned crew-api.js
|
|
332
|
+
// lives in the pin dir). Best-effort, never fails the run.
|
|
333
|
+
async function cleanupPins(env) {
|
|
334
|
+
try {
|
|
335
|
+
rmSync(env.runLib, { recursive: true, force: true });
|
|
336
|
+
} catch (e) {
|
|
337
|
+
log("closeout pin cleanup failed (non-fatal): " + errText(e));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Handle one phase result. Returns a frame (NEED_SPAWN | WAIT | DONE), or
|
|
342
|
+
// null to continue the loop at result.next (ADVANCE/REWIND).
|
|
343
|
+
async function handlePhaseResult(env, state, args, spec, phaseName, result) {
|
|
344
|
+
var taskId = env.taskId;
|
|
345
|
+
switch (result.type) {
|
|
346
|
+
case "NEED_SPAWN": {
|
|
347
|
+
// Permanent pre refusal (2026-09-26): the spawn bridge refused this
|
|
348
|
+
// spawn before opening a ledger row, so re-emitting NEED_SPAWN would
|
|
349
|
+
// loop forever — no ledger state advances between derivations.
|
|
350
|
+
// The ferry reports the refusal via --pre-refusal; terminate here
|
|
351
|
+
// through the normal closeout (pins released, terminal task state
|
|
352
|
+
// written) instead of burning frame budget.
|
|
353
|
+
if (args.preRefusal && TERMINAL_REFUSAL_CODES.indexOf(args.preRefusal) !== -1) {
|
|
354
|
+
var refusalReason = "spawn pre permanently refused (" + args.preRefusal + ")";
|
|
355
|
+
if (args.preRefusal === "BOUNDS_REJECTED") {
|
|
356
|
+
// Operational misconfiguration (project bounds), not a task
|
|
357
|
+
// defect: park for a human to fix the project config and recover.
|
|
358
|
+
// parkTask writes the parked state and runs terminal cleanup;
|
|
359
|
+
// convert to the DONE frame here (the PARK case below assumes a
|
|
360
|
+
// phase already parked, which did not happen on this path).
|
|
361
|
+
var parked = await parkTask(env, refusalReason + ": spawn bounds invalid — fix the project configuration, then recover the task.");
|
|
362
|
+
if (parked.type === "PARK_FAILED") {
|
|
363
|
+
return { type: "DONE", outcome: "park-failed", task_id: taskId, phase: phaseName, reason: parked.reason };
|
|
364
|
+
}
|
|
365
|
+
return { type: "DONE", outcome: "parked", task_id: taskId, phase: phaseName, reason: parked.reason };
|
|
366
|
+
}
|
|
367
|
+
// Budget/attempt exhaustion is a task-level terminal failure: convert
|
|
368
|
+
// to the DONE frame directly (the FAILED case below assumes a phase
|
|
369
|
+
// result, which did not happen on this path).
|
|
370
|
+
return { type: "DONE", outcome: "failed", task_id: taskId, phase: phaseName,
|
|
371
|
+
reason: refusalReason + ": the task exhausted its spawn budget." };
|
|
372
|
+
}
|
|
373
|
+
var request = result.request || {};
|
|
374
|
+
request.bounds = request.bounds || {};
|
|
375
|
+
request.bounds.owner_tick_seq = args.tickSeq;
|
|
376
|
+
request.owner_tick_seq = args.tickSeq;
|
|
377
|
+
return { type: "NEED_SPAWN", request: request };
|
|
378
|
+
}
|
|
379
|
+
case "STANDBY": {
|
|
380
|
+
var wait = { type: "WAIT", reason: result.reason, phase: phaseName, task_id: taskId };
|
|
381
|
+
if (result.key) wait.key = result.key;
|
|
382
|
+
return wait;
|
|
383
|
+
}
|
|
384
|
+
case "STAND_DOWN":
|
|
385
|
+
return { type: "DONE", outcome: "stood-down", task_id: taskId, phase: phaseName, reason: result.reason };
|
|
386
|
+
case "PARK":
|
|
387
|
+
// parkTask already parked the task and ran terminal cleanup.
|
|
388
|
+
return { type: "DONE", outcome: "parked", task_id: taskId, phase: phaseName, reason: result.reason };
|
|
389
|
+
case "PARK_FAILED":
|
|
390
|
+
return { type: "DONE", outcome: "park-failed", task_id: taskId, phase: phaseName, reason: result.reason };
|
|
391
|
+
case "FAILED":
|
|
392
|
+
return { type: "DONE", outcome: "failed", task_id: taskId, phase: phaseName,
|
|
393
|
+
reason: result.reason, detail: result.detail };
|
|
394
|
+
case "ADVANCE":
|
|
395
|
+
case "REWIND": {
|
|
396
|
+
// The phase's typed result seeds the driver's experiential state
|
|
397
|
+
// (Triage ADVANCE carries the boolean from extractExperiential;
|
|
398
|
+
// null leaves any prior value).
|
|
399
|
+
if (result.experiential === true) state.experiential = true;
|
|
400
|
+
else if (result.experiential === false) state.experiential = false;
|
|
401
|
+
// Map-gate bounce counter (Standard): a REWIND to Capture needs fresh
|
|
402
|
+
// spawn keys on the re-visit. Only Capture reads this, and Capture is
|
|
403
|
+
// never re-visited via rework REWINDs, so incrementing on every REWIND
|
|
404
|
+
// is safe.
|
|
405
|
+
if (result.type === "REWIND") state.gateBounceCount = (state.gateBounceCount || 0) + 1;
|
|
406
|
+
if (result.next === null || result.next === undefined) return await doCloseout(env, spec);
|
|
407
|
+
if (spec.phaseNames.indexOf(result.next) === -1) {
|
|
408
|
+
throw new Error("phase " + phaseName + " returned unknown next phase \"" + result.next + "\" — failing closed.");
|
|
409
|
+
}
|
|
410
|
+
// Fresh per-phase claim for the next phase (source parity: one
|
|
411
|
+
// session per phase). Re-derive the rework count — a REWIND just
|
|
412
|
+
// wrote the rejected event this result was computed from.
|
|
413
|
+
state.activeSessionId = null;
|
|
414
|
+
state.reworkCount = await deriveReworkCount(env, spec);
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
default:
|
|
418
|
+
throw new Error("phase " + phaseName + " returned unknown result type \"" + result.type + "\" — failing closed.");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function runDriver(args, spec) {
|
|
423
|
+
var crewHome = args.crewHome, taskId = args.taskId;
|
|
424
|
+
|
|
425
|
+
// Pin lifecycle first (pure fs — no crew-api needed). Persistent disk,
|
|
426
|
+
// NOT /tmp (tmpfs is wiped on cell reboot — canary b5efd1b1).
|
|
427
|
+
var runLib = crewHome + "/.pins/" + taskId;
|
|
428
|
+
var pinsOk = false;
|
|
429
|
+
try {
|
|
430
|
+
pinLifecycle({ crewHome: crewHome, runLib: runLib });
|
|
431
|
+
var listing = parsePinListing({ listing: readdirSync(runLib).join("\n") });
|
|
432
|
+
pinsOk = PIN_BASENAMES.every(function (b) { return listing.indexOf(b) !== -1; });
|
|
433
|
+
} catch (e) {
|
|
434
|
+
throw new Error("pin lifecycle failed: " + errText(e));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Bootstrap reads (task + project) through the release's own crew-api.
|
|
438
|
+
var st = await bootstrapApi(crewHome, "get-state", { events_limit: 1 });
|
|
439
|
+
var task = null;
|
|
440
|
+
var tasks = (st && st.tasks) || [];
|
|
441
|
+
for (var i = 0; i < tasks.length; i++) if (tasks[i].id === taskId) { task = tasks[i]; break; }
|
|
442
|
+
if (!task) throw new Error("task " + taskId + " not found — failing closed.");
|
|
443
|
+
var project = await bootstrapApi(crewHome, "get-project", { id: task.project });
|
|
444
|
+
project = (project && project.project) || null;
|
|
445
|
+
if (!project || !project.id) throw new Error("project " + task.project + " not found — failing closed.");
|
|
446
|
+
var repoPath = project.repo_path || "";
|
|
447
|
+
if (!repoPath) throw new Error("Project '" + project.id + "' has no repo_path configured — failing closed.");
|
|
448
|
+
|
|
449
|
+
var visualProtocol = args.visualProtocol !== null ? args.visualProtocol : (project.visual_protocol === true);
|
|
450
|
+
var resolvedWorkflow = args.resolvedWorkflow || task.workflow || null;
|
|
451
|
+
var workflowWasNull = args.workflowWasNull || (!args.resolvedWorkflow && !task.workflow);
|
|
452
|
+
|
|
453
|
+
// Workflow-declared runtime values (spec-driven, no workflow conditionals):
|
|
454
|
+
// the project description fallback and the prompt-template directory,
|
|
455
|
+
// resolved against the spec file's directory (phaseModuleUrl's rule).
|
|
456
|
+
var specPromptsDir = spec.promptsDir
|
|
457
|
+
? resolve(dirname(resolve(args.spec)), spec.promptsDir)
|
|
458
|
+
: undefined;
|
|
459
|
+
var env = deriveRunEnv({
|
|
460
|
+
crewHome: crewHome, taskId: taskId,
|
|
461
|
+
repoPath: repoPath, orchPath: crewHome + "/.orchestration", runLib: runLib,
|
|
462
|
+
lifecycle: runLib + "/worktree-lifecycle.sh",
|
|
463
|
+
mergeLock: runLib + "/merge-lock.sh",
|
|
464
|
+
publishNpm: runLib + "/publish-npm.sh",
|
|
465
|
+
computeDiff: runLib + "/compute-publish-diff.js",
|
|
466
|
+
crewApi: crewHome + "/current/lib/crew-api.js",
|
|
467
|
+
crewApiPinned: runLib + "/crew-api.js",
|
|
468
|
+
taskTitle: task.title, taskDescription: task.description,
|
|
469
|
+
projectId: task.project, projectConfig: project,
|
|
470
|
+
visualProtocol: visualProtocol,
|
|
471
|
+
workflowWasNull: workflowWasNull, resolvedWorkflow: resolvedWorkflow,
|
|
472
|
+
projectDescFallback: spec.projectDescFallback || undefined,
|
|
473
|
+
promptsDir: specPromptsDir,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// The pins gate the run: an incomplete pin set parks the task for human
|
|
477
|
+
// attention (source parity), exactly like the source's pin-verification.
|
|
478
|
+
if (!pinsOk) {
|
|
479
|
+
var parked = await parkTask(env, "Pin verification failed — required lifecycle scripts missing from " + runLib);
|
|
480
|
+
if (parked.type === "PARK") return { type: "DONE", outcome: "parked", task_id: taskId, reason: parked.reason };
|
|
481
|
+
return { type: "DONE", outcome: "park-failed", task_id: taskId, reason: parked.reason };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Nothing to do for terminal tasks.
|
|
485
|
+
if (task.state === "done" || task.state === "cancelled" || task.state === "parked") {
|
|
486
|
+
return { type: "DONE", outcome: "stood-down", task_id: taskId, reason: "task already " + task.state };
|
|
487
|
+
}
|
|
488
|
+
// The driver only runs tasks for the spec's workflow.
|
|
489
|
+
if (resolvedWorkflow && resolvedWorkflow !== spec.workflow) {
|
|
490
|
+
throw new Error("task workflow is \"" + resolvedWorkflow + "\" but the spec declares \"" + spec.workflow + "\" — refusing.");
|
|
491
|
+
}
|
|
492
|
+
// Optional launch-project guard (the tick worker passes the dispatch
|
|
493
|
+
// claim's project; the per-phase projectGuard re-checks it).
|
|
494
|
+
if (args.projectId && args.projectId !== task.project) {
|
|
495
|
+
throw new Error("task project changed from '" + args.projectId + "' to '" + task.project + "' — failing closed.");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
var phases = await loadPhases(spec, args.spec);
|
|
499
|
+
|
|
500
|
+
var sessions = taskSessions((st && st.sessions) || [], taskId);
|
|
501
|
+
var running = null;
|
|
502
|
+
for (var s = 0; s < sessions.length; s++) {
|
|
503
|
+
if (sessions[s].status === "running") { running = sessions[s]; break; }
|
|
504
|
+
}
|
|
505
|
+
var state = {
|
|
506
|
+
activeSessionId: running ? running.id : null,
|
|
507
|
+
reworkCount: await deriveReworkCount(env, spec),
|
|
508
|
+
gateBounceCount: 0,
|
|
509
|
+
isFirstClaimVisit: sessions.length === 0,
|
|
510
|
+
nextPhaseRouted: task.next_phase || null,
|
|
511
|
+
experiential: undefined,
|
|
512
|
+
memo: {},
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
var runId = await openRunTelemetry(env, spec);
|
|
516
|
+
|
|
517
|
+
var frame;
|
|
518
|
+
try {
|
|
519
|
+
frame = await driveLoop(env, state, args, spec, phases, task, sessions);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
var msg = errText(e);
|
|
522
|
+
// A mid-run terminal transition (done/cancelled under us) is a
|
|
523
|
+
// stand-down, not a failure.
|
|
524
|
+
var terminalRace = /already done|cancelled/i.test(msg);
|
|
525
|
+
frame = {
|
|
526
|
+
type: "DONE",
|
|
527
|
+
outcome: terminalRace ? "stood-down" : "failed",
|
|
528
|
+
task_id: taskId,
|
|
529
|
+
reason: (terminalRace ? "stood down: " : "driver error: ") + msg,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
if (frame.type === "DONE") await closeRunTelemetry(env, spec, runId, frame);
|
|
533
|
+
// Pin cleanup lands after the telemetry close: the pinned crew-api.js
|
|
534
|
+
// the close runs through lives inside the pin dir.
|
|
535
|
+
if (frame.type === "DONE" && frame.outcome === "completed") await cleanupPins(env);
|
|
536
|
+
return frame;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function driveLoop(env, state, args, spec, phases, task, sessions) {
|
|
540
|
+
var taskId = env.taskId;
|
|
541
|
+
var start = deriveStartPhase(task, sessions, args, spec);
|
|
542
|
+
if (start === null) return await doCloseout(env, spec);
|
|
543
|
+
|
|
544
|
+
var current = start;
|
|
545
|
+
for (var iter = 0; iter < MAX_PHASE_ITERATIONS; iter++) {
|
|
546
|
+
var phase = phases[current];
|
|
547
|
+
var guard = await projectGuard(env, state, phase.def);
|
|
548
|
+
if (!guard.ok) {
|
|
549
|
+
return { type: "DONE", outcome: "failed", task_id: taskId, phase: current, reason: guard.reason };
|
|
550
|
+
}
|
|
551
|
+
var result = await phase.run({ env: env, state: state });
|
|
552
|
+
var frame = await handlePhaseResult(env, state, args, spec, current, result);
|
|
553
|
+
if (frame) return frame;
|
|
554
|
+
current = result.next;
|
|
555
|
+
}
|
|
556
|
+
throw new Error("runaway guard: exceeded " + MAX_PHASE_ITERATIONS + " phase iterations — failing closed.");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function main() {
|
|
560
|
+
var args;
|
|
561
|
+
try {
|
|
562
|
+
args = parseArgs(RAW_ARGS);
|
|
563
|
+
} catch (e) {
|
|
564
|
+
process.stderr.write(USAGE + "\n\nError: " + errText(e) + "\n");
|
|
565
|
+
process.exit(2);
|
|
566
|
+
}
|
|
567
|
+
var spec;
|
|
568
|
+
try {
|
|
569
|
+
spec = loadSpec(args.spec);
|
|
570
|
+
if (args.startStep !== null && spec.phaseNames.indexOf(args.startStep) === -1) {
|
|
571
|
+
throw new Error("--start-step must be one of: " + spec.phaseNames.join("|") + ".");
|
|
572
|
+
}
|
|
573
|
+
} catch (e) {
|
|
574
|
+
process.stderr.write(USAGE + "\n\nError: " + errText(e) + "\n");
|
|
575
|
+
process.exit(2);
|
|
576
|
+
}
|
|
577
|
+
var frame;
|
|
578
|
+
try {
|
|
579
|
+
frame = await runDriver(args, spec);
|
|
580
|
+
} catch (e) {
|
|
581
|
+
// Even a catastrophic setup failure emits the one JSON frame.
|
|
582
|
+
frame = { type: "DONE", outcome: "failed", task_id: args.taskId || null, reason: "driver error: " + errText(e) };
|
|
583
|
+
}
|
|
584
|
+
process.stdout.write(JSON.stringify(frame) + "\n");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Import-safe: no side effects on import. main() runs only on direct
|
|
588
|
+
// execution; bare `node lib/workflow-driver.js` reports the usage error
|
|
589
|
+
// (exit 2), matching the other lib CLIs.
|
|
590
|
+
var isDirectExecution = false;
|
|
591
|
+
try {
|
|
592
|
+
isDirectExecution = !!process.argv[1] &&
|
|
593
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
594
|
+
} catch (e) { /* conservative: do not run */ }
|
|
595
|
+
if (isDirectExecution) {
|
|
596
|
+
main().catch(function (e) {
|
|
597
|
+
try {
|
|
598
|
+
process.stdout.write(JSON.stringify({
|
|
599
|
+
type: "DONE", outcome: "failed", task_id: null,
|
|
600
|
+
reason: "driver error: " + errText(e),
|
|
601
|
+
}) + "\n");
|
|
602
|
+
} catch (e2) { /* stdout itself failed */ }
|
|
603
|
+
process.exit(1);
|
|
604
|
+
});
|
|
605
|
+
}
|