@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.
- package/CHANGELOG.md +146 -0
- package/bin/gsd-t-parallel.cjs +64 -13
- package/bin/gsd-t.js +48 -376
- package/bin/journey-coverage.cjs +6 -3
- package/bin/parallel-cli.cjs +13 -1
- package/commands/gsd-t-debug.md +47 -533
- package/commands/gsd-t-design-decompose.md +24 -497
- package/commands/gsd-t-doc-ripple.md +23 -139
- package/commands/gsd-t-execute.md +41 -958
- package/commands/gsd-t-help.md +0 -2
- package/commands/gsd-t-impact.md +26 -295
- package/commands/gsd-t-integrate.md +44 -369
- package/commands/gsd-t-milestone.md +25 -124
- package/commands/gsd-t-partition.md +28 -539
- package/commands/gsd-t-plan.md +26 -472
- package/commands/gsd-t-prd.md +25 -311
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-resume.md +0 -33
- package/commands/gsd-t-verify.md +52 -569
- package/commands/gsd-t-wave.md +41 -430
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +3 -1
- package/scripts/hooks/pre-commit-journey-coverage +0 -2
- package/scripts/hooks/pre-commit-playwright-gate +0 -2
- package/templates/CLAUDE-global.md +43 -173
- package/templates/CLAUDE-project.md +118 -104
- package/templates/prompts/design-verify-subagent.md +3 -0
- package/templates/prompts/qa-subagent.md +3 -0
- package/templates/prompts/red-team-subagent.md +5 -2
- package/templates/workflows/_lib.js +176 -0
- package/templates/workflows/gsd-t-debug.workflow.js +86 -0
- package/templates/workflows/gsd-t-execute.workflow.js +206 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
- package/templates/workflows/gsd-t-phase.workflow.js +94 -0
- package/templates/workflows/gsd-t-quick.workflow.js +72 -0
- package/templates/workflows/gsd-t-verify.workflow.js +348 -0
- package/templates/workflows/gsd-t-wave.workflow.js +43 -0
- package/bin/check-headless-sessions.js +0 -140
- package/bin/context-budget-audit.cjs +0 -447
- package/bin/context-meter-config.cjs +0 -101
- package/bin/context-meter-config.test.cjs +0 -101
- package/bin/event-stream.cjs +0 -205
- package/bin/gsd-t-benchmark-orchestrator.js +0 -437
- package/bin/gsd-t-capture-lint.cjs +0 -440
- package/bin/gsd-t-economics.cjs +0 -315
- package/bin/gsd-t-in-session-usage.cjs +0 -213
- package/bin/gsd-t-orchestrator-config.cjs +0 -161
- package/bin/gsd-t-orchestrator-queue.cjs +0 -180
- package/bin/gsd-t-orchestrator-recover.cjs +0 -231
- package/bin/gsd-t-orchestrator-worker.cjs +0 -219
- package/bin/gsd-t-orchestrator.js +0 -535
- package/bin/gsd-t-parallel-probe.cjs +0 -132
- package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
- package/bin/gsd-t-ratelimit-probe.cjs +0 -648
- package/bin/gsd-t-report-tokens.cjs +0 -549
- package/bin/gsd-t-stream-feed-client.cjs +0 -151
- package/bin/gsd-t-token-backfill.cjs +0 -366
- package/bin/gsd-t-token-capture.cjs +0 -321
- package/bin/gsd-t-token-dashboard.cjs +0 -353
- package/bin/gsd-t-token-regenerate-log.cjs +0 -129
- package/bin/gsd-t-tool-attribution.cjs +0 -377
- package/bin/gsd-t-tool-cost.cjs +0 -195
- package/bin/gsd-t-transcript-tee.cjs +0 -246
- package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
- package/bin/gsd-t-unattended-platform.cjs +0 -551
- package/bin/gsd-t-unattended-platform.js +0 -381
- package/bin/gsd-t-unattended-safety.cjs +0 -773
- package/bin/gsd-t-unattended-safety.js +0 -788
- package/bin/gsd-t-unattended.cjs +0 -2109
- package/bin/gsd-t-unattended.js +0 -1367
- package/bin/gsd-t-worker-dispatch.cjs +0 -211
- package/bin/handoff-lock.cjs +0 -249
- package/bin/handoff-lock.js +0 -249
- package/bin/headless-auto-spawn.cjs +0 -711
- package/bin/headless-auto-spawn.js +0 -373
- package/bin/headless-exit-codes.cjs +0 -67
- package/bin/live-activity-report.cjs +0 -615
- package/bin/log-tail.cjs +0 -81
- package/bin/m44-proof-measure.cjs +0 -285
- package/bin/m46-iter-proof.cjs +0 -149
- package/bin/m46-worker-proof.cjs +0 -201
- package/bin/m55-substrate-proof.cjs +0 -134
- package/bin/metrics-rollup.js +0 -200
- package/bin/model-windows.cjs +0 -99
- package/bin/model-windows.test.cjs +0 -75
- package/bin/parallelism-report.cjs +0 -535
- package/bin/runway-estimator.cjs +0 -242
- package/bin/spawn-plan-derive.cjs +0 -163
- package/bin/spawn-plan-status-updater.cjs +0 -292
- package/bin/spawn-plan-writer.cjs +0 -204
- package/bin/supervisor-pid-fingerprint.cjs +0 -126
- package/bin/token-budget.cjs +0 -265
- package/bin/unattended-watch-format.cjs +0 -178
- package/bin/watch-progress.js +0 -155
- package/commands/gsd-t-unattended-stop.md +0 -85
- package/commands/gsd-t-unattended-watch.md +0 -465
- package/commands/gsd-t-unattended.md +0 -476
- package/commands/gsd-t-visualize.md +0 -116
- package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
- package/scripts/gsd-t-context-meter.js +0 -341
- package/scripts/gsd-t-context-meter.test.js +0 -471
- package/scripts/gsd-t-dashboard-server.js +0 -1203
- package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
- package/scripts/gsd-t-transcript.html +0 -1821
- package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
- package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
- package/templates/hooks/post-commit-spawn-plan.sh +0 -85
package/bin/gsd-t-unattended.cjs
DELETED
|
@@ -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
|
-
}
|