@tekyzinc/gsd-t 3.29.11 → 4.0.12

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 (108) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/bin/gsd-t-parallel.cjs +64 -13
  3. package/bin/gsd-t.js +48 -376
  4. package/bin/journey-coverage.cjs +6 -3
  5. package/bin/parallel-cli.cjs +13 -1
  6. package/commands/gsd-t-debug.md +47 -533
  7. package/commands/gsd-t-design-decompose.md +24 -497
  8. package/commands/gsd-t-doc-ripple.md +23 -139
  9. package/commands/gsd-t-execute.md +41 -958
  10. package/commands/gsd-t-help.md +0 -2
  11. package/commands/gsd-t-impact.md +26 -295
  12. package/commands/gsd-t-integrate.md +44 -369
  13. package/commands/gsd-t-milestone.md +25 -124
  14. package/commands/gsd-t-partition.md +28 -539
  15. package/commands/gsd-t-plan.md +26 -472
  16. package/commands/gsd-t-prd.md +25 -311
  17. package/commands/gsd-t-quick.md +1 -1
  18. package/commands/gsd-t-resume.md +0 -33
  19. package/commands/gsd-t-verify.md +52 -569
  20. package/commands/gsd-t-wave.md +41 -430
  21. package/package.json +1 -1
  22. package/scripts/gsd-t-calibration-hook.js +3 -1
  23. package/scripts/hooks/pre-commit-journey-coverage +0 -2
  24. package/scripts/hooks/pre-commit-playwright-gate +0 -2
  25. package/templates/CLAUDE-global.md +43 -173
  26. package/templates/CLAUDE-project.md +118 -104
  27. package/templates/prompts/design-verify-subagent.md +3 -0
  28. package/templates/prompts/qa-subagent.md +3 -0
  29. package/templates/prompts/red-team-subagent.md +5 -2
  30. package/templates/workflows/_lib.js +176 -0
  31. package/templates/workflows/gsd-t-debug.workflow.js +86 -0
  32. package/templates/workflows/gsd-t-execute.workflow.js +206 -0
  33. package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
  34. package/templates/workflows/gsd-t-phase.workflow.js +94 -0
  35. package/templates/workflows/gsd-t-quick.workflow.js +72 -0
  36. package/templates/workflows/gsd-t-verify.workflow.js +348 -0
  37. package/templates/workflows/gsd-t-wave.workflow.js +43 -0
  38. package/bin/check-headless-sessions.js +0 -140
  39. package/bin/context-budget-audit.cjs +0 -447
  40. package/bin/context-meter-config.cjs +0 -101
  41. package/bin/context-meter-config.test.cjs +0 -101
  42. package/bin/event-stream.cjs +0 -205
  43. package/bin/gsd-t-benchmark-orchestrator.js +0 -437
  44. package/bin/gsd-t-capture-lint.cjs +0 -440
  45. package/bin/gsd-t-economics.cjs +0 -315
  46. package/bin/gsd-t-in-session-usage.cjs +0 -213
  47. package/bin/gsd-t-orchestrator-config.cjs +0 -161
  48. package/bin/gsd-t-orchestrator-queue.cjs +0 -180
  49. package/bin/gsd-t-orchestrator-recover.cjs +0 -231
  50. package/bin/gsd-t-orchestrator-worker.cjs +0 -219
  51. package/bin/gsd-t-orchestrator.js +0 -535
  52. package/bin/gsd-t-parallel-probe.cjs +0 -132
  53. package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
  54. package/bin/gsd-t-ratelimit-probe.cjs +0 -648
  55. package/bin/gsd-t-report-tokens.cjs +0 -549
  56. package/bin/gsd-t-stream-feed-client.cjs +0 -151
  57. package/bin/gsd-t-token-backfill.cjs +0 -366
  58. package/bin/gsd-t-token-capture.cjs +0 -321
  59. package/bin/gsd-t-token-dashboard.cjs +0 -353
  60. package/bin/gsd-t-token-regenerate-log.cjs +0 -129
  61. package/bin/gsd-t-tool-attribution.cjs +0 -377
  62. package/bin/gsd-t-tool-cost.cjs +0 -195
  63. package/bin/gsd-t-transcript-tee.cjs +0 -246
  64. package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
  65. package/bin/gsd-t-unattended-platform.cjs +0 -551
  66. package/bin/gsd-t-unattended-platform.js +0 -381
  67. package/bin/gsd-t-unattended-safety.cjs +0 -773
  68. package/bin/gsd-t-unattended-safety.js +0 -788
  69. package/bin/gsd-t-unattended.cjs +0 -2109
  70. package/bin/gsd-t-unattended.js +0 -1367
  71. package/bin/gsd-t-worker-dispatch.cjs +0 -211
  72. package/bin/handoff-lock.cjs +0 -249
  73. package/bin/handoff-lock.js +0 -249
  74. package/bin/headless-auto-spawn.cjs +0 -711
  75. package/bin/headless-auto-spawn.js +0 -373
  76. package/bin/headless-exit-codes.cjs +0 -67
  77. package/bin/live-activity-report.cjs +0 -615
  78. package/bin/log-tail.cjs +0 -81
  79. package/bin/m44-proof-measure.cjs +0 -285
  80. package/bin/m46-iter-proof.cjs +0 -149
  81. package/bin/m46-worker-proof.cjs +0 -201
  82. package/bin/m55-substrate-proof.cjs +0 -134
  83. package/bin/metrics-rollup.js +0 -200
  84. package/bin/model-windows.cjs +0 -99
  85. package/bin/model-windows.test.cjs +0 -75
  86. package/bin/parallelism-report.cjs +0 -535
  87. package/bin/runway-estimator.cjs +0 -242
  88. package/bin/spawn-plan-derive.cjs +0 -163
  89. package/bin/spawn-plan-status-updater.cjs +0 -292
  90. package/bin/spawn-plan-writer.cjs +0 -204
  91. package/bin/supervisor-pid-fingerprint.cjs +0 -126
  92. package/bin/token-budget.cjs +0 -265
  93. package/bin/unattended-watch-format.cjs +0 -178
  94. package/bin/watch-progress.js +0 -155
  95. package/commands/gsd-t-unattended-stop.md +0 -85
  96. package/commands/gsd-t-unattended-watch.md +0 -465
  97. package/commands/gsd-t-unattended.md +0 -476
  98. package/commands/gsd-t-visualize.md +0 -116
  99. package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
  100. package/scripts/gsd-t-context-meter.js +0 -341
  101. package/scripts/gsd-t-context-meter.test.js +0 -471
  102. package/scripts/gsd-t-dashboard-server.js +0 -1203
  103. package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
  104. package/scripts/gsd-t-transcript.html +0 -1821
  105. package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
  106. package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
  107. package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
  108. package/templates/hooks/post-commit-spawn-plan.sh +0 -85
@@ -1,2109 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * GSD-T Unattended Supervisor — Cross-Platform Detached Worker Relay
5
- *
6
- * The detached OS-level process that spawns fresh `claude -p` workers in a
7
- * relay to drive the active GSD-T milestone to COMPLETED over hours or days
8
- * without human intervention. Owns state.json + PID lifecycle + run.log +
9
- * stop-sentinel handling.
10
- *
11
- * Wave 1 / Task 1 scope:
12
- * - CLI flag parsing (8 flags from contract §6)
13
- * - Runtime file layout (`.gsd-t/.unattended/`) initialization
14
- * - state.json schema (21 fields from §3) with atomic writes
15
- * - supervisor.pid lifecycle
16
- * - process.on('exit') terminal-state finalization
17
- * - Skeleton — main worker loop, safety rails, and platform helpers
18
- * are wired in Tasks 2/4. This file currently transitions to `running`
19
- * and returns cleanly so the launch handshake (§7) can complete.
20
- *
21
- * Zero external dependencies (Node.js built-ins only).
22
- *
23
- * Contract: .gsd-t/contracts/unattended-supervisor-contract.md v1.0.0
24
- * Owner: m36-supervisor-core
25
- */
26
-
27
- const fs = require("fs");
28
- const path = require("path");
29
- const os = require("os");
30
- const { execSync, spawnSync } = require("child_process");
31
- const { mapHeadlessExitCode } = require("./headless-exit-codes.cjs");
32
-
33
- // Safety rails (m36-safety-rails) — pure-function checks for pre-launch,
34
- // supervisor-init, pre-worker, and post-worker hook points per contract §12.
35
- const {
36
- DEFAULTS: SAFETY_DEFAULTS,
37
- loadConfig,
38
- checkGitBranch,
39
- checkWorktreeCleanliness,
40
- checkIterationCap,
41
- checkWallClockCap,
42
- validateState,
43
- detectGutter,
44
- detectBlockerSentinel,
45
- } = require("./gsd-t-unattended-safety.cjs");
46
-
47
- // Cross-platform helpers (m36-cross-platform) — the single place where
48
- // process.platform branches live. The rest of this file stays platform-agnostic.
49
- const {
50
- resolveClaudePath,
51
- isAlive,
52
- spawnWorker: platformSpawnWorker,
53
- preventSleep,
54
- releaseSleep,
55
- notify,
56
- } = require("./gsd-t-unattended-platform.cjs");
57
-
58
- // Event stream (M38 ES) — additive, non-blocking. `_emit` swallows its own
59
- // errors per unattended-event-stream-contract.md §6.
60
- const { appendEvent: _esAppendEvent } = require("./event-stream.cjs");
61
- function _emit(projectDir, ev) {
62
- try { _esAppendEvent(projectDir, ev); } catch (_) { /* never halt the loop */ }
63
- }
64
-
65
- // M44 D9 (v1.5.0) — planner-driven multi-worker fan-out. Lazy-loaded so unit
66
- // tests can stub via deps._runParallel without touching the real module.
67
- let _parallelModule = null;
68
- function _loadRunParallel() {
69
- if (_parallelModule) return _parallelModule;
70
- try {
71
- _parallelModule = require("./gsd-t-parallel.cjs");
72
- } catch {
73
- _parallelModule = { runParallel: () => ({ workerCount: 0, parallelTasks: [], plan: [] }) };
74
- }
75
- return _parallelModule;
76
- }
77
-
78
- // M42 D1 — transcript tee. Captures each worker's stdout lines to an ndjson
79
- // file and registers the spawn so the dashboard sidebar can list + render it.
80
- // Best-effort: every call is swallowed so tee failures never halt the loop.
81
- const transcriptTee = require("./gsd-t-transcript-tee.cjs");
82
-
83
- // M43 liveness heartbeat watchdog (contract v1.4.0 §"Heartbeat Watchdog") —
84
- // pure, testable staleness checker against .gsd-t/events/YYYY-MM-DD.jsonl mtime.
85
- const { checkHeartbeat: _checkHeartbeat } = require("./gsd-t-unattended-heartbeat.cjs");
86
-
87
- // ── Constants ───────────────────────────────────────────────────────────────
88
-
89
- const CONTRACT_VERSION = "1.5.0";
90
- const UNATTENDED_DIR_REL = path.join(".gsd-t", ".unattended");
91
- const PID_FILE = "supervisor.pid";
92
- const STATE_FILE = "state.json";
93
- const STATE_TMP_FILE = "state.json.tmp";
94
- const RUN_LOG = "run.log";
95
-
96
- const DEFAULT_HOURS = 24;
97
- const DEFAULT_MAX_ITERATIONS = 200;
98
- // M43 liveness heartbeat (contract v1.1.0 §"Heartbeat Watchdog"):
99
- // Healthy workers producing events every poll cycle (60 s) run under the
100
- // absolute backstop — raised from 270 s to 1 hour so long-running legitimate
101
- // iterations are NOT cut. Stuck workers are detected by the heartbeat
102
- // checker via events/YYYY-MM-DD.jsonl mtime and SIGTERM'd at the 5-min
103
- // staleness threshold. The 270 s cache-pacing rationale is subsumed by the
104
- // heartbeat check, which fires long before cache-miss cost becomes
105
- // dominant.
106
- const DEFAULT_WORKER_TIMEOUT_MS = 60 * 60 * 1000; // 1 h absolute backstop (contract §13/§16)
107
- const DEFAULT_STALE_HEARTBEAT_MS = 5 * 60 * 1000; // 5 min — stuck-worker threshold
108
- const DEFAULT_HEARTBEAT_POLL_MS = 60 * 1000; // 60 s poll cadence
109
-
110
- const TERMINAL_STATUSES = new Set(["done", "failed", "stopped", "crashed"]);
111
- const VALID_STATUSES = new Set([
112
- "initializing",
113
- "running",
114
- "done",
115
- "failed",
116
- "stopped",
117
- "crashed",
118
- ]);
119
-
120
- // ── Exports ─────────────────────────────────────────────────────────────────
121
-
122
- module.exports = {
123
- doUnattended,
124
- parseArgs,
125
- initState,
126
- writeState,
127
- finalizeState,
128
- makeSessionId,
129
- resolveClaudeBinSafe,
130
- isTerminal,
131
- isMilestoneComplete,
132
- isDone,
133
- stopRequested,
134
- cleanStaleStopSentinel,
135
- releaseSleepPrevention,
136
- runMainLoop,
137
- _spawnWorker,
138
- _spawnWorkerFanOut,
139
- _partitionTasks,
140
- _appendRunLog,
141
- CONTRACT_VERSION,
142
- UNATTENDED_DIR_REL,
143
- TERMINAL_STATUSES,
144
- VALID_STATUSES,
145
- DEFAULT_WORKER_TIMEOUT_MS,
146
- DEFAULT_STALE_HEARTBEAT_MS,
147
- DEFAULT_HEARTBEAT_POLL_MS,
148
- };
149
-
150
- function _reconcile(state, results) {
151
- if (!Array.isArray(results) || results.length === 0) return;
152
- for (const r of results) {
153
- if (!r || typeof r !== 'object') continue;
154
- // append-only completedTasks (preserve order, dedupe)
155
- if (Array.isArray(r.tasksDone) && r.tasksDone.length > 0) {
156
- const current = new Set(state.completedTasks || []);
157
- for (const t of r.tasksDone) {
158
- if (!current.has(t)) {
159
- state.completedTasks = (state.completedTasks || []).concat([t]);
160
- current.add(t);
161
- }
162
- }
163
- }
164
- // last-writer-wins on status — but 'error' is sticky: once set, it stays
165
- // until the next explicit non-error status in a later iter.
166
- if (r.status && r.status !== state.status) {
167
- state.status = r.status;
168
- }
169
- // verifyNeeded is OR-across-results: any iter that flags it wins.
170
- if (r.verifyNeeded === true) {
171
- state.verifyNeeded = true;
172
- }
173
- // artifacts: append-only, concat arrays.
174
- if (Array.isArray(r.artifacts) && r.artifacts.length > 0) {
175
- state.artifacts = (state.artifacts || []).concat(r.artifacts);
176
- }
177
- }
178
- // NOTE: `state.iter` is advanced by the main while loop (pre-M46 contract:
179
- // one increment per fan-out pass, regardless of worker/batch count). We do
180
- // NOT advance it here — doing so would double-increment against the
181
- // existing supervisor-contract invariant (surfaced by m43/m44 tests).
182
- state.lastBatch = {
183
- size: results.length,
184
- endedAt: new Date().toISOString(),
185
- errorCount: results.filter(r => r && r.status === 'error').length,
186
- };
187
- }
188
-
189
- // M46 D1 T2 — expose the extracted single-iter body for future unit tests
190
- // (T7) and the iter-parallel driver (T4/T5). Kept out of the main exports
191
- // block so consumers don't accidentally import implementation details.
192
- module.exports.__test__ = { _runOneIter, _computeIterBatchSize, _runIterParallel, _reconcile };
193
-
194
- // ── parseArgs ───────────────────────────────────────────────────────────────
195
-
196
- /**
197
- * Parse the CLI argv (without the leading `node` and script path).
198
- *
199
- * Supports both `--flag=value` and `--flag value` forms.
200
- *
201
- * @param {string[]} argv
202
- * @returns {{
203
- * hours: number,
204
- * maxIterations: number,
205
- * project: string,
206
- * branch: string,
207
- * onDone: string,
208
- * dryRun: boolean,
209
- * verbose: boolean,
210
- * testMode: boolean,
211
- * }}
212
- */
213
- function parseArgs(argv) {
214
- const out = {
215
- hours: DEFAULT_HOURS,
216
- maxIterations: DEFAULT_MAX_ITERATIONS,
217
- project: ".",
218
- branch: "AUTO",
219
- onDone: "print",
220
- dryRun: false,
221
- verbose: false,
222
- testMode: false,
223
- };
224
-
225
- for (let i = 0; i < argv.length; i++) {
226
- const tok = argv[i];
227
- if (typeof tok !== "string") continue;
228
-
229
- // Boolean flags
230
- if (tok === "--dry-run") {
231
- out.dryRun = true;
232
- continue;
233
- }
234
- if (tok === "--verbose") {
235
- out.verbose = true;
236
- continue;
237
- }
238
- if (tok === "--test-mode") {
239
- out.testMode = true;
240
- continue;
241
- }
242
-
243
- // Key/value flags — accept both `--k=v` and `--k v`
244
- const eq = tok.indexOf("=");
245
- let key, val;
246
- if (tok.startsWith("--") && eq !== -1) {
247
- key = tok.slice(2, eq);
248
- val = tok.slice(eq + 1);
249
- } else if (tok.startsWith("--")) {
250
- key = tok.slice(2);
251
- val = argv[i + 1];
252
- i++;
253
- } else {
254
- continue;
255
- }
256
-
257
- switch (key) {
258
- case "hours":
259
- out.hours = Number(val);
260
- if (!Number.isFinite(out.hours) || out.hours <= 0) {
261
- out.hours = DEFAULT_HOURS;
262
- }
263
- break;
264
- case "max-iterations":
265
- out.maxIterations = parseInt(val, 10);
266
- if (!Number.isFinite(out.maxIterations) || out.maxIterations <= 0) {
267
- out.maxIterations = DEFAULT_MAX_ITERATIONS;
268
- }
269
- break;
270
- case "project":
271
- out.project = val || ".";
272
- break;
273
- case "branch":
274
- out.branch = val || "AUTO";
275
- break;
276
- case "on-done":
277
- out.onDone = val || "print";
278
- break;
279
- case "dry-run":
280
- out.dryRun = true;
281
- break;
282
- case "verbose":
283
- out.verbose = true;
284
- break;
285
- case "test-mode":
286
- out.testMode = true;
287
- break;
288
- case "worker-timeout": {
289
- // Per unattended-supervisor-contract §6 + §16: user-supplied override
290
- // of DEFAULT_WORKER_TIMEOUT_MS (270 s). Accepts ms. Must not be raised
291
- // above 270000 without a separate user-approved contract revision —
292
- // the supervisor clamps but does not refuse silently.
293
- const n = parseInt(val, 10);
294
- if (Number.isFinite(n) && n > 0) {
295
- out.workerTimeoutMs = n;
296
- }
297
- break;
298
- }
299
- default:
300
- // Unknown flag — ignore for forward compatibility
301
- break;
302
- }
303
- }
304
-
305
- return out;
306
- }
307
-
308
- // ── makeSessionId ───────────────────────────────────────────────────────────
309
-
310
- /**
311
- * Produce a session ID matching the contract format:
312
- * `unattended-{YYYY-MM-DD-HHMM}-{random4}`
313
- *
314
- * @param {Date} [date]
315
- * @returns {string}
316
- */
317
- function makeSessionId(date) {
318
- const d = date instanceof Date ? date : new Date();
319
- const pad = (n) => String(n).padStart(2, "0");
320
- const slug =
321
- d.getUTCFullYear() +
322
- "-" +
323
- pad(d.getUTCMonth() + 1) +
324
- "-" +
325
- pad(d.getUTCDate()) +
326
- "-" +
327
- pad(d.getUTCHours()) +
328
- pad(d.getUTCMinutes());
329
- const rand = Math.floor(Math.random() * 0xffff)
330
- .toString(16)
331
- .padStart(4, "0");
332
- return `unattended-${slug}-${rand}`;
333
- }
334
-
335
- // ── resolveClaudeBinSafe ────────────────────────────────────────────────────
336
-
337
- /**
338
- * Best-effort resolution of the `claude` binary path. Task 4 will replace
339
- * this with a proper cross-platform helper from
340
- * `bin/gsd-t-unattended-platform.cjs`. For Task 1 we just shell out to
341
- * `which claude` (POSIX) or `where claude` (win32) with a safe fallback to
342
- * the literal string `"claude"` — enough to satisfy the schema.
343
- *
344
- * @returns {string}
345
- */
346
- function resolveClaudeBinSafe() {
347
- try {
348
- const cmd = process.platform === "win32" ? "where claude" : "which claude";
349
- const out = execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
350
- .toString()
351
- .split(/\r?\n/)[0]
352
- .trim();
353
- if (out) return out;
354
- } catch (_) {
355
- // fall through to default
356
- }
357
- return "claude";
358
- }
359
-
360
- // ── isTerminal ──────────────────────────────────────────────────────────────
361
-
362
- function isTerminal(status) {
363
- return TERMINAL_STATUSES.has(status);
364
- }
365
-
366
- // ── readMilestoneId ─────────────────────────────────────────────────────────
367
-
368
- /**
369
- * Best-effort read of the active milestone ID from `.gsd-t/progress.md`.
370
- * Falls back to the literal string `"UNKNOWN"` if the file or marker is
371
- * absent. We look for an `M{NN}` token on the first 40 lines.
372
- *
373
- * @param {string} projectDir
374
- * @returns {string}
375
- */
376
- function readMilestoneId(projectDir) {
377
- try {
378
- const p = path.join(projectDir, ".gsd-t", "progress.md");
379
- if (!fs.existsSync(p)) return "UNKNOWN";
380
- const head = fs.readFileSync(p, "utf8").split(/\r?\n/).slice(0, 60).join("\n");
381
- const m = head.match(/\bM(\d{1,3})\b/);
382
- return m ? `M${m[1]}` : "UNKNOWN";
383
- } catch (_) {
384
- return "UNKNOWN";
385
- }
386
- }
387
-
388
- // ── initState ───────────────────────────────────────────────────────────────
389
-
390
- /**
391
- * Build the initial `state.json` object (status: 'initializing'). Does NOT
392
- * write to disk — pass the result to `writeState()`.
393
- *
394
- * @param {{
395
- * projectDir: string,
396
- * hours: number,
397
- * maxIterations: number,
398
- * sessionId?: string,
399
- * milestone?: string,
400
- * claudeBin?: string,
401
- * logPath?: string,
402
- * now?: Date,
403
- * }} opts
404
- * @returns {object}
405
- */
406
- function initState(opts) {
407
- if (!opts || !opts.projectDir) {
408
- throw new Error("initState: opts.projectDir is required");
409
- }
410
- const now = opts.now instanceof Date ? opts.now : new Date();
411
- const startedAt = now.toISOString();
412
- const projectDir = path.resolve(opts.projectDir);
413
- const sessionId = opts.sessionId || makeSessionId(now);
414
- const milestone = opts.milestone || readMilestoneId(projectDir);
415
- const claudeBin = opts.claudeBin || resolveClaudeBinSafe();
416
- const logPath =
417
- opts.logPath ||
418
- path.join(UNATTENDED_DIR_REL, RUN_LOG); // project-relative per contract §3
419
-
420
- return {
421
- version: CONTRACT_VERSION,
422
- sessionId,
423
- projectDir,
424
- status: "initializing",
425
- milestone,
426
- // wave / task / lastWorker* / lastExit / lastElapsedMs are populated by
427
- // the main loop in Task 2. They are documented as optional in §3.
428
- iter: 0,
429
- maxIterations: opts.maxIterations || DEFAULT_MAX_ITERATIONS,
430
- startedAt,
431
- lastTick: startedAt,
432
- hours: opts.hours || DEFAULT_HOURS,
433
- wallClockElapsedMs: 0,
434
- supervisorPid: process.pid,
435
- logPath,
436
- sleepPreventionHandle: null,
437
- platform: process.platform,
438
- claudeBin,
439
- };
440
- }
441
-
442
- // ── writeState ──────────────────────────────────────────────────────────────
443
-
444
- /**
445
- * Atomically write `state.json` into the supervisor runtime directory.
446
- * Updates `lastTick` and (if startedAt is set) `wallClockElapsedMs`.
447
- *
448
- * @param {object} state
449
- * @param {string} dir — absolute path to `.gsd-t/.unattended/`
450
- * @returns {object} the (mutated) state object actually written
451
- */
452
- function writeState(state, dir) {
453
- if (!state || typeof state !== "object") {
454
- throw new Error("writeState: state must be an object");
455
- }
456
- if (!dir) throw new Error("writeState: dir is required");
457
-
458
- fs.mkdirSync(dir, { recursive: true });
459
-
460
- const now = new Date();
461
- state.lastTick = now.toISOString();
462
- if (state.startedAt) {
463
- const started = Date.parse(state.startedAt);
464
- if (!Number.isNaN(started)) {
465
- state.wallClockElapsedMs = Math.max(0, now.getTime() - started);
466
- }
467
- }
468
-
469
- const tmp = path.join(dir, STATE_TMP_FILE);
470
- const final = path.join(dir, STATE_FILE);
471
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
472
- fs.renameSync(tmp, final);
473
- return state;
474
- }
475
-
476
- // ── finalizeState ───────────────────────────────────────────────────────────
477
-
478
- /**
479
- * Terminal finalization. If `state.status` is non-terminal, transition it
480
- * to `terminalStatus` (default `'crashed'`). Writes state, releases any
481
- * sleep-prevention handle, then removes the PID file.
482
- *
483
- * **Idempotent**: a state object is marked finalized via a non-enumerable
484
- * `_finalized` flag on first call. Subsequent calls are no-ops that return
485
- * the same (preserved) terminal state. This matters because terminal
486
- * handlers may fire from multiple paths (main-loop exit, SIGINT/SIGTERM,
487
- * process.on('exit')) and we must not corrupt the already-written terminal
488
- * state (e.g., by overwriting 'stopped' with 'crashed').
489
- *
490
- * @param {object} state
491
- * @param {string} dir — absolute path to `.gsd-t/.unattended/`
492
- * @param {string} [terminalStatus='crashed']
493
- * @returns {object} the finalized state
494
- */
495
- function finalizeState(state, dir, terminalStatus) {
496
- if (!state || typeof state !== "object") return state;
497
- if (!dir) throw new Error("finalizeState: dir is required");
498
-
499
- // Idempotency: if already finalized, return the preserved terminal state
500
- // untouched. Do NOT re-write state, do NOT change status.
501
- if (state._finalized === true) return state;
502
-
503
- if (!isTerminal(state.status)) {
504
- const next = terminalStatus && VALID_STATUSES.has(terminalStatus)
505
- ? terminalStatus
506
- : "crashed";
507
- state.status = next;
508
- }
509
-
510
- // Release any sleep-prevention handle. Task 4 wires the real platform
511
- // helper; for now this just clears the field so it can't dangle.
512
- try {
513
- releaseSleepPrevention(state);
514
- } catch (_) {
515
- // best effort — never throw from a shutdown path
516
- }
517
-
518
- // Always re-write so lastTick reflects finalization time and any
519
- // last-second fields (lastExit, etc.) are flushed.
520
- try {
521
- writeState(state, dir);
522
- } catch (_) {
523
- // best effort — never throw from a shutdown path
524
- }
525
- // Remove PID file last so external readers (kill -0) see a live process
526
- // until the state file is on disk.
527
- try {
528
- const pidPath = path.join(dir, PID_FILE);
529
- if (fs.existsSync(pidPath)) fs.unlinkSync(pidPath);
530
- } catch (_) {
531
- // best effort
532
- }
533
-
534
- // Mark finalized via a non-enumerable flag so it doesn't serialize into
535
- // state.json on the next (no-op) call.
536
- try {
537
- Object.defineProperty(state, "_finalized", {
538
- value: true,
539
- writable: false,
540
- enumerable: false,
541
- configurable: false,
542
- });
543
- } catch (_) {
544
- // If the property is already defined (shouldn't happen), fall back to
545
- // a plain assignment — subsequent calls will still see truthy.
546
- state._finalized = true;
547
- }
548
-
549
- return state;
550
- }
551
-
552
- // ── doUnattended ────────────────────────────────────────────────────────────
553
-
554
- /**
555
- * CLI entry point. Parses args, runs preflight, sets up runtime state, and
556
- * runs the main worker relay loop. Task 1 built the skeleton; Task 2 added
557
- * the main loop.
558
- *
559
- * Dependency injection (for tests) via `deps`:
560
- * - `_spawnWorker(state, opts)` → { status, stdout, stderr, signal }
561
- * - `_isMilestoneComplete(projectDir, milestoneId)` → boolean
562
- * - `_stopRequested(projectDir)` → boolean
563
- *
564
- * @param {string[]} argv — argv WITHOUT the leading `node` and script path
565
- * @param {object} [deps]
566
- * @returns {{
567
- * ok: boolean,
568
- * dryRun: boolean,
569
- * state?: object,
570
- * dir?: string,
571
- * exitCode: number,
572
- * reason?: string,
573
- * }}
574
- */
575
- async function doUnattended(argv, deps) {
576
- deps = deps || {};
577
- const rawArgv = argv || [];
578
-
579
- // --watch rejection (headless-default-contract §2) — unattended is detached
580
- // by definition; passing --watch is a category error. Refuse fast so the
581
- // user sees a clear message before any state.json / PID work happens.
582
- if (
583
- Array.isArray(rawArgv) &&
584
- rawArgv.some(
585
- (a) => typeof a === "string" && (a === "--watch" || a.startsWith("--watch=")),
586
- )
587
- ) {
588
- // eslint-disable-next-line no-console
589
- console.error(
590
- "[gsd-t-unattended] --watch is incompatible with unattended.\n" +
591
- "Unattended supervisor is detached by definition.\n" +
592
- "Run /gsd-t-unattended-watch from your interactive session to see live activity.",
593
- );
594
- return {
595
- ok: false,
596
- dryRun: false,
597
- exitCode: 2,
598
- reason: "--watch is incompatible with unattended",
599
- };
600
- }
601
-
602
- const opts = parseArgs(rawArgv);
603
- const projectDir = path.resolve(opts.project || ".");
604
-
605
- // ── Resolve injection points (real impls by default) ─────────────────────
606
- const fn = {
607
- checkGitBranch: deps._checkGitBranch || checkGitBranch,
608
- checkWorktreeCleanliness: deps._checkWorktreeCleanliness || checkWorktreeCleanliness,
609
- checkIterationCap: deps._checkIterationCap || checkIterationCap,
610
- checkWallClockCap: deps._checkWallClockCap || checkWallClockCap,
611
- validateState: deps._validateState || validateState,
612
- detectGutter: deps._detectGutter || detectGutter,
613
- detectBlockerSentinel: deps._detectBlockerSentinel || detectBlockerSentinel,
614
- resolveClaudePath: deps._resolveClaudePath || resolveClaudePath,
615
- preventSleep: deps._preventSleep || preventSleep,
616
- releaseSleep: deps._releaseSleep || releaseSleep,
617
- notify: deps._notify || notify,
618
- loadConfig: deps._loadConfig || loadConfig,
619
- };
620
-
621
- // ── Load config (optional .gsd-t/.unattended/config.json) ────────────────
622
- // CLI flags take precedence over config.json values per standard CLI ergonomics.
623
- let config;
624
- try {
625
- config = fn.loadConfig(projectDir);
626
- } catch (e) {
627
- // Malformed config → preflight failure, no PID/state written.
628
- // eslint-disable-next-line no-console
629
- console.error(`[gsd-t-unattended] preflight-failure: ${e.message}`);
630
- return {
631
- ok: false,
632
- dryRun: !!opts.dryRun,
633
- exitCode: 2,
634
- reason: String(e.message || e),
635
- };
636
- }
637
- // CLI overrides win over config for hours / maxIterations (explicit user
638
- // intent beats on-disk defaults). Parsed defaults of 24/200 match config
639
- // defaults, so this only matters when the user explicitly passed a value —
640
- // but parseArgs doesn't track that. We accept the merge semantics as-is:
641
- // config.hours overrides parseArgs default 24 only if CLI didn't also pass.
642
- // Simple rule: if CLI value equals the hardcoded default, prefer config.
643
- if (opts.hours === DEFAULT_HOURS && typeof config.hours === "number") {
644
- opts.hours = config.hours;
645
- }
646
- if (
647
- opts.maxIterations === DEFAULT_MAX_ITERATIONS &&
648
- typeof config.maxIterations === "number"
649
- ) {
650
- opts.maxIterations = config.maxIterations;
651
- }
652
- if (
653
- opts.workerTimeoutMs === undefined &&
654
- typeof config.workerTimeoutMs === "number" &&
655
- config.workerTimeoutMs > 0
656
- ) {
657
- opts.workerTimeoutMs = config.workerTimeoutMs;
658
- }
659
- if (
660
- opts.staleHeartbeatMs === undefined &&
661
- typeof config.staleHeartbeatMs === "number" &&
662
- config.staleHeartbeatMs > 0
663
- ) {
664
- opts.staleHeartbeatMs = config.staleHeartbeatMs;
665
- }
666
- // CLI values now win — mirror them back into config so the pre-worker
667
- // safety caps (checkIterationCap / checkWallClockCap) use the effective
668
- // supervisor-scoped limits rather than the on-disk file defaults.
669
- config.maxIterations = opts.maxIterations;
670
- config.hours = opts.hours;
671
-
672
- // ── PRE-LAUNCH HOOK (contract §12) ───────────────────────────────────────
673
- // Runs BEFORE any PID/state file is written. Refusal → exit with the
674
- // corresponding code and leave the runtime dir untouched.
675
- const branchRes = fn.checkGitBranch(projectDir, config);
676
- if (!branchRes.ok) {
677
- // eslint-disable-next-line no-console
678
- console.error(
679
- `[gsd-t-unattended] preflight-refusal: ${branchRes.reason || "protected branch"}`,
680
- );
681
- return {
682
- ok: false,
683
- dryRun: !!opts.dryRun,
684
- exitCode: branchRes.code || 7,
685
- reason: branchRes.reason,
686
- };
687
- }
688
- const treeRes = fn.checkWorktreeCleanliness(projectDir, config);
689
- if (!treeRes.ok) {
690
- // eslint-disable-next-line no-console
691
- console.error(
692
- `[gsd-t-unattended] preflight-refusal: ${treeRes.reason || "dirty worktree"}`,
693
- );
694
- return {
695
- ok: false,
696
- dryRun: !!opts.dryRun,
697
- exitCode: treeRes.code || 8,
698
- reason: treeRes.reason,
699
- };
700
- }
701
-
702
- // Dry-run: pre-flight summary only. Do not touch the runtime directory.
703
- // Pre-launch checks have already passed at this point, so dry-run reports OK.
704
- if (opts.dryRun) {
705
- // Resolve claudeBin best-effort for the dry-run summary. A failure here
706
- // is NOT fatal for dry-run — we only need the field for display.
707
- let dryClaudeBin = "claude";
708
- try {
709
- dryClaudeBin = fn.resolveClaudePath();
710
- } catch (_) {
711
- /* best effort */
712
- }
713
- const summary = {
714
- mode: "dry-run",
715
- projectDir,
716
- hours: opts.hours,
717
- maxIterations: opts.maxIterations,
718
- branch: opts.branch,
719
- onDone: opts.onDone,
720
- verbose: opts.verbose,
721
- testMode: opts.testMode,
722
- platform: process.platform,
723
- claudeBin: dryClaudeBin,
724
- milestone: readMilestoneId(projectDir),
725
- };
726
- if (opts.verbose) {
727
- // eslint-disable-next-line no-console
728
- console.log(
729
- "[gsd-t-unattended] dry-run preflight:\n" +
730
- JSON.stringify(summary, null, 2),
731
- );
732
- } else {
733
- // eslint-disable-next-line no-console
734
- console.log(
735
- `[gsd-t-unattended] dry-run OK — project=${projectDir} hours=${opts.hours} max-iter=${opts.maxIterations} platform=${process.platform}`,
736
- );
737
- }
738
- return { ok: true, dryRun: true, exitCode: 0 };
739
- }
740
-
741
- // ── Resolve claudeBin BEFORE writing any runtime state ───────────────────
742
- // A failure here is a preflight-failure (code 2). No PID/state written.
743
- let claudeBin;
744
- try {
745
- claudeBin = fn.resolveClaudePath();
746
- if (!claudeBin || typeof claudeBin !== "string") {
747
- throw new Error("resolveClaudePath returned empty result");
748
- }
749
- } catch (e) {
750
- // eslint-disable-next-line no-console
751
- console.error(
752
- `[gsd-t-unattended] preflight-failure: claude binary not resolvable: ${e.message}`,
753
- );
754
- return {
755
- ok: false,
756
- dryRun: false,
757
- exitCode: 2,
758
- reason: `claude binary not resolvable: ${e.message}`,
759
- };
760
- }
761
-
762
- // Real run — establish runtime directory and PID file.
763
- const dir = path.join(projectDir, UNATTENDED_DIR_REL);
764
- fs.mkdirSync(dir, { recursive: true });
765
-
766
- // Stale stop-sentinel cleanup (contract §10). A previous run may have
767
- // left a `.gsd-t/.unattended/stop` file on disk — remove it now, before
768
- // the main loop's stop-sentinel check would see it and halt immediately.
769
- // The helper logs a reassuring message when it removes a file.
770
- cleanStaleStopSentinel(projectDir);
771
-
772
- // Build initial state and write `initializing`.
773
- const state = initState({
774
- projectDir,
775
- hours: opts.hours,
776
- maxIterations: opts.maxIterations,
777
- claudeBin,
778
- });
779
- writeState(state, dir);
780
-
781
- // Write the PID file. Singleton enforcement (refusing if another
782
- // supervisor is already alive) is owned by the launch handshake — see
783
- // contract §7. We trust the caller for now and just write our PID.
784
- // Contract v1.4.1: write JSON fingerprint {pid, projectDir, startedAt}
785
- // so resume-time liveness checks can distinguish "our supervisor" from
786
- // "some other process recycled this PID" (macOS PID recycling).
787
- const { writePidFile } = require("./supervisor-pid-fingerprint.cjs");
788
- writePidFile(projectDir, process.pid);
789
-
790
- // Install terminal handlers BEFORE transitioning to `running` so a crash
791
- // mid-transition is still finalized.
792
- installTerminalHandlers(state, dir);
793
-
794
- // Transition to `running`. From this point the launch handshake (§7) will
795
- // observe `status === 'running'` and complete its 5-second readiness poll.
796
- state.status = "running";
797
-
798
- // ── Sleep prevention — acquire at `running` transition ───────────────────
799
- // The handle (caffeinate PID on darwin, null elsewhere) is stored on state
800
- // so finalizeState can release it on shutdown.
801
- try {
802
- const sleepHandle = fn.preventSleep("unattended-supervisor");
803
- state.sleepPreventionHandle = sleepHandle == null ? null : sleepHandle;
804
- } catch (_) {
805
- // Sleep prevention failures are non-fatal — log and continue.
806
- state.sleepPreventionHandle = null;
807
- }
808
-
809
- writeState(state, dir);
810
-
811
- if (opts.verbose) {
812
- // eslint-disable-next-line no-console
813
- console.log(
814
- `[gsd-t-unattended] running — sessionId=${state.sessionId} pid=${process.pid} milestone=${state.milestone} platform=${state.platform}`,
815
- );
816
- // M39 D2 — append watch-progress tree below banner (best-effort).
817
- // Banner preserved verbatim above; renderer output appended below per
818
- // watch-progress-contract.md §7. Never throws into the supervisor loop.
819
- try {
820
- const wp = require("./watch-progress.js");
821
- const stateDir = path.join(projectDir, ".gsd-t", ".watch-state");
822
- const tree = wp.buildTree(stateDir);
823
- const rendered = wp.renderTree(tree, { currentAgent: state.sessionId });
824
- if (rendered) {
825
- // eslint-disable-next-line no-console
826
- console.log(rendered);
827
- }
828
- } catch (_) {
829
- /* watch-progress is best-effort; never crash the watch */
830
- }
831
- }
832
-
833
- // ── SUPERVISOR-INIT HOOK (contract §12) ──────────────────────────────────
834
- // State is fully populated and on disk. Run validateState + re-verify the
835
- // branch (defensive — branch may have changed between pre-launch and now).
836
- const vRes = fn.validateState(state);
837
- if (!vRes.ok) {
838
- // eslint-disable-next-line no-console
839
- console.error(
840
- `[gsd-t-unattended] supervisor-init: validateState failed: ${
841
- (vRes.errors || []).join("; ") || vRes.reason
842
- }`,
843
- );
844
- state.status = "failed";
845
- state.lastExit = 2;
846
- writeState(state, dir);
847
- _notifyAndFinalize(state, dir, fn, "failed");
848
- return {
849
- ok: false,
850
- dryRun: false,
851
- state,
852
- dir,
853
- exitCode: 2,
854
- reason: vRes.reason,
855
- };
856
- }
857
- const branchReVerify = fn.checkGitBranch(projectDir, config);
858
- if (!branchReVerify.ok) {
859
- // eslint-disable-next-line no-console
860
- console.error(
861
- `[gsd-t-unattended] supervisor-init: branch check re-verify failed: ${branchReVerify.reason}`,
862
- );
863
- state.status = "failed";
864
- state.lastExit = branchReVerify.code || 7;
865
- writeState(state, dir);
866
- _notifyAndFinalize(state, dir, fn, "failed");
867
- return {
868
- ok: false,
869
- dryRun: false,
870
- state,
871
- dir,
872
- exitCode: branchReVerify.code || 7,
873
- reason: branchReVerify.reason,
874
- };
875
- }
876
-
877
- // Main relay loop. Workers spawn fresh each iteration until the milestone
878
- // completes, the iteration cap is hit, a terminal exit code is returned,
879
- // a stop sentinel is observed, or a safety rails halt fires.
880
- await runMainLoop(state, dir, opts, deps, { fn, config });
881
-
882
- // Terminal notification + explicit finalize. finalizeState is idempotent —
883
- // the process.on('exit') handler will be a no-op after this.
884
- _notifyAndFinalize(state, dir, fn);
885
-
886
- return {
887
- ok: state.status !== "failed",
888
- dryRun: false,
889
- state,
890
- dir,
891
- exitCode: mapStatusToExitCode(state),
892
- };
893
- }
894
-
895
- // ── _notifyAndFinalize ──────────────────────────────────────────────────────
896
- //
897
- // Fire the terminal-transition notification, then call finalizeState. Both
898
- // steps are best-effort and must never throw from a shutdown path.
899
-
900
- function _notifyAndFinalize(state, dir, fn, terminalHint) {
901
- try {
902
- const status = terminalHint || state.status;
903
- const started = Date.parse(state.startedAt || "");
904
- let durStr = "";
905
- if (!Number.isNaN(started)) {
906
- const ms = Math.max(0, Date.now() - started);
907
- const totalMins = Math.round(ms / 60000);
908
- const hrs = Math.floor(totalMins / 60);
909
- const mins = totalMins % 60;
910
- durStr = `${hrs}h ${mins}m`;
911
- }
912
- const logPath =
913
- state.logPath || path.join(UNATTENDED_DIR_REL, RUN_LOG);
914
-
915
- if (status === "done") {
916
- fn.notify(
917
- "GSD-T Unattended: Complete",
918
- `Milestone ${state.milestone || ""} reached COMPLETED in ${durStr}`,
919
- "success",
920
- );
921
- } else if (status === "failed") {
922
- fn.notify(
923
- "GSD-T Unattended: Failed",
924
- `Exit ${state.lastExit || 1} — see ${logPath}`,
925
- "error",
926
- );
927
- } else if (status === "stopped") {
928
- fn.notify(
929
- "GSD-T Unattended: Stopped",
930
- `User stop after iter ${state.iter || 0}`,
931
- "warn",
932
- );
933
- }
934
- } catch (_) {
935
- // best effort — never throw from a shutdown path
936
- }
937
- // Release sleep prevention via the wired (or injected) helper BEFORE
938
- // finalizeState runs so the internal releaseSleepPrevention call is a no-op.
939
- try {
940
- releaseSleepPrevention(state, fn && fn.releaseSleep);
941
- } catch (_) {
942
- // best effort
943
- }
944
- try {
945
- finalizeState(state, dir, terminalHint);
946
- } catch (_) {
947
- // best effort
948
- }
949
- }
950
-
951
- // ── runMainLoop ─────────────────────────────────────────────────────────────
952
-
953
- /**
954
- * The core worker relay loop. Each iteration:
955
- * 1. Check terminal conditions (isDone / stopRequested)
956
- * 2. Increment iter, update lastWorkerStartedAt, write state
957
- * 3. Spawn a worker via `_spawnWorker` (real spawnSync or injected shim)
958
- * 4. Map the exit code via `mapHeadlessExitCode`
959
- * 5. Append worker output to run.log
960
- * 6. Update state with lastExit/lastWorkerFinishedAt/lastElapsedMs, write
961
- * 7. Classify terminal exit branches and transition status if needed
962
- *
963
- * Task 3 will add the stop-sentinel check at step 1 (stub exists via
964
- * `stopRequested`). Task 4 will replace `_spawnWorker` with the real
965
- * cross-platform helper.
966
- */
967
- async function runMainLoop(state, dir, opts, deps, ctx) {
968
- deps = deps || {};
969
- ctx = ctx || {};
970
- // Safety rails + platform helpers wired by doUnattended (fn) + loaded
971
- // config. When runMainLoop is called directly by tests, fall back to real
972
- // impls / defaults so the loop remains usable standalone.
973
- const fn = ctx.fn || {
974
- checkIterationCap: deps._checkIterationCap || checkIterationCap,
975
- checkWallClockCap: deps._checkWallClockCap || checkWallClockCap,
976
- validateState: deps._validateState || validateState,
977
- detectGutter: deps._detectGutter || detectGutter,
978
- detectBlockerSentinel: deps._detectBlockerSentinel || detectBlockerSentinel,
979
- };
980
- const config = ctx.config || SAFETY_DEFAULTS;
981
-
982
- // --test-mode uses a built-in stub that completes on the first iteration.
983
- // Explicit deps override test-mode.
984
- const useTestStub = !!opts.testMode && !deps._spawnWorker;
985
- const spawnWorker =
986
- deps._spawnWorker || (useTestStub ? _testModeSpawnWorker : _spawnWorker);
987
- const milestoneComplete =
988
- deps._isMilestoneComplete || (useTestStub ? () => true : isMilestoneComplete);
989
- // M44 D9 (v1.5.0) — planner injected for multi-worker iter fan-out.
990
- // Tests stub via deps._runParallel; production lazy-loads from gsd-t-parallel.cjs.
991
- const runParallelImpl =
992
- deps._runParallel || ((o) => _loadRunParallel().runParallel(o));
993
- const stopCheck = deps._stopRequested || stopRequested;
994
- const workerTimeoutMs = opts.workerTimeoutMs || DEFAULT_WORKER_TIMEOUT_MS;
995
- const staleHeartbeatMs =
996
- (typeof opts.staleHeartbeatMs === "number" && opts.staleHeartbeatMs > 0
997
- ? opts.staleHeartbeatMs
998
- : (typeof config.staleHeartbeatMs === "number" && config.staleHeartbeatMs > 0
999
- ? config.staleHeartbeatMs
1000
- : DEFAULT_STALE_HEARTBEAT_MS));
1001
- const heartbeatPollMs =
1002
- (typeof opts.heartbeatPollMs === "number" && opts.heartbeatPollMs > 0
1003
- ? opts.heartbeatPollMs
1004
- : DEFAULT_HEARTBEAT_POLL_MS);
1005
- // Test hook: deps._checkHeartbeat lets tests substitute the staleness
1006
- // checker without mocking fs. Production uses the real module.
1007
- const heartbeatImpl = deps._checkHeartbeat || _checkHeartbeat;
1008
- // Test hook: deps._disableHeartbeat lets unit tests bypass the async path
1009
- // for test-mode / stub spawns that return synchronously.
1010
- const heartbeatEnabled = !deps._disableHeartbeat && !useTestStub;
1011
- const projectDir = state.projectDir;
1012
-
1013
- // M46 D1 T2 — pure extract-method refactor. The body of each iteration
1014
- // now lives in the top-level `_runOneIter` helper (below). The while loop
1015
- // itself is unchanged in semantics: stop-check and isDone evaluate per
1016
- // pass, and any terminal state.status ({"done","failed"}) written by the
1017
- // iter body causes us to break, matching every pre-refactor `break` path.
1018
- // Non-terminal outcomes fall through to the next iteration, matching the
1019
- // pre-refactor `continue` paths.
1020
- const iterCtx = {
1021
- dir,
1022
- fn,
1023
- config,
1024
- spawnWorker,
1025
- milestoneComplete,
1026
- runParallelImpl,
1027
- workerTimeoutMs,
1028
- heartbeatImpl,
1029
- heartbeatEnabled,
1030
- staleHeartbeatMs,
1031
- heartbeatPollMs,
1032
- projectDir,
1033
- verbose: !!opts.verbose,
1034
- };
1035
- while (!isDone(state) && !stopCheck(projectDir)) {
1036
- const batchSize = _computeIterBatchSize(state, opts);
1037
- const _batchStartMs = Date.now();
1038
- try {
1039
- fs.appendFileSync(
1040
- path.join(dir, RUN_LOG),
1041
- `[iter-batch-start] batch-size=${batchSize} iter=${state.iter} ts=${new Date(_batchStartMs).toISOString()}\n`,
1042
- "utf8"
1043
- );
1044
- } catch (_) { /* best effort */ }
1045
- const results = await _runIterParallel(state, opts, (s, o) => _runOneIter(s, iterCtx), batchSize);
1046
- _reconcile(state, results);
1047
- try {
1048
- const _ok = results.filter((r) => r.status !== "error").length;
1049
- const _fail = results.length - _ok;
1050
- const _durSec = ((Date.now() - _batchStartMs) / 1000).toFixed(1);
1051
- fs.appendFileSync(
1052
- path.join(dir, RUN_LOG),
1053
- `[iter-batch-complete] size=${results.length} ok=${_ok} fail=${_fail} duration=${_durSec}s iter=${state.iter}\n`,
1054
- "utf8"
1055
- );
1056
- } catch (_) { /* best effort */ }
1057
- if (isTerminal(state.status)) break;
1058
- }
1059
-
1060
- // If we exited because the user dropped a stop sentinel and no terminal
1061
- // status has been assigned yet, transition to 'stopped' now (contract §10).
1062
- // The sentinel file itself is NOT removed by the supervisor — it stays on
1063
- // disk as evidence, to be cleaned by the next launch via
1064
- // `cleanStaleStopSentinel`.
1065
- if (!isTerminal(state.status) && stopCheck(projectDir)) {
1066
- state.status = "stopped";
1067
- writeState(state, dir);
1068
- }
1069
- return state;
1070
- }
1071
-
1072
- // ── _runOneIter (M46 D1 T2) ─────────────────────────────────────────────────
1073
-
1074
- /**
1075
- * Body of a single supervisor iteration, extracted verbatim from the
1076
- * `runMainLoop` while-loop (pre-M46-D1). Mutates `state` in place exactly as
1077
- * the original body did — all writeState calls, event-stream emits, run.log
1078
- * and token-log appends, heartbeat wiring, fan-out dispatch, and exit-code
1079
- * classification are preserved line-for-line.
1080
- *
1081
- * `opts` here is the per-iter context bundle assembled in runMainLoop (not
1082
- * the supervisor-level opts object). It carries the closure values the body
1083
- * used to read from the enclosing scope: fn, config, dir, projectDir,
1084
- * spawnWorker, milestoneComplete, runParallelImpl, workerTimeoutMs,
1085
- * heartbeatImpl, heartbeatEnabled, staleHeartbeatMs, heartbeatPollMs, verbose.
1086
- *
1087
- * Returns an IterResult per iter-parallel-contract.md v1.0.0 §4. T2 emits a
1088
- * minimal shape (tasksDone = []) — T4/T5 will populate tasksDone and use
1089
- * `status` to drive `_computeIterBatchSize`. For now the while-loop driver
1090
- * consumes only `isTerminal(state.status)`; the returned value is forward-
1091
- * compatible scaffolding.
1092
- */
1093
- async function _runOneIter(state, opts) {
1094
- const {
1095
- dir, fn, config, spawnWorker, milestoneComplete, runParallelImpl,
1096
- workerTimeoutMs, heartbeatImpl, heartbeatEnabled,
1097
- staleHeartbeatMs, heartbeatPollMs, projectDir,
1098
- } = opts;
1099
-
1100
- const _result = (status, extras) => ({
1101
- iter: state.iter,
1102
- status,
1103
- tasksDone: [],
1104
- verifyNeeded: status === "verify-needed",
1105
- artifacts: extras || {},
1106
- });
1107
-
1108
- // ── PRE-WORKER HOOK (contract §12) ─────────────────────────────────────
1109
- // Refusal → halt with status=failed, lastExit=6 (caps) or 2 (validate).
1110
- const capIter = fn.checkIterationCap(state, config);
1111
- if (!capIter.ok) {
1112
- state.status = "failed";
1113
- state.lastExit = capIter.code || 6;
1114
- writeState(state, dir);
1115
- return _result("failed", { errorMessage: `iteration_cap:${state.lastExit}` });
1116
- }
1117
- const capWall = fn.checkWallClockCap(state, config);
1118
- if (!capWall.ok) {
1119
- state.status = "failed";
1120
- state.lastExit = capWall.code || 6;
1121
- writeState(state, dir);
1122
- return _result("failed", { errorMessage: `wall_clock_cap:${state.lastExit}` });
1123
- }
1124
- const vRes = fn.validateState(state);
1125
- if (!vRes.ok) {
1126
- state.status = "failed";
1127
- state.lastExit = vRes.code || 2;
1128
- writeState(state, dir);
1129
- return _result("failed", { errorMessage: `validate_state:${state.lastExit}` });
1130
- }
1131
-
1132
- // Pre-spawn bookkeeping
1133
- state.iter = (state.iter || 0) + 1;
1134
- const workerStart = new Date();
1135
- state.lastWorkerStartedAt = workerStart.toISOString();
1136
- writeState(state, dir);
1137
-
1138
- _emit(projectDir, {
1139
- ts: workerStart.toISOString(),
1140
- iter: state.iter,
1141
- type: "task_start",
1142
- source: "supervisor",
1143
- milestone: state.milestone || "",
1144
- wave: state.wave || "",
1145
- task: state.nextTask || "",
1146
- });
1147
-
1148
- let res;
1149
- const workerStartMs = workerStart.getTime();
1150
- const hbOpts = heartbeatEnabled
1151
- ? {
1152
- onHeartbeatCheck: () =>
1153
- heartbeatImpl({
1154
- projectDir,
1155
- workerStartedAt: workerStartMs,
1156
- staleHeartbeatMs,
1157
- }),
1158
- heartbeatPollMs,
1159
- }
1160
- : {};
1161
-
1162
- // M44 D9 (v1.5.0) — planner-driven fan-out decision for this iter.
1163
- // Ask runParallel whether the current task graph supports ≥2 concurrent
1164
- // workers. Any failure in the planner path MUST fall back to the single-
1165
- // worker spawn — the parallel path is purely additive.
1166
- let iterPlan = null;
1167
- try {
1168
- iterPlan = runParallelImpl({
1169
- projectDir,
1170
- mode: "unattended",
1171
- milestone: state.milestone || null,
1172
- dryRun: true,
1173
- });
1174
- } catch (e) {
1175
- iterPlan = null;
1176
- _emit(projectDir, {
1177
- iter: state.iter,
1178
- type: "parallelism_reduced",
1179
- source: "supervisor",
1180
- original_count: null,
1181
- reduced_count: 1,
1182
- reason: `planner_error:${(e && e.message) || "unknown"}`,
1183
- });
1184
- }
1185
- const fanOutCount = iterPlan && Number(iterPlan.workerCount) >= 2 ? Number(iterPlan.workerCount) : 1;
1186
- const parallelTaskIds = iterPlan && Array.isArray(iterPlan.parallelTasks) ? iterPlan.parallelTasks : [];
1187
- const subsets = fanOutCount >= 2 ? _partitionTasks(parallelTaskIds, fanOutCount) : null;
1188
- const useFanOut = !!(subsets && subsets.length >= 2);
1189
-
1190
- try {
1191
- if (useFanOut) {
1192
- _emit(projectDir, {
1193
- ts: workerStart.toISOString(),
1194
- iter: state.iter,
1195
- type: "fan_out",
1196
- source: "supervisor",
1197
- worker_count: subsets.length,
1198
- task_ids: parallelTaskIds,
1199
- });
1200
- res = await _spawnWorkerFanOut(state, {
1201
- cwd: projectDir,
1202
- timeout: workerTimeoutMs,
1203
- verbose: !!opts.verbose,
1204
- ...hbOpts,
1205
- }, spawnWorker, subsets);
1206
- } else {
1207
- res = spawnWorker(state, {
1208
- cwd: projectDir,
1209
- timeout: workerTimeoutMs,
1210
- verbose: !!opts.verbose,
1211
- ...hbOpts,
1212
- });
1213
- if (res && typeof res.then === "function") {
1214
- res = await res;
1215
- }
1216
- }
1217
- } catch (e) {
1218
- // Defensive: a real spawnSync shouldn't throw, but a shim could.
1219
- res = { status: 3, stdout: "", stderr: String((e && e.message) || e), signal: null };
1220
- }
1221
- res = res || { status: null, stdout: "", stderr: "", signal: null };
1222
-
1223
- const workerEnd = new Date();
1224
- const elapsedMs = workerEnd.getTime() - workerStart.getTime();
1225
- const stdout = typeof res.stdout === "string" ? res.stdout : "";
1226
- const stderr = typeof res.stderr === "string" ? res.stderr : "";
1227
-
1228
- // Kill-path detection (M43 heartbeat watchdog precedes wall-clock timeout):
1229
- // - res.staleHeartbeat === true → heartbeat fired, code 125 (new)
1230
- // - res.timedOut === true OR status=null+SIGTERM → wall-clock, code 124
1231
- // Heartbeat wins on ties because it's the more specific signal.
1232
- let exitCode;
1233
- let lastExitReason = null;
1234
- if (res.staleHeartbeat === true) {
1235
- exitCode = 125;
1236
- lastExitReason = "stale_heartbeat";
1237
- } else if (res.timedOut === true || res.status === null || res.signal === "SIGTERM") {
1238
- exitCode = 124;
1239
- lastExitReason = "worker_timeout";
1240
- } else {
1241
- exitCode = mapHeadlessExitCode(res.status, stdout + "\n" + stderr);
1242
- }
1243
-
1244
- // v3.13.11 Bug 1: when a watchdog fires, make the event explicit in
1245
- // run.log so operators can see WHICH iteration was cut without inferring
1246
- // from exit codes. The marker is prepended to stdout and written in the
1247
- // single per-iter run.log append (no duplicate header).
1248
- let loggedStdout = stdout;
1249
- if (exitCode === 124) {
1250
- const marker =
1251
- `[worker_timeout] iter=${state.iter} budget=${workerTimeoutMs}ms ` +
1252
- `elapsed=${elapsedMs}ms — absolute-backstop SIGTERM delivered, ` +
1253
- `supervisor continues relay per contract §16.\n`;
1254
- loggedStdout = marker + (stdout || "");
1255
- } else if (exitCode === 125) {
1256
- const reason = res.heartbeatReason || "no recent events.jsonl writes";
1257
- const marker =
1258
- `[stale_heartbeat] iter=${state.iter} threshold=${staleHeartbeatMs}ms ` +
1259
- `elapsed=${elapsedMs}ms reason="${reason}" — ` +
1260
- `heartbeat watchdog SIGTERM delivered, supervisor continues relay.\n`;
1261
- loggedStdout = marker + (stdout || "");
1262
- }
1263
-
1264
- // Append the full worker output to run.log (never truncate).
1265
- _appendRunLog(dir, state.iter, workerEnd, exitCode, loggedStdout, stderr);
1266
-
1267
- // Append to token-log.md (Fix 1, v3.12.12) — supervisor workers write rows
1268
- // so the log captures headless/unattended activity, not just interactive spawns.
1269
- _appendTokenLog(projectDir, {
1270
- dtStart: workerStart.toISOString().slice(0, 16).replace("T", " "),
1271
- dtEnd: workerEnd.toISOString().slice(0, 16).replace("T", " "),
1272
- command: "gsd-t-resume",
1273
- durationS: Math.round(elapsedMs / 1000),
1274
- exitCode,
1275
- iter: state.iter,
1276
- });
1277
-
1278
- // Post-spawn state update
1279
- state.lastExit = exitCode;
1280
- state.lastWorkerFinishedAt = workerEnd.toISOString();
1281
- state.lastElapsedMs = elapsedMs;
1282
- if (lastExitReason) {
1283
- state.lastExitReason = lastExitReason;
1284
- } else if (exitCode === 0) {
1285
- state.lastExitReason = "clean";
1286
- } else {
1287
- state.lastExitReason = `exit_${exitCode}`;
1288
- }
1289
- // M44 D9 (v1.5.0) — per-iter multi-worker aggregates. Present only when the
1290
- // planner selected fan-out; single-worker iters omit these fields so the
1291
- // state schema stays backward-compatible with v1.4.x readers.
1292
- if (useFanOut && Array.isArray(res.workerResults)) {
1293
- state.lastExits = res.workerResults.map((w) => ({
1294
- idx: w.idx,
1295
- code: typeof w.status === "number" ? w.status : null,
1296
- taskIds: w.taskIds || [],
1297
- elapsedMs: w.elapsedMs,
1298
- spawnId: w.spawnId || null,
1299
- }));
1300
- state.workerPids = res.workerResults.map((w) => w.spawnId || null);
1301
- state.lastFanOutCount = res.workerResults.length;
1302
- } else {
1303
- // Clear stale multi-worker fields on single-worker iters so readers
1304
- // never see a mix of regimes.
1305
- if (state.lastExits) delete state.lastExits;
1306
- if (state.workerPids) delete state.workerPids;
1307
- if (state.lastFanOutCount) delete state.lastFanOutCount;
1308
- }
1309
- writeState(state, dir);
1310
-
1311
- // Event-stream: task_complete on success, error on non-zero.
1312
- const durationS = Math.round(elapsedMs / 1000);
1313
- if (exitCode === 0) {
1314
- _emit(projectDir, {
1315
- ts: workerEnd.toISOString(),
1316
- iter: state.iter,
1317
- type: "task_complete",
1318
- source: "supervisor",
1319
- task: state.nextTask || "",
1320
- verdict: "pass",
1321
- duration_s: durationS,
1322
- });
1323
- } else {
1324
- _emit(projectDir, {
1325
- ts: workerEnd.toISOString(),
1326
- iter: state.iter,
1327
- type: "error",
1328
- source: "supervisor",
1329
- error: `worker exit ${exitCode}`,
1330
- recoverable: exitCode !== 4 && exitCode !== 5,
1331
- });
1332
- }
1333
-
1334
- // ── POST-WORKER HOOK (contract §12) ────────────────────────────────────
1335
- // Read the tail of run.log for pattern detection. ~200 lines is enough
1336
- // to span the last several iteration blocks for the gutter detector.
1337
- let runLogTail = "";
1338
- try {
1339
- const logPath = path.join(dir, RUN_LOG);
1340
- if (fs.existsSync(logPath)) {
1341
- const all = fs.readFileSync(logPath, "utf8");
1342
- const lines = all.split(/\r?\n/);
1343
- runLogTail = lines.slice(-200).join("\n");
1344
- }
1345
- } catch (_) {
1346
- // best effort — tail read failure does not halt the loop
1347
- }
1348
- const blocker = fn.detectBlockerSentinel(runLogTail);
1349
- if (!blocker.ok) {
1350
- state.status = "failed";
1351
- state.lastExit = blocker.code || 6;
1352
- writeState(state, dir);
1353
- return _result("failed", { errorMessage: `blocker_sentinel:${state.lastExit}` });
1354
- }
1355
- const gutter = fn.detectGutter(state, runLogTail, config);
1356
- if (!gutter.ok) {
1357
- state.status = "failed";
1358
- state.lastExit = gutter.code || 6;
1359
- writeState(state, dir);
1360
- return _result("failed", { errorMessage: `gutter:${state.lastExit}` });
1361
- }
1362
-
1363
- // Terminal exit classification
1364
- if (exitCode === 0) {
1365
- // Success — check if the milestone is now complete.
1366
- if (milestoneComplete(projectDir, state.milestone)) {
1367
- state.status = "done";
1368
- writeState(state, dir);
1369
- return _result("done");
1370
- }
1371
- // Not yet done — continue relay.
1372
- _emit(projectDir, {
1373
- iter: state.iter,
1374
- type: "retry",
1375
- source: "supervisor",
1376
- attempt: state.iter,
1377
- reason: "milestone_incomplete",
1378
- });
1379
- return _result("running");
1380
- }
1381
- if (exitCode === 4) {
1382
- // Unrecoverable blocker.
1383
- state.status = "failed";
1384
- writeState(state, dir);
1385
- return _result("failed", { errorMessage: "exit_4_unrecoverable" });
1386
- }
1387
- if (exitCode === 5) {
1388
- // Command dispatch failure — worker invocation is broken.
1389
- state.status = "failed";
1390
- writeState(state, dir);
1391
- return _result("failed", { errorMessage: "exit_5_dispatch_failure" });
1392
- }
1393
- if (exitCode === 124) {
1394
- // Timeout — continue unless the iter cap is hit on the next check.
1395
- _emit(projectDir, {
1396
- iter: state.iter,
1397
- type: "retry",
1398
- source: "supervisor",
1399
- attempt: state.iter,
1400
- reason: "timeout",
1401
- });
1402
- return _result("running");
1403
- }
1404
- if (exitCode === 125) {
1405
- // Stale heartbeat (M43) — continue unless the iter cap hits. The
1406
- // heartbeat kill is recoverable by definition: the worker was not
1407
- // emitting events, which is the most common class of stuck iteration
1408
- // (e.g. child stuck on a long Bash call with no tool_call emits).
1409
- _emit(projectDir, {
1410
- iter: state.iter,
1411
- type: "retry",
1412
- source: "supervisor",
1413
- attempt: state.iter,
1414
- reason: "stale_heartbeat",
1415
- });
1416
- return _result("running");
1417
- }
1418
- // Non-terminal (1/2/3) — continue the relay.
1419
- _emit(projectDir, {
1420
- iter: state.iter,
1421
- type: "retry",
1422
- source: "supervisor",
1423
- attempt: state.iter,
1424
- reason: `exit_${exitCode}`,
1425
- });
1426
- return _result("running");
1427
- }
1428
-
1429
- // ── _computeIterBatchSize (M46 D1 T3) ───────────────────────────────────────
1430
-
1431
- /**
1432
- * Decide how many iterations the supervisor main loop should dispatch
1433
- * concurrently in the next pass. Implements the mode-safety rules from
1434
- * `.gsd-t/contracts/iter-parallel-contract.md` v1.0.0 §3.1.
1435
- *
1436
- * Rules evaluated top-down; first match wins:
1437
- * 1. status === "verify-needed" → 1 (serial verify gate)
1438
- * 2. milestoneBoundary === true → 1 (milestone boundary)
1439
- * 3. status === "complete-milestone" → 1 (single-shot closeout)
1440
- * 4. otherwise → min(opts.maxIterParallel ?? 4, remainingIters, 8)
1441
- * where remainingIters = (state.maxIterations ?? Infinity) - (state.iter ?? 0)
1442
- *
1443
- * Never returns less than 1.
1444
- */
1445
- function _computeIterBatchSize(state, opts) {
1446
- if (state && state.status === "verify-needed") return 1;
1447
- if (state && state.milestoneBoundary === true) return 1;
1448
- if (state && state.status === "complete-milestone") return 1;
1449
-
1450
- // Production default is 1 (serial, pre-M46 behavior). Iter-parallelism is
1451
- // opt-in via `opts.maxIterParallel` — callers that pass a number enable it.
1452
- // Rationale: `_runOneIter` mutates `state.iter` and other shared fields
1453
- // (heartbeat bookkeeping, writeState) that are not safe to execute on the
1454
- // same state object concurrently. Unit tests exercise the parallel path
1455
- // with explicit batch sizes; production main loop omits the flag and runs
1456
- // strictly serial, preserving the pre-M46 supervisor contract (one iter
1457
- // counter increment per fan-out pass). See backlog #24 for the follow-up
1458
- // that makes `_runOneIter` state-clone-safe and lifts this gate.
1459
- if (!opts || typeof opts.maxIterParallel !== "number") return 1;
1460
-
1461
- const cap = opts.maxIterParallel;
1462
- const maxIters = state && typeof state.maxIterations === "number"
1463
- ? state.maxIterations
1464
- : Infinity;
1465
- const currentIter = state && typeof state.iter === "number"
1466
- ? state.iter
1467
- : 0;
1468
- const remainingIters = maxIters - currentIter;
1469
-
1470
- const size = Math.min(cap, remainingIters, 8);
1471
- return size < 1 ? 1 : size;
1472
- }
1473
-
1474
- // ── _runIterParallel (M46 D1 T4) ────────────────────────────────────────────
1475
-
1476
- /**
1477
- * Dispatch `batchSize` independent iter slices concurrently and return an
1478
- * IterResult[] of exactly that length. Implements the error-isolation rule
1479
- * from `.gsd-t/contracts/iter-parallel-contract.md` v1.0.0 §4.2: a single
1480
- * rejected iter is translated into an IterResult with status "error" and
1481
- * does NOT cancel siblings. The caller decides how to react.
1482
- *
1483
- * iterFn defaults to `_runOneIter` for the T7 tests; production callers
1484
- * (T5 main-loop rewrite) pass the same.
1485
- */
1486
- async function _runIterParallel(state, opts, iterFn, batchSize) {
1487
- const fn = typeof iterFn === "function" ? iterFn : _runOneIter;
1488
- const n = typeof batchSize === "number" && batchSize >= 1 ? batchSize : 1;
1489
- const slices = [];
1490
- for (let i = 0; i < n; i++) slices.push(Promise.resolve().then(() => fn(state, opts)));
1491
- const settled = await Promise.allSettled(slices);
1492
- return settled.map((s) => {
1493
- if (s.status === "fulfilled") return s.value;
1494
- const reason = s.reason;
1495
- const msg = (reason && reason.message) ? reason.message : String(reason);
1496
- return {
1497
- status: "error",
1498
- tasksDone: [],
1499
- verifyNeeded: false,
1500
- artifacts: [],
1501
- error: msg,
1502
- };
1503
- });
1504
- }
1505
-
1506
- // ── _appendTokenLog (Fix 1, v3.12.12) ───────────────────────────────────────
1507
-
1508
- const _TOKEN_LOG_HEADER =
1509
- "| Datetime-start | Datetime-end | Command | Step | Model | Duration(s) | Notes | Domain | Task | Ctx% |\n" +
1510
- "|---|---|---|---|---|---|---|---|---|---|\n";
1511
-
1512
- /**
1513
- * Append one row to {projectDir}/.gsd-t/token-log.md for a supervisor worker
1514
- * iteration. Matches the schema used by interactive command-file observability.
1515
- */
1516
- function _appendTokenLog(projectDir, entry) {
1517
- try {
1518
- const logPath = path.join(projectDir, ".gsd-t", "token-log.md");
1519
- const note = entry.exitCode === 0
1520
- ? `supervisor iter=${entry.iter}: ok`
1521
- : `supervisor iter=${entry.iter}: exit ${entry.exitCode}`;
1522
- // v3.12.14: prefer env-var model over the hardcoded "unknown" placeholder.
1523
- const model = process.env.GSD_T_MODEL || "unknown";
1524
- const row =
1525
- `| ${entry.dtStart} | ${entry.dtEnd} | ${entry.command} | supervisor-iter-${entry.iter} | ${model} | ${entry.durationS}s | ${note} | - | - | unknown |\n`;
1526
- const gsdtDir = path.join(projectDir, ".gsd-t");
1527
- if (!fs.existsSync(gsdtDir)) fs.mkdirSync(gsdtDir, { recursive: true });
1528
- if (!fs.existsSync(logPath)) {
1529
- fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${_TOKEN_LOG_HEADER}${row}`);
1530
- } else {
1531
- const existing = fs.readFileSync(logPath, "utf8");
1532
- if (!existing.includes("| Datetime-start |")) {
1533
- fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${_TOKEN_LOG_HEADER}${existing}${row}`);
1534
- } else {
1535
- fs.appendFileSync(logPath, row);
1536
- }
1537
- }
1538
- } catch (_) {
1539
- /* best-effort — never halt the supervisor loop */
1540
- }
1541
- }
1542
-
1543
- // ── _spawnWorker ────────────────────────────────────────────────────────────
1544
-
1545
- /**
1546
- * Spawn a fresh `claude -p /gsd-t-resume` worker via `spawnSync`. Returns a
1547
- * normalized `{ status, stdout, stderr, signal }` result. Task 4 replaces
1548
- * this with the cross-platform helper from `bin/gsd-t-unattended-platform.cjs`.
1549
- *
1550
- * @param {object} state — current supervisor state (reads `claudeBin`)
1551
- * @param {{cwd: string, timeout: number, verbose?: boolean}} opts
1552
- * @returns {{status: (number|null), stdout: string, stderr: string, signal: (string|null)}}
1553
- */
1554
- function _spawnWorker(state, opts) {
1555
- const bin = (state && state.claudeBin) || resolveClaudePath();
1556
- // Inject command/phase/trace/model/project-dir so event-stream tool_call
1557
- // entries (writer CLI + heartbeat hook) are tagged in worker contexts
1558
- // (Fix 2, v3.12.12; trace/model/project-dir added v3.12.14 for the
1559
- // null-telemetry regression fix). Supervisor always runs gsd-t-resume
1560
- // workers; phase is inferred from state when available. Trace/model flow
1561
- // through from parent process.env when set.
1562
- const workerEnv = {
1563
- ...process.env,
1564
- GSD_T_UNATTENDED_WORKER: "1",
1565
- GSD_T_COMMAND: "gsd-t-resume",
1566
- GSD_T_PHASE: (state && state.phase) || "execute",
1567
- GSD_T_PROJECT_DIR: process.env.GSD_T_PROJECT_DIR || opts.cwd || state.projectDir,
1568
- };
1569
- if (state && state.traceId) workerEnv.GSD_T_TRACE_ID = state.traceId;
1570
- else if (process.env.GSD_T_TRACE_ID) workerEnv.GSD_T_TRACE_ID = process.env.GSD_T_TRACE_ID;
1571
- if (state && state.model) workerEnv.GSD_T_MODEL = state.model;
1572
- else if (process.env.GSD_T_MODEL) workerEnv.GSD_T_MODEL = process.env.GSD_T_MODEL;
1573
- // D2 watch-progress: mint a per-worker agent id and forward the supervisor's
1574
- // id as parent, so shims inside the worker write state files that the tree
1575
- // builder can attach under the supervisor root.
1576
- workerEnv.GSD_T_AGENT_ID =
1577
- "supervisor-iter-" + (state && state.iter ? state.iter : Date.now()) +
1578
- (state && typeof state._workerIndex === "number" ? `-w${state._workerIndex}` : "");
1579
- if (process.env.GSD_T_AGENT_ID) {
1580
- workerEnv.GSD_T_PARENT_AGENT_ID = process.env.GSD_T_AGENT_ID;
1581
- }
1582
-
1583
- // M44 D9 (v1.5.0) — planner-driven fan-out: when the supervisor partitions
1584
- // the iter's task graph across N workers, each worker carries its disjoint
1585
- // task-id subset via env var. The worker prompt consumes this to (a) skip
1586
- // the intra-worker Team Mode block (the fan-out is the team), (b) restrict
1587
- // itself to its assigned task IDs.
1588
- const assignedTaskIds = Array.isArray(opts && opts.taskIds) ? opts.taskIds : null;
1589
- if (assignedTaskIds && assignedTaskIds.length > 0) {
1590
- workerEnv.GSD_T_WORKER_TASK_IDS = assignedTaskIds.join(",");
1591
- workerEnv.GSD_T_WORKER_INDEX = String((state && state._workerIndex) || 0);
1592
- workerEnv.GSD_T_WORKER_TOTAL = String((state && state._workerTotal) || 1);
1593
- }
1594
-
1595
- // M42 D1 — allocate a spawn-id + open transcript before spawning. parentId
1596
- // is the supervisor's own spawn-id (set once at supervisor start via
1597
- // GSD_T_SPAWN_ID env) so the sidebar can render parent-indented trees.
1598
- const parentSpawnId = process.env.GSD_T_SPAWN_ID || null;
1599
- let teeSpawnId = null;
1600
- try {
1601
- teeSpawnId = transcriptTee.allocateSpawnId({ parentId: parentSpawnId });
1602
- transcriptTee.openTranscript({
1603
- spawnId: teeSpawnId,
1604
- projectDir: opts.cwd,
1605
- meta: {
1606
- parentId: parentSpawnId,
1607
- command: "gsd-t-unattended-worker",
1608
- description: `iter=${state && state.iter ? state.iter : "?"} milestone=${state && state.milestone ? state.milestone : "-"}`,
1609
- model: (state && state.model) || null,
1610
- },
1611
- });
1612
- workerEnv.GSD_T_SPAWN_ID = teeSpawnId;
1613
- } catch (_) { /* tee is best-effort */ }
1614
-
1615
- const spawnResult = platformSpawnWorker(opts.cwd, opts.timeout, {
1616
- bin,
1617
- onHeartbeatCheck: opts.onHeartbeatCheck,
1618
- heartbeatPollMs: opts.heartbeatPollMs,
1619
- onHeartbeatSample: opts.onHeartbeatSample,
1620
- // M43 live transcript tee — append each worker stdout line to the
1621
- // transcript file as it arrives, so /transcript/:id/stream renders the
1622
- // run in real time instead of waiting for the worker to exit.
1623
- onStdoutLine: teeSpawnId
1624
- ? (line) => {
1625
- try {
1626
- transcriptTee.appendFrame({
1627
- spawnId: teeSpawnId,
1628
- projectDir: opts.cwd,
1629
- frame: line,
1630
- });
1631
- } catch (_) { /* tee is best-effort */ }
1632
- }
1633
- : undefined,
1634
- args: [
1635
- "-p",
1636
- [
1637
- "You are an unattended worker iteration. CRITICAL: Do NOT check supervisor.pid, do NOT auto-reattach to a watch loop, do NOT schedule any ScheduleWakeup. You ARE the worker spawned by the supervisor. Skip Step 0 (auto-reattach) entirely and go directly to Step 0.1.",
1638
- "",
1639
- "# CWD Invariant (v3.13.11 Bug 2)",
1640
- "",
1641
- "Before any other work, assert your current working directory matches the",
1642
- "supervisor's project directory. A worker that silently drifts to a",
1643
- "different repo will commit to the wrong tree and corrupt state.json.",
1644
- "",
1645
- "First Bash call this turn (mandatory):",
1646
- "",
1647
- " [ \"$(pwd)\" = \"$GSD_T_PROJECT_DIR\" ] || cd \"$GSD_T_PROJECT_DIR\"",
1648
- " pwd # confirm",
1649
- "",
1650
- "Thereafter, scope any directory change inside a subshell so a `cd` in",
1651
- "one Bash call cannot contaminate the next one:",
1652
- "",
1653
- " ( cd some/subdir && run-command ) # safe — subshell",
1654
- " cd some/subdir && run-command # UNSAFE — leaks cwd",
1655
- "",
1656
- "# Team Mode (Intra-Wave Parallelism)",
1657
- "",
1658
- "M44 D9 (v1.5.0+) — check env `GSD_T_WORKER_TASK_IDS` FIRST. If SET, you",
1659
- "are one of N planner-assigned workers in a supervisor-level fan-out.",
1660
- "The value is your disjoint task-id subset. DO NOT spawn Task subagents",
1661
- "to re-fan-out (the supervisor already did). Execute ONLY your assigned",
1662
- "task IDs sequentially in this worker, then return. Skip the rest of",
1663
- "this block.",
1664
- "",
1665
- "If GSD_T_WORKER_TASK_IDS is UNSET, the supervisor's planner decided",
1666
- "N=1 for this iter (sequential fallback: gates vetoed, file-disjointness",
1667
- "unprovable, or est CW% too high). Proceed with the legacy worker-level",
1668
- "Team Mode below:",
1669
- "",
1670
- "Before executing tasks for this iteration, read `.gsd-t/partition.md` to",
1671
- "identify the current wave and which domains belong to it.",
1672
- "",
1673
- "If the current wave has MULTIPLE independent domains/tasks (check",
1674
- "`.gsd-t/domains/*/tasks.md` — 2 or more domains with incomplete tasks in the",
1675
- "current wave):",
1676
- "",
1677
- " SPAWN PARALLEL SUBAGENTS — up to 15 concurrent Task subagents, one per",
1678
- " domain, using `general-purpose` subagent_type. Use the same subagent",
1679
- " prompt pattern as `/gsd-t-execute` Team Mode (see `commands/gsd-t-execute.md`",
1680
- " Step 3 Team Mode section). Each subagent:",
1681
- " - Receives the domain name, its scope.md, its tasks.md (only incomplete",
1682
- " tasks from the current wave), and the relevant contracts",
1683
- " - Works ONLY within its domain boundary",
1684
- " - Returns when all its current-wave tasks are committed",
1685
- " WAIT for ALL spawned subagents to report back before advancing.",
1686
- "",
1687
- "If the current wave has only 1 domain with incomplete tasks, execute",
1688
- "sequentially in this worker (no subagent spawn needed).",
1689
- "",
1690
- "Inter-wave boundaries always remain sequential — never parallelize across",
1691
- "waves, because wave-N+1 may depend on wave-N contract/state updates.",
1692
- "",
1693
- "Your job: run /gsd-t-resume but skip the unattended supervisor auto-reattach check in Step 0.",
1694
- ].join("\n"),
1695
- "--output-format", "stream-json",
1696
- "--verbose",
1697
- "--dangerously-skip-permissions",
1698
- ],
1699
- env: workerEnv,
1700
- });
1701
-
1702
- // M43 — finalize: live tee already wrote each line via onStdoutLine in the
1703
- // platform layer; here we only mark the transcript closed with the worker's
1704
- // terminal status. Legacy sync path (no onHeartbeatCheck) doesn't fire
1705
- // onStdoutLine, but the supervisor always provides a heartbeat callback so
1706
- // that branch is unreachable in production. If a future caller goes async
1707
- // without heartbeat, transcripts would be empty — acceptable until then.
1708
- const finalize = (res) => {
1709
- if (teeSpawnId) {
1710
- try {
1711
- const status =
1712
- typeof res.status === "number" && res.status === 0
1713
- ? "done"
1714
- : res.timedOut
1715
- ? "stopped"
1716
- : "failed";
1717
- transcriptTee.closeTranscript({
1718
- spawnId: teeSpawnId,
1719
- projectDir: opts.cwd,
1720
- status,
1721
- });
1722
- } catch (_) { /* tee is best-effort */ }
1723
- }
1724
-
1725
- return {
1726
- status: typeof res.status === "number" ? res.status : null,
1727
- stdout: res.stdout || "",
1728
- stderr: res.stderr || "",
1729
- signal: res.signal || null,
1730
- timedOut: !!res.timedOut,
1731
- staleHeartbeat: !!res.staleHeartbeat,
1732
- heartbeatReason: res.heartbeatReason || null,
1733
- error: res.error || null,
1734
- spawnId: teeSpawnId,
1735
- };
1736
- };
1737
-
1738
- if (spawnResult && typeof spawnResult.then === "function") {
1739
- return spawnResult.then(finalize);
1740
- }
1741
- return finalize(spawnResult);
1742
- }
1743
-
1744
- // ── _spawnWorkerFanOut (M44 D9, contract v1.5.0) ────────────────────────────
1745
-
1746
- /**
1747
- * Planner-driven multi-worker fan-out. Spawns N concurrent workers via the
1748
- * injected `spawnWorker` shim, each receiving a disjoint subset of the iter's
1749
- * parallel task IDs (passed through `opts.taskIds`). Waits on all via
1750
- * Promise.all before returning a merged result shape compatible with the
1751
- * single-worker path.
1752
- *
1753
- * Merge semantics:
1754
- * - `status` — 0 if every worker cleanly returned 0, else the first
1755
- * non-zero status encountered (worst exit wins).
1756
- * - `stdout` — per-worker blocks joined by `[WORKER i/N tasks=...]` headers.
1757
- * - `stderr` — concatenated.
1758
- * - `staleHeartbeat`/`timedOut` — true if any worker triggered them.
1759
- * - `workerResults` — array of per-worker {status, taskIds, pid, spawnId, elapsedMs}
1760
- * for state.json aggregation.
1761
- *
1762
- * The caller (runMainLoop) treats this result exactly like a single-worker
1763
- * result for downstream classification. Multi-worker observability lives in
1764
- * the `workerResults` array, not in new control-flow branches.
1765
- */
1766
- async function _spawnWorkerFanOut(state, opts, spawnWorker, subsets) {
1767
- const launches = subsets.map((taskIds, i) => {
1768
- const subState = { ...state, _workerIndex: i, _workerTotal: subsets.length, _workerTaskIds: taskIds };
1769
- const started = Date.now();
1770
- return Promise.resolve()
1771
- .then(() => spawnWorker(subState, { ...opts, taskIds }))
1772
- .then((r) => ({ r: r || {}, taskIds, started, ended: Date.now(), idx: i }))
1773
- .catch((e) => ({
1774
- r: { status: 3, stdout: "", stderr: String((e && e.message) || e), signal: null },
1775
- taskIds, started, ended: Date.now(), idx: i,
1776
- }));
1777
- });
1778
- const outcomes = await Promise.all(launches);
1779
- outcomes.sort((a, b) => a.idx - b.idx);
1780
-
1781
- let mergedStatus = 0;
1782
- let stale = false;
1783
- let timedOut = false;
1784
- let heartbeatReason = null;
1785
- const stdoutBlocks = [];
1786
- const stderrBlocks = [];
1787
- const workerResults = [];
1788
-
1789
- for (const o of outcomes) {
1790
- const s = typeof o.r.status === "number" ? o.r.status : null;
1791
- if (mergedStatus === 0 && s !== 0) mergedStatus = s === null ? 1 : s;
1792
- if (o.r.staleHeartbeat) stale = true;
1793
- if (o.r.timedOut) timedOut = true;
1794
- if (!heartbeatReason && o.r.heartbeatReason) heartbeatReason = o.r.heartbeatReason;
1795
- const tag = `[WORKER ${o.idx + 1}/${outcomes.length} tasks=${(o.taskIds || []).join(",") || "-"}]`;
1796
- stdoutBlocks.push(`${tag}\n${o.r.stdout || ""}`);
1797
- if (o.r.stderr) stderrBlocks.push(`${tag}\n${o.r.stderr}`);
1798
- workerResults.push({
1799
- idx: o.idx,
1800
- status: s,
1801
- taskIds: o.taskIds,
1802
- spawnId: o.r.spawnId || null,
1803
- signal: o.r.signal || null,
1804
- elapsedMs: o.ended - o.started,
1805
- staleHeartbeat: !!o.r.staleHeartbeat,
1806
- timedOut: !!o.r.timedOut,
1807
- });
1808
- }
1809
-
1810
- return {
1811
- status: mergedStatus,
1812
- stdout: stdoutBlocks.join("\n"),
1813
- stderr: stderrBlocks.join("\n"),
1814
- signal: null,
1815
- timedOut,
1816
- staleHeartbeat: stale,
1817
- heartbeatReason,
1818
- workerResults,
1819
- fanOutCount: outcomes.length,
1820
- };
1821
- }
1822
-
1823
- /**
1824
- * Partition a task-id list into `workerCount` roughly-equal subsets. Simple
1825
- * round-robin — each subset is non-empty as long as `tasks.length >= workerCount`.
1826
- */
1827
- function _partitionTasks(tasks, workerCount) {
1828
- if (!Array.isArray(tasks) || tasks.length === 0 || workerCount < 1) return [];
1829
- const n = Math.min(workerCount, tasks.length);
1830
- const subsets = Array.from({ length: n }, () => []);
1831
- for (let i = 0; i < tasks.length; i++) subsets[i % n].push(tasks[i]);
1832
- return subsets;
1833
- }
1834
-
1835
- // ── _testModeSpawnWorker ────────────────────────────────────────────────────
1836
-
1837
- /**
1838
- * Built-in stub worker for `--test-mode`. Returns a canned "milestone
1839
- * complete" result so the loop terminates in one iteration without invoking
1840
- * a real `claude` binary. Exists for CI/smoke tests and for the Task 1
1841
- * "real run" test that must not hang on the real `claude` CLI.
1842
- */
1843
- function _testModeSpawnWorker(state, _opts) {
1844
- return {
1845
- status: 0,
1846
- stdout: `[test-mode] stub worker iter=${state.iter} milestone=${state.milestone} COMPLETE\n`,
1847
- stderr: "",
1848
- signal: null,
1849
- };
1850
- }
1851
-
1852
- // ── _appendRunLog ───────────────────────────────────────────────────────────
1853
-
1854
- /**
1855
- * Append a single worker iteration to `run.log`. Format:
1856
- * --- ITER {n} @ {ISO8601} exit={code} ---
1857
- * {stdout}
1858
- * {stderr}
1859
- *
1860
- * Never truncates. Creates the log file if it does not exist.
1861
- */
1862
- function _appendRunLog(dir, iter, when, exitCode, stdout, stderr) {
1863
- try {
1864
- fs.mkdirSync(dir, { recursive: true });
1865
- const logPath = path.join(dir, RUN_LOG);
1866
- const iso = when instanceof Date ? when.toISOString() : String(when);
1867
- const header = `--- ITER ${iter} @ ${iso} exit=${exitCode} ---\n`;
1868
- const body =
1869
- (stdout || "") +
1870
- (stdout && !stdout.endsWith("\n") ? "\n" : "") +
1871
- (stderr ? "[stderr]\n" + stderr + (stderr.endsWith("\n") ? "" : "\n") : "");
1872
- fs.appendFileSync(logPath, header + body, "utf8");
1873
- } catch (_) {
1874
- // best effort — never throw from the main loop
1875
- }
1876
- }
1877
-
1878
- // ── isMilestoneComplete ─────────────────────────────────────────────────────
1879
-
1880
- /**
1881
- * Returns true if `.gsd-t/progress.md` indicates the given milestone is
1882
- * complete. Checks for either:
1883
- * - The string `{milestoneId} COMPLETE` (case-insensitive) anywhere in the
1884
- * file (matches the `Status: M36 COMPLETE` convention used in progress.md)
1885
- * - A row containing the milestone ID in a completed-milestones table,
1886
- * detected heuristically by a `| M36 ... | complete` row.
1887
- *
1888
- * @param {string} projectDir
1889
- * @param {string} milestoneId
1890
- * @returns {boolean}
1891
- */
1892
- function isMilestoneComplete(projectDir, milestoneId) {
1893
- if (!milestoneId || milestoneId === "UNKNOWN") return false;
1894
- try {
1895
- const p = path.join(projectDir, ".gsd-t", "progress.md");
1896
- if (!fs.existsSync(p)) return false;
1897
- const body = fs.readFileSync(p, "utf8");
1898
- const idEsc = milestoneId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1899
- const lines = body.split(/\r?\n/);
1900
- const COMPLETE_PHASES = new Set(["COMPLETE", "COMPLETED", "DONE", "VERIFIED"]);
1901
-
1902
- // Authoritative signal #1: the top-of-file Status header.
1903
- // `## Status: M43 PARTITIONED — Token Attribution & ...`
1904
- // Match `## Status:` lines that name the active milestone id and read
1905
- // the first ALL-CAPS phase keyword that follows. Anything outside the
1906
- // keyword set (PARTITIONED, EXECUTING, VERIFYING, ...) is non-terminal.
1907
- const statusRe = new RegExp(
1908
- `^##\\s*Status:[^\\n]*\\b${idEsc}\\b\\s+([A-Z][A-Z-]+)`,
1909
- );
1910
- for (const ln of lines) {
1911
- const m = ln.match(statusRe);
1912
- if (m && COMPLETE_PHASES.has(m[1])) return true;
1913
- }
1914
-
1915
- // Authoritative signal #2: the milestone's row in the Milestones table.
1916
- // `| M43 | <name> | PARTITIONED | <version> | <domains> |`
1917
- // The third pipe column is the milestone's status. Decision Log prose,
1918
- // descriptive paragraphs, and "see also M43" mentions never satisfy
1919
- // this — the row format is structural.
1920
- const rowRe = new RegExp(
1921
- `^\\|\\s*${idEsc}\\s*\\|[^|]*\\|\\s*([A-Z][A-Z-]+)\\s*\\|`,
1922
- );
1923
- for (const ln of lines) {
1924
- const m = ln.match(rowRe);
1925
- if (m && COMPLETE_PHASES.has(m[1])) return true;
1926
- }
1927
-
1928
- return false;
1929
- } catch (_) {
1930
- return false;
1931
- }
1932
- }
1933
-
1934
- // ── isDone ──────────────────────────────────────────────────────────────────
1935
-
1936
- /**
1937
- * Returns true if the supervisor should stop looping: a terminal status was
1938
- * reached OR the iteration cap was hit.
1939
- */
1940
- function isDone(state) {
1941
- if (!state) return true;
1942
- if (isTerminal(state.status)) return true;
1943
- if (
1944
- typeof state.iter === "number" &&
1945
- typeof state.maxIterations === "number" &&
1946
- state.iter >= state.maxIterations
1947
- ) {
1948
- return true;
1949
- }
1950
- return false;
1951
- }
1952
-
1953
- // ── stopRequested ───────────────────────────────────────────────────────────
1954
-
1955
- /**
1956
- * Check for the stop sentinel file `.gsd-t/.unattended/stop`. Returns true
1957
- * if the user has requested a halt (contract §10). The supervisor checks
1958
- * this between workers; the file itself is NEVER removed by the supervisor
1959
- * on detection — it stays on disk as evidence, and `cleanStaleStopSentinel`
1960
- * wipes it on the next launch.
1961
- */
1962
- function stopRequested(projectDir) {
1963
- try {
1964
- const p = path.join(projectDir, UNATTENDED_DIR_REL, "stop");
1965
- return fs.existsSync(p);
1966
- } catch (_) {
1967
- return false;
1968
- }
1969
- }
1970
-
1971
- // ── cleanStaleStopSentinel ──────────────────────────────────────────────────
1972
-
1973
- /**
1974
- * Remove any pre-existing `.gsd-t/.unattended/stop` file left over from a
1975
- * previous supervisor run. Called ONCE at launch, before the first worker
1976
- * is spawned, so the new run is not immediately halted by a stale sentinel.
1977
- *
1978
- * If a stale file is found, the helper logs a reassuring message of the form
1979
- * `"Removed stale stop sentinel from {timestamp}"` where `{timestamp}` is
1980
- * the sentinel file's mtime (ISO8601). If no stale file exists, the helper
1981
- * is a silent no-op.
1982
- *
1983
- * Contract §10: "Next launch detects the stale sentinel and removes it
1984
- * before starting, after printing a reassuring message."
1985
- *
1986
- * @param {string} projectDir
1987
- * @param {(msg: string) => void} [log] — optional logger (defaults to console.log)
1988
- * @returns {boolean} true if a stale sentinel was removed
1989
- */
1990
- function cleanStaleStopSentinel(projectDir, log) {
1991
- const logger = typeof log === "function" ? log : (m) => {
1992
- // eslint-disable-next-line no-console
1993
- console.log(m);
1994
- };
1995
- try {
1996
- const p = path.join(projectDir, UNATTENDED_DIR_REL, "stop");
1997
- if (!fs.existsSync(p)) return false;
1998
- let mtimeIso = "unknown";
1999
- try {
2000
- mtimeIso = fs.statSync(p).mtime.toISOString();
2001
- } catch (_) {
2002
- // best effort
2003
- }
2004
- fs.unlinkSync(p);
2005
- logger(`[gsd-t-unattended] Removed stale stop sentinel from ${mtimeIso}`);
2006
- return true;
2007
- } catch (_) {
2008
- return false;
2009
- }
2010
- }
2011
-
2012
- // ── releaseSleepPrevention ──────────────────────────────────────────────────
2013
-
2014
- /**
2015
- * Release any active sleep-prevention handle recorded in the supervisor
2016
- * state. Task 4 (m36-cross-platform) will wire this into the real platform
2017
- * helper (`caffeinate` on darwin, `powercfg`/`SetThreadExecutionState` on
2018
- * win32, `systemd-inhibit` on linux). For Task 3 this is a stub that simply
2019
- * clears the handle field so `finalizeState` can be idempotent and safe to
2020
- * call on shutdown.
2021
- *
2022
- * @param {object} state
2023
- * @returns {boolean} true if a handle was released, false if none was set
2024
- */
2025
- function releaseSleepPrevention(state, releaseFn) {
2026
- if (!state || typeof state !== "object") return false;
2027
- if (state.sleepPreventionHandle == null) return false;
2028
- const handle = state.sleepPreventionHandle;
2029
- // Call the real platform helper (or injected fake). Never throw from a
2030
- // shutdown path.
2031
- try {
2032
- const release = typeof releaseFn === "function" ? releaseFn : releaseSleep;
2033
- release(handle);
2034
- } catch (_) {
2035
- // best effort
2036
- }
2037
- state.sleepPreventionHandle = null;
2038
- return true;
2039
- }
2040
-
2041
- // ── mapStatusToExitCode ─────────────────────────────────────────────────────
2042
-
2043
- /**
2044
- * Terminal status → CLI exit code. Used for the `gsd-t unattended` process
2045
- * exit code.
2046
- */
2047
- function mapStatusToExitCode(state) {
2048
- if (!state) return 1;
2049
- switch (state.status) {
2050
- case "done":
2051
- return 0;
2052
- case "stopped":
2053
- return 0;
2054
- case "failed":
2055
- return state.lastExit || 1;
2056
- case "crashed":
2057
- return 3;
2058
- default:
2059
- return 0; // still running / initializing — supervisor hasn't halted
2060
- }
2061
- }
2062
-
2063
- // ── installTerminalHandlers ─────────────────────────────────────────────────
2064
-
2065
- /**
2066
- * Wire `process.on('exit')` (and SIGINT/SIGTERM where possible) so an
2067
- * unexpected termination still removes the PID file and writes a terminal
2068
- * status. Safe to call multiple times — guarded by a closure flag.
2069
- */
2070
- function installTerminalHandlers(state, dir) {
2071
- let finalized = false;
2072
- const finalizeOnce = (terminal) => {
2073
- if (finalized) return;
2074
- finalized = true;
2075
- finalizeState(state, dir, terminal);
2076
- };
2077
-
2078
- process.on("exit", () => {
2079
- // 'exit' only allows synchronous work — finalizeState is sync.
2080
- finalizeOnce(undefined); // default → 'crashed' if non-terminal
2081
- });
2082
-
2083
- // Best-effort signal handlers. Don't override existing handlers; just
2084
- // chain a finalization step. On SIGINT/SIGTERM we let process.exit() run
2085
- // the 'exit' handler.
2086
- const onSignal = (sig) => {
2087
- if (state && !isTerminal(state.status)) {
2088
- // SIGINT / SIGTERM are user-initiated halts → 'stopped'
2089
- finalizeOnce("stopped");
2090
- }
2091
- // Re-raise default behavior
2092
- process.exit(0);
2093
- };
2094
- try {
2095
- process.on("SIGINT", onSignal);
2096
- process.on("SIGTERM", onSignal);
2097
- } catch (_) {
2098
- // Some environments don't support these — ignore.
2099
- }
2100
- }
2101
-
2102
- // ── CLI dispatch ────────────────────────────────────────────────────────────
2103
-
2104
- if (require.main === module) {
2105
- const result = doUnattended(process.argv.slice(2));
2106
- if (!result.ok) {
2107
- process.exitCode = result.exitCode || 1;
2108
- }
2109
- }