claude-code-session-manager 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,545 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * definitionOfDone.cjs — pure helpers for the definition-of-done drain gate.
5
+ *
6
+ * No scheduler imports; no side effects beyond fs reads in reportExists.
7
+ * The scheduler wires these in (PRD 111).
8
+ */
9
+
10
+ const crypto = require('node:crypto');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { spawn, spawnSync } = require('node:child_process');
15
+ const { splitFrontmatter } = require('./prdFrontmatter.cjs');
16
+
17
+ // Regex identifying meta/dod slugs that must NOT influence the batchKey.
18
+ // This is the load-bearing loop-avoidance filter: when the gate job itself
19
+ // completes, the real batchKey must remain unchanged so the drain branch stays
20
+ // a no-op (idempotent) instead of re-firing forever.
21
+ const DOD_SLUG_RE = /(^|-)dod(-|$)|definition-of-done/i;
22
+
23
+ const RUNS_DIR = path.join(
24
+ os.homedir(),
25
+ '.claude', 'session-manager', 'scheduled-plans', 'runs'
26
+ );
27
+
28
+ /**
29
+ * Compute a stable short hash for a completed job-set.
30
+ *
31
+ * Complexity: O(n log n) for the sort over n completed jobs; n is small
32
+ * (the scheduler queue, not user-scaled data).
33
+ *
34
+ * @param {Array<{slug: string, runId: string}>} jobs
35
+ * @returns {string} 8-char hex prefix of SHA-1 over sorted identity strings
36
+ */
37
+ function batchKey(jobs) {
38
+ const identities = jobs
39
+ .filter(j => !DOD_SLUG_RE.test(j.slug))
40
+ .map(j => `${j.slug}@${j.runId}`)
41
+ .sort();
42
+
43
+ return crypto
44
+ .createHash('sha1')
45
+ .update(identities.join('\n'))
46
+ .digest('hex')
47
+ .slice(0, 8);
48
+ }
49
+
50
+ /**
51
+ * Canonical path for a DoD report file in a new timestamped run directory.
52
+ * Callers that write the report must create the directory themselves.
53
+ *
54
+ * NOTE: each call mints a fresh timestamp, so every call returns a DIFFERENT
55
+ * path even for the same key. Call once, save the result, reuse it — do not
56
+ * call twice expecting the same directory.
57
+ *
58
+ * @param {string} key Output of batchKey()
59
+ * @returns {string} Absolute path under runs/<iso-ts>/definition-of-done-<key>.md
60
+ */
61
+ function reportPathFor(key) {
62
+ if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
63
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
64
+ return path.join(RUNS_DIR, ts, `definition-of-done-${key}.md`);
65
+ }
66
+
67
+ /**
68
+ * Return true if a DoD report for this batchKey already exists in any
69
+ * run subdirectory. Scans runs/<ts>/ (shallow, one level).
70
+ *
71
+ * @param {string} key Output of batchKey()
72
+ * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
73
+ * @returns {boolean}
74
+ */
75
+ function reportExists(key, runsDir = RUNS_DIR) {
76
+ if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
77
+ let entries;
78
+ try {
79
+ entries = fs.readdirSync(runsDir, { withFileTypes: true });
80
+ } catch {
81
+ return false;
82
+ }
83
+
84
+ const target = `definition-of-done-${key}.md`;
85
+ for (const entry of entries) {
86
+ if (!entry.isDirectory()) continue;
87
+ const candidate = path.join(runsDir, entry.name, target);
88
+ if (fs.existsSync(candidate)) return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ // Shell metacharacters that require shell:true — prohibited by CLAUDE.md for
94
+ // non-user-supplied strings. Commands containing these are marked unverifiable
95
+ // rather than running under a shell, since CLAUDE.md restricts shell:true to
96
+ // watchers.cjs and app:test-fire-hook only.
97
+ const SHELL_META_RE = /[|>&;<`]|\$[({]/;
98
+
99
+ const PRDS_DIR = path.join(
100
+ os.homedir(),
101
+ '.claude', 'session-manager', 'scheduled-plans', 'prds'
102
+ );
103
+
104
+ /**
105
+ * Extract the first bounded AC test command from a PRD body.
106
+ *
107
+ * Searches the # Acceptance criteria section for lines containing a
108
+ * `timeout NNN cmd [args...]` invocation. Returns the command string or null.
109
+ *
110
+ * Commands containing shell metacharacters (pipe, redirect, subshell) are
111
+ * skipped — they cannot be run without shell:true which is prohibited here.
112
+ * The parser falls through to the next AC line in that case.
113
+ *
114
+ * Complexity: O(L) where L = number of lines in the body (not user-scaled;
115
+ * PRD bodies are bounded documents, typically < 200 lines).
116
+ *
117
+ * @param {string} prdBody PRD markdown with frontmatter already stripped.
118
+ * @returns {string|null}
119
+ */
120
+ function extractAcCommand(prdBody) {
121
+ if (!prdBody || typeof prdBody !== 'string') return null;
122
+
123
+ // Parse line-by-line to locate the # Acceptance criteria section.
124
+ // A regex lookahead approach with the `m` flag misidentifies `$` as
125
+ // end-of-line (not end-of-string), causing the lazy capture to terminate
126
+ // immediately. Line-by-line parsing avoids that pitfall.
127
+ const lines = prdBody.split('\n');
128
+ let inAcSection = false;
129
+ let hasAcSection = false;
130
+ let acHeadingLevel = 0;
131
+ const acLines = [];
132
+
133
+ for (const line of lines) {
134
+ if (/^#+\s/i.test(line)) {
135
+ const level = line.match(/^(#+)/)[1].length;
136
+ if (/^#+\s*Acceptance\s+criteria/i.test(line)) {
137
+ inAcSection = true;
138
+ hasAcSection = true;
139
+ acHeadingLevel = level;
140
+ } else if (inAcSection && level <= acHeadingLevel) {
141
+ // A sibling or parent heading ends the AC section;
142
+ // sub-headings (level > acHeadingLevel) stay inside it.
143
+ inAcSection = false;
144
+ }
145
+ continue;
146
+ }
147
+ if (inAcSection) acLines.push(line);
148
+ }
149
+
150
+ // Fall back to full body if no AC section header was found.
151
+ const candidates = hasAcSection ? acLines : lines;
152
+
153
+ for (const line of candidates) {
154
+ // Prefer backtick-delimited inline code: `timeout NNN cmd ...`
155
+ const backtickMatch = line.match(/`(timeout\s+\d+\s+[^`]+)`/i);
156
+ if (backtickMatch) {
157
+ const cmd = backtickMatch[1].trim();
158
+ if (!SHELL_META_RE.test(cmd)) return cmd;
159
+ }
160
+
161
+ // Fall back: bare `timeout NNN cmd ...` anywhere on the line.
162
+ // Trim trailing all-lowercase-alpha tokens (prose words like "passes",
163
+ // "and", "the") while keeping at least 4 tokens (timeout, N, binary,
164
+ // first-arg). This prevents over-matching into prose that follows the
165
+ // command on the same AC line (e.g. "timeout 60 npm test and verify").
166
+ const rawMatch = line.match(/\btimeout\s+\d+\s+\S+(?:\s+\S+)*/);
167
+ if (rawMatch) {
168
+ const tokens = rawMatch[0].trim().split(/\s+/);
169
+ while (tokens.length > 4 && /^[a-z]+$/.test(tokens[tokens.length - 1])) {
170
+ tokens.pop();
171
+ }
172
+ const cmd = tokens.join(' ');
173
+ if (!SHELL_META_RE.test(cmd)) return cmd;
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * Re-run the AC test command for a single completed job and report the result.
181
+ *
182
+ * Never touches queue.json or spawns claude — only re-runs the already-authored
183
+ * test command. That is what keeps this function loop-safe and cheap.
184
+ *
185
+ * @param {{ slug: string, cwd: string }} job
186
+ * @param {{ timeoutMs?: number, prdsDir?: string }} opts
187
+ * timeoutMs Hard kill ceiling for the child (default 60s).
188
+ * prdsDir Override PRD directory (for tests).
189
+ * @returns {Promise<{ slug: string, status: 'pass'|'fail'|'unverifiable', code: number|null, ms: number }>}
190
+ */
191
+ function reverifyAc(job, { timeoutMs = 60_000, prdsDir } = {}) {
192
+ const resolvedPrdsDir = prdsDir ?? PRDS_DIR;
193
+ const startNs = process.hrtime.bigint();
194
+
195
+ function elapsedMs() {
196
+ return Math.round(Number(process.hrtime.bigint() - startNs) / 1e6);
197
+ }
198
+
199
+ function unverifiable() {
200
+ return { slug: job.slug, status: 'unverifiable', code: null, ms: elapsedMs() };
201
+ }
202
+
203
+ // Guard: cwd must exist (target project may have been deleted).
204
+ try {
205
+ fs.statSync(job.cwd);
206
+ } catch {
207
+ return Promise.resolve(unverifiable());
208
+ }
209
+
210
+ // Read and strip PRD frontmatter via the shared parser.
211
+ const prdPath = path.join(resolvedPrdsDir, `${job.slug}.md`);
212
+ let prdBody;
213
+ try {
214
+ const raw = fs.readFileSync(prdPath, 'utf8');
215
+ prdBody = splitFrontmatter(raw).body;
216
+ } catch {
217
+ return Promise.resolve(unverifiable());
218
+ }
219
+
220
+ const cmd = extractAcCommand(prdBody);
221
+ if (!cmd) return Promise.resolve(unverifiable());
222
+
223
+ // Simple whitespace split — SHELL_META_RE in extractAcCommand already excludes
224
+ // commands that would need a shell. Paths with spaces are not expected in AC
225
+ // commands (PRD authoring convention: use relative paths from cwd).
226
+ const argv = cmd.trim().split(/\s+/);
227
+ if (argv.length < 2) return Promise.resolve(unverifiable());
228
+
229
+ return new Promise((resolve) => {
230
+ let settled = false;
231
+
232
+ let child;
233
+ try {
234
+ child = spawn(argv[0], argv.slice(1), {
235
+ cwd: job.cwd,
236
+ stdio: 'ignore',
237
+ // No shell:true — extractAcCommand rejects commands with shell metacharacters.
238
+ });
239
+ } catch (err) {
240
+ resolve(unverifiable());
241
+ return;
242
+ }
243
+
244
+ let escalate;
245
+ const killTimer = setTimeout(() => {
246
+ try { child.kill('SIGTERM'); } catch { /* already dead */ }
247
+ escalate = setTimeout(() => {
248
+ try { child.kill('SIGKILL'); } catch { /* race */ }
249
+ }, 5_000);
250
+ if (escalate.unref) escalate.unref();
251
+ }, timeoutMs);
252
+ if (killTimer.unref) killTimer.unref();
253
+
254
+ child.on('error', () => {
255
+ if (settled) return;
256
+ settled = true;
257
+ clearTimeout(killTimer);
258
+ clearTimeout(escalate);
259
+ resolve(unverifiable());
260
+ });
261
+
262
+ child.on('close', (code) => {
263
+ if (settled) return;
264
+ settled = true;
265
+ clearTimeout(killTimer);
266
+ clearTimeout(escalate);
267
+ const exitCode = typeof code === 'number' ? code : -1;
268
+ resolve({
269
+ slug: job.slug,
270
+ status: exitCode === 0 ? 'pass' : 'fail',
271
+ code: exitCode,
272
+ ms: elapsedMs(),
273
+ });
274
+ });
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Re-run AC commands sequentially over a batch of completed jobs.
280
+ *
281
+ * Sequential execution respects the machine's max-3-concurrent rule — a drain
282
+ * event that fires reverifyBatch is already consuming one slot; sequential
283
+ * children ensure we never pile additional pressure on top.
284
+ *
285
+ * Total wall-time is bounded by batchTimeoutMs. When the cap is reached,
286
+ * remaining jobs are returned as unverifiable without being started — the batch
287
+ * cannot hang regardless of how many jobs are queued.
288
+ *
289
+ * Complexity: O(n) sequential spawns; n = number of completed jobs (bounded
290
+ * by the scheduler queue size, not user-scaled data).
291
+ *
292
+ * @param {Array<{ slug: string, cwd: string }>} jobs
293
+ * @param {{ timeoutMs?: number, batchTimeoutMs?: number, prdsDir?: string }} opts
294
+ * timeoutMs Per-job kill ceiling (default 60s).
295
+ * batchTimeoutMs Total wall-time cap for the whole batch (default 10m).
296
+ * prdsDir Override PRD directory (for tests).
297
+ * @returns {Promise<Array<{ slug: string, status: string, code: number|null, ms: number }>>}
298
+ */
299
+ async function reverifyBatch(jobs, { timeoutMs = 60_000, batchTimeoutMs = 600_000, prdsDir } = {}) {
300
+ const batchStartNs = process.hrtime.bigint();
301
+ const results = [];
302
+
303
+ for (const job of jobs) {
304
+ const elapsedMs = Number(process.hrtime.bigint() - batchStartNs) / 1e6;
305
+ if (elapsedMs >= batchTimeoutMs) {
306
+ results.push({ slug: job.slug, status: 'unverifiable', code: null, ms: 0 });
307
+ continue;
308
+ }
309
+ const result = await reverifyAc(job, { timeoutMs, prdsDir });
310
+ results.push(result);
311
+ }
312
+
313
+ return results;
314
+ }
315
+
316
+ // ─── Risk heuristics ──────────────────────────────────────────────────────────
317
+ // Conservative keyword/path matches — false positives are fine (they only add
318
+ // a "review recommended" line); the cost of a miss on a money path is higher.
319
+ const RISK_HEURISTICS = [
320
+ { surface: 'money-path', re: /money|trade|order|position|price|payment/i },
321
+ { surface: 'auth', re: /auth|token|credential|secret/i },
322
+ { surface: 'migration', re: /migration|schema|alembic|\.sql/i },
323
+ ];
324
+
325
+ /**
326
+ * Extract a named heading section from a PRD body string.
327
+ * Returns the lines under the heading until the next sibling/parent heading.
328
+ * Complexity: O(L) where L = number of lines in body (bounded PRD document).
329
+ *
330
+ * @param {string} body
331
+ * @param {string} headingText Case-insensitive heading to find.
332
+ * @returns {string}
333
+ */
334
+ function _extractSection(body, headingText) {
335
+ const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
336
+ const headingRe = new RegExp(`^#+\\s+${escapedHeading}`, 'i');
337
+ const lines = body.split('\n');
338
+ let inSection = false;
339
+ let headingLevel = 0;
340
+ const sectionLines = [];
341
+
342
+ for (const line of lines) {
343
+ if (/^#+\s/.test(line)) {
344
+ const level = line.match(/^(#+)/)[1].length;
345
+ if (headingRe.test(line)) {
346
+ inSection = true;
347
+ headingLevel = level;
348
+ } else if (inSection && level <= headingLevel) {
349
+ inSection = false;
350
+ }
351
+ continue;
352
+ }
353
+ if (inSection) sectionLines.push(line);
354
+ }
355
+
356
+ return sectionLines.join('\n');
357
+ }
358
+
359
+ /**
360
+ * Inspect the files each job touched and flag risk surfaces.
361
+ *
362
+ * For each job:
363
+ * 1. If job.landedCommit is set, run `git show --name-only` (bounded by
364
+ * gitTimeoutMs) to obtain changed file paths.
365
+ * 2. Otherwise fall back to the text of the PRD's `# Implementation notes`
366
+ * section which authors are expected to list the files they plan to touch.
367
+ * Then check the candidate text against RISK_HEURISTICS (keyword/path match).
368
+ *
369
+ * Complexity: O(n * H) where n = jobs.length, H = RISK_HEURISTICS.length (3,
370
+ * a constant). Not user-scaled data — the scheduler queue is bounded.
371
+ *
372
+ * @param {Array<{ slug: string, cwd: string, landedCommit?: string }>} jobs
373
+ * @param {{ prdsDir?: string, gitTimeoutMs?: number }} opts
374
+ * @returns {Array<{ slug: string, surfaces: string[] }>} Only jobs with ≥1 hit.
375
+ */
376
+ function flagRiskySurfaces(jobs, { prdsDir, gitTimeoutMs = 10_000 } = {}) {
377
+ const resolvedPrdsDir = prdsDir ?? PRDS_DIR;
378
+ const results = [];
379
+
380
+ for (const job of jobs) {
381
+ let candidateText = '';
382
+
383
+ // 1. Try git commit — bounded by gitTimeoutMs.
384
+ if (job.landedCommit && job.cwd) {
385
+ try {
386
+ const r = spawnSync(
387
+ 'git',
388
+ ['-C', job.cwd, 'show', '--name-only', '--format=', job.landedCommit],
389
+ { timeout: gitTimeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
390
+ );
391
+ if (r.status === 0 && r.stdout) candidateText = r.stdout;
392
+ } catch { /* fall through to PRD body */ }
393
+ }
394
+
395
+ // 2. Fallback: PRD # Implementation notes section.
396
+ // Guard: slug must contain only safe chars to prevent path traversal via a
397
+ // corrupted queue.json entry (e.g. slug: "../../.ssh/id_rsa").
398
+ if (!candidateText && job.slug && /^[\w-]+$/.test(job.slug)) {
399
+ try {
400
+ const raw = fs.readFileSync(path.join(resolvedPrdsDir, `${job.slug}.md`), 'utf8');
401
+ const body = splitFrontmatter(raw).body;
402
+ candidateText = _extractSection(body, 'Implementation notes');
403
+ } catch { /* no candidate text — skip */ }
404
+ }
405
+
406
+ if (!candidateText) continue;
407
+
408
+ // 3. Apply risk heuristics (O(H), constant).
409
+ const surfaces = RISK_HEURISTICS
410
+ .filter(h => h.re.test(candidateText))
411
+ .map(h => h.surface);
412
+
413
+ if (surfaces.length > 0) results.push({ slug: job.slug, surfaces });
414
+ }
415
+
416
+ return results;
417
+ }
418
+
419
+ // ─── Atomic write helper ───────────────────────────────────────────────────────
420
+ // Re-implements the tmp+rename recipe from config.cjs writeTextAtomic (sync
421
+ // variant), avoiding an import of Electron IPC code in a pure-node context.
422
+ // Cross-ref: src/main/config.cjs writeJsonSync (same pattern).
423
+ function _writeFileAtomic(absPath, text) {
424
+ const tmp = `${absPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
425
+ try {
426
+ fs.writeFileSync(tmp, text, 'utf8');
427
+ fs.renameSync(tmp, absPath);
428
+ } catch (err) {
429
+ try { fs.unlinkSync(tmp); } catch { /* tmp never created or already gone */ }
430
+ throw err;
431
+ }
432
+ }
433
+
434
+ const STATUS_EMOJI = { pass: '✅', fail: '❌', unverifiable: '⚠️' };
435
+
436
+ /**
437
+ * Write a definition-of-done report for a completed batch.
438
+ *
439
+ * The report contains:
440
+ * (a) a per-PRD AC table (slug · pass/fail/unverifiable),
441
+ * (b) a risk-flag summary,
442
+ * (c) a "needs human attention" list with a one-line recommendation per entry.
443
+ *
444
+ * The report is written atomically (tmp + rename) to avoid partial reads.
445
+ * Each call mints a fresh timestamped directory under runsDir, so two calls
446
+ * with the same key produce two separate files; use reportExists() to gate
447
+ * before calling.
448
+ *
449
+ * @param {string} key Output of batchKey().
450
+ * @param {{
451
+ * acResults: Array<{ slug: string, status: string, code: number|null, ms: number }>,
452
+ * riskFlags: Array<{ slug: string, surfaces: string[] }>,
453
+ * runsDir?: string,
454
+ * }} opts
455
+ * @returns {string} Absolute path of the written report.
456
+ */
457
+ function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
458
+ if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
459
+
460
+ const resolvedRunsDir = runsDir ?? RUNS_DIR;
461
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
462
+ const dir = path.join(resolvedRunsDir, ts);
463
+ const reportPath = path.join(dir, `definition-of-done-${key}.md`);
464
+
465
+ // ── AC table ────────────────────────────────────────────────────────────────
466
+ const acRows = acResults.map(r => {
467
+ const emoji = STATUS_EMOJI[r.status] ?? '';
468
+ return `| ${r.slug} | ${emoji} ${r.status} |`;
469
+ });
470
+ const acTable = [
471
+ '| Slug | Status |',
472
+ '|------|--------|',
473
+ ...acRows,
474
+ ].join('\n');
475
+
476
+ // ── Risk flags table ─────────────────────────────────────────────────────────
477
+ let riskSection;
478
+ if (riskFlags.length === 0) {
479
+ riskSection = '_No risk surfaces detected._';
480
+ } else {
481
+ riskSection = [
482
+ '| Slug | Surfaces |',
483
+ '|------|----------|',
484
+ ...riskFlags.map(r => `| ${r.slug} | ${r.surfaces.join(', ')} |`),
485
+ ].join('\n');
486
+ }
487
+
488
+ // ── Needs human attention ────────────────────────────────────────────────────
489
+ const attentionLines = [];
490
+ for (const r of acResults) {
491
+ if (r.status === 'fail') {
492
+ attentionLines.push(`- **${r.slug}** — AC failed; run \`/code-review\` on ${r.slug}`);
493
+ } else if (r.status === 'unverifiable') {
494
+ attentionLines.push(`- **${r.slug}** — unverifiable AC; manual inspection recommended for ${r.slug}`);
495
+ }
496
+ }
497
+ for (const r of riskFlags) {
498
+ attentionLines.push(
499
+ `- **${r.slug}** — touches ${r.surfaces.join(', ')}; run \`/code-review\` on ${r.slug} — ${r.surfaces.join('/')} path`
500
+ );
501
+ }
502
+ const attentionSection = attentionLines.length > 0
503
+ ? attentionLines.join('\n')
504
+ : '_None — all checks passed and no risk surfaces detected._';
505
+
506
+ // ── Assemble report ──────────────────────────────────────────────────────────
507
+ const report = [
508
+ `# Definition of Done — batch ${key}`,
509
+ '',
510
+ `Generated: ${new Date().toISOString()}`,
511
+ '',
512
+ '> **Note:** This report flags surfaces for human review.',
513
+ '> Deep LLM code-review is **recommended, not auto-run** — the gate detects',
514
+ '> and flags; humans or `/code-review` do the deep pass.',
515
+ '',
516
+ '## AC Results',
517
+ '',
518
+ acTable,
519
+ '',
520
+ '## Risk Flags',
521
+ '',
522
+ riskSection,
523
+ '',
524
+ '## Needs Human Attention',
525
+ '',
526
+ attentionSection,
527
+ '',
528
+ ].join('\n');
529
+
530
+ fs.mkdirSync(dir, { recursive: true });
531
+ _writeFileAtomic(reportPath, report);
532
+
533
+ return reportPath;
534
+ }
535
+
536
+ module.exports = {
537
+ batchKey,
538
+ reportPathFor,
539
+ reportExists,
540
+ extractAcCommand,
541
+ reverifyAc,
542
+ reverifyBatch,
543
+ flagRiskySurfaces,
544
+ writeReport,
545
+ };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * dodDrainHook.cjs — definition-of-done gate that fires at queue drain.
5
+ *
6
+ * Extracted so the scheduler can call it fire-and-forget (non-blocking)
7
+ * while tests drive it directly with await.
8
+ *
9
+ * Kill-switch: SM_DOD_DISABLE=1 (mirrors SM_SUPERVISOR_DISABLE convention).
10
+ * Loop-safe: batchKey excludes dod/meta slugs; reportExists() + _inFlight
11
+ * dedup make re-drains over the same completed set a single fs-stat no-op,
12
+ * even when tickQueue fires twice in quick succession before the first
13
+ * reverifyBatch completes.
14
+ * Non-blocking: callers should fire-and-forget via .catch(); this function
15
+ * never throws to the caller (errors are logged instead).
16
+ */
17
+
18
+ const {
19
+ batchKey,
20
+ reportExists,
21
+ reverifyBatch,
22
+ flagRiskySurfaces,
23
+ writeReport,
24
+ } = require('./definitionOfDone.cjs');
25
+
26
+ // Track keys whose DoD pass is currently in-flight.
27
+ // Prevents concurrent tickQueue drains from spawning duplicate reverifyBatch
28
+ // runs for the same completed set before the first one has written its report.
29
+ const _inFlight = new Set();
30
+
31
+ /**
32
+ * Run the definition-of-done gate when the scheduler queue drains.
33
+ *
34
+ * Guards (in order, fast-fail):
35
+ * 1. SM_DOD_DISABLE=1 → skip
36
+ * 2. state.paused (covers rate-limit) → defer, no report
37
+ * 3. cancelToken.cancelled → skip
38
+ * 4. No completed jobs → nothing to verify
39
+ * 5. reportExists(key) → already done for this batch, no-op
40
+ * 6. _inFlight.has(key) → concurrent drain for same batch, no-op
41
+ * 7. cancelToken.cancelled re-checked after reverifyBatch (up to 600s)
42
+ *
43
+ * Complexity: O(n) over completed jobs for batchKey + reverifyBatch;
44
+ * reportExists is O(d) where d = number of run subdirectories (bounded).
45
+ *
46
+ * @param {{ paused: object|null, jobs: Array }} state Scheduler queue state.
47
+ * @param {{
48
+ * cancelToken?: { cancelled: boolean },
49
+ * prdsDir?: string,
50
+ * runsDir?: string,
51
+ * }} opts
52
+ * @returns {Promise<void>}
53
+ */
54
+ async function runDefinitionOfDoneOnDrain(state, opts = {}) {
55
+ const { cancelToken = {}, prdsDir, runsDir } = opts;
56
+
57
+ if (process.env.SM_DOD_DISABLE === '1') return;
58
+ if (state.paused) return;
59
+ if (cancelToken.cancelled) return;
60
+
61
+ const completedJobs = (state.jobs || []).filter(j => j.status === 'completed');
62
+ if (completedJobs.length === 0) return;
63
+
64
+ const key = batchKey(completedJobs);
65
+
66
+ if (reportExists(key, runsDir)) return;
67
+ if (_inFlight.has(key)) return;
68
+
69
+ _inFlight.add(key);
70
+ try {
71
+ const acResults = await reverifyBatch(completedJobs, { prdsDir });
72
+ // Re-check after the slow await — scheduler may have stopped mid-flight.
73
+ if (cancelToken.cancelled) return;
74
+ const riskFlags = flagRiskySurfaces(completedJobs, { prdsDir });
75
+ writeReport(key, { acResults, riskFlags, runsDir });
76
+ } finally {
77
+ _inFlight.delete(key);
78
+ }
79
+ }
80
+
81
+ module.exports = { runDefinitionOfDoneOnDrain };
@@ -65,6 +65,7 @@ const {
65
65
  MAX_JOB_DURATION_MS,
66
66
  } = require('./lib/schedulerConfig.cjs');
67
67
  const { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD } = require('./lib/schedulerBatch.cjs');
68
+ const { runDefinitionOfDoneOnDrain } = require('./lib/dodDrainHook.cjs');
68
69
 
69
70
  const MAX_INVESTIGATION_DURATION_MS = 30 * 60_000;
70
71
 
@@ -1413,19 +1414,22 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1413
1414
  await broadcast();
1414
1415
 
1415
1416
  if (actuallyFailed && failedJobSnapshot) {
1416
- // Transient-failure detector: SIGTERM/SIGKILL within 45s = almost
1417
- // always external kill (user-initiated app restart, OOM-kill, manual
1418
- // process kill, Electron HMR). The PRD itself didn't fail; the run was
1419
- // interrupted before it could do meaningful work. Spawning an Opus
1420
- // investigator on these is wasted tokens AND pollutes the queue with
1421
- // redundant fix-PRDs (real example 2026-05-21: 07-agent-view-... got
1422
- // SIGTERMed at 10s by an app restart, the rename had already been done
1423
- // anyway). The 45s cutoff (was 30s) catches Electron-HMR borderline
1424
- // cases like PRD 26 SIGTERM'd at 33s that was 3s over the old cutoff
1425
- // and fell through to a fix-PRD that just acknowledged the work was
1426
- // already done. Auto-retry up to 2x before falling through to investigation.
1417
+ // Transient-failure detector. A 143/137 exit is ALWAYS a signal kill — the
1418
+ // agent never self-exits with those so the only question is WHO killed it.
1419
+ // The scheduler's own intentional kills before the idle watchdog are the
1420
+ // result-tail post-success kill (which maps back to exit 0, so it never
1421
+ // reaches here) and the rare supervisor kill-agent. The idle-output
1422
+ // watchdog only fires at IDLE_OUTPUT_KILL_MS (20 min) of stalled output,
1423
+ // and the deadman at 4 h. THEREFORE any 143/137 with a run shorter than the
1424
+ // idle threshold is an EXTERNAL/transient kill an app restart (incl. our
1425
+ // own self-restart on publish/HMRsee feedback 2026-06-15-01), an
1426
+ // OOM-kill, or a manual kill not a real failure. Re-queue it (bounded)
1427
+ // instead of marking it failed + spawning a spurious fix-plan. (Was 45 s,
1428
+ // which wrongly hard-failed externally-killed jobs like 115 SIGTERM'd at
1429
+ // 67 s.) Genuine watchdog kills (idle ≥20 min, deadman 4 h) run longer than
1430
+ // the threshold and still fall through to investigation.
1427
1431
  const ec = failedJobSnapshot.exitCode;
1428
- const transient = (ec === 143 || ec === 137) && res.durationMs < 45_000;
1432
+ const transient = (ec === 143 || ec === 137) && res.durationMs < IDLE_OUTPUT_KILL_MS;
1429
1433
  const retries = failedJobSnapshot.transientRetries ?? 0;
1430
1434
  if (transient && retries < 2) {
1431
1435
  console.log(`[scheduler] transient failure (exit=${ec} dur=${res.durationMs}ms) — auto-retry ${retries + 1}/2 for ${job.slug}`);
@@ -1469,7 +1473,14 @@ function tickQueue() {
1469
1473
  await reconcile(state);
1470
1474
  const cap = ENV_CAP ?? state.config.concurrencyCap;
1471
1475
  const batch = pickNextBatch(state.jobs, runningSet, cap);
1472
- if (batch.length === 0) return;
1476
+ if (batch.length === 0) {
1477
+ // Queue drained — run the definition-of-done gate fire-and-forget.
1478
+ // Non-blocking: does not hold the mutate lock; errors are logged, not thrown.
1479
+ runDefinitionOfDoneOnDrain(state, { cancelToken }).catch((err) => {
1480
+ console.log(`[scheduler] dod-drain: ${err?.message ?? String(err)}`);
1481
+ });
1482
+ return;
1483
+ }
1473
1484
 
1474
1485
  const availableMb = getAvailableMemMb();
1475
1486
  const allowed = memoryLimitedBatchSize(availableMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length);
@@ -2153,6 +2164,7 @@ async function init() {
2153
2164
  for (const j of s.jobs) counts[j.status] = (counts[j.status] || 0) + 1;
2154
2165
  appendHeartbeat({
2155
2166
  ts: Date.now(),
2167
+ pid: process.pid,
2156
2168
  counts,
2157
2169
  paused: s.paused ? { reason: s.paused.reason, resumeAt: s.paused.resumeAt } : null,
2158
2170
  nextReset: cachedNextReset,