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.
- package/dist/assets/{TiptapBody-BIuH7h34.js → TiptapBody-BsI_35c5.js} +1 -1
- package/dist/assets/index-BKkf6gtA.css +32 -0
- package/dist/assets/{index-H0IXEKiC.js → index-C61eFtxs.js} +880 -880
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/dod-batchkey.test.cjs +183 -0
- package/src/main/__tests__/dod-drain-hook.test.cjs +197 -0
- package/src/main/__tests__/dod-report.test.cjs +304 -0
- package/src/main/__tests__/dod-reverify.test.cjs +285 -0
- package/src/main/index.cjs +26 -1
- package/src/main/lib/definitionOfDone.cjs +545 -0
- package/src/main/lib/dodDrainHook.cjs +81 -0
- package/src/main/scheduler.cjs +25 -13
- package/dist/assets/index-Cu9X6oyA.css +0 -32
|
@@ -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 };
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -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
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
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/HMR — see 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 <
|
|
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)
|
|
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,
|