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,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/chore-tick.js — the tick worker's ferry for worker-layer Chore claims
|
|
3
|
+
// (sandbox exit, cutover piece, 2026-09-26).
|
|
4
|
+
//
|
|
5
|
+
// The tick worker invokes this once per worker-chore claim per tick:
|
|
6
|
+
// node lib/chore-tick.js --crew-home H --task-id T --tick-seq N [options]
|
|
7
|
+
// node lib/chore-tick.js --crew-home H --task-id T --tick-seq N --session S [options]
|
|
8
|
+
//
|
|
9
|
+
// It sequences the driver and the spawn bridge in-process (no shell), so
|
|
10
|
+
// the NEED_SPAWN request JSON never crosses a shell-quoting boundary.
|
|
11
|
+
// The one act it cannot do — spawning the agent — stays with the tick
|
|
12
|
+
// worker (an LLM runtime capability), via the "spawn" action.
|
|
13
|
+
//
|
|
14
|
+
// Stdout carries EXACTLY ONE JSON line; all logs go to stderr.
|
|
15
|
+
// Actions:
|
|
16
|
+
// {"action":"spawn","spawn":{prompt,cwd,env,timeout_ms,session_path,key,attempt_n,row_id},...}
|
|
17
|
+
// — spawn-step pre succeeded. The tick worker spawns ONE subagent
|
|
18
|
+
// with the packet, records child+outcome into the session file,
|
|
19
|
+
// then re-invokes this script with --session.
|
|
20
|
+
// {"action":"wait","reason":..,...}
|
|
21
|
+
// — a spawn is in flight (or the frame budget ran out). Stop; the
|
|
22
|
+
// next tick re-invokes. Never a failure.
|
|
23
|
+
// {"action":"done","outcome":..,...}
|
|
24
|
+
// — the driver reached a terminal outcome. The driver already wrote
|
|
25
|
+
// the task state.
|
|
26
|
+
// {"action":"error","reason":..}
|
|
27
|
+
// — transport failure (unparseable driver frame). Log and stop; the
|
|
28
|
+
// next tick retries. Exit code is 1.
|
|
29
|
+
//
|
|
30
|
+
// With --session S: runs `spawn-step.js post --session S` first (settles
|
|
31
|
+
// the spawn the tick worker just completed), then continues driving.
|
|
32
|
+
// Every post outcome is safe to drive through: closed, already_closed,
|
|
33
|
+
// kill_unverified, and post errors all resolve via the driver's next
|
|
34
|
+
// derivation (advance, or WAIT on the still-running row).
|
|
35
|
+
//
|
|
36
|
+
// A spawn-step pre refusal (DUPLICATE_RUNNING, ATTEMPTS_EXHAUSTED,
|
|
37
|
+
// TRANSPORT_BUDGET_EXHAUSTED, BOUNDS_REJECTED) is NOT an error: the
|
|
38
|
+
// ledger now reflects the refusal and the driver's next derivation
|
|
39
|
+
// resolves it (WAIT or DONE/failed). It counts against the frame budget.
|
|
40
|
+
//
|
|
41
|
+
// Frame budget (--max-frames, default 20): bounds the in-tick drive loop.
|
|
42
|
+
// Exhaustion emits action "wait" (never "done"/"failed") — the next tick
|
|
43
|
+
// resumes. Budgets end the tick's participation, never the task.
|
|
44
|
+
//
|
|
45
|
+
// Concurrency: the driver is safe to re-invoke any time (STANDBY→WAIT on
|
|
46
|
+
// a running spawn; claim-as-gate STAND_DOWN on a lost race). The tick
|
|
47
|
+
// worker must not invoke this twice concurrently for the same task.
|
|
48
|
+
//
|
|
49
|
+
// No wall-clock reads and no randomness in decision paths (G3).
|
|
50
|
+
|
|
51
|
+
import { execFileSync } from "node:child_process";
|
|
52
|
+
import { dirname, join } from "node:path";
|
|
53
|
+
import { fileURLToPath } from "node:url";
|
|
54
|
+
|
|
55
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
const CHORE_RUN = join(LIB_DIR, "chore-run.js");
|
|
57
|
+
const SPAWN_STEP = join(LIB_DIR, "spawn-step.js");
|
|
58
|
+
|
|
59
|
+
const DEFAULT_MAX_FRAMES = 20;
|
|
60
|
+
|
|
61
|
+
// Permanent spawn-pre refusal codes (2026-09-26): the refusal fires before
|
|
62
|
+
// any spawn-ledger row is opened, so the ledger cannot reflect it and the
|
|
63
|
+
// next derivation would emit an identical NEED_SPAWN forever. On these
|
|
64
|
+
// codes the ferry re-drives the driver ONCE with --pre-refusal so the
|
|
65
|
+
// driver terminates (PARK for BOUNDS_REJECTED, FAILED for the budget
|
|
66
|
+
// codes) through the normal closeout. DUPLICATE_RUNNING is the legitimate
|
|
67
|
+
// re-drive case and keeps the old behavior. Must match
|
|
68
|
+
// TERMINAL_REFUSAL_CODES in lib/chore-run.js.
|
|
69
|
+
const TERMINAL_REFUSAL_CODES = [
|
|
70
|
+
"BOUNDS_REJECTED",
|
|
71
|
+
"ATTEMPTS_EXHAUSTED",
|
|
72
|
+
"TRANSPORT_BUDGET_EXHAUSTED",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const USAGE = [
|
|
76
|
+
"Usage: node lib/chore-tick.js --crew-home <dir> --task-id <id> --tick-seq <n> [options]",
|
|
77
|
+
"",
|
|
78
|
+
"Tick ferry for one worker-layer Chore claim: drives chore-run.js and the",
|
|
79
|
+
"spawn bridge, emitting exactly one JSON action on stdout (spawn | wait |",
|
|
80
|
+
"done | error). The tick worker performs the agent spawn on \"spawn\" and",
|
|
81
|
+
"re-invokes with --session; on \"wait\"/\"done\" it stops for this tick.",
|
|
82
|
+
"",
|
|
83
|
+
"Required:",
|
|
84
|
+
" --crew-home <dir> crew home directory",
|
|
85
|
+
" --task-id <id> task to drive",
|
|
86
|
+
" --tick-seq <n> owning tick sequence (positive integer)",
|
|
87
|
+
"",
|
|
88
|
+
"Options:",
|
|
89
|
+
" --session <path> settle this spawn session first (spawn-step post), then drive",
|
|
90
|
+
" --max-frames <n> drive-loop bound before yielding wait (default 20)",
|
|
91
|
+
" --project-id <id> expected project (forwarded to the driver)",
|
|
92
|
+
" --visual-protocol visual protocol available (forwarded)",
|
|
93
|
+
" --no-visual-protocol visual protocol unavailable (forwarded)",
|
|
94
|
+
" --resolved-workflow <w> workflow name (forwarded; defaults to the task's)",
|
|
95
|
+
" --workflow-was-null the dispatcher did not specify a workflow",
|
|
96
|
+
" --help print this usage",
|
|
97
|
+
].join("\n");
|
|
98
|
+
|
|
99
|
+
// --help is answered on stdout with exit 0 BEFORE required-argument parsing
|
|
100
|
+
// (lib/AGENTS.md shebang⇔CLI contract).
|
|
101
|
+
const RAW_ARGS = process.argv.slice(2);
|
|
102
|
+
if (RAW_ARGS.indexOf("--help") !== -1 || RAW_ARGS.indexOf("-h") !== -1) {
|
|
103
|
+
process.stdout.write(USAGE + "\n");
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function log(message) {
|
|
108
|
+
process.stderr.write("[chore-tick] " + String(message) + "\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function printJson(obj) {
|
|
112
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function errText(e) {
|
|
116
|
+
return (e && e.message) || String(e);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseArgs(argv) {
|
|
120
|
+
const o = {
|
|
121
|
+
crewHome: null, taskId: null, tickSeq: null, session: null,
|
|
122
|
+
maxFrames: DEFAULT_MAX_FRAMES, projectId: null, visualProtocol: null,
|
|
123
|
+
resolvedWorkflow: null, workflowWasNull: false, preRefusal: null,
|
|
124
|
+
};
|
|
125
|
+
for (let i = 0; i < argv.length; i++) {
|
|
126
|
+
const a = argv[i];
|
|
127
|
+
if (a === "--crew-home") o.crewHome = argv[++i];
|
|
128
|
+
else if (a === "--task-id") o.taskId = argv[++i];
|
|
129
|
+
else if (a === "--tick-seq") o.tickSeq = argv[++i];
|
|
130
|
+
else if (a === "--session") o.session = argv[++i];
|
|
131
|
+
else if (a === "--max-frames") o.maxFrames = argv[++i];
|
|
132
|
+
else if (a === "--project-id") o.projectId = argv[++i];
|
|
133
|
+
else if (a === "--visual-protocol") o.visualProtocol = true;
|
|
134
|
+
else if (a === "--no-visual-protocol") o.visualProtocol = false;
|
|
135
|
+
else if (a === "--resolved-workflow") o.resolvedWorkflow = argv[++i];
|
|
136
|
+
else if (a === "--workflow-was-null") o.workflowWasNull = true;
|
|
137
|
+
else throw new Error("unknown argument: " + a);
|
|
138
|
+
}
|
|
139
|
+
if (!o.crewHome) throw new Error("--crew-home is required");
|
|
140
|
+
if (!o.taskId) throw new Error("--task-id is required");
|
|
141
|
+
if (!/^[1-9][0-9]*$/.test(String(o.tickSeq || ""))) {
|
|
142
|
+
throw new Error("--tick-seq must be a positive integer");
|
|
143
|
+
}
|
|
144
|
+
if (!/^[1-9][0-9]*$/.test(String(o.maxFrames))) {
|
|
145
|
+
throw new Error("--max-frames must be a positive integer");
|
|
146
|
+
}
|
|
147
|
+
o.maxFrames = parseInt(o.maxFrames, 10);
|
|
148
|
+
return o;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Run a node script with argv (no shell). Returns {ok, stdout} — ok
|
|
152
|
+
// reflects the exit code, but callers parse stdout regardless: both
|
|
153
|
+
// chore-run.js and spawn-step.js print structured JSON on failure paths.
|
|
154
|
+
function runNode(script, args) {
|
|
155
|
+
try {
|
|
156
|
+
const stdout = execFileSync("node", [script].concat(args), {
|
|
157
|
+
encoding: "utf8",
|
|
158
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
159
|
+
});
|
|
160
|
+
return { ok: true, stdout: stdout };
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
stdout: (e.stdout || "").toString(),
|
|
165
|
+
stderr: (e.stderr || "").toString(),
|
|
166
|
+
code: e.status,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseJsonLine(text) {
|
|
172
|
+
const line = String(text || "").trim().split("\n").pop() || "";
|
|
173
|
+
if (!line) return { ok: false, reason: "empty stdout" };
|
|
174
|
+
try {
|
|
175
|
+
return { ok: true, value: JSON.parse(line) };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return { ok: false, reason: "unparseable stdout: " + errText(e) };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function choreRunArgv(o) {
|
|
182
|
+
const a = ["--crew-home", o.crewHome, "--task-id", o.taskId, "--tick-seq", String(o.tickSeq)];
|
|
183
|
+
if (o.projectId) a.push("--project-id", o.projectId);
|
|
184
|
+
if (o.visualProtocol === true) a.push("--visual-protocol");
|
|
185
|
+
if (o.visualProtocol === false) a.push("--no-visual-protocol");
|
|
186
|
+
if (o.resolvedWorkflow) a.push("--resolved-workflow", o.resolvedWorkflow);
|
|
187
|
+
if (o.workflowWasNull) a.push("--workflow-was-null");
|
|
188
|
+
if (o.preRefusal) a.push("--pre-refusal", o.preRefusal);
|
|
189
|
+
return a;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// One driver invocation. Returns {frame} or {error}. A parseable frame is
|
|
193
|
+
// used even when the driver exited non-zero (its fail-closed contract
|
|
194
|
+
// still emits the one JSON frame on catastrophic failure).
|
|
195
|
+
function driveOnce(o) {
|
|
196
|
+
const r = runNode(CHORE_RUN, choreRunArgv(o));
|
|
197
|
+
const p = parseJsonLine(r.stdout);
|
|
198
|
+
if (!p.ok) {
|
|
199
|
+
return { error: "chore-run produced no parseable frame (" + p.reason + ")" +
|
|
200
|
+
(r.stderr ? "; stderr: " + r.stderr.slice(0, 300) : "") };
|
|
201
|
+
}
|
|
202
|
+
const frame = p.value;
|
|
203
|
+
if (!frame || (frame.type !== "NEED_SPAWN" && frame.type !== "WAIT" && frame.type !== "DONE")) {
|
|
204
|
+
return { error: "chore-run emitted unknown frame type: " + JSON.stringify(frame).slice(0, 200) };
|
|
205
|
+
}
|
|
206
|
+
return { frame: frame };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Settle a completed spawn session, then let the driver re-derive.
|
|
210
|
+
// Every post outcome is safe to drive through.
|
|
211
|
+
function settleSession(o) {
|
|
212
|
+
const r = runNode(SPAWN_STEP, ["post", "--session", o.session]);
|
|
213
|
+
const p = parseJsonLine(r.stdout);
|
|
214
|
+
if (p.ok && p.value) {
|
|
215
|
+
const v = p.value;
|
|
216
|
+
log("post: closed=" + v.closed + (v.reason ? " reason=" + v.reason : "") +
|
|
217
|
+
(v.row_id ? " row=" + v.row_id : "") + (v.outcome ? " outcome=" + v.outcome : ""));
|
|
218
|
+
} else {
|
|
219
|
+
log("post produced no parseable result; driving anyway (" + p.reason + ")" +
|
|
220
|
+
(r.stderr ? "; stderr: " + r.stderr.slice(0, 200) : ""));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function main() {
|
|
225
|
+
let o;
|
|
226
|
+
try {
|
|
227
|
+
o = parseArgs(RAW_ARGS);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
process.stderr.write(USAGE + "\n\nError: " + errText(e) + "\n");
|
|
230
|
+
process.exit(2);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (o.session) settleSession(o);
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < o.maxFrames; i++) {
|
|
236
|
+
const d = driveOnce(o);
|
|
237
|
+
if (d.error) {
|
|
238
|
+
log(d.error);
|
|
239
|
+
printJson({ action: "error", reason: d.error, task_id: o.taskId });
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
const frame = d.frame;
|
|
243
|
+
if (frame.type === "NEED_SPAWN") {
|
|
244
|
+
const pre = runNode(SPAWN_STEP, ["pre", "--request", JSON.stringify(frame.request || {})]);
|
|
245
|
+
if (pre.ok) {
|
|
246
|
+
const p = parseJsonLine(pre.stdout);
|
|
247
|
+
if (p.ok && p.value && p.value.session_path) {
|
|
248
|
+
printJson({ action: "spawn", spawn: p.value, task_id: o.taskId });
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
log("spawn pre exited 0 with unparseable packet; re-driving");
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// Pre refused (or crashed): transient refusals (DUPLICATE_RUNNING — a
|
|
255
|
+
// spawn is in flight) re-drive so the next derivation resolves to
|
|
256
|
+
// WAIT; the ledger reflects those. Permanent refusals fire before any
|
|
257
|
+
// ledger row opens, so re-driving blindly would loop forever — report
|
|
258
|
+
// the code back to the driver once via --pre-refusal and let it
|
|
259
|
+
// terminate through the normal closeout. Counts as a frame either way.
|
|
260
|
+
const ep = parseJsonLine(pre.stdout);
|
|
261
|
+
const code = (ep.ok && ep.value && ep.value.error && ep.value.error.code) || "pre_failed";
|
|
262
|
+
if (TERMINAL_REFUSAL_CODES.indexOf(code) !== -1) {
|
|
263
|
+
log("spawn pre refused (" + code + "); terminal — informing driver");
|
|
264
|
+
o.preRefusal = code;
|
|
265
|
+
} else {
|
|
266
|
+
log("spawn pre refused (" + code + "); re-driving");
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (frame.type === "WAIT") {
|
|
271
|
+
printJson({
|
|
272
|
+
action: "wait", reason: frame.reason || null, phase: frame.phase || null,
|
|
273
|
+
key: frame.key || null, task_id: o.taskId,
|
|
274
|
+
});
|
|
275
|
+
process.exit(0);
|
|
276
|
+
}
|
|
277
|
+
// DONE
|
|
278
|
+
printJson({
|
|
279
|
+
action: "done", outcome: frame.outcome, task_id: o.taskId,
|
|
280
|
+
phase: frame.phase || null, reason: frame.reason || null,
|
|
281
|
+
});
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
284
|
+
log("frame budget (" + o.maxFrames + ") exhausted; yielding wait");
|
|
285
|
+
printJson({ action: "wait", reason: "frame-budget-exhausted", task_id: o.taskId });
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
main();
|
package/lib/crew-api.js
CHANGED
|
@@ -38,6 +38,57 @@ import { classifySurface } from "./classify-surface.js";
|
|
|
38
38
|
// of crew-api.js is pinned in the workflows' pinLifecycle (PIN_BASENAMES),
|
|
39
39
|
// mechanically verified by tests/pin-closure.test.js.
|
|
40
40
|
import { matchTerminalPublishNote, TERMINAL_NOTE_MEANINGS, assertWritablePublishNote, assertCodeWritablePublishNote } from "./publish-note-vocabulary.js";
|
|
41
|
+
// Spawn-ledger vocabularies (sandbox exit, chore pilot Piece 2, 2026-09-26):
|
|
42
|
+
// the closed spawn kinds, terminal outcome vocabulary, attempt caps, and
|
|
43
|
+
// transport-budget constants. These are INLINED, not imported, deliberately:
|
|
44
|
+
// crew-api.js may only carry runtime-relative dependencies listed in the
|
|
45
|
+
// workflows' PIN_BASENAMES (mechanically verified by tests/pin-closure.test.js),
|
|
46
|
+
// and the workflow files are frozen — a new pin entry cannot be added.
|
|
47
|
+
// The vocabularies are frozen by design (DESIGN-chore-pilot.md §1), so the
|
|
48
|
+
// inline copy cannot drift; lib/chore/spawn-vocab.js remains the canonical
|
|
49
|
+
// source for the bridge CLIs (lib/spawn-step.js, lib/reap-spawns.js).
|
|
50
|
+
const SPAWN_KINDS = Object.freeze([
|
|
51
|
+
"work-agent",
|
|
52
|
+
"verdict-reask",
|
|
53
|
+
"evidence-gatherer",
|
|
54
|
+
]);
|
|
55
|
+
const CLOSED_OUTCOMES = Object.freeze([
|
|
56
|
+
"completed",
|
|
57
|
+
"synthetic",
|
|
58
|
+
"transport_timeout",
|
|
59
|
+
"transport_error",
|
|
60
|
+
"transport_empty",
|
|
61
|
+
"transport_parse",
|
|
62
|
+
"killed_confirmed",
|
|
63
|
+
"killed_by_reaper",
|
|
64
|
+
"orphaned_unkillable",
|
|
65
|
+
"orphaned_unverified",
|
|
66
|
+
"transport_failed",
|
|
67
|
+
]);
|
|
68
|
+
function isClosedOutcome(outcome) {
|
|
69
|
+
return typeof outcome === "string" && CLOSED_OUTCOMES.includes(outcome);
|
|
70
|
+
}
|
|
71
|
+
const TRANSPORT_FAILURE_OUTCOMES = Object.freeze([
|
|
72
|
+
"transport_timeout",
|
|
73
|
+
"transport_error",
|
|
74
|
+
"transport_empty",
|
|
75
|
+
"transport_parse",
|
|
76
|
+
"killed_confirmed",
|
|
77
|
+
"killed_by_reaper",
|
|
78
|
+
"orphaned_unkillable",
|
|
79
|
+
"orphaned_unverified",
|
|
80
|
+
"transport_failed",
|
|
81
|
+
]);
|
|
82
|
+
function isTransportFailureOutcome(outcome) {
|
|
83
|
+
return typeof outcome === "string" && TRANSPORT_FAILURE_OUTCOMES.includes(outcome);
|
|
84
|
+
}
|
|
85
|
+
const MAX_ATTEMPTS = Object.freeze({
|
|
86
|
+
"work-agent": 3,
|
|
87
|
+
"verdict-reask": 2,
|
|
88
|
+
"evidence-gatherer": 3,
|
|
89
|
+
});
|
|
90
|
+
const TRANSPORT_BUDGET_CAP = 5;
|
|
91
|
+
const REAP_MARGIN_MS = 5 * 60 * 1000;
|
|
41
92
|
|
|
42
93
|
// ---------------------------------------------------------------------------
|
|
43
94
|
// Errors
|
|
@@ -272,6 +323,52 @@ function openDb(crewHome) {
|
|
|
272
323
|
const schema = readFileSync(SCHEMA_PATH, "utf8");
|
|
273
324
|
db.exec(schema);
|
|
274
325
|
} else {
|
|
326
|
+
// Migration: worker_runs spawn-ledger columns (sandbox exit, chore
|
|
327
|
+
// pilot Piece 2, 2026-09-26). MUST run before db.exec(schema) below:
|
|
328
|
+
// schema.sql declares worker_runs_spawn_key_idx, and the schema exec
|
|
329
|
+
// would fail with "no such column: spawn_key" on a pre-migration
|
|
330
|
+
// database (hit live on the 0.17.2 -> ce305e9 cell upgrade, 2026-09-26).
|
|
331
|
+
// The production spawn wrapper (lib/spawn-step.js pre/post) opens and
|
|
332
|
+
// closes one worker_runs row per creative spawn (work-agent,
|
|
333
|
+
// verdict-reask, evidence-gatherer); the Step-0 reaper
|
|
334
|
+
// (lib/reap-spawns.js) derives reapability from these columns. Additive
|
|
335
|
+
// and NULL for pre-existing rows (the dispatcher rows); the 3-state
|
|
336
|
+
// status CHECK is unchanged — terminal kill states live in the result
|
|
337
|
+
// JSON's closed outcome vocabulary, never as new statuses. attempt_n
|
|
338
|
+
// is derived by count, never stored. Same idempotent PRAGMA-check
|
|
339
|
+
// pattern; never rebuilds the live table.
|
|
340
|
+
// Skip when the table doesn't exist yet (a partial/legacy database):
|
|
341
|
+
// the schema exec below creates it with all columns.
|
|
342
|
+
const workerRunsExists = db.prepare(
|
|
343
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='worker_runs'"
|
|
344
|
+
).get();
|
|
345
|
+
if (workerRunsExists) {
|
|
346
|
+
const workerRunCols = db.prepare("PRAGMA table_info(worker_runs)").all();
|
|
347
|
+
const workerRunColNames = new Set(workerRunCols.map((c) => c.name));
|
|
348
|
+
const spawnLedgerMigration = [
|
|
349
|
+
["spawn_key", "TEXT"],
|
|
350
|
+
["owner_tick_seq", "INTEGER"],
|
|
351
|
+
// SQLite forbids cross-column CHECKs on ADD COLUMN; the single-column
|
|
352
|
+
// form below references only the new column (same shape as the
|
|
353
|
+
// park_reason migration above).
|
|
354
|
+
["kind", "TEXT CHECK (kind IS NULL OR kind IN ('work-agent','verdict-reask','evidence-gatherer'))"],
|
|
355
|
+
["timeout_ms", "INTEGER"],
|
|
356
|
+
["result", "TEXT"],
|
|
357
|
+
];
|
|
358
|
+
for (const [colName, colType] of spawnLedgerMigration) {
|
|
359
|
+
if (!workerRunColNames.has(colName)) {
|
|
360
|
+
try {
|
|
361
|
+
db.exec(`ALTER TABLE worker_runs ADD COLUMN ${colName} ${colType}`);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
// Another process may have added it concurrently; ignore duplicate-column errors.
|
|
364
|
+
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// The fresh schema declares worker_runs_spawn_key_idx; existing
|
|
369
|
+
// databases need it too. IF NOT EXISTS keeps the migration idempotent.
|
|
370
|
+
db.exec(`CREATE INDEX IF NOT EXISTS worker_runs_spawn_key_idx ON worker_runs (spawn_key)`);
|
|
371
|
+
}
|
|
275
372
|
// Idempotent: CREATE TABLE IF NOT EXISTS covers upgrades that only add.
|
|
276
373
|
const schema = readFileSync(SCHEMA_PATH, "utf8");
|
|
277
374
|
db.exec(schema);
|
|
@@ -1349,6 +1446,182 @@ commands["record-worker-run"] = (db, args) => {
|
|
|
1349
1446
|
return { ok: true, id: args.id, status };
|
|
1350
1447
|
};
|
|
1351
1448
|
|
|
1449
|
+
// get-worker-run: read-only lookup of the latest worker-layer run row for
|
|
1450
|
+
// a task+phase (kind IS NULL — spawn-ledger rows are excluded). The chore
|
|
1451
|
+
// driver uses it to reuse its run-telemetry row across invocations instead
|
|
1452
|
+
// of opening one row per tick; a stale running row left by a killed driver
|
|
1453
|
+
// is reused and closed by the next invocation.
|
|
1454
|
+
commands["get-worker-run"] = (db, args) => {
|
|
1455
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
1456
|
+
if (!args.phase) throw usageError("phase is required.");
|
|
1457
|
+
const row = db.prepare(
|
|
1458
|
+
`SELECT id, task_id, phase, status, started_at, ended_at, error
|
|
1459
|
+
FROM worker_runs WHERE task_id = ? AND phase = ? AND kind IS NULL
|
|
1460
|
+
ORDER BY id DESC LIMIT 1`
|
|
1461
|
+
).get(args.task_id, args.phase);
|
|
1462
|
+
if (!row) throw notFound("no worker run for task " + args.task_id + " phase " + args.phase + ".");
|
|
1463
|
+
return { ok: true, row };
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
// Spawn ledger (sandbox exit, chore pilot Piece 2, 2026-09-26). The
|
|
1467
|
+
// production spawn wrapper (lib/spawn-step.js pre/post) and the Step-0
|
|
1468
|
+
// reaper (lib/reap-spawns.js) keep one worker_runs row per creative spawn.
|
|
1469
|
+
// Crew state stays behind this API: writers never touch the DB directly.
|
|
1470
|
+
//
|
|
1471
|
+
// assess-spawn-pre: read-only pre-flight for spawn-step pre — the
|
|
1472
|
+
// duplicate-running guard (M2), the derived attempt_n (F5: 1 + count of
|
|
1473
|
+
// terminal rows for task/phase/kind), and the consecutive
|
|
1474
|
+
// transport-failure budget for the task. Returns
|
|
1475
|
+
// { duplicate_running, attempt_n, max_attempts, transport_failures,
|
|
1476
|
+
// budget_cap }. The caller enforces the caps; this command only reports.
|
|
1477
|
+
commands["assess-spawn-pre"] = (db, args) => {
|
|
1478
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
1479
|
+
if (!args.phase) throw usageError("phase is required.");
|
|
1480
|
+
if (!args.kind) throw usageError("kind is required.");
|
|
1481
|
+
if (!SPAWN_KINDS.includes(args.kind)) throw usageError("unknown spawn kind: " + args.kind);
|
|
1482
|
+
if (!args.spawn_key) throw usageError("spawn_key is required.");
|
|
1483
|
+
const dup = db.prepare(
|
|
1484
|
+
"SELECT id FROM worker_runs WHERE spawn_key = ? AND status = 'running' LIMIT 1"
|
|
1485
|
+
).get(args.spawn_key);
|
|
1486
|
+
const attemptCount = db.prepare(
|
|
1487
|
+
`SELECT COUNT(*) AS n FROM worker_runs
|
|
1488
|
+
WHERE task_id = ? AND phase = ? AND kind = ? AND status != 'running'`
|
|
1489
|
+
).get(args.task_id, args.phase, args.kind).n;
|
|
1490
|
+
// Consecutive transport failures for the task, newest first: count
|
|
1491
|
+
// transport-failure outcomes until a completed non-transport row resets.
|
|
1492
|
+
// Running rows are in-flight, not terminal — skipped, not counted.
|
|
1493
|
+
// A failed row with an unrecognized outcome counts (fail-closed: an
|
|
1494
|
+
// unknown outcome is a bug, and the safe direction parks the task).
|
|
1495
|
+
const history = db.prepare(
|
|
1496
|
+
`SELECT status, result FROM worker_runs
|
|
1497
|
+
WHERE task_id = ? AND kind IS NOT NULL AND status != 'running'
|
|
1498
|
+
ORDER BY id DESC`
|
|
1499
|
+
).all(args.task_id);
|
|
1500
|
+
let transportFailures = 0;
|
|
1501
|
+
for (const row of history) {
|
|
1502
|
+
let outcome = null;
|
|
1503
|
+
try {
|
|
1504
|
+
const parsed = row.result ? JSON.parse(row.result) : null;
|
|
1505
|
+
outcome = parsed && typeof parsed.outcome === "string" ? parsed.outcome : null;
|
|
1506
|
+
} catch (e) {
|
|
1507
|
+
outcome = null;
|
|
1508
|
+
}
|
|
1509
|
+
if (row.status === "completed" && !isTransportFailureOutcome(outcome)) break;
|
|
1510
|
+
if (isTransportFailureOutcome(outcome)) {
|
|
1511
|
+
transportFailures++;
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
if (row.status === "failed") {
|
|
1515
|
+
// Failed with a non-transport outcome (e.g. a reask row closed
|
|
1516
|
+
// completed-but-unrecovered is 'completed'; a genuinely unknown
|
|
1517
|
+
// failed outcome is a bug) — count fail-closed, except the
|
|
1518
|
+
// verdict-reask terminal failures which are explicitly counted.
|
|
1519
|
+
transportFailures++;
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
// Completed with a transport outcome (should not happen — completed
|
|
1523
|
+
// rows carry completed/synthetic) — count it; the writer is buggy.
|
|
1524
|
+
transportFailures++;
|
|
1525
|
+
}
|
|
1526
|
+
return {
|
|
1527
|
+
ok: true,
|
|
1528
|
+
duplicate_running: !!dup,
|
|
1529
|
+
duplicate_row_id: dup ? dup.id : null,
|
|
1530
|
+
attempt_n: attemptCount + 1,
|
|
1531
|
+
max_attempts: MAX_ATTEMPTS[args.kind],
|
|
1532
|
+
transport_failures: transportFailures,
|
|
1533
|
+
budget_cap: TRANSPORT_BUDGET_CAP,
|
|
1534
|
+
};
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
// record-spawn-open: opens the ledger row. Validates the frozen kind and
|
|
1538
|
+
// re-checks the duplicate-running guard inside the same call (race safety:
|
|
1539
|
+
// two pres for the same key cannot both open). Returns { ok, id } or
|
|
1540
|
+
// { refused: "duplicate_running", row_id }.
|
|
1541
|
+
commands["record-spawn-open"] = (db, args) => {
|
|
1542
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
1543
|
+
if (!args.phase) throw usageError("phase is required.");
|
|
1544
|
+
if (!args.spawn_key) throw usageError("spawn_key is required.");
|
|
1545
|
+
if (!SPAWN_KINDS.includes(args.kind)) throw usageError("unknown spawn kind: " + args.kind);
|
|
1546
|
+
if (!Number.isInteger(args.owner_tick_seq) || args.owner_tick_seq <= 0) {
|
|
1547
|
+
throw usageError("owner_tick_seq must be a positive integer.");
|
|
1548
|
+
}
|
|
1549
|
+
if (!Number.isInteger(args.timeout_ms) || args.timeout_ms <= 0) {
|
|
1550
|
+
throw usageError("timeout_ms must be a positive integer.");
|
|
1551
|
+
}
|
|
1552
|
+
const dup = db.prepare(
|
|
1553
|
+
"SELECT id FROM worker_runs WHERE spawn_key = ? AND status = 'running' LIMIT 1"
|
|
1554
|
+
).get(args.spawn_key);
|
|
1555
|
+
if (dup) return { ok: false, refused: "duplicate_running", row_id: dup.id };
|
|
1556
|
+
const row = db.prepare(
|
|
1557
|
+
`INSERT INTO worker_runs (task_id, phase, executor, status, spawn_key, owner_tick_seq, kind, timeout_ms)
|
|
1558
|
+
VALUES (?, ?, 'worker', 'running', ?, ?, ?, ?) RETURNING id`
|
|
1559
|
+
).get(args.task_id, args.phase, args.spawn_key, args.owner_tick_seq, args.kind, args.timeout_ms);
|
|
1560
|
+
return { ok: true, id: row.id, status: "running" };
|
|
1561
|
+
};
|
|
1562
|
+
|
|
1563
|
+
// record-spawn-close: closes the ledger row with the typed result. The
|
|
1564
|
+
// closed outcome vocabulary is enforced here fail-closed (K3: both writers
|
|
1565
|
+
// go through this command, and validate before calling). Terminal rows are
|
|
1566
|
+
// immutable — a close against an already-terminal row is a reported no-op.
|
|
1567
|
+
commands["record-spawn-close"] = (db, args) => {
|
|
1568
|
+
if (args.id === undefined || args.id === null) throw usageError("id is required to close a spawn row.");
|
|
1569
|
+
if (!["completed", "failed"].includes(args.status)) {
|
|
1570
|
+
throw usageError("status must be completed or failed.");
|
|
1571
|
+
}
|
|
1572
|
+
let outcome = null;
|
|
1573
|
+
let resultJson = null;
|
|
1574
|
+
if (args.result !== undefined && args.result !== null) {
|
|
1575
|
+
const resultObj = typeof args.result === "string" ? JSON.parse(args.result) : args.result;
|
|
1576
|
+
outcome = resultObj && typeof resultObj.outcome === "string" ? resultObj.outcome : null;
|
|
1577
|
+
if (!isClosedOutcome(outcome)) {
|
|
1578
|
+
throw usageError("result.outcome must be in the closed outcome vocabulary; got: " + outcome);
|
|
1579
|
+
}
|
|
1580
|
+
resultJson = JSON.stringify(resultObj);
|
|
1581
|
+
} else {
|
|
1582
|
+
throw usageError("result (with a closed-vocabulary outcome) is required to close a spawn row.");
|
|
1583
|
+
}
|
|
1584
|
+
const result = db.prepare(
|
|
1585
|
+
`UPDATE worker_runs SET status = ?, error = ?, result = ?, ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
1586
|
+
WHERE id = ? AND status = 'running'`
|
|
1587
|
+
).run(args.status, args.error || null, resultJson, args.id);
|
|
1588
|
+
if (result.changes === 0) return { ok: true, noop: true };
|
|
1589
|
+
return { ok: true, id: args.id, status: args.status, outcome };
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
// list-reapable-spawns: the Step-0 reaper's candidate set — rows still
|
|
1593
|
+
// 'running' past timeout_ms + REAP_MARGIN_MS. (The design's owner_ended_at
|
|
1594
|
+
// fast path needs a ticks table that does not exist; the timeout clause
|
|
1595
|
+
// alone is correct and fail-closed — reaping is deferred, never early.)
|
|
1596
|
+
commands["list-reapable-spawns"] = (db, args) => {
|
|
1597
|
+
const marginMs = Number.isInteger(args.reap_margin_ms) && args.reap_margin_ms >= 0
|
|
1598
|
+
? args.reap_margin_ms
|
|
1599
|
+
: REAP_MARGIN_MS;
|
|
1600
|
+
const rows = db.prepare(
|
|
1601
|
+
`SELECT id, task_id, phase, spawn_key, owner_tick_seq, kind, timeout_ms, started_at
|
|
1602
|
+
FROM worker_runs
|
|
1603
|
+
WHERE status = 'running' AND kind IS NOT NULL
|
|
1604
|
+
AND (julianday('now') - julianday(started_at)) * 86400000 > timeout_ms + ?
|
|
1605
|
+
ORDER BY id`
|
|
1606
|
+
).all(marginMs);
|
|
1607
|
+
return { ok: true, spawns: rows, reap_margin_ms: marginMs };
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
// get-spawn-row: fetch one ledger row by id or spawn_key (spawn-step post
|
|
1611
|
+
// locates the row for the session's key; the reaper reads rows it reaps).
|
|
1612
|
+
commands["get-spawn-row"] = (db, args) => {
|
|
1613
|
+
let row = null;
|
|
1614
|
+
if (args.id !== undefined && args.id !== null) {
|
|
1615
|
+
row = db.prepare("SELECT * FROM worker_runs WHERE id = ?").get(args.id);
|
|
1616
|
+
} else if (args.spawn_key) {
|
|
1617
|
+
row = db.prepare("SELECT * FROM worker_runs WHERE spawn_key = ? ORDER BY id DESC LIMIT 1").get(args.spawn_key);
|
|
1618
|
+
} else {
|
|
1619
|
+
throw usageError("id or spawn_key is required.");
|
|
1620
|
+
}
|
|
1621
|
+
if (!row) throw notFound("spawn row not found.");
|
|
1622
|
+
return { ok: true, row };
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1352
1625
|
commands["get-run-timeline"] = (db, args) => {
|
|
1353
1626
|
if (!args.run_id && !args.task_id) throw usageError("run_id or task_id is required.");
|
|
1354
1627
|
let runs;
|
|
@@ -25,12 +25,22 @@
|
|
|
25
25
|
//
|
|
26
26
|
// Usage:
|
|
27
27
|
// node lib/crew-dispatch-worker.js --crew-home <path> [--registry-text <json>]
|
|
28
|
-
// [--read-only] [--max-tasks-per-poll <n>]
|
|
28
|
+
// [--read-only] [--authoritative] [--max-tasks-per-poll <n>]
|
|
29
29
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// (default) Shadow mode: compute claims but perform no writes — no
|
|
31
|
+
// parks, no playtest filing, no completions, no ack, no
|
|
32
|
+
// reservations, no decision logs. The worker_runs row is
|
|
33
|
+
// still recorded (observability, not task state). The
|
|
34
|
+
// default is the safe mode: an invocation without an
|
|
35
|
+
// explicit mode flag can never mutate task state.
|
|
36
|
+
// --read-only Explicit alias for the default shadow mode (backward
|
|
37
|
+
// compatible with the Piece-1 tick's Step 2.5 invocation).
|
|
38
|
+
// --authoritative Acquire claims: reserve-dispatch (same atomic UPSERT
|
|
39
|
+
// the sandboxed dispatcher uses — a live reservation wins,
|
|
40
|
+
// the loser sees acquired:false), acknowledge-poll, the
|
|
41
|
+
// tick-release log, and the dispatch-decision log. Claims
|
|
42
|
+
// carry executor:"worker". The cutover tick invokes this
|
|
43
|
+
// mode explicitly; nothing else should.
|
|
34
44
|
//
|
|
35
45
|
// Output: one JSON object on stdout:
|
|
36
46
|
// { status, message, claims, partial, would_file_playtest, run_id }
|
|
@@ -64,7 +74,11 @@ determines eligible tasks, and returns structured launch claims.
|
|
|
64
74
|
--crew-home <path> Crew home directory (required).
|
|
65
75
|
--registry-text <json> Workflow registry JSON text. When omitted, read
|
|
66
76
|
from <crewHome>/workflows/registry.json.
|
|
67
|
-
--read-only Shadow mode: compute claims, perform no
|
|
77
|
+
--read-only Shadow mode (default): compute claims, perform no
|
|
78
|
+
writes. Explicit alias for the default.
|
|
79
|
+
--authoritative Acquire claims via reserve-dispatch, acknowledge
|
|
80
|
+
the poll, write tick-release and dispatch-decision
|
|
81
|
+
logs. The cutover tick invokes this explicitly.
|
|
68
82
|
--max-tasks-per-poll <n> Override the per-poll task cap (default 6).
|
|
69
83
|
|
|
70
84
|
Output: JSON { status, message, claims, partial, would_file_playtest, run_id }.
|
|
@@ -79,6 +93,7 @@ function parseArgs(argv) {
|
|
|
79
93
|
else if (a === "--crew-home") { out.crewHome = argv[++i]; }
|
|
80
94
|
else if (a === "--registry-text") { out.registryText = argv[++i]; }
|
|
81
95
|
else if (a === "--read-only") { out.readOnly = true; }
|
|
96
|
+
else if (a === "--authoritative") { out.authoritative = true; }
|
|
82
97
|
else if (a === "--max-tasks-per-poll") { out.maxTasksPerPoll = argv[++i]; }
|
|
83
98
|
else { throw transportError("CONFIG", "unknown argument: " + a); }
|
|
84
99
|
}
|
|
@@ -468,9 +483,14 @@ async function main() {
|
|
|
468
483
|
|
|
469
484
|
const crewHome = args.crewHome;
|
|
470
485
|
if (!crewHome) throw transportError("CONFIG", "--crew-home is required");
|
|
486
|
+
if (args.authoritative && args.readOnly) {
|
|
487
|
+
throw transportError("CONFIG", "--authoritative and --read-only are mutually exclusive");
|
|
488
|
+
}
|
|
471
489
|
CREW_HOME = crewHome;
|
|
472
490
|
CREW_API = join(crewHome, "current", "lib", "crew-api.js");
|
|
473
|
-
|
|
491
|
+
// The default is the safe mode: without --authoritative nothing is
|
|
492
|
+
// acquired, acked, or logged. --read-only is an explicit alias for it.
|
|
493
|
+
const readOnly = !args.authoritative;
|
|
474
494
|
const MAX_TASKS_PER_POLL = args.maxTasksPerPoll ? parseInt(args.maxTasksPerPoll, 10) : 6;
|
|
475
495
|
|
|
476
496
|
// worker_runs ledger: record the run start (identity minted SQLite-side).
|
package/lib/crew-release.sh
CHANGED
|
@@ -279,8 +279,13 @@ _validate_lib_entries() {
|
|
|
279
279
|
fi
|
|
280
280
|
|
|
281
281
|
_gate_row() { # file kind verdict exit reason
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
# NOTE: must write via fs.appendFileSync, NOT console.log with a shell
|
|
283
|
+
# redirect. console.log to a redirected file is async; node -e can exit
|
|
284
|
+
# before the write flushes, silently dropping (or truncating) rows and
|
|
285
|
+
# breaking the aggregation parse below. (Found 2026-09-26: the gate
|
|
286
|
+
# rejected a fully-passing release on "no entry rows".)
|
|
287
|
+
node -e 'require("fs").appendFileSync(process.argv[6], JSON.stringify({file:process.argv[1],kind:process.argv[2],verdict:process.argv[3],exit:+process.argv[4],reason:process.argv[5]})+"\n")' \
|
|
288
|
+
"$1" "$2" "$3" "$4" "$5" "$rows"
|
|
284
289
|
}
|
|
285
290
|
|
|
286
291
|
_gate_js() { # $1 = real file path; invoked through $link (the $CREW_HOME/current-shaped symlink)
|