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.
Files changed (70) hide show
  1. package/API.md +11 -0
  2. package/docs/decisions/composition-machinery.md +180 -0
  3. package/docs/decisions/publish-path.md +6 -0
  4. package/docs/decisions/workflow-core.md +5 -4
  5. package/lib/AGENTS.md +2 -1
  6. package/lib/bugfix/phases/build.js +168 -0
  7. package/lib/bugfix/phases/capture.js +170 -0
  8. package/lib/bugfix/phases/integrate.js +165 -0
  9. package/lib/bugfix/phases/map.js +129 -0
  10. package/lib/bugfix/phases/publish.js +592 -0
  11. package/lib/bugfix/phases/qa.js +356 -0
  12. package/lib/bugfix/phases/reproduce.js +254 -0
  13. package/lib/bugfix/phases/review.js +292 -0
  14. package/lib/bugfix/phases/triage.js +69 -0
  15. package/lib/chore/CONTRACT.md +181 -0
  16. package/lib/chore/DISPOSITION.md +98 -0
  17. package/lib/chore/extract.js +204 -0
  18. package/lib/chore/phase-lib.js +885 -0
  19. package/lib/chore/phases/build.js +110 -0
  20. package/lib/chore/phases/capture.js +82 -0
  21. package/lib/chore/phases/integrate.js +109 -0
  22. package/lib/chore/phases/map.js +81 -0
  23. package/lib/chore/phases/publish.js +543 -0
  24. package/lib/chore/phases/review.js +255 -0
  25. package/lib/chore/phases/triage.js +57 -0
  26. package/lib/chore/prompts/evidence-gatherer.js +41 -0
  27. package/lib/chore/prompts/evidence-gatherer.schema.json +1 -0
  28. package/lib/chore/prompts/tool-check.js +15 -0
  29. package/lib/chore/prompts/trailers.js +56 -0
  30. package/lib/chore/prompts/verdict-reask.js +28 -0
  31. package/lib/chore/prompts/verdict-reask.schema.json +1 -0
  32. package/lib/chore/prompts/work-agent.js +52 -0
  33. package/lib/chore/prompts/work-agent.schema.json +1 -0
  34. package/lib/chore/spawn-keys.js +44 -0
  35. package/lib/chore/spawn-vocab.js +87 -0
  36. package/lib/chore-run.js +538 -0
  37. package/lib/chore-tick.js +289 -0
  38. package/lib/crew-api.js +273 -0
  39. package/lib/crew-dispatch-worker.js +27 -7
  40. package/lib/crew-release.sh +7 -2
  41. package/lib/extract.js +252 -0
  42. package/lib/prompts/tool-check.js +18 -0
  43. package/lib/prompts/trailers.js +59 -0
  44. package/lib/prompts/verdict-reask.js +31 -0
  45. package/lib/prompts/verdict-reask.schema.json +1 -0
  46. package/lib/prompts/work-agent.js +56 -0
  47. package/lib/prompts/work-agent.schema.json +1 -0
  48. package/lib/reap-spawns.js +407 -0
  49. package/lib/schema.sql +12 -1
  50. package/lib/spawn-keys.js +47 -0
  51. package/lib/spawn-step.js +572 -0
  52. package/lib/standard/phases/build.js +120 -0
  53. package/lib/standard/phases/capture.js +163 -0
  54. package/lib/standard/phases/integrate.js +172 -0
  55. package/lib/standard/phases/map.js +119 -0
  56. package/lib/standard/phases/publish.js +565 -0
  57. package/lib/standard/phases/qa.js +399 -0
  58. package/lib/standard/phases/review.js +281 -0
  59. package/lib/standard/phases/triage.js +64 -0
  60. package/lib/test-detached-integrate.sh +47 -0
  61. package/lib/workflow-driver.js +605 -0
  62. package/lib/workflow-lib.js +1012 -0
  63. package/lib/workflow-spec.js +187 -0
  64. package/lib/worktree-lifecycle.sh +55 -3
  65. package/package.json +1 -1
  66. package/seed/cron-body-template.md +61 -9
  67. package/workflows/bugfix.js +17 -17
  68. package/workflows/chore.js +16 -16
  69. package/workflows/docs.js +14 -11
  70. 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
+ }