claude-code-session-manager 0.60.0 → 0.62.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-BtVPGBaq.js → TiptapBody-Bn-LMce4.js} +42 -42
- package/dist/assets/{index-DtNip-Zx.js → index-BfiMRkln.js} +763 -760
- package/dist/assets/{index-CPMP2XZ_.css → index-DOXvph-Z.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/main/__tests__/chat-preamble-anchors.test.cjs +100 -0
- package/src/main/__tests__/epicContextDigest.test.cjs +34 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +58 -1
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +12 -4
- package/src/main/__tests__/scheduler-verify-prd-path.test.cjs +109 -0
- package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +122 -0
- package/src/main/chatRunner.cjs +4 -0
- package/src/main/config.cjs +29 -1
- package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +87 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +197 -0
- package/src/main/lib/activeIndexMerge.cjs +9 -0
- package/src/main/lib/epicContextDigest.cjs +23 -9
- package/src/main/lib/jobWorktree.cjs +327 -0
- package/src/main/lib/rcaReport.cjs +3 -2
- package/src/main/scheduler.cjs +254 -72
package/src/main/scheduler.cjs
CHANGED
|
@@ -108,6 +108,7 @@ function resolveOriginSessionId(cwd, epicId) {
|
|
|
108
108
|
return session && typeof session.claudeSessionId === 'string' ? session.claudeSessionId : null;
|
|
109
109
|
}
|
|
110
110
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
111
|
+
const jobWorktree = require('./lib/jobWorktree.cjs');
|
|
111
112
|
const queueStore = require('./lib/queueStore.cjs');
|
|
112
113
|
const { splitFrontmatter } = require('./lib/prdFrontmatter.cjs');
|
|
113
114
|
const { migratePrds, consolidateFlatPrds } = require('./lib/prdMigration.cjs');
|
|
@@ -119,6 +120,9 @@ const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
|
|
|
119
120
|
// an exemption it should have applied landed on disk, and nothing in the
|
|
120
121
|
// run record showed that; this is the fix).
|
|
121
122
|
const SCHEDULER_BOOTED_AT = new Date().toISOString();
|
|
123
|
+
// Resolves against __dirname (this app's OWN source checkout) — unaffected by
|
|
124
|
+
// PRD 994's job worktrees, which live under a job's PROJECT cwd, never under
|
|
125
|
+
// this app's install directory.
|
|
122
126
|
const SCHEDULER_CODE_SHA = (() => {
|
|
123
127
|
try {
|
|
124
128
|
return execFileSync('git', ['-C', __dirname, 'rev-parse', '--short', 'HEAD'], {
|
|
@@ -195,8 +199,14 @@ sequence. Do not stop before the commit lands; committing is part of the job.
|
|
|
195
199
|
3. VERIFY — run the project's OWN check commands (typecheck / lint / tests — the
|
|
196
200
|
project's CLAUDE.md names them; infer from the repo if not) and make them
|
|
197
201
|
pass. Do not assume npm; use whatever the target project uses.
|
|
198
|
-
4. COMMIT —
|
|
199
|
-
|
|
202
|
+
4. COMMIT — the queue can run several jobs against this SAME working tree at
|
|
203
|
+
once. Do NOT stage the whole working tree in one blanket/wildcard git-add
|
|
204
|
+
sweep — that captures whatever a concurrent sibling job is mid-writing and
|
|
205
|
+
mis-attributes its work to this commit, corrupting both jobs' verdicts.
|
|
206
|
+
Stage only the exact paths YOU created or modified for this PRD, then
|
|
207
|
+
commit: \`git add <path> [<path>...] && git commit -m "<type>(<scope>): <summary>"\`.
|
|
208
|
+
Your own work must still never be left uncommitted — this only changes
|
|
209
|
+
which paths get staged, never whether you commit.
|
|
200
210
|
5. VERDICT SENTINEL — as the LAST LINE of your final result text, emit exactly
|
|
201
211
|
one of these two lines (no trailing text after it):
|
|
202
212
|
SCHEDULER_VERDICT: PASS
|
|
@@ -512,7 +522,22 @@ function prdDirForCwd(cwd) {
|
|
|
512
522
|
return resolvePrdWriteDir(cwd || DEFAULT_PROJECT_CWD);
|
|
513
523
|
}
|
|
514
524
|
|
|
515
|
-
/**
|
|
525
|
+
/**
|
|
526
|
+
* Absolute path to `<job's project PRDs dir>/<job.slug>.md`. Resolves to
|
|
527
|
+
* `resolvePrdWriteDir(cwd)` — the RETIRED flat
|
|
528
|
+
* `<cwd>/session-manager-operations/scheduler/prds/` dir, which holds only
|
|
529
|
+
* zero-byte `.reserved-NNN` stubs and no PRD source for any Epic-scoped PRD
|
|
530
|
+
* (see resolveNotifyPrd's and resolveVerifyPrdPath's doc comments, PRD 985
|
|
531
|
+
* and 991). Do NOT reach for this in new code — use `findPrdDir(slug)` (via
|
|
532
|
+
* `resolveVerifyPrdPath`/`resolveNotifyPrd`, or directly for a fresh path)
|
|
533
|
+
* instead. The one legitimate remaining caller is executeJob's `prdPath`
|
|
534
|
+
* (~line 2235), which only falls back to this AFTER `findPrdDir` already
|
|
535
|
+
* came up empty — a real miss, not a resolution shortcut — matching this
|
|
536
|
+
* function's genuinely-legacy semantics rather than a bug. (PRD 991 also
|
|
537
|
+
* removed the two verify call sites and buildInvestigationPrompt's
|
|
538
|
+
* originalBody read from this list — all three now go through
|
|
539
|
+
* resolveVerifyPrdPath instead.)
|
|
540
|
+
*/
|
|
516
541
|
function prdPathForJob(job) {
|
|
517
542
|
return path.join(prdDirForCwd(job && job.cwd), `${job && job.slug}.md`);
|
|
518
543
|
}
|
|
@@ -1845,8 +1870,7 @@ function extractResultTextFromLog(logPath) {
|
|
|
1845
1870
|
*/
|
|
1846
1871
|
async function resolveNotifyPrd(job, parsePrdRaw) {
|
|
1847
1872
|
if (!job || !job.slug) return null;
|
|
1848
|
-
const
|
|
1849
|
-
const livePath = liveDir ? safeSlugPathIn(liveDir, job.slug) : null;
|
|
1873
|
+
const livePath = await resolveVerifyPrdPath(job);
|
|
1850
1874
|
if (livePath) {
|
|
1851
1875
|
const parsed = await parsePrdRaw(livePath).catch(() => null);
|
|
1852
1876
|
if (parsed) return parsed;
|
|
@@ -1856,6 +1880,26 @@ async function resolveNotifyPrd(job, parsePrdRaw) {
|
|
|
1856
1880
|
return await parsePrdRaw(archivedPath).catch(() => null);
|
|
1857
1881
|
}
|
|
1858
1882
|
|
|
1883
|
+
/**
|
|
1884
|
+
* resolveVerifyPrdPath(job) → Promise<string|null>
|
|
1885
|
+
*
|
|
1886
|
+
* Path-only sibling of resolveNotifyPrd's live-dir lookup (PRD 985/991):
|
|
1887
|
+
* find `<slug>.md` via findPrdDir's full candidate search (legacy flat dir +
|
|
1888
|
+
* every project's Epic-scoped dirs) and return its safe, containment-checked
|
|
1889
|
+
* path. Used by verifyRun call sites, which need a path string to read the
|
|
1890
|
+
* PRD body from — not a parsed object — so it stops short of the
|
|
1891
|
+
* parse-then-fall-back-to-archived step resolveNotifyPrd layers on top.
|
|
1892
|
+
* Deliberately does NOT fall back to `prdPathForJob` (the retired flat dir,
|
|
1893
|
+
* see that function's own doc comment) — a null here means the caller
|
|
1894
|
+
* should treat the PRD as unresolved, not read a path that is known to
|
|
1895
|
+
* never contain the file.
|
|
1896
|
+
*/
|
|
1897
|
+
async function resolveVerifyPrdPath(job) {
|
|
1898
|
+
if (!job || !job.slug) return null;
|
|
1899
|
+
const liveDir = await findPrdDir(job.slug).catch(() => null);
|
|
1900
|
+
return liveDir ? safeSlugPathIn(liveDir, job.slug) : null;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1859
1903
|
/**
|
|
1860
1904
|
* notifyOriginatingTab(job) → void
|
|
1861
1905
|
*
|
|
@@ -2134,27 +2178,62 @@ function classifyFailureOutcome({ exitCode, networkError, durationMs, transientR
|
|
|
2134
2178
|
* Commit-guard verdict decision. Pure/no I/O so the false-positive defenses
|
|
2135
2179
|
* can be unit-tested directly rather than only through a live spawnJob run.
|
|
2136
2180
|
* Returns the flagged verifyResult replacement, or null if the guard should
|
|
2137
|
-
* not fire (any of the
|
|
2181
|
+
* not fire (any of the defenses below applies).
|
|
2138
2182
|
*
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
*
|
|
2143
|
-
*
|
|
2144
|
-
*
|
|
2145
|
-
*
|
|
2183
|
+
* Runs for BOTH shapes of "no commit landed": (a) newlyDirty.length > 0 — the
|
|
2184
|
+
* finish protocol's COMMIT step didn't run and left evidence behind — and
|
|
2185
|
+
* (b) newlyDirty.length === 0 on a clean tree — the strongest possible silent
|
|
2186
|
+
* no-op signal (exitCode 0, no commit, nothing even left dirty; PRD 972's
|
|
2187
|
+
* shape). Before PRD 972-followup, case (b) bypassed the guard entirely
|
|
2188
|
+
* because the call site only invoked commitGuardVerdict when the working
|
|
2189
|
+
* tree was dirty — closed by widening that call site's condition, not by
|
|
2190
|
+
* changing this function's four defenses below, which still apply to both
|
|
2191
|
+
* shapes identically:
|
|
2192
|
+
* - siblingRunning: a concurrent job in the same cwd makes working-tree
|
|
2193
|
+
* evidence unreliable in both directions (extra dirt OR a clean tree
|
|
2194
|
+
* that isn't this job's doing).
|
|
2195
|
+
* - jobSelfCommitted: HEAD moved during the run, so the job's deliverable
|
|
2196
|
+
* landed even if dirt (from a concurrent actor) remains.
|
|
2197
|
+
* - legitimateNoOp (COMPLETED_EQUIVALENT_VERDICTS): runVerify.cjs's own
|
|
2198
|
+
* pass_no_commit exemptions (pass_no_commit_already_shipped, etc.)
|
|
2199
|
+
* already independently proved a truthful PASS-with-no-commit is
|
|
2200
|
+
* correct; without this check the commit-guard double-punishes that
|
|
2201
|
+
* same honest no-op (incidents: 655-needs-review-rca-feedback-hook,
|
|
2202
|
+
* 672-fix-feedback-session-manager, 2026-07-31).
|
|
2203
|
+
* - isFixPlanJob (zero-edit case only): a fix-plan investigation
|
|
2204
|
+
* (slug ^\d+-fix-) legitimately concluding "the original work already
|
|
2205
|
+
* landed, nothing to change" makes no commit on an already-clean tree —
|
|
2206
|
+
* runVerify.cjs:896 exempts this same shape from ever raising
|
|
2207
|
+
* pass_no_commit in the first place (so its verdict stays 'clean', not a
|
|
2208
|
+
* COMPLETED_EQUIVALENT member — legitimateNoOp alone can't catch it),
|
|
2209
|
+
* which is why this is a distinct exemption. Only applies when
|
|
2210
|
+
* newlyDirty is empty: a fix-plan job that left real dirt behind is
|
|
2211
|
+
* still a genuine finish-protocol violation (incident:
|
|
2212
|
+
* 523-fix-bounded-fix-plan-retry, 2026-07-12).
|
|
2146
2213
|
*/
|
|
2147
|
-
function commitGuardVerdict({ newlyDirty, siblingRunning, jobSelfCommitted, legitimateNoOp, verifyResult }) {
|
|
2148
|
-
if (!newlyDirty || newlyDirty.length === 0) return null;
|
|
2214
|
+
function commitGuardVerdict({ newlyDirty, siblingRunning, jobSelfCommitted, legitimateNoOp, isFixPlanJob, verifyResult }) {
|
|
2149
2215
|
if (siblingRunning || jobSelfCommitted || legitimateNoOp) return null;
|
|
2150
|
-
const
|
|
2216
|
+
const dirty = newlyDirty || [];
|
|
2217
|
+
if (dirty.length === 0 && isFixPlanJob) return null;
|
|
2218
|
+
|
|
2151
2219
|
const carried = [...(verifyResult?.annotations ?? [])];
|
|
2152
2220
|
if (verifyResult && verifyResult.verdict !== 'clean') {
|
|
2153
2221
|
carried.push({ verdict: verifyResult.verdict, reason: verifyResult.reason });
|
|
2154
2222
|
}
|
|
2223
|
+
|
|
2224
|
+
if (dirty.length === 0) {
|
|
2225
|
+
return {
|
|
2226
|
+
verdict: 'silent_no_op',
|
|
2227
|
+
reason: 'finish protocol incomplete: run exited 0, made no commit, and left the working tree exactly as it started — no evidence any work was done',
|
|
2228
|
+
downgradeTo: 'needs_review',
|
|
2229
|
+
annotations: carried.length ? carried : undefined,
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
const sample = dirty.slice(0, 3).join(', ');
|
|
2155
2234
|
return {
|
|
2156
2235
|
verdict: 'uncommitted_changes',
|
|
2157
|
-
reason: `finish protocol incomplete: ${
|
|
2236
|
+
reason: `finish protocol incomplete: ${dirty.length} uncommitted file(s) left in working tree (e.g. ${sample})`,
|
|
2158
2237
|
downgradeTo: 'needs_review',
|
|
2159
2238
|
annotations: carried.length ? carried : undefined,
|
|
2160
2239
|
};
|
|
@@ -2178,10 +2257,15 @@ function pickRunDir() {
|
|
|
2178
2257
|
* Watchdogs are declared as an array; the result-tailer's exit-code mapping
|
|
2179
2258
|
* (success+killedBySignal → 0) is scheduler-specific and lives in onExit.
|
|
2180
2259
|
*/
|
|
2181
|
-
async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
2260
|
+
async function executeJob(job, runDir, defaultCwd, onPid, execCwd) {
|
|
2182
2261
|
const logPath = path.join(runDir, `${job.slug}.log`);
|
|
2183
2262
|
const metaPath = path.join(runDir, `${job.slug}.meta.json`);
|
|
2263
|
+
// `cwd` stays the MAIN tree throughout — PRD lookup (findPrdDir/prdPathForJob)
|
|
2264
|
+
// and every git call below key off this value, never the worktree. Only the
|
|
2265
|
+
// spawned child's own process cwd (spawnCwd) may point at a job worktree —
|
|
2266
|
+
// see jobWorktree.cjs's header comment for why the two must never merge.
|
|
2184
2267
|
const cwd = job.cwd || defaultCwd;
|
|
2268
|
+
const spawnCwd = execCwd || cwd;
|
|
2185
2269
|
const startedAt = Date.now();
|
|
2186
2270
|
const sessionId = randomUUID();
|
|
2187
2271
|
|
|
@@ -2190,11 +2274,13 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2190
2274
|
// of fd/safeLog/closeFd from the point it is called.
|
|
2191
2275
|
const { fd, safeLog, closeFd } = openLog(logPath);
|
|
2192
2276
|
|
|
2193
|
-
safeLog(`[scheduler] starting ${job.slug} at ${new Date().toISOString()}\n[scheduler] cwd=${cwd}
|
|
2277
|
+
safeLog(`[scheduler] starting ${job.slug} at ${new Date().toISOString()}\n[scheduler] cwd=${cwd}` +
|
|
2278
|
+
(spawnCwd !== cwd ? ` (running isolated in worktree ${spawnCwd})` : '') + '\n\n');
|
|
2194
2279
|
|
|
2195
2280
|
// Dead-cwd guard: verify the target directory exists and is traversable
|
|
2196
|
-
// before handing it to the child process.
|
|
2197
|
-
|
|
2281
|
+
// before handing it to the child process. Checks spawnCwd (where the child
|
|
2282
|
+
// ACTUALLY runs) — cwd is only used for PRD/git bookkeeping.
|
|
2283
|
+
try { fs.accessSync(spawnCwd, fs.constants.X_OK); }
|
|
2198
2284
|
catch {
|
|
2199
2285
|
const errMsg = `cwd does not exist on this machine: ${cwd}`;
|
|
2200
2286
|
safeLog(`[scheduler] ${errMsg}\n`);
|
|
@@ -2216,9 +2302,11 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2216
2302
|
let prdPath = resolvedDir ? path.join(resolvedDir, `${job.slug}.md`) : prdPathForJob(job);
|
|
2217
2303
|
try {
|
|
2218
2304
|
const parsed = await parsePrd(prdPath);
|
|
2219
|
-
//
|
|
2220
|
-
//
|
|
2221
|
-
|
|
2305
|
+
// The review → security-review → verify → commit finish sequence is
|
|
2306
|
+
// folded in below via composeExecutorPrompt, after the Epic digest is
|
|
2307
|
+
// resolved — never concatenated here, so it can't end up ahead of the
|
|
2308
|
+
// digest in the composed prompt (PRD 992).
|
|
2309
|
+
prompt = parsed.body;
|
|
2222
2310
|
} catch (e) {
|
|
2223
2311
|
// The project-scoped dir isn't the only place a PRD source can live — a
|
|
2224
2312
|
// writer that hasn't migrated to prdLocations.cjs yet (or a not-yet-run
|
|
@@ -2230,7 +2318,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2230
2318
|
safeLog(`[scheduler] PRD not in project dir; found ${job.slug}.md in ${fallbackDir}\n`);
|
|
2231
2319
|
try {
|
|
2232
2320
|
const parsed = await parsePrd(fallbackPath);
|
|
2233
|
-
prompt = parsed.body
|
|
2321
|
+
prompt = parsed.body;
|
|
2234
2322
|
prdPath = fallbackPath;
|
|
2235
2323
|
} catch (e2) {
|
|
2236
2324
|
// Found the dir a moment ago but the read still failed. Case A: the
|
|
@@ -2275,17 +2363,20 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2275
2363
|
const digestEpicId = job.epicId ?? job.sourcePromptId ?? null;
|
|
2276
2364
|
const originSessionId = resolveOriginSessionId(cwd, digestEpicId);
|
|
2277
2365
|
let contextDigestApplied = false;
|
|
2366
|
+
let digestText = '';
|
|
2278
2367
|
if (originSessionId) {
|
|
2279
2368
|
try {
|
|
2280
|
-
|
|
2281
|
-
if (digestText)
|
|
2282
|
-
prompt = composeExecutorPrompt({ prdBody: prompt, digestText });
|
|
2283
|
-
contextDigestApplied = true;
|
|
2284
|
-
}
|
|
2369
|
+
digestText = await buildContextDigest({ cwd, epicId: digestEpicId });
|
|
2370
|
+
if (digestText) contextDigestApplied = true;
|
|
2285
2371
|
} catch (e) {
|
|
2286
2372
|
safeLog(`[scheduler] context digest build failed (job still dispatches without it): ${e?.message ?? e}\n`);
|
|
2373
|
+
digestText = '';
|
|
2287
2374
|
}
|
|
2288
2375
|
}
|
|
2376
|
+
// Always route through composeExecutorPrompt (even with an empty digest)
|
|
2377
|
+
// so the finish protocol is appended in the prompt's tail exactly once,
|
|
2378
|
+
// after any digest fence rather than concatenated ahead of it.
|
|
2379
|
+
prompt = composeExecutorPrompt({ prdBody: prompt, digestText, finishProtocol: FINISH_PROTOCOL });
|
|
2289
2380
|
|
|
2290
2381
|
const promptCheck = validatePromptForSpawn(prompt, prdPath);
|
|
2291
2382
|
if (!promptCheck.ok) {
|
|
@@ -2418,7 +2509,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2418
2509
|
'--session-id', sessionId,
|
|
2419
2510
|
],
|
|
2420
2511
|
options: {
|
|
2421
|
-
cwd,
|
|
2512
|
+
cwd: spawnCwd,
|
|
2422
2513
|
env: childEnv,
|
|
2423
2514
|
// detached:true puts the child in its own process group so we can kill
|
|
2424
2515
|
// the entire descendant tree (including any stray background bashes the
|
|
@@ -2683,7 +2774,8 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
2683
2774
|
|
|
2684
2775
|
let originalBody = '';
|
|
2685
2776
|
try {
|
|
2686
|
-
|
|
2777
|
+
const originalPath = (await resolveVerifyPrdPath(failedJob)) ?? archivedPrdPathForJob(failedJob);
|
|
2778
|
+
originalBody = (await parsePrd(originalPath)).body;
|
|
2687
2779
|
} catch {
|
|
2688
2780
|
originalBody = failedJob.bodyPreview || '(original PRD missing from disk)';
|
|
2689
2781
|
}
|
|
@@ -2867,16 +2959,65 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
2867
2959
|
const guardBaseline = await uncommittedChanges(guardCwd);
|
|
2868
2960
|
const guardHeadBefore = await gitHead(guardCwd);
|
|
2869
2961
|
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2962
|
+
// Worktree isolation (PRD 994): give this job its own linked `git worktree`
|
|
2963
|
+
// checkout so its edits/tests/commit never collide with a sibling job or
|
|
2964
|
+
// an interactive session in the SAME repo. `worktree.ok` is false (with a
|
|
2965
|
+
// logged reason) for a non-git cwd, a dirty base tree, the cap being hit,
|
|
2966
|
+
// or SM_JOB_WORKTREE_DISABLE=1 — every case falls back to running in place,
|
|
2967
|
+
// never a hard failure. See jobWorktree.cjs's header comment for why
|
|
2968
|
+
// job.cwd (guardCwd) itself is NEVER repointed at the worktree dir.
|
|
2969
|
+
const worktree = await jobWorktree.createJobWorktree({ cwd: guardCwd, slug: job.slug });
|
|
2970
|
+
if (worktree.ok) {
|
|
2971
|
+
console.log(`[scheduler] ${job.slug}: isolated in worktree ${worktree.dir} (branch ${worktree.branch})`);
|
|
2972
|
+
} else {
|
|
2973
|
+
console.log(`[scheduler] ${job.slug}: running in main tree (worktree not used: ${worktree.reason})`);
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
// Integrate the job's branch back into guardCwd's own HEAD, THEN tear the
|
|
2977
|
+
// worktree checkout down — both must happen BEFORE any git read below
|
|
2978
|
+
// (verify/commit-guard/sigterm check all read guardCwd), so a commit made
|
|
2979
|
+
// inside the worktree is a real commit on the main tree by the time those
|
|
2980
|
+
// checks run, exactly like an in-place commit would be. A leftover
|
|
2981
|
+
// uncommitted file in the worktree would otherwise vanish unnoticed when
|
|
2982
|
+
// the checkout is removed — captured here (worktreeLeftoverDirty) and
|
|
2983
|
+
// folded into the commit-guard's dirty-file list further down.
|
|
2984
|
+
//
|
|
2985
|
+
// Wrapped in try/finally (not straight-line) so an unexpected throw from
|
|
2986
|
+
// executeJob itself still runs integration+cleanup — otherwise a bug
|
|
2987
|
+
// elsewhere in executeJob would leak the worktree checkout (and its
|
|
2988
|
+
// activeWorktreeCount slot) for the rest of this process's life.
|
|
2989
|
+
let res;
|
|
2990
|
+
let worktreeLeftoverDirty = [];
|
|
2991
|
+
let worktreeIntegrationFailure = null;
|
|
2992
|
+
try {
|
|
2993
|
+
res = await executeJob(job, runDir, defaultCwd, async (pid, sessionId, cwd) => {
|
|
2994
|
+
await mutate((s) => {
|
|
2995
|
+
const idx = s.jobs.findIndex((x) => x.slug === job.slug);
|
|
2996
|
+
if (idx >= 0) {
|
|
2997
|
+
s.jobs[idx].sessionId = sessionId;
|
|
2998
|
+
s.jobs[idx].runtime = { pid, runId, startedAt: s.jobs[idx].startedAt, sessionId, cwd };
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
await broadcast({ flush: true });
|
|
3002
|
+
}, worktree.ok ? worktree.dir : undefined);
|
|
3003
|
+
} finally {
|
|
3004
|
+
if (worktree.ok) {
|
|
3005
|
+
worktreeLeftoverDirty = (await uncommittedChanges(worktree.dir)) || [];
|
|
3006
|
+
const integration = await jobWorktree.integrateJobBranch({ cwd: guardCwd, branch: worktree.branch, slug: job.slug });
|
|
3007
|
+
if (!integration.ok) {
|
|
3008
|
+
worktreeIntegrationFailure = integration.reason;
|
|
3009
|
+
console.error(`[scheduler] ${job.slug}: worktree branch integration FAILED (${integration.reason}) — branch ${worktree.branch} preserved in ${guardCwd} for manual recovery`);
|
|
3010
|
+
} else if (integration.integrated) {
|
|
3011
|
+
console.log(`[scheduler] ${job.slug}: worktree branch ${worktree.branch} integrated into ${guardCwd}${integration.mergeCommit ? ' (merge commit)' : ' (fast-forward)'}`);
|
|
2876
3012
|
}
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
3013
|
+
await jobWorktree.cleanupJobWorktree({
|
|
3014
|
+
cwd: guardCwd,
|
|
3015
|
+
dir: worktree.dir,
|
|
3016
|
+
branch: worktree.branch,
|
|
3017
|
+
keepBranch: !integration.ok,
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
2880
3021
|
|
|
2881
3022
|
if (res.rateLimited) {
|
|
2882
3023
|
const resetIso = await refreshNextReset().catch(() => cachedNextReset);
|
|
@@ -2929,7 +3070,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
2929
3070
|
jobLandedCommitThisRun = headAtExit;
|
|
2930
3071
|
}
|
|
2931
3072
|
|
|
2932
|
-
const prdPath =
|
|
3073
|
+
const prdPath = (await resolveVerifyPrdPath(job)) ?? archivedPrdPathForJob(job);
|
|
2933
3074
|
const stateForDeps = await readQueue();
|
|
2934
3075
|
// priorLandedCommit: the commit a PREVIOUS run of this same slug landed,
|
|
2935
3076
|
// if any — prefer the live jobs[] row (survives a resetJob, see
|
|
@@ -2962,29 +3103,17 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
2962
3103
|
}
|
|
2963
3104
|
}
|
|
2964
3105
|
|
|
2965
|
-
// Commit guard: a clean exit that
|
|
2966
|
-
//
|
|
2967
|
-
//
|
|
2968
|
-
//
|
|
2969
|
-
//
|
|
2970
|
-
//
|
|
2971
|
-
//
|
|
2972
|
-
//
|
|
2973
|
-
//
|
|
2974
|
-
//
|
|
2975
|
-
//
|
|
2976
|
-
// unsaved work — so skip rather than false-flag a completed job.
|
|
2977
|
-
// - legitimate-no-op skip: if the verifier already independently proved
|
|
2978
|
-
// this run's PASS-with-no-commit is truthful (verdict is one of
|
|
2979
|
-
// COMPLETED_EQUIVALENT_VERDICTS — pass_no_commit_target_verified,
|
|
2980
|
-
// _prior_run_verified, _already_shipped), the run itself did nothing
|
|
2981
|
-
// wrong; dirt left by a concurrent interactive session (e.g.
|
|
2982
|
-
// /process-feedback writing new PRD .md files into the same repo
|
|
2983
|
-
// while this job's own AC turned out to already be satisfied) is not
|
|
2984
|
-
// this job's unfinished work. Without this skip, runVerify.cjs's
|
|
2985
|
-
// exemption and this guard double-punish the same honest no-op from
|
|
2986
|
-
// two different code paths (incidents: 655-needs-review-rca-feedback-hook,
|
|
2987
|
-
// 672-fix-feedback-session-manager, 2026-07-31).
|
|
3106
|
+
// Commit guard: a clean exit that landed no commit means the finish
|
|
3107
|
+
// protocol's COMMIT step did not run. Surface it as needs_review instead
|
|
3108
|
+
// of letting it masquerade as 'completed' (the PRD 03/04 left-uncommitted
|
|
3109
|
+
// incident, and PRD 972's zero-edit variant of the same failure). Runs
|
|
3110
|
+
// for BOTH shapes — newly-dirty files left behind, AND an already-clean
|
|
3111
|
+
// tree with no commit at all — since a clean exit that commits nothing
|
|
3112
|
+
// and dirties nothing is the strongest possible silent-no-op signal, not
|
|
3113
|
+
// proof the guard has nothing to check. commitGuardVerdict's own doc
|
|
3114
|
+
// comment (above) covers all four false-positive defenses that apply to
|
|
3115
|
+
// both shapes: baseline DELTA (below), sibling skip, self-commit skip,
|
|
3116
|
+
// legitimate-no-op skip, and (zero-edit only) the fix-plan exemption.
|
|
2988
3117
|
// Non-git cwds resolve to null and are skipped (the guard is best-effort).
|
|
2989
3118
|
//
|
|
2990
3119
|
// Runs even when a transcript-pattern verdict already fired: the commit-guard
|
|
@@ -2998,9 +3127,19 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
2998
3127
|
const guardIsLegitimateNoOp = verifyResult && COMPLETED_EQUIVALENT_VERDICTS.has(verifyResult.verdict);
|
|
2999
3128
|
if (res.exitCode === 0 && !res.rateLimited && !guardWillRefire && !guardIsLegitimateNoOp) {
|
|
3000
3129
|
const after = await uncommittedChanges(guardCwd);
|
|
3001
|
-
|
|
3130
|
+
// after === null means non-git cwd (or git errored) — best-effort skip,
|
|
3131
|
+
// same as always; only a git-status result (even an empty one) counts
|
|
3132
|
+
// as evidence for the zero-edit path.
|
|
3133
|
+
if (after !== null) {
|
|
3002
3134
|
const baseSet = new Set(guardBaseline || []);
|
|
3003
|
-
|
|
3135
|
+
// worktreeLeftoverDirty was captured from a FRESH checkout (no baseline
|
|
3136
|
+
// to diff against — every path in it is inherently new) right before
|
|
3137
|
+
// the worktree was torn down, so it must be counted here or a job's
|
|
3138
|
+
// uncommitted leftovers silently vanish with the worktree.
|
|
3139
|
+
const newlyDirty = [...new Set([
|
|
3140
|
+
...after.filter((p) => !baseSet.has(p)),
|
|
3141
|
+
...worktreeLeftoverDirty,
|
|
3142
|
+
])];
|
|
3004
3143
|
const guardState = await readQueue().catch(() => ({ jobs: [] }));
|
|
3005
3144
|
const siblingRunning = (guardState.jobs || []).some(
|
|
3006
3145
|
(j) => j.slug !== job.slug && j.status === 'running' && (j.cwd || defaultCwd) === guardCwd,
|
|
@@ -3012,15 +3151,31 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
3012
3151
|
siblingRunning,
|
|
3013
3152
|
jobSelfCommitted,
|
|
3014
3153
|
legitimateNoOp: guardIsLegitimateNoOp,
|
|
3154
|
+
isFixPlanJob: isFixPlanSlug(job.slug),
|
|
3015
3155
|
verifyResult,
|
|
3016
3156
|
});
|
|
3017
3157
|
if (guardVerdict) {
|
|
3018
3158
|
verifyResult = guardVerdict;
|
|
3019
|
-
|
|
3159
|
+
const what = newlyDirty.length > 0 ? `left ${newlyDirty.length} files uncommitted` : 'made no commit on an already-clean tree';
|
|
3160
|
+
console.log(`[scheduler] commit-guard: ${job.slug} ${what} → needs_review`);
|
|
3020
3161
|
}
|
|
3021
3162
|
}
|
|
3022
3163
|
}
|
|
3023
3164
|
|
|
3165
|
+
// Worktree branch integration failure is a materially-checkable git-state
|
|
3166
|
+
// signal exactly like the commit-guard above, and takes the same priority:
|
|
3167
|
+
// a job whose commit could not be merged back into the main tree is NOT a
|
|
3168
|
+
// success, whatever its exit code or verifier verdict said — the commit
|
|
3169
|
+
// guard AC explicitly requires this failure be surfaced as an explicit job
|
|
3170
|
+
// outcome, never silently dropped alongside the branch it's stranded on.
|
|
3171
|
+
if (worktreeIntegrationFailure) {
|
|
3172
|
+
verifyResult = {
|
|
3173
|
+
verdict: 'worktree_integration_failed',
|
|
3174
|
+
reason: `worktree branch integration failed: ${worktreeIntegrationFailure} — branch preserved for manual merge`,
|
|
3175
|
+
downgradeTo: 'needs_review',
|
|
3176
|
+
};
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3024
3179
|
// SIGTERM commit check: reuse the same commit-window scan the exit=0
|
|
3025
3180
|
// guard uses above (one commit-detection path, not two) to see whether a
|
|
3026
3181
|
// 143 (SIGTERM) run still landed a deliverable before it died. Scoped
|
|
@@ -3082,7 +3237,13 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
3082
3237
|
s.jobs[i2].exitCode = res.exitCode;
|
|
3083
3238
|
s.jobs[i2].error = effectiveStatus === 'needs_review'
|
|
3084
3239
|
? (verifyResult?.reason ?? sigtermOverrideReason ?? null)
|
|
3085
|
-
|
|
3240
|
+
// A failed job (non-zero exit) never consults verifyResult above,
|
|
3241
|
+
// but a worktree integration failure is still worth surfacing on
|
|
3242
|
+
// the row so the stranded branch isn't silently invisible —
|
|
3243
|
+
// concatenated (not `||`), so it's never dropped when res.error
|
|
3244
|
+
// is ALSO set (e.g. a real exit failure whose branch also failed
|
|
3245
|
+
// to integrate must show both, not just the first one).
|
|
3246
|
+
: [res.error, worktreeIntegrationFailure ? verifyResult.reason : null].filter(Boolean).join('; ') || null;
|
|
3086
3247
|
// Persist the commit THIS run landed (if HEAD advanced) so a later
|
|
3087
3248
|
// re-fire of the same slug can prove its own no-op re-run is
|
|
3088
3249
|
// truthful via the pass_no_commit_prior_run_verified exemption.
|
|
@@ -3222,7 +3383,14 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
3222
3383
|
if (maybeTransient) {
|
|
3223
3384
|
const afterFailure = await uncommittedChanges(guardCwd);
|
|
3224
3385
|
const baseSet = new Set(guardBaseline || []);
|
|
3225
|
-
|
|
3386
|
+
// See the commit-guard block above: worktreeLeftoverDirty was captured
|
|
3387
|
+
// (and the checkout already torn down) before this point, so it must
|
|
3388
|
+
// be folded in here too or a transiently-killed job's leftover WIP
|
|
3389
|
+
// silently disappears with its worktree.
|
|
3390
|
+
const newlyDirty = [...new Set([
|
|
3391
|
+
...(afterFailure || []).filter((p) => !baseSet.has(p)),
|
|
3392
|
+
...worktreeLeftoverDirty,
|
|
3393
|
+
])];
|
|
3226
3394
|
newlyDirtyCount = newlyDirty.length;
|
|
3227
3395
|
dirtySample = newlyDirty.slice(0, 3).join(', ');
|
|
3228
3396
|
}
|
|
@@ -3881,7 +4049,7 @@ async function reverifyNeedsReview() {
|
|
|
3881
4049
|
const leftForReview = [];
|
|
3882
4050
|
for (const job of candidates) {
|
|
3883
4051
|
const runDir = path.join(RUNS_DIR, job.runId || resolveRunId(job));
|
|
3884
|
-
const prdPath =
|
|
4052
|
+
const prdPath = (await resolveVerifyPrdPath(job)) ?? archivedPrdPathForJob(job);
|
|
3885
4053
|
// Derive committedDuringRun from the recorded run window. The live
|
|
3886
4054
|
// commit-guard uses gitHead() (before/after HEAD diff); here the run is
|
|
3887
4055
|
// already over so we query git log filtered to [startedAt, finishedAt+60s].
|
|
@@ -4418,6 +4586,20 @@ async function init() {
|
|
|
4418
4586
|
// see partitionBootOrphans. Everything else (dead pid or no pid) is safe to
|
|
4419
4587
|
// classify immediately below.
|
|
4420
4588
|
const bootSnap = readQueueSync();
|
|
4589
|
+
|
|
4590
|
+
// Worktree boot reconciliation (PRD 994): a job worktree that survives an
|
|
4591
|
+
// app crash/host reboot must not leak disk or a dangling branch forever —
|
|
4592
|
+
// this sweeps every known project cwd (every cwd referenced by a queue
|
|
4593
|
+
// row, plus the default project) and removes any of OUR worktrees still
|
|
4594
|
+
// registered there. Best-effort: never blocks the rest of boot.
|
|
4595
|
+
try {
|
|
4596
|
+
const worktreeCwds = new Set(bootSnap.jobs.map((j) => j.cwd).filter(Boolean));
|
|
4597
|
+
worktreeCwds.add(DEFAULT_PROJECT_CWD);
|
|
4598
|
+
await jobWorktree.reconcileWorktreesOnBoot([...worktreeCwds]);
|
|
4599
|
+
} catch (e) {
|
|
4600
|
+
console.error('[scheduler] boot worktree reconciliation failed', e?.message);
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4421
4603
|
const { immediate: immediateSlugs, deferred: deferredSlugs } = partitionBootOrphans(bootSnap.jobs);
|
|
4422
4604
|
const bootOutcomes = new Map();
|
|
4423
4605
|
for (const j of bootSnap.jobs) {
|
|
@@ -4774,4 +4956,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
4774
4956
|
});
|
|
4775
4957
|
}
|
|
4776
4958
|
|
|
4777
|
-
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
|
|
4959
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, resolveVerifyPrdPath, resolveNotifyPrd, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
|