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.
@@ -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 — stage and commit ALL changes with a clear conventional message:
199
- \`git add -A && git commit -m "<type>(<scope>): <summary>"\`.
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
- /** Absolute path to `<job's project PRDs dir>/<job.slug>.md`. */
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 liveDir = await findPrdDir(job.slug).catch(() => null);
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 four defenses applies).
2181
+ * not fire (any of the defenses below applies).
2138
2182
  *
2139
- * The fourth defense (legitimateNoOp) exists because runVerify.cjs's own
2140
- * pass_no_commit exemptions (COMPLETED_EQUIVALENT_VERDICTS members like
2141
- * pass_no_commit_already_shipped) already independently proved a truthful
2142
- * PASS-with-no-commit is correct; without this check the commit-guard
2143
- * double-punishes that same honest no-op for dirt a concurrent interactive
2144
- * session left behind (incidents: 655-needs-review-rca-feedback-hook,
2145
- * 672-fix-feedback-session-manager, 2026-07-31).
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 sample = newlyDirty.slice(0, 3).join(', ');
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: ${newlyDirty.length} uncommitted file(s) left in working tree (e.g. ${sample})`,
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}\n\n`);
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
- try { fs.accessSync(cwd, fs.constants.X_OK); }
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
- // Centrally enforce the review → security-review → verify → commit finish
2220
- // sequence on every job, regardless of what the PRD body says.
2221
- prompt = parsed.body + FINISH_PROTOCOL;
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 + FINISH_PROTOCOL;
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
- const digestText = await buildContextDigest({ cwd, epicId: digestEpicId });
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
- originalBody = (await parsePrd(prdPathForJob(failedJob))).body;
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
- const res = await executeJob(job, runDir, defaultCwd, async (pid, sessionId, cwd) => {
2871
- await mutate((s) => {
2872
- const idx = s.jobs.findIndex((x) => x.slug === job.slug);
2873
- if (idx >= 0) {
2874
- s.jobs[idx].sessionId = sessionId;
2875
- s.jobs[idx].runtime = { pid, runId, startedAt: s.jobs[idx].startedAt, sessionId, cwd };
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
- await broadcast({ flush: true });
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 = prdPathForJob(job);
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 left NEW uncommitted changes means the
2966
- // finish protocol's COMMIT step did not run. Surface it as needs_review
2967
- // instead of letting it masquerade as 'completed' (the PRD 03/04
2968
- // left-uncommitted incident). Two false-positive defenses:
2969
- // - baseline DELTA: only files dirtied during THIS run count, so
2970
- // pre-existing user WIP is excluded; and
2971
- // - sibling skip: if another job is concurrently writing the same repo,
2972
- // working-tree dirt can't be attributed to this job, so skip the guard.
2973
- // - self-commit skip: if HEAD moved during the run, the job committed its
2974
- // deliverable; leftover dirt is presumptively a concurrent external edit
2975
- // (e.g. an interactive session editing the same repo), not the job's
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
- if (after && after.length > 0) {
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
- const newlyDirty = after.filter((p) => !baseSet.has(p));
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
- console.log(`[scheduler] commit-guard: ${job.slug} left ${newlyDirty.length} files uncommitted → needs_review`);
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
- : (res.error || null);
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
- const newlyDirty = (afterFailure || []).filter((p) => !baseSet.has(p));
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 = prdPathForJob(job);
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 };