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,407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/reap-spawns.js — the Step-0 reaper (sandbox exit, chore pilot Piece 2,
|
|
3
|
+
// 2026-09-26).
|
|
4
|
+
//
|
|
5
|
+
// Shebang CLI. --help answers with usage on stdout, exit 0.
|
|
6
|
+
//
|
|
7
|
+
// Invoked by the tick worker at Step 0, before any new spawns. Reaps
|
|
8
|
+
// worker_runs rows left 'running' by a dead or wedged owner: a row is
|
|
9
|
+
// reapable when it is past timeout_ms + REAP_MARGIN_MS. (The design's
|
|
10
|
+
// owner_ended_at fast path needs a ticks table that does not exist; the
|
|
11
|
+
// timeout clause alone is correct and fail-closed — reaping is deferred,
|
|
12
|
+
// never early.)
|
|
13
|
+
//
|
|
14
|
+
// Mechanics (the honest shape): a Node process cannot close another tick's
|
|
15
|
+
// subagent handle — only the tick worker's runtime can. So the reaper is
|
|
16
|
+
// the decider and recorder, and the tick worker is the hands:
|
|
17
|
+
//
|
|
18
|
+
// reap-spawns.js --crew-home <home> [--reap-margin-ms <n>]
|
|
19
|
+
// For each reapable row:
|
|
20
|
+
// (1) read the session file — a terminal outcome means the driver died
|
|
21
|
+
// after the outcome: finish the close via `spawn-step.js post`
|
|
22
|
+
// (never kill-and-retry work already done);
|
|
23
|
+
// (2) otherwise, if the child is an OS pid with a verified
|
|
24
|
+
// pid+start-time identity, attempt the kill directly and record
|
|
25
|
+
// killed_by_reaper | orphaned_unkillable | orphaned_unverified;
|
|
26
|
+
// (3) otherwise (subagent child, or no child identity), emit a kill
|
|
27
|
+
// instruction for the tick worker.
|
|
28
|
+
// Prints one JSON line:
|
|
29
|
+
// {closed:[...], kill_instructions:[...], errors:[]}
|
|
30
|
+
// Exit 0 — reaping is normal operation, not a failure.
|
|
31
|
+
//
|
|
32
|
+
// reap-spawns.js --crew-home <home> --record-close --id <row_id>
|
|
33
|
+
// --result <killed|not_found|refused> [--detail ...]
|
|
34
|
+
// The tick worker calls this after attempting the runtime close from
|
|
35
|
+
// a kill instruction. Re-reads the session file first (race: the child
|
|
36
|
+
// may have finished between assess and close — then the close is
|
|
37
|
+
// finished via post semantics instead). Records the kill with machine
|
|
38
|
+
// evidence: killed → killed_by_reaper; not_found → killed_by_reaper
|
|
39
|
+
// ('not found at reap'); refused → orphaned_unkillable. A row with no
|
|
40
|
+
// child identity at all closes as orphaned_unverified (liveness
|
|
41
|
+
// unverifiable; no kill attempted).
|
|
42
|
+
// Prints {recorded:true,row_id,outcome} or {recorded:false,reason}.
|
|
43
|
+
//
|
|
44
|
+
// The reaper MAY: read worker_runs, finish closes, attempt kills with
|
|
45
|
+
// identity match, update rows. It MAY NOT: promote any attempt's output,
|
|
46
|
+
// touch tasks/notes, author verdicts. Unknown kill states keep the row
|
|
47
|
+
// running — a row may only leave 'running' with a closed-vocabulary
|
|
48
|
+
// outcome (enforced by crew-api record-spawn-close).
|
|
49
|
+
//
|
|
50
|
+
// Merge-lock discipline (§7 step 4): the reaper never breaks a merge-lock
|
|
51
|
+
// lease. Lease-break is permitted only from orphaned_unkillable /
|
|
52
|
+
// orphaned_unverified / killed_by_reaper — enforced by the driver
|
|
53
|
+
// (chore-run.js), which reads these outcomes from the ledger.
|
|
54
|
+
|
|
55
|
+
import { execFileSync } from "node:child_process";
|
|
56
|
+
import { readFileSync } from "node:fs";
|
|
57
|
+
import { join, dirname } from "node:path";
|
|
58
|
+
import { fileURLToPath } from "node:url";
|
|
59
|
+
import { REAP_MARGIN_MS, isClosedOutcome } from "./chore/spawn-vocab.js";
|
|
60
|
+
|
|
61
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const CREW_API = join(LIB_DIR, "crew-api.js");
|
|
63
|
+
const SPAWN_STEP = join(LIB_DIR, "spawn-step.js");
|
|
64
|
+
|
|
65
|
+
const USAGE = [
|
|
66
|
+
"usage: reap-spawns.js --crew-home <path> [--reap-margin-ms <n>]",
|
|
67
|
+
" reap-spawns.js --crew-home <path> --record-close --id <row_id> --result <killed|not_found|refused> [--detail <text>]",
|
|
68
|
+
" reap-spawns.js --help",
|
|
69
|
+
"",
|
|
70
|
+
"Step-0 reaper: finish-closes reapable spawn rows whose session file",
|
|
71
|
+
"already holds a terminal outcome, kills verifiable OS-pid children",
|
|
72
|
+
"directly, and emits kill instructions for subagent children (the tick",
|
|
73
|
+
"worker performs the runtime close, then --record-close records it).",
|
|
74
|
+
].join("\n");
|
|
75
|
+
|
|
76
|
+
function log(msg) {
|
|
77
|
+
process.stderr.write("[reap-spawns] " + msg + "\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function printJson(obj) {
|
|
81
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function callCrewApi(crewHome, command, argsObj) {
|
|
85
|
+
const argv = ["--crew-home", crewHome, command];
|
|
86
|
+
if (argsObj !== undefined) argv.push("--json", JSON.stringify(argsObj));
|
|
87
|
+
let stdout;
|
|
88
|
+
try {
|
|
89
|
+
stdout = execFileSync("node", [CREW_API, ...argv], {
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
92
|
+
});
|
|
93
|
+
} catch (e) {
|
|
94
|
+
const detail = (e.stderr && e.stderr.toString()) || e.message;
|
|
95
|
+
throw new Error("crew-api " + command + " failed: " + detail.slice(0, 500));
|
|
96
|
+
}
|
|
97
|
+
return JSON.parse(stdout);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function callSpawnPost(sessionPath, key) {
|
|
101
|
+
// spawn-step post implements the close semantics (classify → closed
|
|
102
|
+
// vocabulary). The reaper delegates finish-closes to it.
|
|
103
|
+
let stdout;
|
|
104
|
+
try {
|
|
105
|
+
stdout = execFileSync("node", [SPAWN_STEP, "post", "--session", sessionPath, "--key", key], {
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
108
|
+
});
|
|
109
|
+
} catch (e) {
|
|
110
|
+
const detail = (e.stderr && e.stderr.toString()) || e.message;
|
|
111
|
+
throw new Error("spawn-step post failed: " + detail.slice(0, 500));
|
|
112
|
+
}
|
|
113
|
+
return JSON.parse(stdout);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sessionPathFor(crewHome, spawnKey) {
|
|
117
|
+
return join(crewHome, ".spawn-sessions", spawnKey + ".json");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readSession(path) {
|
|
121
|
+
try {
|
|
122
|
+
return { ok: true, session: JSON.parse(readFileSync(path, "utf8")) };
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { ok: false, error: e.message };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const TERMINAL_SESSION_OUTCOMES = ["returned", "spawn_error", "synthetic", "timeout"];
|
|
129
|
+
|
|
130
|
+
function sessionIsTerminal(session) {
|
|
131
|
+
if (!session || !TERMINAL_SESSION_OUTCOMES.includes(session.outcome)) return false;
|
|
132
|
+
if (session.outcome === "timeout") {
|
|
133
|
+
// A timeout is terminal only with machine-verified kill evidence;
|
|
134
|
+
// otherwise the row stays running and the reaper verifies.
|
|
135
|
+
const ke = session.kill_evidence || {};
|
|
136
|
+
return ke.verified_dead === true;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── OS-pid kill path ────────────────────────────────────────────────
|
|
142
|
+
// Identity: pid + start-time. /proc/<pid>/stat field 22 (starttime) is the
|
|
143
|
+
// boot-relative tick count — a pid reuse never collides on it. Mismatch →
|
|
144
|
+
// orphaned_unverified, no kill, never.
|
|
145
|
+
|
|
146
|
+
function procStartTime(pid) {
|
|
147
|
+
try {
|
|
148
|
+
const stat = readFileSync("/proc/" + pid + "/stat", "utf8");
|
|
149
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
|
|
150
|
+
// afterComm[0] is state (field 3); starttime (field 22) is index 19.
|
|
151
|
+
return afterComm[19] || null;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function pidAlive(pid) {
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
return true;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return e.code !== "ESRCH";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// /proc/<pid>/stat state field (field 3). A zombie (Z) is dead — kill(pid,0)
|
|
167
|
+
// still succeeds on zombies, so the state field is the authority after a kill,
|
|
168
|
+
// not the liveness probe.
|
|
169
|
+
function procState(pid) {
|
|
170
|
+
try {
|
|
171
|
+
const stat = readFileSync("/proc/" + pid + "/stat", "utf8");
|
|
172
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
|
|
173
|
+
return afterComm[0] || null;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function attemptOsPidKill(crewHome, row, child) {
|
|
180
|
+
const pid = child.pid;
|
|
181
|
+
const recordedStart = String(child.start_time || "");
|
|
182
|
+
const result = (outcome, evidence) =>
|
|
183
|
+
({ outcome, evidence: Object.assign({ method: "os_pid_kill", pid }, evidence || {}) });
|
|
184
|
+
|
|
185
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
186
|
+
return result("orphaned_unverified", { detail: "no usable pid in session child record" });
|
|
187
|
+
}
|
|
188
|
+
const currentStart = procStartTime(pid);
|
|
189
|
+
if (currentStart === null) {
|
|
190
|
+
// No /proc entry: the process is gone.
|
|
191
|
+
return result("killed_by_reaper", { detail: "not found at reap" });
|
|
192
|
+
}
|
|
193
|
+
if (recordedStart && currentStart !== recordedStart) {
|
|
194
|
+
return result("orphaned_unverified", {
|
|
195
|
+
detail: "pid+start-time identity mismatch (pid reused); no kill attempted",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (!recordedStart) {
|
|
199
|
+
return result("orphaned_unverified", {
|
|
200
|
+
detail: "no recorded start-time; identity unverifiable, no kill attempted",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
process.kill(pid, "SIGKILL");
|
|
205
|
+
} catch (e) {
|
|
206
|
+
if (e.code === "ESRCH") {
|
|
207
|
+
return result("killed_by_reaper", { detail: "not found at reap" });
|
|
208
|
+
}
|
|
209
|
+
return result("orphaned_unkillable", { detail: "SIGKILL refused: " + e.code });
|
|
210
|
+
}
|
|
211
|
+
// kill(2) delivers SIGKILL synchronously, but the target needs a scheduling
|
|
212
|
+
// slice to tear down: immediately after the syscall it can still read as
|
|
213
|
+
// state R with kill(pid,0) succeeding. Poll briefly for the terminal state
|
|
214
|
+
// (gone, or zombie Z = dead awaiting parent reap) before judging.
|
|
215
|
+
const deadline = Date.now() + 2000;
|
|
216
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
217
|
+
let state = procState(pid);
|
|
218
|
+
while (state !== null && state !== "Z" && Date.now() < deadline) {
|
|
219
|
+
Atomics.wait(sleeper, 0, 0, 50);
|
|
220
|
+
state = procState(pid);
|
|
221
|
+
}
|
|
222
|
+
// Gone entirely (no /proc entry), or a zombie (Z — dead, awaiting parent
|
|
223
|
+
// reap): the kill worked. kill(pid,0) still succeeds on zombies, so the
|
|
224
|
+
// state field is the authority after a kill, not the liveness probe.
|
|
225
|
+
if (state === null || state === "Z" || !pidAlive(pid)) {
|
|
226
|
+
return result("killed_by_reaper", {
|
|
227
|
+
detail:
|
|
228
|
+
state === "Z"
|
|
229
|
+
? "SIGKILL delivered; process is a zombie (dead, awaiting parent reap)"
|
|
230
|
+
: "SIGKILL delivered; liveness check confirms dead",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return result("orphaned_unkillable", { detail: "SIGKILL delivered but process still alive" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function recordCloseOutcome(crewHome, rowId, outcome, evidence, taskCtx) {
|
|
237
|
+
if (!isClosedOutcome(outcome)) {
|
|
238
|
+
throw new Error("refusing to record outcome outside the closed vocabulary: " + outcome);
|
|
239
|
+
}
|
|
240
|
+
const closed = callCrewApi(crewHome, "record-spawn-close", {
|
|
241
|
+
id: rowId,
|
|
242
|
+
status: "failed",
|
|
243
|
+
result: Object.assign(
|
|
244
|
+
{
|
|
245
|
+
outcome,
|
|
246
|
+
kind: taskCtx.kind,
|
|
247
|
+
task_id: taskCtx.task_id,
|
|
248
|
+
phase: taskCtx.phase,
|
|
249
|
+
spawn_key: taskCtx.spawn_key,
|
|
250
|
+
},
|
|
251
|
+
evidence ? { kill_evidence: evidence } : {}
|
|
252
|
+
),
|
|
253
|
+
});
|
|
254
|
+
return closed;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── assess ──────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
function cmdAssess(crewHome, reapMarginMs) {
|
|
260
|
+
const listed = callCrewApi(crewHome, "list-reapable-spawns", { reap_margin_ms: reapMarginMs });
|
|
261
|
+
const closed = [];
|
|
262
|
+
const killInstructions = [];
|
|
263
|
+
const errors = [];
|
|
264
|
+
|
|
265
|
+
for (const row of listed.spawns || []) {
|
|
266
|
+
const sp = sessionPathFor(crewHome, row.spawn_key);
|
|
267
|
+
try {
|
|
268
|
+
const rs = readSession(sp);
|
|
269
|
+
if (rs.ok && sessionIsTerminal(rs.session)) {
|
|
270
|
+
// The driver died after the outcome: finish the close, never
|
|
271
|
+
// kill-and-retry work already done.
|
|
272
|
+
const postOut = callSpawnPost(sp, row.spawn_key);
|
|
273
|
+
closed.push({
|
|
274
|
+
row_id: row.id,
|
|
275
|
+
spawn_key: row.spawn_key,
|
|
276
|
+
via: "session",
|
|
277
|
+
post: postOut,
|
|
278
|
+
});
|
|
279
|
+
log("reaped row " + row.id + " (" + row.spawn_key + ") via session finish-close");
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const child = rs.ok && rs.session ? rs.session.child : null;
|
|
283
|
+
if (child && child.kind === "os_pid") {
|
|
284
|
+
const killRes = attemptOsPidKill(crewHome, row, child);
|
|
285
|
+
const rec = recordCloseOutcome(crewHome, row.id, killRes.outcome, killRes.evidence, row);
|
|
286
|
+
closed.push({
|
|
287
|
+
row_id: row.id,
|
|
288
|
+
spawn_key: row.spawn_key,
|
|
289
|
+
via: "os_pid_kill",
|
|
290
|
+
outcome: killRes.outcome,
|
|
291
|
+
noop: !!(rec && rec.noop),
|
|
292
|
+
});
|
|
293
|
+
log("reaped row " + row.id + " (" + row.spawn_key + ") via os_pid_kill: " + killRes.outcome);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// Subagent child (or no child identity): Node cannot close it. Emit
|
|
297
|
+
// the instruction; the tick worker performs the runtime close and
|
|
298
|
+
// calls --record-close.
|
|
299
|
+
killInstructions.push({
|
|
300
|
+
row_id: row.id,
|
|
301
|
+
spawn_key: row.spawn_key,
|
|
302
|
+
kind: row.kind,
|
|
303
|
+
task_id: row.task_id,
|
|
304
|
+
phase: row.phase,
|
|
305
|
+
child: child || null,
|
|
306
|
+
reason: rs.ok ? "no terminal session outcome" : "no session file",
|
|
307
|
+
});
|
|
308
|
+
log("kill instruction for row " + row.id + " (" + row.spawn_key + "): child=" + (child ? child.kind : "unknown"));
|
|
309
|
+
} catch (e) {
|
|
310
|
+
errors.push({ row_id: row.id, spawn_key: row.spawn_key, error: e.message });
|
|
311
|
+
log("ERROR reaping row " + row.id + ": " + e.message);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
printJson({ closed, kill_instructions: killInstructions, errors });
|
|
316
|
+
process.exit(0);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── record-close ────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
function cmdRecordClose(crewHome, rowId, result, detail) {
|
|
322
|
+
if (!["killed", "not_found", "refused"].includes(result)) {
|
|
323
|
+
printJson({ recorded: false, reason: "bad_result", detail: "result must be killed|not_found|refused" });
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
let rowRes;
|
|
327
|
+
try {
|
|
328
|
+
rowRes = callCrewApi(crewHome, "get-spawn-row", { id: rowId });
|
|
329
|
+
} catch (e) {
|
|
330
|
+
printJson({ recorded: false, reason: "row_not_found", detail: e.message });
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
const row = rowRes.row;
|
|
334
|
+
if (row.status !== "running") {
|
|
335
|
+
printJson({ recorded: false, reason: "already_closed", row_id: row.id, status: row.status });
|
|
336
|
+
process.exit(0);
|
|
337
|
+
}
|
|
338
|
+
// Race: the child may have finished between assess and the close. If the
|
|
339
|
+
// session now holds a terminal outcome, finish that close instead.
|
|
340
|
+
const rs = readSession(sessionPathFor(crewHome, row.spawn_key));
|
|
341
|
+
if (rs.ok && sessionIsTerminal(rs.session)) {
|
|
342
|
+
const postOut = callSpawnPost(sessionPathFor(crewHome, row.spawn_key), row.spawn_key);
|
|
343
|
+
printJson({ recorded: true, row_id: row.id, via: "session_race", post: postOut });
|
|
344
|
+
process.exit(0);
|
|
345
|
+
}
|
|
346
|
+
const evidence = {
|
|
347
|
+
method: "runtime_close",
|
|
348
|
+
result,
|
|
349
|
+
detail: detail || (result === "not_found" ? "not found at reap" : result === "killed" ? "runtime close confirmed" : "runtime close refused"),
|
|
350
|
+
};
|
|
351
|
+
const outcome = result === "refused" ? "orphaned_unkillable" : "killed_by_reaper";
|
|
352
|
+
let rec;
|
|
353
|
+
try {
|
|
354
|
+
rec = recordCloseOutcome(crewHome, row.id, outcome, evidence, row);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
printJson({ recorded: false, reason: "record_failed", detail: e.message });
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
if (rec && rec.noop) {
|
|
360
|
+
printJson({ recorded: false, reason: "already_closed", row_id: row.id });
|
|
361
|
+
process.exit(0);
|
|
362
|
+
}
|
|
363
|
+
log("recorded close for row " + row.id + ": " + outcome);
|
|
364
|
+
printJson({ recorded: true, row_id: row.id, outcome });
|
|
365
|
+
process.exit(0);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── main ────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
function main() {
|
|
371
|
+
const argv = process.argv.slice(2);
|
|
372
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
373
|
+
process.stdout.write(USAGE + "\n");
|
|
374
|
+
process.exit(0);
|
|
375
|
+
}
|
|
376
|
+
const hi = argv.indexOf("--crew-home");
|
|
377
|
+
const crewHome = hi === -1 ? null : argv[hi + 1];
|
|
378
|
+
if (!crewHome) {
|
|
379
|
+
process.stderr.write("reap-spawns.js: --crew-home <path> is required\n");
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
if (argv.includes("--record-close")) {
|
|
383
|
+
const ii = argv.indexOf("--id");
|
|
384
|
+
const ri = argv.indexOf("--result");
|
|
385
|
+
const di = argv.indexOf("--detail");
|
|
386
|
+
const rowId = ii === -1 ? NaN : parseInt(argv[ii + 1], 10);
|
|
387
|
+
if (!Number.isInteger(rowId) || rowId <= 0) {
|
|
388
|
+
process.stderr.write("reap-spawns.js: --record-close requires --id <row_id>\n");
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
if (ri === -1 || !argv[ri + 1]) {
|
|
392
|
+
process.stderr.write("reap-spawns.js: --record-close requires --result <killed|not_found|refused>\n");
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
cmdRecordClose(crewHome, rowId, argv[ri + 1], di === -1 ? null : argv[di + 1]);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const mi = argv.indexOf("--reap-margin-ms");
|
|
399
|
+
const margin = mi === -1 ? REAP_MARGIN_MS : parseInt(argv[mi + 1], 10);
|
|
400
|
+
if (!Number.isInteger(margin) || margin < 0) {
|
|
401
|
+
process.stderr.write("reap-spawns.js: --reap-margin-ms must be a non-negative integer\n");
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
cmdAssess(crewHome, margin);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
main();
|
package/lib/schema.sql
CHANGED
|
@@ -296,7 +296,18 @@ CREATE TABLE IF NOT EXISTS worker_runs (
|
|
|
296
296
|
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed')),
|
|
297
297
|
error TEXT,
|
|
298
298
|
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
299
|
-
ended_at TEXT
|
|
299
|
+
ended_at TEXT,
|
|
300
|
+
-- Spawn ledger (sandbox exit, chore pilot Piece 2, 2026-09-26): one row
|
|
301
|
+
-- per creative spawn, opened by spawn-step pre and closed by spawn-step
|
|
302
|
+
-- post / the Step-0 reaper. NULL for non-spawn rows (e.g. dispatcher).
|
|
303
|
+
-- Kill states live in the result JSON's closed outcome vocabulary, never
|
|
304
|
+
-- as new statuses. attempt_n is derived by count, never stored.
|
|
305
|
+
spawn_key TEXT NULL,
|
|
306
|
+
owner_tick_seq INTEGER NULL,
|
|
307
|
+
kind TEXT NULL CHECK (kind IS NULL OR kind IN ('work-agent','verdict-reask','evidence-gatherer')),
|
|
308
|
+
timeout_ms INTEGER NULL,
|
|
309
|
+
result TEXT NULL
|
|
300
310
|
);
|
|
301
311
|
CREATE INDEX IF NOT EXISTS worker_runs_task_id_idx ON worker_runs(task_id);
|
|
302
312
|
CREATE INDEX IF NOT EXISTS worker_runs_phase_idx ON worker_runs(phase);
|
|
313
|
+
CREATE INDEX IF NOT EXISTS worker_runs_spawn_key_idx ON worker_runs(spawn_key);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// lib/spawn-keys.js — pure spawn-key builders for the worker-layer
|
|
2
|
+
// phase modules (sandbox exit, Phase C Piece 2).
|
|
3
|
+
//
|
|
4
|
+
// Generalized from lib/chore/spawn-keys.js (which stays live and untouched):
|
|
5
|
+
// every function here is workflow-neutral.
|
|
6
|
+
//
|
|
7
|
+
// Import-safe: pure functions, no I/O, no clock, no randomness.
|
|
8
|
+
// Key logic from workflows/chore.js:
|
|
9
|
+
// attemptKey(base, reworkCount)
|
|
10
|
+
// workRetryKey(stepName, reworkSuffix, attempt)
|
|
11
|
+
// verdictReaskKey(stepName, reworkSuffix, attempt)
|
|
12
|
+
// plus workKeyBase (the first-attempt work key, inline in chore.js as
|
|
13
|
+
// `"work-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "")`).
|
|
14
|
+
//
|
|
15
|
+
// Worker-layer difference (2026-09-26): the source's keys were implicitly
|
|
16
|
+
// scoped per workflow process — the runtime keyed agent() calls by explicit
|
|
17
|
+
// key per process, so `work-Triage` could never collide across tasks. The
|
|
18
|
+
// worker-layer ledger (worker_runs) is global per crew home, so every key
|
|
19
|
+
// carries the task id as a suffix. Without it, task B's driver would read
|
|
20
|
+
// task A's completed `work-Triage` row as its own, and the global
|
|
21
|
+
// duplicate-running guard in record-spawn-open would serialize unrelated
|
|
22
|
+
// tasks. The task suffix is the mechanical equivalent of the runtime's
|
|
23
|
+
// per-process scoping.
|
|
24
|
+
|
|
25
|
+
// attemptKey — replay-key scoping: on rework a re-executed phase mints fresh
|
|
26
|
+
// keys with the same -r<N> suffix as the phase loop. Pure function of inputs,
|
|
27
|
+
// no clock, no randomness (determinism contract).
|
|
28
|
+
export function attemptKey(base, reworkCount) {
|
|
29
|
+
return base + (reworkCount > 0 ? "-r" + reworkCount : "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// workKeyBase — the first-attempt work-agent key for a phase visit.
|
|
33
|
+
export function workKeyBase(taskId, stepName, reworkCount) {
|
|
34
|
+
return "work-" + stepName + (reworkCount > 0 ? "-r" + reworkCount : "") + "-" + taskId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// workRetryKey — transport-retry work-agent keys: work-<Step>[-r<N>]-t<attempt>-<taskId>
|
|
38
|
+
// (attempt is the 1-based retry number: first retry is -t1).
|
|
39
|
+
export function workRetryKey(taskId, stepName, reworkSuffix, attempt) {
|
|
40
|
+
return "work-" + stepName + reworkSuffix + "-t" + attempt + "-" + taskId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// verdictReaskKey — verdict re-ask keys: verdict-reask-<Step>[-r<N>]-a<attempt>-<taskId>
|
|
44
|
+
// (note the -a- infix, not -t-).
|
|
45
|
+
export function verdictReaskKey(taskId, stepName, reworkSuffix, attempt) {
|
|
46
|
+
return "verdict-reask-" + stepName + reworkSuffix + "-a" + attempt + "-" + taskId;
|
|
47
|
+
}
|