@tekyzinc/gsd-t 2.76.10 → 3.10.11

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,1259 @@
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("./gsd-t.js");
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.js");
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.js");
57
+
58
+ // ── Constants ───────────────────────────────────────────────────────────────
59
+
60
+ const CONTRACT_VERSION = "1.0.0";
61
+ const UNATTENDED_DIR_REL = path.join(".gsd-t", ".unattended");
62
+ const PID_FILE = "supervisor.pid";
63
+ const STATE_FILE = "state.json";
64
+ const STATE_TMP_FILE = "state.json.tmp";
65
+ const RUN_LOG = "run.log";
66
+
67
+ const DEFAULT_HOURS = 24;
68
+ const DEFAULT_MAX_ITERATIONS = 200;
69
+ const DEFAULT_WORKER_TIMEOUT_MS = 3600000; // 1 hour per contract §13
70
+
71
+ const TERMINAL_STATUSES = new Set(["done", "failed", "stopped", "crashed"]);
72
+ const VALID_STATUSES = new Set([
73
+ "initializing",
74
+ "running",
75
+ "done",
76
+ "failed",
77
+ "stopped",
78
+ "crashed",
79
+ ]);
80
+
81
+ // ── Exports ─────────────────────────────────────────────────────────────────
82
+
83
+ module.exports = {
84
+ doUnattended,
85
+ parseArgs,
86
+ initState,
87
+ writeState,
88
+ finalizeState,
89
+ makeSessionId,
90
+ resolveClaudeBinSafe,
91
+ isTerminal,
92
+ isMilestoneComplete,
93
+ isDone,
94
+ stopRequested,
95
+ cleanStaleStopSentinel,
96
+ releaseSleepPrevention,
97
+ runMainLoop,
98
+ _spawnWorker,
99
+ _appendRunLog,
100
+ CONTRACT_VERSION,
101
+ UNATTENDED_DIR_REL,
102
+ TERMINAL_STATUSES,
103
+ VALID_STATUSES,
104
+ DEFAULT_WORKER_TIMEOUT_MS,
105
+ };
106
+
107
+ // ── parseArgs ───────────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Parse the CLI argv (without the leading `node` and script path).
111
+ *
112
+ * Supports both `--flag=value` and `--flag value` forms.
113
+ *
114
+ * @param {string[]} argv
115
+ * @returns {{
116
+ * hours: number,
117
+ * maxIterations: number,
118
+ * project: string,
119
+ * branch: string,
120
+ * onDone: string,
121
+ * dryRun: boolean,
122
+ * verbose: boolean,
123
+ * testMode: boolean,
124
+ * }}
125
+ */
126
+ function parseArgs(argv) {
127
+ const out = {
128
+ hours: DEFAULT_HOURS,
129
+ maxIterations: DEFAULT_MAX_ITERATIONS,
130
+ project: ".",
131
+ branch: "AUTO",
132
+ onDone: "print",
133
+ dryRun: false,
134
+ verbose: false,
135
+ testMode: false,
136
+ };
137
+
138
+ for (let i = 0; i < argv.length; i++) {
139
+ const tok = argv[i];
140
+ if (typeof tok !== "string") continue;
141
+
142
+ // Boolean flags
143
+ if (tok === "--dry-run") {
144
+ out.dryRun = true;
145
+ continue;
146
+ }
147
+ if (tok === "--verbose") {
148
+ out.verbose = true;
149
+ continue;
150
+ }
151
+ if (tok === "--test-mode") {
152
+ out.testMode = true;
153
+ continue;
154
+ }
155
+
156
+ // Key/value flags — accept both `--k=v` and `--k v`
157
+ const eq = tok.indexOf("=");
158
+ let key, val;
159
+ if (tok.startsWith("--") && eq !== -1) {
160
+ key = tok.slice(2, eq);
161
+ val = tok.slice(eq + 1);
162
+ } else if (tok.startsWith("--")) {
163
+ key = tok.slice(2);
164
+ val = argv[i + 1];
165
+ i++;
166
+ } else {
167
+ continue;
168
+ }
169
+
170
+ switch (key) {
171
+ case "hours":
172
+ out.hours = Number(val);
173
+ if (!Number.isFinite(out.hours) || out.hours <= 0) {
174
+ out.hours = DEFAULT_HOURS;
175
+ }
176
+ break;
177
+ case "max-iterations":
178
+ out.maxIterations = parseInt(val, 10);
179
+ if (!Number.isFinite(out.maxIterations) || out.maxIterations <= 0) {
180
+ out.maxIterations = DEFAULT_MAX_ITERATIONS;
181
+ }
182
+ break;
183
+ case "project":
184
+ out.project = val || ".";
185
+ break;
186
+ case "branch":
187
+ out.branch = val || "AUTO";
188
+ break;
189
+ case "on-done":
190
+ out.onDone = val || "print";
191
+ break;
192
+ case "dry-run":
193
+ out.dryRun = true;
194
+ break;
195
+ case "verbose":
196
+ out.verbose = true;
197
+ break;
198
+ case "test-mode":
199
+ out.testMode = true;
200
+ break;
201
+ default:
202
+ // Unknown flag — ignore for forward compatibility
203
+ break;
204
+ }
205
+ }
206
+
207
+ return out;
208
+ }
209
+
210
+ // ── makeSessionId ───────────────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Produce a session ID matching the contract format:
214
+ * `unattended-{YYYY-MM-DD-HHMM}-{random4}`
215
+ *
216
+ * @param {Date} [date]
217
+ * @returns {string}
218
+ */
219
+ function makeSessionId(date) {
220
+ const d = date instanceof Date ? date : new Date();
221
+ const pad = (n) => String(n).padStart(2, "0");
222
+ const slug =
223
+ d.getUTCFullYear() +
224
+ "-" +
225
+ pad(d.getUTCMonth() + 1) +
226
+ "-" +
227
+ pad(d.getUTCDate()) +
228
+ "-" +
229
+ pad(d.getUTCHours()) +
230
+ pad(d.getUTCMinutes());
231
+ const rand = Math.floor(Math.random() * 0xffff)
232
+ .toString(16)
233
+ .padStart(4, "0");
234
+ return `unattended-${slug}-${rand}`;
235
+ }
236
+
237
+ // ── resolveClaudeBinSafe ────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Best-effort resolution of the `claude` binary path. Task 4 will replace
241
+ * this with a proper cross-platform helper from
242
+ * `bin/gsd-t-unattended-platform.js`. For Task 1 we just shell out to
243
+ * `which claude` (POSIX) or `where claude` (win32) with a safe fallback to
244
+ * the literal string `"claude"` — enough to satisfy the schema.
245
+ *
246
+ * @returns {string}
247
+ */
248
+ function resolveClaudeBinSafe() {
249
+ try {
250
+ const cmd = process.platform === "win32" ? "where claude" : "which claude";
251
+ const out = execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
252
+ .toString()
253
+ .split(/\r?\n/)[0]
254
+ .trim();
255
+ if (out) return out;
256
+ } catch (_) {
257
+ // fall through to default
258
+ }
259
+ return "claude";
260
+ }
261
+
262
+ // ── isTerminal ──────────────────────────────────────────────────────────────
263
+
264
+ function isTerminal(status) {
265
+ return TERMINAL_STATUSES.has(status);
266
+ }
267
+
268
+ // ── readMilestoneId ─────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Best-effort read of the active milestone ID from `.gsd-t/progress.md`.
272
+ * Falls back to the literal string `"UNKNOWN"` if the file or marker is
273
+ * absent. We look for an `M{NN}` token on the first 40 lines.
274
+ *
275
+ * @param {string} projectDir
276
+ * @returns {string}
277
+ */
278
+ function readMilestoneId(projectDir) {
279
+ try {
280
+ const p = path.join(projectDir, ".gsd-t", "progress.md");
281
+ if (!fs.existsSync(p)) return "UNKNOWN";
282
+ const head = fs.readFileSync(p, "utf8").split(/\r?\n/).slice(0, 60).join("\n");
283
+ const m = head.match(/\bM(\d{1,3})\b/);
284
+ return m ? `M${m[1]}` : "UNKNOWN";
285
+ } catch (_) {
286
+ return "UNKNOWN";
287
+ }
288
+ }
289
+
290
+ // ── initState ───────────────────────────────────────────────────────────────
291
+
292
+ /**
293
+ * Build the initial `state.json` object (status: 'initializing'). Does NOT
294
+ * write to disk — pass the result to `writeState()`.
295
+ *
296
+ * @param {{
297
+ * projectDir: string,
298
+ * hours: number,
299
+ * maxIterations: number,
300
+ * sessionId?: string,
301
+ * milestone?: string,
302
+ * claudeBin?: string,
303
+ * logPath?: string,
304
+ * now?: Date,
305
+ * }} opts
306
+ * @returns {object}
307
+ */
308
+ function initState(opts) {
309
+ if (!opts || !opts.projectDir) {
310
+ throw new Error("initState: opts.projectDir is required");
311
+ }
312
+ const now = opts.now instanceof Date ? opts.now : new Date();
313
+ const startedAt = now.toISOString();
314
+ const projectDir = path.resolve(opts.projectDir);
315
+ const sessionId = opts.sessionId || makeSessionId(now);
316
+ const milestone = opts.milestone || readMilestoneId(projectDir);
317
+ const claudeBin = opts.claudeBin || resolveClaudeBinSafe();
318
+ const logPath =
319
+ opts.logPath ||
320
+ path.join(UNATTENDED_DIR_REL, RUN_LOG); // project-relative per contract §3
321
+
322
+ return {
323
+ version: CONTRACT_VERSION,
324
+ sessionId,
325
+ projectDir,
326
+ status: "initializing",
327
+ milestone,
328
+ // wave / task / lastWorker* / lastExit / lastElapsedMs are populated by
329
+ // the main loop in Task 2. They are documented as optional in §3.
330
+ iter: 0,
331
+ maxIterations: opts.maxIterations || DEFAULT_MAX_ITERATIONS,
332
+ startedAt,
333
+ lastTick: startedAt,
334
+ hours: opts.hours || DEFAULT_HOURS,
335
+ wallClockElapsedMs: 0,
336
+ supervisorPid: process.pid,
337
+ logPath,
338
+ sleepPreventionHandle: null,
339
+ platform: process.platform,
340
+ claudeBin,
341
+ };
342
+ }
343
+
344
+ // ── writeState ──────────────────────────────────────────────────────────────
345
+
346
+ /**
347
+ * Atomically write `state.json` into the supervisor runtime directory.
348
+ * Updates `lastTick` and (if startedAt is set) `wallClockElapsedMs`.
349
+ *
350
+ * @param {object} state
351
+ * @param {string} dir — absolute path to `.gsd-t/.unattended/`
352
+ * @returns {object} the (mutated) state object actually written
353
+ */
354
+ function writeState(state, dir) {
355
+ if (!state || typeof state !== "object") {
356
+ throw new Error("writeState: state must be an object");
357
+ }
358
+ if (!dir) throw new Error("writeState: dir is required");
359
+
360
+ fs.mkdirSync(dir, { recursive: true });
361
+
362
+ const now = new Date();
363
+ state.lastTick = now.toISOString();
364
+ if (state.startedAt) {
365
+ const started = Date.parse(state.startedAt);
366
+ if (!Number.isNaN(started)) {
367
+ state.wallClockElapsedMs = Math.max(0, now.getTime() - started);
368
+ }
369
+ }
370
+
371
+ const tmp = path.join(dir, STATE_TMP_FILE);
372
+ const final = path.join(dir, STATE_FILE);
373
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
374
+ fs.renameSync(tmp, final);
375
+ return state;
376
+ }
377
+
378
+ // ── finalizeState ───────────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Terminal finalization. If `state.status` is non-terminal, transition it
382
+ * to `terminalStatus` (default `'crashed'`). Writes state, releases any
383
+ * sleep-prevention handle, then removes the PID file.
384
+ *
385
+ * **Idempotent**: a state object is marked finalized via a non-enumerable
386
+ * `_finalized` flag on first call. Subsequent calls are no-ops that return
387
+ * the same (preserved) terminal state. This matters because terminal
388
+ * handlers may fire from multiple paths (main-loop exit, SIGINT/SIGTERM,
389
+ * process.on('exit')) and we must not corrupt the already-written terminal
390
+ * state (e.g., by overwriting 'stopped' with 'crashed').
391
+ *
392
+ * @param {object} state
393
+ * @param {string} dir — absolute path to `.gsd-t/.unattended/`
394
+ * @param {string} [terminalStatus='crashed']
395
+ * @returns {object} the finalized state
396
+ */
397
+ function finalizeState(state, dir, terminalStatus) {
398
+ if (!state || typeof state !== "object") return state;
399
+ if (!dir) throw new Error("finalizeState: dir is required");
400
+
401
+ // Idempotency: if already finalized, return the preserved terminal state
402
+ // untouched. Do NOT re-write state, do NOT change status.
403
+ if (state._finalized === true) return state;
404
+
405
+ if (!isTerminal(state.status)) {
406
+ const next = terminalStatus && VALID_STATUSES.has(terminalStatus)
407
+ ? terminalStatus
408
+ : "crashed";
409
+ state.status = next;
410
+ }
411
+
412
+ // Release any sleep-prevention handle. Task 4 wires the real platform
413
+ // helper; for now this just clears the field so it can't dangle.
414
+ try {
415
+ releaseSleepPrevention(state);
416
+ } catch (_) {
417
+ // best effort — never throw from a shutdown path
418
+ }
419
+
420
+ // Always re-write so lastTick reflects finalization time and any
421
+ // last-second fields (lastExit, etc.) are flushed.
422
+ try {
423
+ writeState(state, dir);
424
+ } catch (_) {
425
+ // best effort — never throw from a shutdown path
426
+ }
427
+ // Remove PID file last so external readers (kill -0) see a live process
428
+ // until the state file is on disk.
429
+ try {
430
+ const pidPath = path.join(dir, PID_FILE);
431
+ if (fs.existsSync(pidPath)) fs.unlinkSync(pidPath);
432
+ } catch (_) {
433
+ // best effort
434
+ }
435
+
436
+ // Mark finalized via a non-enumerable flag so it doesn't serialize into
437
+ // state.json on the next (no-op) call.
438
+ try {
439
+ Object.defineProperty(state, "_finalized", {
440
+ value: true,
441
+ writable: false,
442
+ enumerable: false,
443
+ configurable: false,
444
+ });
445
+ } catch (_) {
446
+ // If the property is already defined (shouldn't happen), fall back to
447
+ // a plain assignment — subsequent calls will still see truthy.
448
+ state._finalized = true;
449
+ }
450
+
451
+ return state;
452
+ }
453
+
454
+ // ── doUnattended ────────────────────────────────────────────────────────────
455
+
456
+ /**
457
+ * CLI entry point. Parses args, runs preflight, sets up runtime state, and
458
+ * runs the main worker relay loop. Task 1 built the skeleton; Task 2 added
459
+ * the main loop.
460
+ *
461
+ * Dependency injection (for tests) via `deps`:
462
+ * - `_spawnWorker(state, opts)` → { status, stdout, stderr, signal }
463
+ * - `_isMilestoneComplete(projectDir, milestoneId)` → boolean
464
+ * - `_stopRequested(projectDir)` → boolean
465
+ *
466
+ * @param {string[]} argv — argv WITHOUT the leading `node` and script path
467
+ * @param {object} [deps]
468
+ * @returns {{
469
+ * ok: boolean,
470
+ * dryRun: boolean,
471
+ * state?: object,
472
+ * dir?: string,
473
+ * exitCode: number,
474
+ * reason?: string,
475
+ * }}
476
+ */
477
+ function doUnattended(argv, deps) {
478
+ deps = deps || {};
479
+ const opts = parseArgs(argv || []);
480
+ const projectDir = path.resolve(opts.project || ".");
481
+
482
+ // ── Resolve injection points (real impls by default) ─────────────────────
483
+ const fn = {
484
+ checkGitBranch: deps._checkGitBranch || checkGitBranch,
485
+ checkWorktreeCleanliness: deps._checkWorktreeCleanliness || checkWorktreeCleanliness,
486
+ checkIterationCap: deps._checkIterationCap || checkIterationCap,
487
+ checkWallClockCap: deps._checkWallClockCap || checkWallClockCap,
488
+ validateState: deps._validateState || validateState,
489
+ detectGutter: deps._detectGutter || detectGutter,
490
+ detectBlockerSentinel: deps._detectBlockerSentinel || detectBlockerSentinel,
491
+ resolveClaudePath: deps._resolveClaudePath || resolveClaudePath,
492
+ preventSleep: deps._preventSleep || preventSleep,
493
+ releaseSleep: deps._releaseSleep || releaseSleep,
494
+ notify: deps._notify || notify,
495
+ loadConfig: deps._loadConfig || loadConfig,
496
+ };
497
+
498
+ // ── Load config (optional .gsd-t/.unattended/config.json) ────────────────
499
+ // CLI flags take precedence over config.json values per standard CLI ergonomics.
500
+ let config;
501
+ try {
502
+ config = fn.loadConfig(projectDir);
503
+ } catch (e) {
504
+ // Malformed config → preflight failure, no PID/state written.
505
+ // eslint-disable-next-line no-console
506
+ console.error(`[gsd-t-unattended] preflight-failure: ${e.message}`);
507
+ return {
508
+ ok: false,
509
+ dryRun: !!opts.dryRun,
510
+ exitCode: 2,
511
+ reason: String(e.message || e),
512
+ };
513
+ }
514
+ // CLI overrides win over config for hours / maxIterations (explicit user
515
+ // intent beats on-disk defaults). Parsed defaults of 24/200 match config
516
+ // defaults, so this only matters when the user explicitly passed a value —
517
+ // but parseArgs doesn't track that. We accept the merge semantics as-is:
518
+ // config.hours overrides parseArgs default 24 only if CLI didn't also pass.
519
+ // Simple rule: if CLI value equals the hardcoded default, prefer config.
520
+ if (opts.hours === DEFAULT_HOURS && typeof config.hours === "number") {
521
+ opts.hours = config.hours;
522
+ }
523
+ if (
524
+ opts.maxIterations === DEFAULT_MAX_ITERATIONS &&
525
+ typeof config.maxIterations === "number"
526
+ ) {
527
+ opts.maxIterations = config.maxIterations;
528
+ }
529
+ // CLI values now win — mirror them back into config so the pre-worker
530
+ // safety caps (checkIterationCap / checkWallClockCap) use the effective
531
+ // supervisor-scoped limits rather than the on-disk file defaults.
532
+ config.maxIterations = opts.maxIterations;
533
+ config.hours = opts.hours;
534
+
535
+ // ── PRE-LAUNCH HOOK (contract §12) ───────────────────────────────────────
536
+ // Runs BEFORE any PID/state file is written. Refusal → exit with the
537
+ // corresponding code and leave the runtime dir untouched.
538
+ const branchRes = fn.checkGitBranch(projectDir, config);
539
+ if (!branchRes.ok) {
540
+ // eslint-disable-next-line no-console
541
+ console.error(
542
+ `[gsd-t-unattended] preflight-refusal: ${branchRes.reason || "protected branch"}`,
543
+ );
544
+ return {
545
+ ok: false,
546
+ dryRun: !!opts.dryRun,
547
+ exitCode: branchRes.code || 7,
548
+ reason: branchRes.reason,
549
+ };
550
+ }
551
+ const treeRes = fn.checkWorktreeCleanliness(projectDir, config);
552
+ if (!treeRes.ok) {
553
+ // eslint-disable-next-line no-console
554
+ console.error(
555
+ `[gsd-t-unattended] preflight-refusal: ${treeRes.reason || "dirty worktree"}`,
556
+ );
557
+ return {
558
+ ok: false,
559
+ dryRun: !!opts.dryRun,
560
+ exitCode: treeRes.code || 8,
561
+ reason: treeRes.reason,
562
+ };
563
+ }
564
+
565
+ // Dry-run: pre-flight summary only. Do not touch the runtime directory.
566
+ // Pre-launch checks have already passed at this point, so dry-run reports OK.
567
+ if (opts.dryRun) {
568
+ // Resolve claudeBin best-effort for the dry-run summary. A failure here
569
+ // is NOT fatal for dry-run — we only need the field for display.
570
+ let dryClaudeBin = "claude";
571
+ try {
572
+ dryClaudeBin = fn.resolveClaudePath();
573
+ } catch (_) {
574
+ /* best effort */
575
+ }
576
+ const summary = {
577
+ mode: "dry-run",
578
+ projectDir,
579
+ hours: opts.hours,
580
+ maxIterations: opts.maxIterations,
581
+ branch: opts.branch,
582
+ onDone: opts.onDone,
583
+ verbose: opts.verbose,
584
+ testMode: opts.testMode,
585
+ platform: process.platform,
586
+ claudeBin: dryClaudeBin,
587
+ milestone: readMilestoneId(projectDir),
588
+ };
589
+ if (opts.verbose) {
590
+ // eslint-disable-next-line no-console
591
+ console.log(
592
+ "[gsd-t-unattended] dry-run preflight:\n" +
593
+ JSON.stringify(summary, null, 2),
594
+ );
595
+ } else {
596
+ // eslint-disable-next-line no-console
597
+ console.log(
598
+ `[gsd-t-unattended] dry-run OK — project=${projectDir} hours=${opts.hours} max-iter=${opts.maxIterations} platform=${process.platform}`,
599
+ );
600
+ }
601
+ return { ok: true, dryRun: true, exitCode: 0 };
602
+ }
603
+
604
+ // ── Resolve claudeBin BEFORE writing any runtime state ───────────────────
605
+ // A failure here is a preflight-failure (code 2). No PID/state written.
606
+ let claudeBin;
607
+ try {
608
+ claudeBin = fn.resolveClaudePath();
609
+ if (!claudeBin || typeof claudeBin !== "string") {
610
+ throw new Error("resolveClaudePath returned empty result");
611
+ }
612
+ } catch (e) {
613
+ // eslint-disable-next-line no-console
614
+ console.error(
615
+ `[gsd-t-unattended] preflight-failure: claude binary not resolvable: ${e.message}`,
616
+ );
617
+ return {
618
+ ok: false,
619
+ dryRun: false,
620
+ exitCode: 2,
621
+ reason: `claude binary not resolvable: ${e.message}`,
622
+ };
623
+ }
624
+
625
+ // Real run — establish runtime directory and PID file.
626
+ const dir = path.join(projectDir, UNATTENDED_DIR_REL);
627
+ fs.mkdirSync(dir, { recursive: true });
628
+
629
+ // Stale stop-sentinel cleanup (contract §10). A previous run may have
630
+ // left a `.gsd-t/.unattended/stop` file on disk — remove it now, before
631
+ // the main loop's stop-sentinel check would see it and halt immediately.
632
+ // The helper logs a reassuring message when it removes a file.
633
+ cleanStaleStopSentinel(projectDir);
634
+
635
+ // Build initial state and write `initializing`.
636
+ const state = initState({
637
+ projectDir,
638
+ hours: opts.hours,
639
+ maxIterations: opts.maxIterations,
640
+ claudeBin,
641
+ });
642
+ writeState(state, dir);
643
+
644
+ // Write the PID file. Singleton enforcement (refusing if another
645
+ // supervisor is already alive) is owned by the launch handshake — see
646
+ // contract §7. We trust the caller for now and just write our PID.
647
+ const pidPath = path.join(dir, PID_FILE);
648
+ fs.writeFileSync(pidPath, String(process.pid) + "\n", "utf8");
649
+
650
+ // Install terminal handlers BEFORE transitioning to `running` so a crash
651
+ // mid-transition is still finalized.
652
+ installTerminalHandlers(state, dir);
653
+
654
+ // Transition to `running`. From this point the launch handshake (§7) will
655
+ // observe `status === 'running'` and complete its 5-second readiness poll.
656
+ state.status = "running";
657
+
658
+ // ── Sleep prevention — acquire at `running` transition ───────────────────
659
+ // The handle (caffeinate PID on darwin, null elsewhere) is stored on state
660
+ // so finalizeState can release it on shutdown.
661
+ try {
662
+ const sleepHandle = fn.preventSleep("unattended-supervisor");
663
+ state.sleepPreventionHandle = sleepHandle == null ? null : sleepHandle;
664
+ } catch (_) {
665
+ // Sleep prevention failures are non-fatal — log and continue.
666
+ state.sleepPreventionHandle = null;
667
+ }
668
+
669
+ writeState(state, dir);
670
+
671
+ if (opts.verbose) {
672
+ // eslint-disable-next-line no-console
673
+ console.log(
674
+ `[gsd-t-unattended] running — sessionId=${state.sessionId} pid=${process.pid} milestone=${state.milestone} platform=${state.platform}`,
675
+ );
676
+ }
677
+
678
+ // ── SUPERVISOR-INIT HOOK (contract §12) ──────────────────────────────────
679
+ // State is fully populated and on disk. Run validateState + re-verify the
680
+ // branch (defensive — branch may have changed between pre-launch and now).
681
+ const vRes = fn.validateState(state);
682
+ if (!vRes.ok) {
683
+ // eslint-disable-next-line no-console
684
+ console.error(
685
+ `[gsd-t-unattended] supervisor-init: validateState failed: ${
686
+ (vRes.errors || []).join("; ") || vRes.reason
687
+ }`,
688
+ );
689
+ state.status = "failed";
690
+ state.lastExit = 2;
691
+ writeState(state, dir);
692
+ _notifyAndFinalize(state, dir, fn, "failed");
693
+ return {
694
+ ok: false,
695
+ dryRun: false,
696
+ state,
697
+ dir,
698
+ exitCode: 2,
699
+ reason: vRes.reason,
700
+ };
701
+ }
702
+ const branchReVerify = fn.checkGitBranch(projectDir, config);
703
+ if (!branchReVerify.ok) {
704
+ // eslint-disable-next-line no-console
705
+ console.error(
706
+ `[gsd-t-unattended] supervisor-init: branch check re-verify failed: ${branchReVerify.reason}`,
707
+ );
708
+ state.status = "failed";
709
+ state.lastExit = branchReVerify.code || 7;
710
+ writeState(state, dir);
711
+ _notifyAndFinalize(state, dir, fn, "failed");
712
+ return {
713
+ ok: false,
714
+ dryRun: false,
715
+ state,
716
+ dir,
717
+ exitCode: branchReVerify.code || 7,
718
+ reason: branchReVerify.reason,
719
+ };
720
+ }
721
+
722
+ // Main relay loop. Workers spawn fresh each iteration until the milestone
723
+ // completes, the iteration cap is hit, a terminal exit code is returned,
724
+ // a stop sentinel is observed, or a safety rails halt fires.
725
+ runMainLoop(state, dir, opts, deps, { fn, config });
726
+
727
+ // Terminal notification + explicit finalize. finalizeState is idempotent —
728
+ // the process.on('exit') handler will be a no-op after this.
729
+ _notifyAndFinalize(state, dir, fn);
730
+
731
+ return {
732
+ ok: state.status !== "failed",
733
+ dryRun: false,
734
+ state,
735
+ dir,
736
+ exitCode: mapStatusToExitCode(state),
737
+ };
738
+ }
739
+
740
+ // ── _notifyAndFinalize ──────────────────────────────────────────────────────
741
+ //
742
+ // Fire the terminal-transition notification, then call finalizeState. Both
743
+ // steps are best-effort and must never throw from a shutdown path.
744
+
745
+ function _notifyAndFinalize(state, dir, fn, terminalHint) {
746
+ try {
747
+ const status = terminalHint || state.status;
748
+ const started = Date.parse(state.startedAt || "");
749
+ let durStr = "";
750
+ if (!Number.isNaN(started)) {
751
+ const ms = Math.max(0, Date.now() - started);
752
+ const totalMins = Math.round(ms / 60000);
753
+ const hrs = Math.floor(totalMins / 60);
754
+ const mins = totalMins % 60;
755
+ durStr = `${hrs}h ${mins}m`;
756
+ }
757
+ const logPath =
758
+ state.logPath || path.join(UNATTENDED_DIR_REL, RUN_LOG);
759
+
760
+ if (status === "done") {
761
+ fn.notify(
762
+ "GSD-T Unattended: Complete",
763
+ `Milestone ${state.milestone || ""} reached COMPLETED in ${durStr}`,
764
+ "success",
765
+ );
766
+ } else if (status === "failed") {
767
+ fn.notify(
768
+ "GSD-T Unattended: Failed",
769
+ `Exit ${state.lastExit || 1} — see ${logPath}`,
770
+ "error",
771
+ );
772
+ } else if (status === "stopped") {
773
+ fn.notify(
774
+ "GSD-T Unattended: Stopped",
775
+ `User stop after iter ${state.iter || 0}`,
776
+ "warn",
777
+ );
778
+ }
779
+ } catch (_) {
780
+ // best effort — never throw from a shutdown path
781
+ }
782
+ // Release sleep prevention via the wired (or injected) helper BEFORE
783
+ // finalizeState runs so the internal releaseSleepPrevention call is a no-op.
784
+ try {
785
+ releaseSleepPrevention(state, fn && fn.releaseSleep);
786
+ } catch (_) {
787
+ // best effort
788
+ }
789
+ try {
790
+ finalizeState(state, dir, terminalHint);
791
+ } catch (_) {
792
+ // best effort
793
+ }
794
+ }
795
+
796
+ // ── runMainLoop ─────────────────────────────────────────────────────────────
797
+
798
+ /**
799
+ * The core worker relay loop. Each iteration:
800
+ * 1. Check terminal conditions (isDone / stopRequested)
801
+ * 2. Increment iter, update lastWorkerStartedAt, write state
802
+ * 3. Spawn a worker via `_spawnWorker` (real spawnSync or injected shim)
803
+ * 4. Map the exit code via `mapHeadlessExitCode`
804
+ * 5. Append worker output to run.log
805
+ * 6. Update state with lastExit/lastWorkerFinishedAt/lastElapsedMs, write
806
+ * 7. Classify terminal exit branches and transition status if needed
807
+ *
808
+ * Task 3 will add the stop-sentinel check at step 1 (stub exists via
809
+ * `stopRequested`). Task 4 will replace `_spawnWorker` with the real
810
+ * cross-platform helper.
811
+ */
812
+ function runMainLoop(state, dir, opts, deps, ctx) {
813
+ deps = deps || {};
814
+ ctx = ctx || {};
815
+ // Safety rails + platform helpers wired by doUnattended (fn) + loaded
816
+ // config. When runMainLoop is called directly by tests, fall back to real
817
+ // impls / defaults so the loop remains usable standalone.
818
+ const fn = ctx.fn || {
819
+ checkIterationCap: deps._checkIterationCap || checkIterationCap,
820
+ checkWallClockCap: deps._checkWallClockCap || checkWallClockCap,
821
+ validateState: deps._validateState || validateState,
822
+ detectGutter: deps._detectGutter || detectGutter,
823
+ detectBlockerSentinel: deps._detectBlockerSentinel || detectBlockerSentinel,
824
+ };
825
+ const config = ctx.config || SAFETY_DEFAULTS;
826
+
827
+ // --test-mode uses a built-in stub that completes on the first iteration.
828
+ // Explicit deps override test-mode.
829
+ const useTestStub = !!opts.testMode && !deps._spawnWorker;
830
+ const spawnWorker =
831
+ deps._spawnWorker || (useTestStub ? _testModeSpawnWorker : _spawnWorker);
832
+ const milestoneComplete =
833
+ deps._isMilestoneComplete || (useTestStub ? () => true : isMilestoneComplete);
834
+ const stopCheck = deps._stopRequested || stopRequested;
835
+ const workerTimeoutMs = opts.workerTimeoutMs || DEFAULT_WORKER_TIMEOUT_MS;
836
+ const projectDir = state.projectDir;
837
+
838
+ while (!isDone(state) && !stopCheck(projectDir)) {
839
+ // ── PRE-WORKER HOOK (contract §12) ─────────────────────────────────────
840
+ // Refusal → halt with status=failed, lastExit=6 (caps) or 2 (validate).
841
+ const capIter = fn.checkIterationCap(state, config);
842
+ if (!capIter.ok) {
843
+ state.status = "failed";
844
+ state.lastExit = capIter.code || 6;
845
+ writeState(state, dir);
846
+ break;
847
+ }
848
+ const capWall = fn.checkWallClockCap(state, config);
849
+ if (!capWall.ok) {
850
+ state.status = "failed";
851
+ state.lastExit = capWall.code || 6;
852
+ writeState(state, dir);
853
+ break;
854
+ }
855
+ const vRes = fn.validateState(state);
856
+ if (!vRes.ok) {
857
+ state.status = "failed";
858
+ state.lastExit = vRes.code || 2;
859
+ writeState(state, dir);
860
+ break;
861
+ }
862
+
863
+ // Pre-spawn bookkeeping
864
+ state.iter = (state.iter || 0) + 1;
865
+ const workerStart = new Date();
866
+ state.lastWorkerStartedAt = workerStart.toISOString();
867
+ writeState(state, dir);
868
+
869
+ let res;
870
+ try {
871
+ res = spawnWorker(state, {
872
+ cwd: projectDir,
873
+ timeout: workerTimeoutMs,
874
+ verbose: !!opts.verbose,
875
+ });
876
+ } catch (e) {
877
+ // Defensive: a real spawnSync shouldn't throw, but a shim could.
878
+ res = { status: 3, stdout: "", stderr: String((e && e.message) || e), signal: null };
879
+ }
880
+ res = res || { status: null, stdout: "", stderr: "", signal: null };
881
+
882
+ const workerEnd = new Date();
883
+ const elapsedMs = workerEnd.getTime() - workerStart.getTime();
884
+ const stdout = typeof res.stdout === "string" ? res.stdout : "";
885
+ const stderr = typeof res.stderr === "string" ? res.stderr : "";
886
+
887
+ // Timeout detection: spawnSync sets status=null and signal='SIGTERM' on
888
+ // timeout (legacy shim), OR sets res.timedOut=true (platform.spawnWorker).
889
+ // Map to contract code 124.
890
+ let exitCode;
891
+ if (res.timedOut === true || res.status === null || res.signal === "SIGTERM") {
892
+ exitCode = 124;
893
+ } else {
894
+ exitCode = mapHeadlessExitCode(res.status, stdout + "\n" + stderr);
895
+ }
896
+
897
+ // Append the full worker output to run.log (never truncate).
898
+ _appendRunLog(dir, state.iter, workerEnd, exitCode, stdout, stderr);
899
+
900
+ // Post-spawn state update
901
+ state.lastExit = exitCode;
902
+ state.lastWorkerFinishedAt = workerEnd.toISOString();
903
+ state.lastElapsedMs = elapsedMs;
904
+ writeState(state, dir);
905
+
906
+ // ── POST-WORKER HOOK (contract §12) ────────────────────────────────────
907
+ // Read the tail of run.log for pattern detection. ~200 lines is enough
908
+ // to span the last several iteration blocks for the gutter detector.
909
+ let runLogTail = "";
910
+ try {
911
+ const logPath = path.join(dir, RUN_LOG);
912
+ if (fs.existsSync(logPath)) {
913
+ const all = fs.readFileSync(logPath, "utf8");
914
+ const lines = all.split(/\r?\n/);
915
+ runLogTail = lines.slice(-200).join("\n");
916
+ }
917
+ } catch (_) {
918
+ // best effort — tail read failure does not halt the loop
919
+ }
920
+ const blocker = fn.detectBlockerSentinel(runLogTail);
921
+ if (!blocker.ok) {
922
+ state.status = "failed";
923
+ state.lastExit = blocker.code || 6;
924
+ writeState(state, dir);
925
+ break;
926
+ }
927
+ const gutter = fn.detectGutter(state, runLogTail, config);
928
+ if (!gutter.ok) {
929
+ state.status = "failed";
930
+ state.lastExit = gutter.code || 6;
931
+ writeState(state, dir);
932
+ break;
933
+ }
934
+
935
+ // Terminal exit classification
936
+ if (exitCode === 0) {
937
+ // Success — check if the milestone is now complete.
938
+ if (milestoneComplete(projectDir, state.milestone)) {
939
+ state.status = "done";
940
+ writeState(state, dir);
941
+ break;
942
+ }
943
+ // Not yet done — continue relay.
944
+ continue;
945
+ }
946
+ if (exitCode === 4) {
947
+ // Unrecoverable blocker.
948
+ state.status = "failed";
949
+ writeState(state, dir);
950
+ break;
951
+ }
952
+ if (exitCode === 5) {
953
+ // Command dispatch failure — worker invocation is broken.
954
+ state.status = "failed";
955
+ writeState(state, dir);
956
+ break;
957
+ }
958
+ if (exitCode === 124) {
959
+ // Timeout — continue unless the iter cap is hit on the next check.
960
+ continue;
961
+ }
962
+ // Non-terminal (1/2/3) — continue the relay.
963
+ }
964
+
965
+ // If we exited because the user dropped a stop sentinel and no terminal
966
+ // status has been assigned yet, transition to 'stopped' now (contract §10).
967
+ // The sentinel file itself is NOT removed by the supervisor — it stays on
968
+ // disk as evidence, to be cleaned by the next launch via
969
+ // `cleanStaleStopSentinel`.
970
+ if (!isTerminal(state.status) && stopCheck(projectDir)) {
971
+ state.status = "stopped";
972
+ writeState(state, dir);
973
+ }
974
+ return state;
975
+ }
976
+
977
+ // ── _spawnWorker ────────────────────────────────────────────────────────────
978
+
979
+ /**
980
+ * Spawn a fresh `claude -p /gsd-t-resume` worker via `spawnSync`. Returns a
981
+ * normalized `{ status, stdout, stderr, signal }` result. Task 4 replaces
982
+ * this with the cross-platform helper from `bin/gsd-t-unattended-platform.js`.
983
+ *
984
+ * @param {object} state — current supervisor state (reads `claudeBin`)
985
+ * @param {{cwd: string, timeout: number, verbose?: boolean}} opts
986
+ * @returns {{status: (number|null), stdout: string, stderr: string, signal: (string|null)}}
987
+ */
988
+ function _spawnWorker(state, opts) {
989
+ const bin = (state && state.claudeBin) || resolveClaudePath();
990
+ const res = platformSpawnWorker(opts.cwd, opts.timeout, {
991
+ bin,
992
+ args: ["-p", "/gsd-t-resume"],
993
+ });
994
+ return {
995
+ status: typeof res.status === "number" ? res.status : null,
996
+ stdout: res.stdout || "",
997
+ stderr: res.stderr || "",
998
+ signal: res.signal || null,
999
+ timedOut: !!res.timedOut,
1000
+ error: res.error || null,
1001
+ };
1002
+ }
1003
+
1004
+ // ── _testModeSpawnWorker ────────────────────────────────────────────────────
1005
+
1006
+ /**
1007
+ * Built-in stub worker for `--test-mode`. Returns a canned "milestone
1008
+ * complete" result so the loop terminates in one iteration without invoking
1009
+ * a real `claude` binary. Exists for CI/smoke tests and for the Task 1
1010
+ * "real run" test that must not hang on the real `claude` CLI.
1011
+ */
1012
+ function _testModeSpawnWorker(state, _opts) {
1013
+ return {
1014
+ status: 0,
1015
+ stdout: `[test-mode] stub worker iter=${state.iter} milestone=${state.milestone} COMPLETE\n`,
1016
+ stderr: "",
1017
+ signal: null,
1018
+ };
1019
+ }
1020
+
1021
+ // ── _appendRunLog ───────────────────────────────────────────────────────────
1022
+
1023
+ /**
1024
+ * Append a single worker iteration to `run.log`. Format:
1025
+ * --- ITER {n} @ {ISO8601} exit={code} ---
1026
+ * {stdout}
1027
+ * {stderr}
1028
+ *
1029
+ * Never truncates. Creates the log file if it does not exist.
1030
+ */
1031
+ function _appendRunLog(dir, iter, when, exitCode, stdout, stderr) {
1032
+ try {
1033
+ fs.mkdirSync(dir, { recursive: true });
1034
+ const logPath = path.join(dir, RUN_LOG);
1035
+ const iso = when instanceof Date ? when.toISOString() : String(when);
1036
+ const header = `--- ITER ${iter} @ ${iso} exit=${exitCode} ---\n`;
1037
+ const body =
1038
+ (stdout || "") +
1039
+ (stdout && !stdout.endsWith("\n") ? "\n" : "") +
1040
+ (stderr ? "[stderr]\n" + stderr + (stderr.endsWith("\n") ? "" : "\n") : "");
1041
+ fs.appendFileSync(logPath, header + body, "utf8");
1042
+ } catch (_) {
1043
+ // best effort — never throw from the main loop
1044
+ }
1045
+ }
1046
+
1047
+ // ── isMilestoneComplete ─────────────────────────────────────────────────────
1048
+
1049
+ /**
1050
+ * Returns true if `.gsd-t/progress.md` indicates the given milestone is
1051
+ * complete. Checks for either:
1052
+ * - The string `{milestoneId} COMPLETE` (case-insensitive) anywhere in the
1053
+ * file (matches the `Status: M36 COMPLETE` convention used in progress.md)
1054
+ * - A row containing the milestone ID in a completed-milestones table,
1055
+ * detected heuristically by a `| M36 ... | complete` row.
1056
+ *
1057
+ * @param {string} projectDir
1058
+ * @param {string} milestoneId
1059
+ * @returns {boolean}
1060
+ */
1061
+ function isMilestoneComplete(projectDir, milestoneId) {
1062
+ if (!milestoneId || milestoneId === "UNKNOWN") return false;
1063
+ try {
1064
+ const p = path.join(projectDir, ".gsd-t", "progress.md");
1065
+ if (!fs.existsSync(p)) return false;
1066
+ const body = fs.readFileSync(p, "utf8");
1067
+ const idEsc = milestoneId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1068
+ // Form 1: "{id} COMPLETE" or "{id} COMPLETED"
1069
+ const directRe = new RegExp(`\\b${idEsc}\\b[^\\n]*\\bCOMPLETED?\\b`, "i");
1070
+ if (directRe.test(body)) return true;
1071
+ // Form 2: "Status: complete" on a line mentioning the milestone id
1072
+ const lines = body.split(/\r?\n/);
1073
+ for (const ln of lines) {
1074
+ if (new RegExp(`\\b${idEsc}\\b`).test(ln) && /\bcomplete(d)?\b/i.test(ln)) {
1075
+ return true;
1076
+ }
1077
+ }
1078
+ return false;
1079
+ } catch (_) {
1080
+ return false;
1081
+ }
1082
+ }
1083
+
1084
+ // ── isDone ──────────────────────────────────────────────────────────────────
1085
+
1086
+ /**
1087
+ * Returns true if the supervisor should stop looping: a terminal status was
1088
+ * reached OR the iteration cap was hit.
1089
+ */
1090
+ function isDone(state) {
1091
+ if (!state) return true;
1092
+ if (isTerminal(state.status)) return true;
1093
+ if (
1094
+ typeof state.iter === "number" &&
1095
+ typeof state.maxIterations === "number" &&
1096
+ state.iter >= state.maxIterations
1097
+ ) {
1098
+ return true;
1099
+ }
1100
+ return false;
1101
+ }
1102
+
1103
+ // ── stopRequested ───────────────────────────────────────────────────────────
1104
+
1105
+ /**
1106
+ * Check for the stop sentinel file `.gsd-t/.unattended/stop`. Returns true
1107
+ * if the user has requested a halt (contract §10). The supervisor checks
1108
+ * this between workers; the file itself is NEVER removed by the supervisor
1109
+ * on detection — it stays on disk as evidence, and `cleanStaleStopSentinel`
1110
+ * wipes it on the next launch.
1111
+ */
1112
+ function stopRequested(projectDir) {
1113
+ try {
1114
+ const p = path.join(projectDir, UNATTENDED_DIR_REL, "stop");
1115
+ return fs.existsSync(p);
1116
+ } catch (_) {
1117
+ return false;
1118
+ }
1119
+ }
1120
+
1121
+ // ── cleanStaleStopSentinel ──────────────────────────────────────────────────
1122
+
1123
+ /**
1124
+ * Remove any pre-existing `.gsd-t/.unattended/stop` file left over from a
1125
+ * previous supervisor run. Called ONCE at launch, before the first worker
1126
+ * is spawned, so the new run is not immediately halted by a stale sentinel.
1127
+ *
1128
+ * If a stale file is found, the helper logs a reassuring message of the form
1129
+ * `"Removed stale stop sentinel from {timestamp}"` where `{timestamp}` is
1130
+ * the sentinel file's mtime (ISO8601). If no stale file exists, the helper
1131
+ * is a silent no-op.
1132
+ *
1133
+ * Contract §10: "Next launch detects the stale sentinel and removes it
1134
+ * before starting, after printing a reassuring message."
1135
+ *
1136
+ * @param {string} projectDir
1137
+ * @param {(msg: string) => void} [log] — optional logger (defaults to console.log)
1138
+ * @returns {boolean} true if a stale sentinel was removed
1139
+ */
1140
+ function cleanStaleStopSentinel(projectDir, log) {
1141
+ const logger = typeof log === "function" ? log : (m) => {
1142
+ // eslint-disable-next-line no-console
1143
+ console.log(m);
1144
+ };
1145
+ try {
1146
+ const p = path.join(projectDir, UNATTENDED_DIR_REL, "stop");
1147
+ if (!fs.existsSync(p)) return false;
1148
+ let mtimeIso = "unknown";
1149
+ try {
1150
+ mtimeIso = fs.statSync(p).mtime.toISOString();
1151
+ } catch (_) {
1152
+ // best effort
1153
+ }
1154
+ fs.unlinkSync(p);
1155
+ logger(`[gsd-t-unattended] Removed stale stop sentinel from ${mtimeIso}`);
1156
+ return true;
1157
+ } catch (_) {
1158
+ return false;
1159
+ }
1160
+ }
1161
+
1162
+ // ── releaseSleepPrevention ──────────────────────────────────────────────────
1163
+
1164
+ /**
1165
+ * Release any active sleep-prevention handle recorded in the supervisor
1166
+ * state. Task 4 (m36-cross-platform) will wire this into the real platform
1167
+ * helper (`caffeinate` on darwin, `powercfg`/`SetThreadExecutionState` on
1168
+ * win32, `systemd-inhibit` on linux). For Task 3 this is a stub that simply
1169
+ * clears the handle field so `finalizeState` can be idempotent and safe to
1170
+ * call on shutdown.
1171
+ *
1172
+ * @param {object} state
1173
+ * @returns {boolean} true if a handle was released, false if none was set
1174
+ */
1175
+ function releaseSleepPrevention(state, releaseFn) {
1176
+ if (!state || typeof state !== "object") return false;
1177
+ if (state.sleepPreventionHandle == null) return false;
1178
+ const handle = state.sleepPreventionHandle;
1179
+ // Call the real platform helper (or injected fake). Never throw from a
1180
+ // shutdown path.
1181
+ try {
1182
+ const release = typeof releaseFn === "function" ? releaseFn : releaseSleep;
1183
+ release(handle);
1184
+ } catch (_) {
1185
+ // best effort
1186
+ }
1187
+ state.sleepPreventionHandle = null;
1188
+ return true;
1189
+ }
1190
+
1191
+ // ── mapStatusToExitCode ─────────────────────────────────────────────────────
1192
+
1193
+ /**
1194
+ * Terminal status → CLI exit code. Used for the `gsd-t unattended` process
1195
+ * exit code.
1196
+ */
1197
+ function mapStatusToExitCode(state) {
1198
+ if (!state) return 1;
1199
+ switch (state.status) {
1200
+ case "done":
1201
+ return 0;
1202
+ case "stopped":
1203
+ return 0;
1204
+ case "failed":
1205
+ return state.lastExit || 1;
1206
+ case "crashed":
1207
+ return 3;
1208
+ default:
1209
+ return 0; // still running / initializing — supervisor hasn't halted
1210
+ }
1211
+ }
1212
+
1213
+ // ── installTerminalHandlers ─────────────────────────────────────────────────
1214
+
1215
+ /**
1216
+ * Wire `process.on('exit')` (and SIGINT/SIGTERM where possible) so an
1217
+ * unexpected termination still removes the PID file and writes a terminal
1218
+ * status. Safe to call multiple times — guarded by a closure flag.
1219
+ */
1220
+ function installTerminalHandlers(state, dir) {
1221
+ let finalized = false;
1222
+ const finalizeOnce = (terminal) => {
1223
+ if (finalized) return;
1224
+ finalized = true;
1225
+ finalizeState(state, dir, terminal);
1226
+ };
1227
+
1228
+ process.on("exit", () => {
1229
+ // 'exit' only allows synchronous work — finalizeState is sync.
1230
+ finalizeOnce(undefined); // default → 'crashed' if non-terminal
1231
+ });
1232
+
1233
+ // Best-effort signal handlers. Don't override existing handlers; just
1234
+ // chain a finalization step. On SIGINT/SIGTERM we let process.exit() run
1235
+ // the 'exit' handler.
1236
+ const onSignal = (sig) => {
1237
+ if (state && !isTerminal(state.status)) {
1238
+ // SIGINT / SIGTERM are user-initiated halts → 'stopped'
1239
+ finalizeOnce("stopped");
1240
+ }
1241
+ // Re-raise default behavior
1242
+ process.exit(0);
1243
+ };
1244
+ try {
1245
+ process.on("SIGINT", onSignal);
1246
+ process.on("SIGTERM", onSignal);
1247
+ } catch (_) {
1248
+ // Some environments don't support these — ignore.
1249
+ }
1250
+ }
1251
+
1252
+ // ── CLI dispatch ────────────────────────────────────────────────────────────
1253
+
1254
+ if (require.main === module) {
1255
+ const result = doUnattended(process.argv.slice(2));
1256
+ if (!result.ok) {
1257
+ process.exitCode = result.exitCode || 1;
1258
+ }
1259
+ }