muse-crew 0.16.0 → 0.17.0

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.
@@ -0,0 +1,1059 @@
1
+ #!/usr/bin/env node
2
+ // lib/crew-dispatch-worker.js — worker-layer dispatcher (Piece 1, 2026-09-26).
3
+ //
4
+ // Mechanical port of workflows/crew-dispatch.js off the sandboxed workflow
5
+ // runtime and onto the worker layer. The sandboxed dispatcher ferried every
6
+ // operation through agent() calls; this module performs the same operations
7
+ // directly: crew-api.js via execFile (argv, never shell), filesystem reads
8
+ // via node:fs, and the inline scripts the workflow used to ferry through the
9
+ // agent run as plain functions.
10
+ //
11
+ // Decision logic (eligibility, retry caps, simultaneity, playtest rotation)
12
+ // is copied verbatim from the workflow — the two implementations must agree
13
+ // exactly during the Piece 1 shadow period.
14
+ //
15
+ // Substrate (DESIGN-v2 Piece 1):
16
+ // - Per-call timeouts on every execFile (EXEC_TIMEOUT_MS).
17
+ // - Error taxonomy: TRANSPORT_TIMEOUT / TRANSPORT_ERROR / TRANSPORT_PARSE /
18
+ // CONFIG. Transport failures are the orchestrator's fault, never the
19
+ // task's — they fail the tick, not the task.
20
+ // - worker_runs ledger: the run records start/finish via the
21
+ // record-worker-run crew-api command (identity minted SQLite-side).
22
+ // - Determinism: no Date.now(), Math.random(), or new Date() in decision
23
+ // paths — enforced by tests/determinism.test.js (G3).
24
+ // - Claims carry executor: "worker" (the sandboxed workflow tags "sandbox").
25
+ //
26
+ // Usage:
27
+ // node lib/crew-dispatch-worker.js --crew-home <path> [--registry-text <json>]
28
+ // [--read-only] [--max-tasks-per-poll <n>]
29
+ //
30
+ // --read-only 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).
34
+ //
35
+ // Output: one JSON object on stdout:
36
+ // { status, message, claims, partial, would_file_playtest, run_id }
37
+ // Exit 0 on success (even with zero claims); exit 1 on transport/config
38
+ // failure with { ok:false, error, code } on stdout.
39
+
40
+ import { execFile } from "node:child_process";
41
+ import { promisify } from "node:util";
42
+ import { readFileSync, readdirSync, readlinkSync, appendFileSync } from "node:fs";
43
+ import { join, basename, resolve } from "node:path";
44
+
45
+ const execFileAsync = promisify(execFile);
46
+
47
+ // ── Error taxonomy ──────────────────────────────────────────────────
48
+ // TRANSPORT_*: the orchestrator's fault — fail the tick, never the task.
49
+ // CONFIG: bad invocation — fail closed before touching anything.
50
+ function transportError(code, message) {
51
+ const err = new Error(message);
52
+ err.code = code;
53
+ return err;
54
+ }
55
+ const EXEC_TIMEOUT_MS = 30000;
56
+
57
+ // ── Args ────────────────────────────────────────────────────────────
58
+ function printHelp() {
59
+ console.log(`Usage: node lib/crew-dispatch-worker.js --crew-home <path> [--registry-text <json>] [--read-only] [--max-tasks-per-poll <n>]
60
+
61
+ Worker-layer dispatcher (Piece 1 of the sandbox exit). Reads the board,
62
+ determines eligible tasks, and returns structured launch claims.
63
+
64
+ --crew-home <path> Crew home directory (required).
65
+ --registry-text <json> Workflow registry JSON text. When omitted, read
66
+ from <crewHome>/workflows/registry.json.
67
+ --read-only Shadow mode: compute claims, perform no writes.
68
+ --max-tasks-per-poll <n> Override the per-poll task cap (default 6).
69
+
70
+ Output: JSON { status, message, claims, partial, would_file_playtest, run_id }.
71
+ Exit 0 on success; exit 1 on transport/config failure.`);
72
+ }
73
+
74
+ function parseArgs(argv) {
75
+ const out = {};
76
+ for (let i = 0; i < argv.length; i++) {
77
+ const a = argv[i];
78
+ if (a === "--help" || a === "-h") { out.help = true; }
79
+ else if (a === "--crew-home") { out.crewHome = argv[++i]; }
80
+ else if (a === "--registry-text") { out.registryText = argv[++i]; }
81
+ else if (a === "--read-only") { out.readOnly = true; }
82
+ else if (a === "--max-tasks-per-poll") { out.maxTasksPerPoll = argv[++i]; }
83
+ else { throw transportError("CONFIG", "unknown argument: " + a); }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // ── Logging ─────────────────────────────────────────────────────────
89
+ // log() goes to stderr — stdout carries exactly one JSON object.
90
+ function log(msg) {
91
+ process.stderr.write(String(msg) + "\n");
92
+ }
93
+
94
+ // ── crew-api via execFile (argv, never shell) ────────────────────────
95
+ let CREW_HOME = null;
96
+ let CREW_API = null;
97
+
98
+ async function runApi(command, argsObj, opts = {}) {
99
+ const timeout = opts.timeout || EXEC_TIMEOUT_MS;
100
+ const args = ["--crew-home", CREW_HOME, command];
101
+ if (argsObj !== undefined) args.push("--json", JSON.stringify(argsObj));
102
+ let stdout;
103
+ try {
104
+ ({ stdout } = await execFileAsync("node", [CREW_API, ...args], {
105
+ timeout,
106
+ maxBuffer: 16 * 1024 * 1024,
107
+ }));
108
+ } catch (e) {
109
+ if (e.killed) {
110
+ throw transportError("TRANSPORT_TIMEOUT",
111
+ `crew-api ${command} timed out after ${timeout}ms`);
112
+ }
113
+ throw transportError("TRANSPORT_ERROR",
114
+ `crew-api ${command} failed: ${(e.message || String(e)).slice(0, 300)}`);
115
+ }
116
+ return stdout;
117
+ }
118
+
119
+ function parseApiJson(stdout, command) {
120
+ try {
121
+ return JSON.parse(stdout);
122
+ } catch (e) {
123
+ throw transportError("TRANSPORT_PARSE",
124
+ `crew-api ${command} returned unparseable JSON: ${e.message}`);
125
+ }
126
+ }
127
+
128
+ // ── Pure functions (verbatim from workflows/crew-dispatch.js) ───────
129
+ // These MUST stay byte-identical in behavior to the workflow's copies
130
+ // for the Piece 1 shadow comparison to hold.
131
+
132
+ function effectiveVisualProtocol(project) {
133
+ return !!project && project.visual_protocol === true;
134
+ }
135
+
136
+ function parseRegistryText(text) {
137
+ if (text === undefined || text === null) {
138
+ return { ok: false, reason: "registryText is missing" };
139
+ }
140
+ if (typeof text !== "string") {
141
+ return { ok: false, reason: "registryText is not a string" };
142
+ }
143
+ try {
144
+ return { ok: true, registry: JSON.parse(text) };
145
+ } catch (e) {
146
+ return { ok: false, reason: "registryText is not valid JSON: " + e.message };
147
+ }
148
+ }
149
+
150
+ function validateRegistry(reg) {
151
+ var wfNames = ["standard", "bugfix", "chore", "docs", "upgrade"];
152
+ if (!reg || typeof reg !== "object" || Array.isArray(reg)) {
153
+ return { ok: false, reason: "registry is missing or not an object" };
154
+ }
155
+ for (var i = 0; i < wfNames.length; i++) {
156
+ var wf = reg[wfNames[i]];
157
+ if (!wf || typeof wf !== "object") {
158
+ return { ok: false, reason: "workflow '" + wfNames[i] + "' is missing or not an object" };
159
+ }
160
+ if (!Array.isArray(wf.steps)) {
161
+ return { ok: false, reason: "workflow '" + wfNames[i] + "' has no steps array" };
162
+ }
163
+ for (var j = 0; j < wf.steps.length; j++) {
164
+ if (!wf.steps[j] || typeof wf.steps[j].name !== "string") {
165
+ return { ok: false, reason: "workflow '" + wfNames[i] + "' step " + j + " has no name" };
166
+ }
167
+ }
168
+ if (typeof wf.reworkTarget !== "string") {
169
+ return { ok: false, reason: "workflow '" + wfNames[i] + "' has no reworkTarget string" };
170
+ }
171
+ var stepNames = {};
172
+ for (var j = 0; j < wf.steps.length; j++) {
173
+ stepNames[wf.steps[j].name] = true;
174
+ }
175
+ if (!stepNames[wf.reworkTarget]) {
176
+ return { ok: false, reason: "workflow '" + wfNames[i] + "' reworkTarget '" + wf.reworkTarget + "' names no step" };
177
+ }
178
+ }
179
+ return { ok: true };
180
+ }
181
+
182
+ function parseBoardJson(boardReturn) {
183
+ if (typeof boardReturn === "string") {
184
+ try {
185
+ return JSON.parse(boardReturn);
186
+ } catch (e) {
187
+ throw new Error("parseBoardJson: read-board stdout is not valid JSON: " + e.message);
188
+ }
189
+ }
190
+ if (boardReturn !== null && typeof boardReturn === "object") {
191
+ return boardReturn;
192
+ }
193
+ throw new Error(
194
+ "parseBoardJson: expected the read-board return to be a raw stdout string or a parsed board object, got " +
195
+ (boardReturn === null ? "null" : typeof boardReturn)
196
+ );
197
+ }
198
+
199
+ function extractJsonObject(raw) {
200
+ if (raw !== null && typeof raw === "object") return raw;
201
+ if (typeof raw !== "string") {
202
+ throw new Error("extractJsonObject: expected a string or object, got " +
203
+ (raw === null ? "null" : typeof raw));
204
+ }
205
+ try { return JSON.parse(raw.trim()); } catch (e) { /* fall through to scan */ }
206
+ var start = raw.indexOf("{");
207
+ while (start !== -1) {
208
+ var end = matchJsonBrace(raw, start);
209
+ if (end !== -1) {
210
+ try { return JSON.parse(raw.slice(start, end + 1)); } catch (e2) { /* keep scanning */ }
211
+ }
212
+ start = raw.indexOf("{", start + 1);
213
+ }
214
+ throw new Error("extractJsonObject: no parseable JSON object found in agent output");
215
+ }
216
+
217
+ function matchJsonBrace(s, start) {
218
+ var depth = 0, inStr = false, esc = false;
219
+ for (var i = start; i < s.length; i++) {
220
+ var ch = s[i];
221
+ if (inStr) {
222
+ if (esc) esc = false;
223
+ else if (ch === "\\") esc = true;
224
+ else if (ch === "\"") inStr = false;
225
+ } else if (ch === "\"") {
226
+ inStr = true;
227
+ } else if (ch === "{") {
228
+ depth++;
229
+ } else if (ch === "}") {
230
+ depth--;
231
+ if (depth === 0) return i;
232
+ }
233
+ }
234
+ return -1;
235
+ }
236
+
237
+ function projectTaskRecord(t) {
238
+ if (!t || typeof t !== "object") return null;
239
+ var ls = (t.latest_session && typeof t.latest_session === "object") ? t.latest_session : null;
240
+ var retry = (t.retry && typeof t.retry === "object") ? t.retry : null;
241
+ var cf = retry ? retry.consecutive_failures : undefined;
242
+ var rsr = retry ? retry.rejections_since_reset : undefined;
243
+ return {
244
+ id: t.id,
245
+ title: t.title,
246
+ description: t.description,
247
+ state: t.state,
248
+ project: t.project,
249
+ workflow: t.workflow,
250
+ blocked: t.blocked,
251
+ deps: Array.isArray(t.deps) ? t.deps.slice() : [],
252
+ priority: t.priority,
253
+ next_phase: (typeof t.next_phase === "string" && t.next_phase.length > 0) ? t.next_phase : null,
254
+ latest_session: ls ? { id: ls.id, status: ls.status, step: ls.step, notes: ls.notes } : null,
255
+ retry: {
256
+ consecutive_failures: (typeof cf === "number" && cf >= 0) ? cf : 0,
257
+ rejections_since_reset: (typeof rsr === "number" && rsr >= 0) ? rsr : 0
258
+ }
259
+ };
260
+ }
261
+
262
+ function unwrapBoardResult(boardResult) {
263
+ var boardData = boardResult;
264
+ if (boardData && typeof boardData === 'object' && typeof boardData.stdout === 'string' && !Array.isArray(boardData.ready_tasks)) {
265
+ try {
266
+ var parsed = JSON.parse(boardData.stdout);
267
+ if (parsed && typeof parsed === 'object') {
268
+ boardData = parsed;
269
+ }
270
+ } catch (e) { /* fall through */ }
271
+ }
272
+ if (boardData && typeof boardData === 'object' && !Array.isArray(boardData.ready_tasks)) {
273
+ if (boardData.data && typeof boardData.data === 'object' && Array.isArray(boardData.data.ready_tasks)) {
274
+ boardData = boardData.data;
275
+ } else if (boardData.result && typeof boardData.result === 'object') {
276
+ var inner = boardData.result;
277
+ if (inner.data && typeof inner.data === 'object' && Array.isArray(inner.data.ready_tasks)) {
278
+ boardData = inner.data;
279
+ } else if (Array.isArray(inner.ready_tasks)) {
280
+ boardData = inner;
281
+ }
282
+ }
283
+ }
284
+ if (boardData && typeof boardData === 'object' && typeof boardData.result === 'string' && !Array.isArray(boardData.ready_tasks)) {
285
+ var parsedResult = null, resultParsed = false;
286
+ try {
287
+ parsedResult = JSON.parse(boardData.result);
288
+ resultParsed = true;
289
+ } catch (e) { /* fall through */ }
290
+ if (resultParsed) {
291
+ if (Array.isArray(parsedResult)) {
292
+ boardData.ready_tasks = parsedResult;
293
+ } else if (parsedResult && typeof parsedResult === 'object') {
294
+ return unwrapBoardResult(parsedResult);
295
+ }
296
+ }
297
+ }
298
+ if (boardData && typeof boardData === 'object' && typeof boardData.ready_tasks === 'string') {
299
+ try {
300
+ var parsedReadyTasks = JSON.parse(boardData.ready_tasks);
301
+ if (Array.isArray(parsedReadyTasks)) {
302
+ boardData.ready_tasks = parsedReadyTasks;
303
+ } else if (parsedReadyTasks && typeof parsedReadyTasks === 'object') {
304
+ return unwrapBoardResult(parsedReadyTasks);
305
+ }
306
+ } catch (e) { /* fall through */ }
307
+ }
308
+ if (!boardData || typeof boardData !== 'object' || !Array.isArray(boardData.ready_tasks)) {
309
+ throw new Error(
310
+ "unwrapBoardResult: unknown board envelope — expected ready_tasks at top level, " +
311
+ "in .data, in .result (object or JSON string), in .result.data, or as a JSON string of a ready_tasks array " +
312
+ "or of a whole board object. Got keys: " +
313
+ (boardData && typeof boardData === 'object' ? Object.keys(boardData).join(",") : typeof boardData)
314
+ );
315
+ }
316
+ return boardData;
317
+ }
318
+
319
+ const PLAYTEST_PREFIX = "Idle playtest: ";
320
+
321
+ function isPlaytestTask(t) {
322
+ return !!t && typeof t.title === "string" && t.title.indexOf(PLAYTEST_PREFIX) === 0;
323
+ }
324
+
325
+ function parseJourneys(md) {
326
+ var journeys = [];
327
+ if (!md || !String(md).trim()) return journeys;
328
+ var lines = String(md).split("\n");
329
+ var cur = null;
330
+ for (var i = 0; i < lines.length; i++) {
331
+ var m = lines[i].match(/^##\s+(.+?)\s*$/);
332
+ if (m) {
333
+ if (cur) { cur.body = cur.body.trim(); journeys.push(cur); }
334
+ cur = { name: m[1], body: "" };
335
+ } else if (cur) {
336
+ cur.body += lines[i] + "\n";
337
+ }
338
+ }
339
+ if (cur) { cur.body = cur.body.trim(); journeys.push(cur); }
340
+ return journeys.filter(function (j) { return j.name.length > 0; });
341
+ }
342
+
343
+ function configInt(cfg, key, fallback) {
344
+ var n = parseInt(cfg[key], 10);
345
+ return isNaN(n) ? fallback : n;
346
+ }
347
+
348
+ function decideRetry(consecutiveFailures, maxFailures) {
349
+ if (!(maxFailures > 0)) return "retry";
350
+ if (typeof consecutiveFailures !== "number" || consecutiveFailures < 0) return "retry";
351
+ return consecutiveFailures >= maxFailures ? "park" : "retry";
352
+ }
353
+
354
+ function buildPlaytestDescription(journey, personaName, personaFile, maxFilings, crewHome) {
355
+ var filingRule = maxFilings === 0
356
+ ? "File no follow-up tasks on this run (max_filings is 0). Note anything you find in your summary instead."
357
+ : "File follow-up tasks for real issues you find: at most " + maxFilings + " on this run (max_filings). If you find more, note the extras in your summary.";
358
+ var body = journey.body.length > 3000 ? journey.body.slice(0, 3000) + "\n[truncated]" : journey.body;
359
+ return (
360
+ "IDLE PLAYTEST — this task carries no code change. You are Hazel, code-blind QA.\n" +
361
+ "\n" +
362
+ "Assignment: journey \"" + journey.name + "\" as the \"" + personaName + "\" persona.\n" +
363
+ "Persona file: " + crewHome + "/.orchestration/personas/" + personaFile + " — read it first and wear that perspective for the whole run.\n" +
364
+ "\n" +
365
+ "Journey:\n" + body + "\n" +
366
+ "\n" +
367
+ "Rules:\n" +
368
+ "- Walk the journey end to end from a user's perspective, wearing the persona.\n" +
369
+ "- Use the normal QA surface for this project (the published artifact's own user interface for artifact projects; read-only dashboard API checks otherwise). Note: there is currently no agent-callable visual-inspection tool (artifact_inspect was removed by the platform 2026-09-14; artifact.inspect is malfunction diagnosis, not a substitute) — judge only what you can observe directly.\n" +
370
+ "- There is no code change under test: skip change-verification steps (provenance / publish checks) and judge only what you observe.\n" +
371
+ "- " + filingRule + "\n" +
372
+ "- File via the existing createtask action with filed_by \"hazel\": set workflow to \"standard\" when the fix is clear feature work, or omit workflow (untriaged) when unsure — triage routes untriaged filings to bugfix/feature as usual.\n"
373
+ ).slice(0, 4800);
374
+ }
375
+
376
+ // ── Inline scripts (were node -e ferries in the workflow) ──────────
377
+ // Tick-release identity log: one append-only line per dispatcher run in
378
+ // $CREW_HOME/.tick-releases.jsonl. seq is the non-empty line count + 1;
379
+ // file order is the proof (no wall-clock).
380
+ function writeTickRelease(crewHome) {
381
+ const release = basename(resolve(crewHome, readlinkTarget(join(crewHome, "current"))));
382
+ const file = join(crewHome, ".tick-releases.jsonl");
383
+ let count = 0;
384
+ try {
385
+ const lines = readFileSync(file, "utf8").split("\n");
386
+ for (const l of lines) { if (l.trim() !== "") count++; }
387
+ } catch (e) { /* missing file: count stays 0 */ }
388
+ const line = JSON.stringify({ seq: count + 1, release });
389
+ appendFileSync(file, line + "\n");
390
+ return line;
391
+ }
392
+
393
+ function readlinkTarget(p) {
394
+ // Minimal readlink for the release symlink; throws on failure.
395
+ return readlinkSync(p);
396
+ }
397
+
398
+ function countNonEmptyLines(file) {
399
+ let count = 0;
400
+ try {
401
+ const lines = readFileSync(file, "utf8").split("\n");
402
+ for (const l of lines) { if (l.trim() !== "") count++; }
403
+ } catch (e) { /* missing file: count stays 0 */ }
404
+ return count;
405
+ }
406
+
407
+ // Shadow evidence (Piece 1, 2026-09-26): in --read-only mode the worker
408
+ // dispatcher appends one line per run to $CREW_HOME/.dispatch-shadow.jsonl.
409
+ // The line carries the normalized claim set the worker WOULD have launched,
410
+ // plus tick_seq_before — the .tick-releases line count BEFORE this tick's
411
+ // authoritative dispatcher runs. The authoritative decision line pairs
412
+ // mechanically: decision.tick_seq === shadow.tick_seq_before + 1 (the
413
+ // sandbox dispatcher writes its tick-release line, then its decision).
414
+ // lib/compare-dispatch-shadow.js performs the pairing and the verdict;
415
+ // no agent ever transcribes claims.
416
+ function writeShadowEvidence(crewHome, evidence) {
417
+ if (!evidence || !Array.isArray(evidence.claims)) {
418
+ throw new Error("bad shadow evidence payload");
419
+ }
420
+ const file = join(crewHome, ".dispatch-shadow.jsonl");
421
+ const seq = countNonEmptyLines(file) + 1;
422
+ const tickSeqBefore = countNonEmptyLines(join(crewHome, ".tick-releases.jsonl"));
423
+ const line = JSON.stringify({
424
+ seq, mode: "shadow", tick_seq_before: tickSeqBefore,
425
+ run_id: evidence.run_id || null,
426
+ claims: evidence.claims,
427
+ board: evidence.board || null, skipped: evidence.skipped || {},
428
+ partial: !!evidence.partial,
429
+ });
430
+ appendFileSync(file, line + "\n");
431
+ return line;
432
+ }
433
+
434
+ // Dispatch-decision log: one append-only line per completed tick in
435
+ // $CREW_HOME/.dispatch-decisions.jsonl. tick_seq couples to the
436
+ // .tick-releases line for the same poll.
437
+ function writeDispatchDecision(crewHome, decision) {
438
+ if (!decision || typeof decision.decision !== "string" || !Array.isArray(decision.launched)) {
439
+ throw new Error("bad decision payload");
440
+ }
441
+ const release = basename(resolve(crewHome, readlinkTarget(join(crewHome, "current"))));
442
+ const file = join(crewHome, ".dispatch-decisions.jsonl");
443
+ let count = 0;
444
+ try {
445
+ const lines = readFileSync(file, "utf8").split("\n");
446
+ for (const l of lines) { if (l.trim() !== "") count++; }
447
+ } catch (e) { /* missing file */ }
448
+ let tickSeq = 0;
449
+ try {
450
+ const tlines = readFileSync(join(crewHome, ".tick-releases.jsonl"), "utf8").split("\n");
451
+ for (const l of tlines) { if (l.trim() !== "") tickSeq++; }
452
+ } catch (e) { /* missing file */ }
453
+ const line = JSON.stringify({
454
+ seq: count + 1, tick_seq: tickSeq, release,
455
+ decision: decision.decision, launched: decision.launched,
456
+ completed: decision.completed || [], parked: decision.parked || [],
457
+ board: decision.board || null, skipped: decision.skipped || {},
458
+ partial: !!decision.partial,
459
+ });
460
+ appendFileSync(file, line + "\n");
461
+ return line;
462
+ }
463
+
464
+ // ── Main ────────────────────────────────────────────────────────────
465
+ async function main() {
466
+ const args = parseArgs(process.argv.slice(2));
467
+ if (args.help) { printHelp(); return; }
468
+
469
+ const crewHome = args.crewHome;
470
+ if (!crewHome) throw transportError("CONFIG", "--crew-home is required");
471
+ CREW_HOME = crewHome;
472
+ CREW_API = join(crewHome, "current", "lib", "crew-api.js");
473
+ const readOnly = !!args.readOnly;
474
+ const MAX_TASKS_PER_POLL = args.maxTasksPerPoll ? parseInt(args.maxTasksPerPoll, 10) : 6;
475
+
476
+ // worker_runs ledger: record the run start (identity minted SQLite-side).
477
+ let runId = null;
478
+ try {
479
+ const startOut = await runApi("record-worker-run", { phase: "dispatch", status: "running" });
480
+ runId = parseApiJson(startOut, "record-worker-run").id;
481
+ } catch (e) {
482
+ // The ledger is observability — a ledger failure must not fail dispatch.
483
+ log("WARNING: worker_runs start record failed: " + e.message);
484
+ }
485
+ const finishRun = async (status, error) => {
486
+ if (runId === null || runId === undefined) return;
487
+ try {
488
+ await runApi("record-worker-run", { id: runId, phase: "dispatch", status, error: error || null });
489
+ } catch (e) {
490
+ log("WARNING: worker_runs finish record failed: " + e.message);
491
+ }
492
+ };
493
+
494
+ try {
495
+ const result = await dispatch({ readOnly, MAX_TASKS_PER_POLL, registryText: args.registryText, runId });
496
+ await finishRun("completed", null);
497
+ console.log(JSON.stringify(result));
498
+ } catch (e) {
499
+ await finishRun("failed", (e.message || String(e)).slice(0, 500));
500
+ const code = e.code || "TRANSPORT_ERROR";
501
+ console.log(JSON.stringify({ ok: false, error: e.message || String(e), code }));
502
+ process.exit(1);
503
+ }
504
+ }
505
+
506
+ async function dispatch(opts) {
507
+ const { readOnly, MAX_TASKS_PER_POLL, registryText, runId } = opts;
508
+ const crewHome = CREW_HOME;
509
+
510
+ // ── Registry ──────────────────────────────────────────────────
511
+ let registryResult = null;
512
+ const registryTextResult = parseRegistryText(
513
+ registryText !== undefined ? registryText : readRegistryFile(crewHome)
514
+ );
515
+ if (registryTextResult.ok) {
516
+ registryResult = registryTextResult.registry;
517
+ } else if (registryText !== undefined && registryText !== null) {
518
+ log("WARNING: " + registryTextResult.reason + " — failing closed (no slow load on the worker layer)");
519
+ throw transportError("CONFIG", "registry: " + registryTextResult.reason);
520
+ }
521
+ // On the worker layer there is no slow agent load: the registry file is
522
+ // read directly from disk. A missing/unusable registry fails closed.
523
+ const registryCheck = validateRegistry(registryResult);
524
+ if (!registryCheck.ok) {
525
+ throw transportError("CONFIG", "registry failed shape validation (" + registryCheck.reason + ")");
526
+ }
527
+
528
+ const WORKFLOWS = {};
529
+ const BUILD_STEPS = {};
530
+ const wfNames = ["standard", "bugfix", "chore", "docs", "upgrade"];
531
+ for (const wfName of wfNames) {
532
+ const wfData = registryResult[wfName];
533
+ WORKFLOWS[wfName] = wfData.steps.map((s) => s.name);
534
+ BUILD_STEPS[wfName] = wfData.reworkTarget;
535
+ }
536
+
537
+ // ── 1. Read the board (2 attempts, like the workflow) ─────────
538
+ let boardReturn = null;
539
+ let boardReadError = null;
540
+ for (let readAttempt = 1; readAttempt <= 2; readAttempt++) {
541
+ try {
542
+ const stdout = await runApi("get-dispatch-state");
543
+ parseBoardJson(stdout); // parse eagerly so a malformed ferry retries
544
+ boardReturn = stdout;
545
+ boardReadError = null;
546
+ break;
547
+ } catch (readErr) {
548
+ boardReadError = readErr;
549
+ log("WARNING: read-board attempt " + readAttempt + " failed (" +
550
+ (readErr && readErr.message ? readErr.message : readErr) + ")" +
551
+ (readAttempt < 2 ? " — retrying" : " — attempts exhausted"));
552
+ boardReturn = null;
553
+ }
554
+ }
555
+ if (boardReturn === null) {
556
+ throw transportError("TRANSPORT_ERROR",
557
+ "read-board: all 2 attempts failed: " +
558
+ (boardReadError && boardReadError.message ? boardReadError.message : boardReadError));
559
+ }
560
+
561
+ const boardData = unwrapBoardResult(parseBoardJson(boardReturn));
562
+ const allTasks = boardData.ready_tasks.map(projectTaskRecord).filter((t) => t !== null);
563
+ const config = boardData.config || {};
564
+ const projects = boardData.projects || [];
565
+
566
+ const reservedTaskIds = new Set((boardData.reservations || []).map((r) => r.task_id));
567
+ if (reservedTaskIds.size > 0) {
568
+ log("Reservations: skipping " + reservedTaskIds.size + " task(s) with an active dispatch reservation");
569
+ }
570
+
571
+ const DEFAULT_PROJECT = projects.length > 0 ? projects[0].id : "";
572
+
573
+ const quiescedProjects = {};
574
+ for (const p of projects) { if (p.quiesced) quiescedProjects[p.id] = true; }
575
+
576
+ const projectSimultaneity = {};
577
+ for (const p of projects) {
578
+ const rawSim = Number(p.simultaneity);
579
+ projectSimultaneity[p.id] = isNaN(rawSim) ? 2 : rawSim;
580
+ }
581
+ const rawGlobal = Number(config.simultaneity);
582
+ const globalSimultaneity = isNaN(rawGlobal) ? 1 : rawGlobal;
583
+
584
+ const PROJECTS = {};
585
+ for (const proj of projects) {
586
+ PROJECTS[proj.id] = {
587
+ repo_path: proj.repo_path,
588
+ deploy_type: proj.deploy_type,
589
+ deploy_slug: proj.deploy_slug,
590
+ description: proj.description,
591
+ environment_type: proj.environment_type || null,
592
+ versioned_build: !!proj.versioned_build,
593
+ version_file: proj.version_file || null,
594
+ };
595
+ }
596
+
597
+ log("Board: " + allTasks.length + " active tasks, quiesced=" + Object.keys(quiescedProjects).join(","));
598
+
599
+ // ── 2. Eligibility (verbatim decision logic) ──────────────────
600
+ const eligible = [];
601
+ const retryCandidates = [];
602
+ const rejectionCandidates = [];
603
+ const decisionSkipped = {};
604
+ const countSkipped = (reason) => { decisionSkipped[reason] = (decisionSkipped[reason] || 0) + 1; };
605
+
606
+ const MAX_CONSECUTIVE_FAILURES = configInt(config, "maxConsecutiveFailures", 3);
607
+ const MAX_CONSECUTIVE_REJECTIONS = configInt(config, "maxConsecutiveRejections", 2);
608
+
609
+ const qaStepIndex = (workflowName) => {
610
+ const steps = WORKFLOWS[workflowName] || WORKFLOWS.standard;
611
+ const idx = steps.indexOf("QA");
612
+ return idx >= 0 ? idx : 0;
613
+ };
614
+
615
+ for (const task of allTasks) {
616
+ if (task.blocked) { countSkipped("blocked"); continue; }
617
+ if (reservedTaskIds.has(task.id)) {
618
+ countSkipped("reserved");
619
+ log("Skipped \"" + task.title + "\" — active dispatch reservation (workflow launched, claim pending)");
620
+ continue;
621
+ }
622
+ const taskProject = task.project || DEFAULT_PROJECT;
623
+ if (quiescedProjects[taskProject]) {
624
+ countSkipped("quiesced");
625
+ log("Skipped \"" + task.title + "\" — project " + taskProject + " is quiesced");
626
+ continue;
627
+ }
628
+ const taskProjCfg = PROJECTS[taskProject];
629
+ if (!taskProjCfg || !taskProjCfg.repo_path) {
630
+ countSkipped("no_repo");
631
+ log("Skipped \"" + task.title + "\" — project " + taskProject + " has no repo_path configured");
632
+ continue;
633
+ }
634
+
635
+ const latest = task.latest_session;
636
+ const workflow = task.workflow || "standard";
637
+ const steps = WORKFLOWS[workflow] || WORKFLOWS.standard;
638
+
639
+ if (task.next_phase) {
640
+ if (task.state !== "todo" && task.state !== "in_progress") {
641
+ countSkipped("next_phase_state");
642
+ log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" set but state is " + task.state + "; left set for inspection");
643
+ continue;
644
+ }
645
+ if (latest && latest.status === "running") { countSkipped("in_flight"); continue; }
646
+ const npIdx = steps.indexOf(task.next_phase);
647
+ if (npIdx < 0) {
648
+ countSkipped("next_phase_bad_step");
649
+ log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" not in " + workflow + " step registry; left set for a corrected recover-task");
650
+ continue;
651
+ }
652
+ eligible.push({ task, startStep: npIdx, reason: "next_phase", workflow, nextPhase: task.next_phase });
653
+ continue;
654
+ }
655
+
656
+ if (task.state === "todo") {
657
+ const todoStart = isPlaytestTask(task) ? qaStepIndex(task.workflow || "standard") : 0;
658
+ eligible.push({ task, startStep: todoStart, reason: "new", workflow });
659
+ continue;
660
+ }
661
+
662
+ if (task.state !== "in_progress") { countSkipped("terminal_state"); continue; }
663
+ if (!latest) {
664
+ eligible.push({ task, startStep: 0, reason: "no_session", workflow });
665
+ continue;
666
+ }
667
+ if (latest.status === "running") { countSkipped("in_flight"); continue; }
668
+
669
+ if (latest.status === "completed") {
670
+ const stepName = latest.step || "";
671
+ const stepIndex = steps.indexOf(stepName);
672
+ if (stepIndex < 0) {
673
+ countSkipped("unknown_step");
674
+ log("Skipped \"" + task.title + "\" — step \"" + stepName + "\" not in " + workflow + " step registry");
675
+ continue;
676
+ }
677
+ const nextIndex = stepIndex + 1;
678
+ if (nextIndex >= steps.length) {
679
+ eligible.push({ task, action: "complete", workflow });
680
+ } else {
681
+ eligible.push({ task, startStep: nextIndex, reason: "next_step", workflow });
682
+ }
683
+ continue;
684
+ }
685
+
686
+ if (latest.status === "rejected") {
687
+ const buildStepName = BUILD_STEPS[workflow] || "Build";
688
+ const buildIdx = steps.indexOf(buildStepName);
689
+ if (buildIdx >= 0) {
690
+ rejectionCandidates.push({ task, startStep: buildIdx, reason: "rework", workflow, rejectionNotes: latest.notes || "" });
691
+ }
692
+ continue;
693
+ }
694
+
695
+ if (latest.status === "failed" || latest.status === "timed_out" || latest.status === "stalled") {
696
+ const failedStep = latest.step || "";
697
+ const retryIdx = steps.indexOf(failedStep);
698
+ if (retryIdx < 0) {
699
+ countSkipped("unknown_step");
700
+ log("Skipped \"" + task.title + "\" — step \"" + failedStep + "\" not in " + workflow + " step registry");
701
+ continue;
702
+ }
703
+ retryCandidates.push({ task, startStep: retryIdx, reason: "retry", workflow, failedStep });
704
+ continue;
705
+ }
706
+ }
707
+
708
+ // ── 2a. Retry cap and rejection budget ────────────────────────
709
+ const parkJobs = [];
710
+ for (const rc of retryCandidates) {
711
+ const retryState = (rc.task && rc.task.retry) || null;
712
+ const consecutiveFailures = retryState ? retryState.consecutive_failures : undefined;
713
+ if (typeof consecutiveFailures !== "number" || consecutiveFailures < 0) {
714
+ log("WARNING: invalid retry state for \"" + rc.task.title + "\" (consecutive_failures=" + String(consecutiveFailures) + ") — retrying, cap unenforced");
715
+ } else if (retryState == null) {
716
+ log("WARNING: no explicit retry state for \"" + rc.task.title + "\" — retrying (dashboard predates retry field)");
717
+ }
718
+ if (decideRetry(consecutiveFailures, MAX_CONSECUTIVE_FAILURES) === "park") {
719
+ parkJobs.push({ task: rc.task, message: ("Parked: " + rc.failedStep + " failed " + consecutiveFailures + " consecutive times (cap " + MAX_CONSECUTIVE_FAILURES + ")").slice(0, 1000) });
720
+ } else {
721
+ eligible.push({ task: rc.task, startStep: rc.startStep, reason: "retry", workflow: rc.workflow });
722
+ }
723
+ }
724
+ for (const jc of rejectionCandidates) {
725
+ const jcRetryState = (jc.task && jc.task.retry) || null;
726
+ const rejectionsSinceReset = jcRetryState ? jcRetryState.rejections_since_reset : undefined;
727
+ if (typeof rejectionsSinceReset !== "number" || rejectionsSinceReset < 0) {
728
+ log("WARNING: invalid retry state for \"" + jc.task.title + "\" (rejections_since_reset=" + String(rejectionsSinceReset) + ") — reworking, budget unenforced");
729
+ } else if (jcRetryState == null) {
730
+ log("WARNING: no explicit retry state for \"" + jc.task.title + "\" — reworking (dashboard predates retry field)");
731
+ }
732
+ if (decideRetry(rejectionsSinceReset, MAX_CONSECUTIVE_REJECTIONS) === "park") {
733
+ parkJobs.push({ task: jc.task, message: ("Parked: " + rejectionsSinceReset + " rejections since reset (budget " + MAX_CONSECUTIVE_REJECTIONS + ") — needs human review").slice(0, 1000) });
734
+ } else {
735
+ eligible.push({ task: jc.task, startStep: jc.startStep, reason: "rework", workflow: jc.workflow, rejectionNotes: jc.rejectionNotes });
736
+ }
737
+ }
738
+
739
+ // ── Parks (skipped in read-only) ──────────────────────────────
740
+ const parkedTaskIds = [];
741
+ if (!readOnly && parkJobs.length > 0) {
742
+ for (const pj of parkJobs) {
743
+ log("Parking \"" + pj.task.title + "\" — " + pj.message);
744
+ try {
745
+ await runApi("park-task", { task_id: pj.task.id, message: pj.message });
746
+ parkedTaskIds.push(pj.task.id);
747
+ } catch (parkErr) {
748
+ log("WARNING: park-task failed for " + pj.task.id + " (" + parkErr.message + ") — task remains retryable");
749
+ }
750
+ }
751
+ log("Parked " + parkedTaskIds.length + "/" + parkJobs.length + " task(s)");
752
+ } else if (parkJobs.length > 0) {
753
+ log("Read-only: would park " + parkJobs.length + " task(s), skipping");
754
+ }
755
+
756
+ log("Eligible: " + eligible.length + " tasks (" + retryCandidates.length + " retry candidates)");
757
+
758
+ // ── 2b. Idle playtesting ─────────────────────────────────────
759
+ let wouldFilePlaytest = false;
760
+ let playtestTitle = null;
761
+ if (eligible.length === 0) {
762
+ const ptProject = DEFAULT_PROJECT;
763
+ let ptArmed = true;
764
+ let ptWhy = "";
765
+ if (!ptProject) { ptArmed = false; ptWhy = "no default project"; }
766
+ if (ptArmed && quiescedProjects[ptProject]) { ptArmed = false; ptWhy = "project " + ptProject + " is quiesced"; }
767
+ if (ptArmed) {
768
+ for (const gt of allTasks) {
769
+ if (isPlaytestTask(gt) && (gt.state === "todo" || gt.state === "in_progress")) {
770
+ ptArmed = false; ptWhy = "playtest already outstanding: " + gt.id;
771
+ break;
772
+ }
773
+ }
774
+ }
775
+ let ptJourneys = [];
776
+ if (ptArmed) {
777
+ ptJourneys = parseJourneys(config["playtest." + ptProject + ".journeys"]);
778
+ if (ptJourneys.length === 0) { ptArmed = false; ptWhy = "no journeys roster for project " + ptProject + " (unarmed)"; }
779
+ }
780
+ let ptPersonas = [];
781
+ if (ptArmed) {
782
+ try {
783
+ ptPersonas = readdirSync(join(crewHome, ".orchestration", "personas"))
784
+ .filter((f) => typeof f === "string" && f.slice(-3).toLowerCase() === ".md")
785
+ .sort();
786
+ } catch (e) {
787
+ ptPersonas = [];
788
+ }
789
+ if (ptPersonas.length === 0) { ptArmed = false; ptWhy = "no persona files in " + crewHome + "/.orchestration/personas"; }
790
+ }
791
+
792
+ if (ptArmed) {
793
+ const J = ptJourneys.length;
794
+ const P = ptPersonas.length;
795
+ const jc = configInt(config, "playtest." + ptProject + ".journey_cursor", 0);
796
+ const pc = configInt(config, "playtest." + ptProject + ".persona_cursor", 0);
797
+ const pti = ((jc % J) + J) % J;
798
+ const ppi = ((pc % P) + P) % P;
799
+ const ptJourney = ptJourneys[pti];
800
+ const ptPersonaFile = ptPersonas[ppi];
801
+ const ptPersonaName = ptPersonaFile.replace(/\.md$/i, "");
802
+ const nextJ = (pti + 1) % J;
803
+ const nextP = (nextJ === 0) ? (ppi + 1) % P : ppi;
804
+ let maxFilings = configInt(config, "playtest." + ptProject + ".max_filings", 5);
805
+ if (maxFilings < 0) maxFilings = 5;
806
+
807
+ playtestTitle = (PLAYTEST_PREFIX + ptJourney.name + " as " + ptPersonaName).slice(0, 160);
808
+ const ptDesc = buildPlaytestDescription(ptJourney, ptPersonaName, ptPersonaFile, maxFilings, crewHome);
809
+
810
+ if (readOnly) {
811
+ wouldFilePlaytest = true;
812
+ log("Read-only: would file idle playtest \"" + playtestTitle + "\", skipping");
813
+ } else {
814
+ try {
815
+ await runApi("update-config", { key: "playtest." + ptProject + ".journey_cursor", value: String(nextJ) });
816
+ await runApi("update-config", { key: "playtest." + ptProject + ".persona_cursor", value: String(nextP) });
817
+ const createOut = await runApi("create-task", {
818
+ title: playtestTitle, description: ptDesc,
819
+ project: ptProject, workflow: "standard", filed_by: "hazel",
820
+ });
821
+ const createParsed = parseApiJson(createOut, "create-task");
822
+ const taskId = createParsed && createParsed.task ? createParsed.task.id : undefined;
823
+ if (taskId) {
824
+ // Witness re-read: the task must be observed in todo state.
825
+ let ptWitnessOk = false;
826
+ try {
827
+ const ptBoardOut = await runApi("get-dispatch-state");
828
+ const ptBoard = unwrapBoardResult(parseBoardJson(ptBoardOut));
829
+ const ptTasks = (ptBoard.ready_tasks || []).map(projectTaskRecord).filter((t) => t !== null);
830
+ ptWitnessOk = ptTasks.some((t) => t.id === taskId && t.state === "todo");
831
+ } catch (e) {
832
+ log("Playtest witness re-read failed (" + e.message + ") — task not verified, skipping eligible");
833
+ }
834
+ if (ptWitnessOk) {
835
+ eligible.push({
836
+ task: { id: taskId, title: playtestTitle, description: ptDesc, project: ptProject, workflow: "standard", state: "todo" },
837
+ startStep: qaStepIndex("standard"),
838
+ reason: "playtest",
839
+ workflow: "standard",
840
+ });
841
+ log("Filed idle playtest \"" + playtestTitle + "\" [" + ptProject + "] (witness observed todo)");
842
+ } else {
843
+ log("Idle playtest task " + taskId + " not observed in todo state on re-read — skipping eligible (fail-closed)");
844
+ }
845
+ } else {
846
+ log("Idle playtest filing failed — cursors already advanced, assignment skipped");
847
+ }
848
+ } catch (e) {
849
+ log("WARNING: playtest filing failed: " + e.message);
850
+ }
851
+ }
852
+ } else {
853
+ log("Idle playtest not armed: " + ptWhy);
854
+ }
855
+ }
856
+
857
+ // ── 3. Simultaneity + per-poll cap ────────────────────────────
858
+ const results = [];
859
+ let partial = false;
860
+ {
861
+ const inFlightByProject = {};
862
+ for (const ctask of allTasks) {
863
+ if (ctask.state === "in_progress" && ctask.latest_session && ctask.latest_session.status === "running") {
864
+ const cp = ctask.project || DEFAULT_PROJECT;
865
+ inFlightByProject[cp] = (inFlightByProject[cp] || 0) + 1;
866
+ }
867
+ }
868
+ const toProcess = [];
869
+ for (const eitem of eligible) {
870
+ const ep = eitem.task.project || DEFAULT_PROJECT;
871
+ const limit = (ep in projectSimultaneity) ? projectSimultaneity[ep] : globalSimultaneity;
872
+ const current = inFlightByProject[ep] || 0;
873
+ if (current < limit) {
874
+ toProcess.push(eitem);
875
+ inFlightByProject[ep] = current + 1;
876
+ } else {
877
+ decisionSkipped["at_limit"] = (decisionSkipped["at_limit"] || 0) + 1;
878
+ log("Skipped \"" + eitem.task.title + "\" — project " + ep + " at simultaneity limit (" + limit + ")");
879
+ }
880
+ }
881
+ if (toProcess.length > MAX_TASKS_PER_POLL) {
882
+ const deferred = toProcess.length - MAX_TASKS_PER_POLL;
883
+ log("Deferring " + deferred + " task(s) to next poll (per-poll cap " + MAX_TASKS_PER_POLL + ")");
884
+ toProcess.splice(MAX_TASKS_PER_POLL);
885
+ partial = true;
886
+ }
887
+
888
+ const WORKFLOW_DIR = join(crewHome, "workflows");
889
+ for (const item of toProcess) {
890
+ const itask = item.task;
891
+ const iworkflow = item.workflow;
892
+ const isteps = WORKFLOWS[iworkflow] || WORKFLOWS.standard;
893
+
894
+ if (item.action === "complete") {
895
+ if (readOnly) {
896
+ log("Read-only: would complete task " + itask.id + ", skipping");
897
+ results.push({ task_id: itask.id, action: "completed" });
898
+ continue;
899
+ }
900
+ await runApi("update-task", { id: itask.id, state: "done" });
901
+ await runApi("log-event", { task_id: itask.id, type: "completed", message: "All workflow steps complete." });
902
+ results.push({ task_id: itask.id, action: "completed" });
903
+ continue;
904
+ }
905
+
906
+ const nextStepName = isteps[item.startStep];
907
+ const scriptPath = join(WORKFLOW_DIR, iworkflow + ".js");
908
+ const taskProject = itask.project || DEFAULT_PROJECT;
909
+ const projectCfg = PROJECTS[taskProject] || PROJECTS[DEFAULT_PROJECT];
910
+ const launchArgs = {
911
+ task_id: itask.id,
912
+ task_title: itask.title || "",
913
+ task_description: itask.description || "",
914
+ start_step_index: item.startStep,
915
+ rejection_notes: item.rejectionNotes || "",
916
+ project_config: projectCfg,
917
+ project_id: taskProject,
918
+ crewHome: crewHome,
919
+ visual_protocol: effectiveVisualProtocol(projectCfg),
920
+ resolved_workflow: iworkflow,
921
+ workflow_was_null: (itask.workflow == null),
922
+ next_phase: item.nextPhase || null,
923
+ };
924
+ log("Recommended " + iworkflow + " for \"" + itask.title + "\" [" + taskProject + "] at step " + nextStepName);
925
+ // executor tag (Piece 1, semantics settled 2026-09-26): `executor`
926
+ // names the layer that COMPUTED the recommendation, not the layer
927
+ // that launched it. In Piece 1 the sandboxed dispatcher remains the
928
+ // only launch authority and the worker dispatcher runs --read-only,
929
+ // so no executor:"worker" claim is ever launched; the tag is
930
+ // forward-looking provenance the Piece-2 router will key on. Do not
931
+ // read it as "this executed on the worker layer."
932
+ results.push({ task_id: itask.id, workflow: iworkflow, step: nextStepName, action: "recommended", scriptPath, args: launchArgs, executor: "worker" });
933
+ }
934
+ }
935
+
936
+ // ── 4. Acknowledge (skipped in read-only) ──────────────────────
937
+ if (!readOnly) {
938
+ await runApi("acknowledge-poll");
939
+ } else {
940
+ log("Read-only: skipping acknowledge-poll");
941
+ }
942
+
943
+ // ── 4b. Tick release-identity log (skipped in read-only) ───────
944
+ if (!readOnly) {
945
+ try {
946
+ writeTickRelease(crewHome);
947
+ } catch (e) {
948
+ log("WARNING: tick release-identity log write failed (tick continues): " + e.message);
949
+ }
950
+ }
951
+
952
+ let recommended = [];
953
+ const seenClaims = {};
954
+ for (const r of results) {
955
+ if (r.action !== "recommended") continue;
956
+ if (seenClaims[r.task_id]) {
957
+ log("WARNING: dropped duplicate claim for task " + r.task_id + " within this run (kept first)");
958
+ continue;
959
+ }
960
+ seenClaims[r.task_id] = true;
961
+ recommended.push(r);
962
+ }
963
+ const completed = results.filter((r) => r.action === "completed");
964
+
965
+ // ── Reservations (skipped in read-only) ────────────────────────
966
+ if (readOnly) {
967
+ // Shadow evidence: the claim set we WOULD have launched, machine-
968
+ // written for the post-tick comparator. Never touches reservations,
969
+ // the poll ack, or the tick-release/decision logs.
970
+ try {
971
+ writeShadowEvidence(crewHome, {
972
+ run_id: runId,
973
+ claims: recommended.map((r) => ({ task_id: r.task_id, workflow: r.workflow, step: r.step })),
974
+ board: { seen: allTasks.length, eligible: eligible.length },
975
+ skipped: decisionSkipped,
976
+ partial,
977
+ });
978
+ } catch (e) {
979
+ log("WARNING: shadow evidence write failed (tick continues): " + e.message);
980
+ }
981
+ }
982
+ if (!readOnly && recommended.length > 0) {
983
+ const acquired = [];
984
+ for (const rec of recommended) {
985
+ try {
986
+ const reserveOut = await runApi("reserve-dispatch", {
987
+ task_id: rec.task_id,
988
+ run_id: "pending-dispatch",
989
+ workflow: rec.workflow,
990
+ });
991
+ const reserveParsed = extractJsonObject(parseApiJson(reserveOut, "reserve-dispatch"));
992
+ if (reserveParsed && reserveParsed.acquired) {
993
+ log("Reservation acquired for task " + rec.task_id.slice(0, 8) + " (claim-task is authoritative)");
994
+ acquired.push(rec);
995
+ } else {
996
+ log("SKIPPED task " + rec.task_id.slice(0, 8) + " — reservation not acquired (another dispatcher owns it)");
997
+ }
998
+ } catch (e) {
999
+ log("SKIPPED task " + rec.task_id.slice(0, 8) + " — reservation failed: " + e.message);
1000
+ }
1001
+ }
1002
+ recommended = acquired;
1003
+ } else if (recommended.length > 0) {
1004
+ log("Read-only: would reserve " + recommended.length + " task(s), skipping");
1005
+ }
1006
+
1007
+ // ── 4c. Dispatch-decision log (skipped in read-only) ───────────
1008
+ const launchedRecs = recommended.map((r) => ({ task_id: r.task_id, workflow: r.workflow, step: r.step }));
1009
+ const decisionObj = {
1010
+ decision: launchedRecs.length > 0 ? "launch" : "stand_down",
1011
+ launched: launchedRecs,
1012
+ completed: completed.map((r) => r.task_id),
1013
+ parked: parkedTaskIds,
1014
+ board: { seen: allTasks.length, eligible: eligible.length },
1015
+ skipped: decisionSkipped,
1016
+ partial,
1017
+ };
1018
+ if (!readOnly) {
1019
+ try {
1020
+ writeDispatchDecision(crewHome, decisionObj);
1021
+ } catch (e) {
1022
+ log("WARNING: dispatch-decision log write failed (tick continues): " + e.message);
1023
+ }
1024
+ }
1025
+
1026
+ let msg = "Dispatch complete.";
1027
+ if (recommended.length > 0) {
1028
+ msg += " Recommended: " + recommended.map((r) => r.workflow + "/" + r.step + " for " + r.task_id).join(", ") + ".";
1029
+ }
1030
+ if (completed.length > 0) {
1031
+ msg += " Completed: " + completed.map((r) => r.task_id).join(", ") + ".";
1032
+ }
1033
+ if (partial) { msg += " (partial: per-poll cap deferred tasks)"; }
1034
+ if (eligible.length === 0) { msg = "No tasks ready."; }
1035
+
1036
+ return {
1037
+ status: "ok",
1038
+ message: msg,
1039
+ claims: recommended,
1040
+ partial,
1041
+ would_file_playtest: wouldFilePlaytest,
1042
+ playtest_title: playtestTitle,
1043
+ run_id: runId,
1044
+ };
1045
+ }
1046
+
1047
+ function readRegistryFile(crewHome) {
1048
+ try {
1049
+ return readFileSync(join(crewHome, "workflows", "registry.json"), "utf8");
1050
+ } catch (e) {
1051
+ return null;
1052
+ }
1053
+ }
1054
+
1055
+ main().catch((e) => {
1056
+ const code = e.code || "TRANSPORT_ERROR";
1057
+ console.log(JSON.stringify({ ok: false, error: e.message || String(e), code }));
1058
+ process.exit(1);
1059
+ });