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.
@@ -0,0 +1,327 @@
1
+ /**
2
+ * jobWorktree.cjs — per-job `git worktree` isolation for concurrent scheduler
3
+ * runs (PRD 994).
4
+ *
5
+ * Problem: once the queue genuinely runs several jobs wide in one project,
6
+ * N headless `claude -p` processes edit the SAME working tree simultaneously.
7
+ * Observed live on 2026-08-02 with only two concurrent writers: a full test
8
+ * run failed on another job's half-written renderer files, a commit had to be
9
+ * hand-staged to avoid sweeping a sibling job's WIP, and a commit was silently
10
+ * rewritten to a new SHA by a concurrent rebase. This module gives each job
11
+ * its own linked worktree — its edits, its test runs, its commit — isolated
12
+ * from every sibling job and from the human's own interactive session in the
13
+ * same repo.
14
+ *
15
+ * ---------- the ops-root hazard (read before touching cwd plumbing) ----------
16
+ *
17
+ * `<cwd>/session-manager-operations/` (PRDs, queue state, run logs) is
18
+ * resolved from a project's cwd by lib/prdLocations.cjs and lib/queueStore.cjs
19
+ * — every function there takes `cwd` as an explicit parameter, never
20
+ * `process.cwd()`. That means the ops root follows WHATEVER cwd value the
21
+ * caller passes in. A prior incident (recorded in this project's memory as
22
+ * `no_schedule_self_e2e`) is exactly this mistake: swapping the job's cwd for
23
+ * a worktree cwd gives the job a different, EMPTY ops root and orphans its own
24
+ * PRD/queue row.
25
+ *
26
+ * The fix here is structural: this module never changes `job.cwd`. It hands
27
+ * back a SEPARATE `execCwd` (the worktree directory) that scheduler.cjs uses
28
+ * ONLY as the spawned child process's `cwd` spawn option. Every PRD-path
29
+ * resolution, queue read/write, and run-log path in scheduler.cjs keeps using
30
+ * `job.cwd` (the main tree) exactly as before — this module is additive, not
31
+ * a cwd substitution.
32
+ *
33
+ * ---------- commit-guard visibility across the worktree boundary ----------
34
+ *
35
+ * scheduler.cjs's commit-guard reads git state (uncommittedChanges/gitHead)
36
+ * from `job.cwd` (the main tree) before and after a run. If a job's commit
37
+ * only ever lands on a throwaway worktree branch, the main tree's HEAD never
38
+ * moves and the guard would wrongly conclude nothing was committed (this is
39
+ * the pre-existing `pass-no-commit-worktree-commit-invisible-at-exit` /
40
+ * RCA 770-pr269 incident class scheduler.cjs already has retry logic for).
41
+ * `integrateJobBranch` closes that gap for OUR managed worktrees: it merges
42
+ * the job's branch into `job.cwd`'s own HEAD (ff-only when possible, a real
43
+ * merge commit otherwise) BEFORE scheduler.cjs runs any post-run git check —
44
+ * so the commit is a real commit on the main tree, not something the guard
45
+ * has to go looking for.
46
+ */
47
+ 'use strict';
48
+
49
+ const fs = require('node:fs');
50
+ const fsp = require('node:fs/promises');
51
+ const path = require('node:path');
52
+ const os = require('node:os');
53
+ const crypto = require('node:crypto');
54
+ const { execFile } = require('node:child_process');
55
+
56
+ // Root under which every job worktree is checked out. Kept OUTSIDE any
57
+ // project's own tree (os.tmpdir(), not `<cwd>/.git/...`) so a job's worktree
58
+ // never shows up in the main tree's own file listings, `find`, or a tsc
59
+ // rootDir scan of `<cwd>`.
60
+ const WORKTREE_ROOT = path.join(os.tmpdir(), 'session-manager-job-worktrees');
61
+
62
+ // Disk estimate for this repo (session-manager): `git worktree add` checks
63
+ // out only git-tracked source, not `node_modules`/`dist` — a fresh checkout
64
+ // of this repo's tracked tree is ~30-40 MB (measured via `git ls-files | xargs
65
+ // du -ch` on the working tree, minus the .git object store the worktree
66
+ // shares with the main tree rather than duplicating). At the default cap of
67
+ // 4 concurrent worktrees that's under 200 MB — negligible next to the
68
+ // multi-GB `node_modules` a job's own `npm install`/build step may add inside
69
+ // its worktree (uncounted here; that risk is bounded by MIN_FREE_MB_PER_JOB's
70
+ // existing per-job memory gate in scheduler.cjs, not by this cap).
71
+ const DEFAULT_MAX_CONCURRENT_WORKTREES = 4;
72
+
73
+ function isWorktreeDisabled() {
74
+ return process.env.SM_JOB_WORKTREE_DISABLE === '1';
75
+ }
76
+
77
+ function getMaxConcurrentWorktrees() {
78
+ const raw = Number(process.env.SM_JOB_WORKTREE_MAX);
79
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_CONCURRENT_WORKTREES;
80
+ }
81
+
82
+ // In-memory count of worktrees currently checked out by THIS process. Reset
83
+ // to 0 on every restart by design — a crash can never leave this counter
84
+ // permanently wedged above the cap; reconcileWorktreesOnBoot cleans up any
85
+ // leaked ON-DISK checkouts separately (see below).
86
+ let activeWorktreeCount = 0;
87
+
88
+ function execGit(args, { cwd, timeout = 20_000 } = {}) {
89
+ return new Promise((resolve, reject) => {
90
+ execFile('git', args, { cwd, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
91
+ if (err) {
92
+ err.stderrText = stderr;
93
+ reject(err);
94
+ return;
95
+ }
96
+ resolve(stdout || '');
97
+ });
98
+ });
99
+ }
100
+
101
+ async function isGitRepo(cwd) {
102
+ if (!cwd) return false;
103
+ try {
104
+ const out = await execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 10_000 });
105
+ return out.trim() === 'true';
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /** True when `cwd`'s own working tree (not any worktree) has zero pending changes. */
112
+ async function isBaseTreeClean(cwd) {
113
+ try {
114
+ const out = await execGit(['status', '--porcelain'], { cwd, timeout: 10_000 });
115
+ return out.trim().length === 0;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function hashOf(input) {
122
+ return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
123
+ }
124
+
125
+ function worktreeDirFor(cwd, slug) {
126
+ return path.join(WORKTREE_ROOT, hashOf(cwd), slug);
127
+ }
128
+
129
+ function branchNameFor(slug) {
130
+ return `sm-job/${slug}`;
131
+ }
132
+
133
+ /** Best-effort teardown of one worktree checkout — never throws. */
134
+ async function removeWorktreeDir(cwd, dir) {
135
+ try {
136
+ await execGit(['worktree', 'remove', '--force', dir], { cwd, timeout: 15_000 });
137
+ } catch {
138
+ // Not registered (already removed) or dir already gone — fall through to
139
+ // a plain rm so a half-created checkout never leaks disk either.
140
+ }
141
+ try {
142
+ await fsp.rm(dir, { recursive: true, force: true });
143
+ } catch {
144
+ /* best-effort */
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Create a linked worktree for one job, on a fresh branch checked out from
150
+ * the main tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd }`
151
+ * on success, or `{ ok: false, reason }` — the reason is always a short,
152
+ * human-readable string meant to be logged verbatim so a fallback to running
153
+ * in place is never silent.
154
+ *
155
+ * Never throws: every failure mode (not a repo, dirty base, cap reached, git
156
+ * error) is a normal, expected outcome for a project that hasn't opted into
157
+ * — or currently can't support — isolation, not an exceptional one.
158
+ */
159
+ async function createJobWorktree({ cwd, slug }) {
160
+ if (isWorktreeDisabled()) return { ok: false, reason: 'disabled via SM_JOB_WORKTREE_DISABLE=1' };
161
+ if (!cwd || typeof cwd !== 'string') return { ok: false, reason: 'no cwd provided' };
162
+ if (!slug || typeof slug !== 'string') return { ok: false, reason: 'no slug provided' };
163
+
164
+ if (!(await isGitRepo(cwd))) return { ok: false, reason: 'not a git repository' };
165
+
166
+ // A dirty base tree means the job may be depending on the human's own
167
+ // uncommitted WIP in `cwd` — a worktree only ever checks out committed
168
+ // HEAD content, so isolating into one here would silently drop that WIP
169
+ // from what the job sees. Falling back to running in place is strictly
170
+ // safer than guessing.
171
+ if (!(await isBaseTreeClean(cwd))) return { ok: false, reason: 'base working tree has uncommitted changes' };
172
+
173
+ if (activeWorktreeCount >= getMaxConcurrentWorktrees()) {
174
+ return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees()} concurrent)` };
175
+ }
176
+ // Reserve the slot SYNCHRONOUSLY (before any `await` below) so two jobs
177
+ // spawned in the same tick can't both pass the check above and both
178
+ // proceed — without this, the cap is a TOCTOU race: N concurrent callers
179
+ // all read the pre-increment count before either increments it. Released
180
+ // again below on any failure path so a failed create never permanently
181
+ // shrinks capacity.
182
+ activeWorktreeCount++;
183
+
184
+ const dir = worktreeDirFor(cwd, slug);
185
+ const branch = branchNameFor(slug);
186
+ try {
187
+ await fsp.mkdir(path.dirname(dir), { recursive: true });
188
+ // Defensive: a same-slug leftover from a prior crashed run (same slug can
189
+ // legitimately re-fire after a transient failure) must not collide with
190
+ // `git worktree add`'s own branch/path checks.
191
+ await removeWorktreeDir(cwd, dir);
192
+ try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* didn't exist */ }
193
+ await execGit(['worktree', 'add', '-b', branch, dir, 'HEAD'], { cwd, timeout: 30_000 });
194
+ } catch (e) {
195
+ activeWorktreeCount = Math.max(0, activeWorktreeCount - 1);
196
+ return { ok: false, reason: `git worktree add failed: ${(e && (e.stderrText || e.message)) || e}` };
197
+ }
198
+ return { ok: true, dir, branch, baseCwd: cwd };
199
+ }
200
+
201
+ /**
202
+ * Integrate a job's branch back into `cwd`'s current HEAD — fast-forward
203
+ * when possible, a real merge commit when the main tree advanced underneath
204
+ * (a sibling job merged first) since the worktree was created. Returns
205
+ * `{ ok: true, integrated: boolean, ...}` on success (integrated:false means
206
+ * the branch had no new commits — a legitimate no-op job, not a failure), or
207
+ * `{ ok: false, reason }` when neither ff-only nor a real merge could land —
208
+ * e.g. a genuine content conflict between two jobs that touched the same
209
+ * lines. On failure the branch is left un-merged and NOT deleted (see
210
+ * cleanupJobWorktree) so the work is recoverable, never silently discarded.
211
+ */
212
+ async function integrateJobBranch({ cwd, branch, slug }) {
213
+ if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
214
+ let branchHead;
215
+ try {
216
+ branchHead = (await execGit(['rev-parse', branch], { cwd, timeout: 10_000 })).trim();
217
+ } catch (e) {
218
+ return { ok: false, reason: `branch ${branch} not found: ${(e && (e.stderrText || e.message)) || e}` };
219
+ }
220
+ let mergeBase = '';
221
+ try {
222
+ mergeBase = (await execGit(['merge-base', 'HEAD', branch], { cwd, timeout: 10_000 })).trim();
223
+ } catch {
224
+ mergeBase = '';
225
+ }
226
+ if (mergeBase && mergeBase === branchHead) {
227
+ return { ok: true, integrated: false, reason: 'branch has no new commits' };
228
+ }
229
+
230
+ try {
231
+ await execGit(['merge', '--ff-only', branch], { cwd, timeout: 30_000 });
232
+ return { ok: true, integrated: true, fastForward: true };
233
+ } catch {
234
+ // Main tree advanced since the worktree branched (a sibling job merged
235
+ // first) — a real merge commit still lands the job's own commit(s).
236
+ }
237
+ try {
238
+ await execGit(['merge', '--no-ff', '--no-edit', '-m', `merge scheduler job ${slug || branch}`, branch], { cwd, timeout: 30_000 });
239
+ return { ok: true, integrated: true, mergeCommit: true };
240
+ } catch (e) {
241
+ // Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
242
+ try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
243
+ return { ok: false, reason: `merge failed (likely a real content conflict): ${(e && (e.stderrText || e.message)) || e}` };
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Tear down one job's worktree checkout after it has been integrated (or
249
+ * failed to integrate). Always removes the linked-worktree checkout (freeing
250
+ * its disk); only deletes the branch ref when `keepBranch` is falsy — a
251
+ * failed integration keeps the branch around for manual recovery per
252
+ * integrateJobBranch's contract above. Never throws.
253
+ */
254
+ async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
255
+ if (dir) await removeWorktreeDir(cwd, dir);
256
+ if (branch && !keepBranch) {
257
+ try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
258
+ }
259
+ try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
260
+ activeWorktreeCount = Math.max(0, activeWorktreeCount - 1);
261
+ }
262
+
263
+ /** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
264
+ function parseWorktreeListPorcelain(text) {
265
+ const entries = [];
266
+ let cur = null;
267
+ for (const line of String(text || '').split('\n')) {
268
+ if (line.startsWith('worktree ')) {
269
+ cur = { worktree: line.slice('worktree '.length).trim(), branch: null };
270
+ entries.push(cur);
271
+ } else if (line.startsWith('branch ') && cur) {
272
+ cur.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
273
+ }
274
+ }
275
+ return entries;
276
+ }
277
+
278
+ /**
279
+ * Boot reconciliation: a job worktree that survives a process crash (app
280
+ * killed mid-run, host reboot) leaks disk and a dangling branch forever
281
+ * unless something cleans it up — this is that something. For each known
282
+ * project cwd, lists every registered worktree, forcibly removes any that
283
+ * live under WORKTREE_ROOT (ours; never touches a worktree a human created
284
+ * for their own purposes), deletes its branch, and prunes stale registrations.
285
+ * Never throws — a project that isn't a git repo, or has no worktrees, is a
286
+ * silent no-op.
287
+ */
288
+ async function reconcileWorktreesOnBoot(cwds) {
289
+ const list = Array.isArray(cwds) ? cwds.filter(Boolean) : [];
290
+ for (const cwd of list) {
291
+ if (!(await isGitRepo(cwd))) continue;
292
+ let out = '';
293
+ try {
294
+ out = await execGit(['worktree', 'list', '--porcelain'], { cwd, timeout: 15_000 });
295
+ } catch {
296
+ continue;
297
+ }
298
+ const entries = parseWorktreeListPorcelain(out);
299
+ for (const entry of entries) {
300
+ if (!entry.worktree || !entry.worktree.startsWith(WORKTREE_ROOT + path.sep)) continue;
301
+ await removeWorktreeDir(cwd, entry.worktree);
302
+ if (entry.branch) {
303
+ try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
304
+ }
305
+ }
306
+ try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
307
+ }
308
+ }
309
+
310
+ module.exports = {
311
+ WORKTREE_ROOT,
312
+ DEFAULT_MAX_CONCURRENT_WORKTREES,
313
+ isWorktreeDisabled,
314
+ getMaxConcurrentWorktrees,
315
+ isGitRepo,
316
+ isBaseTreeClean,
317
+ worktreeDirFor,
318
+ branchNameFor,
319
+ createJobWorktree,
320
+ integrateJobBranch,
321
+ cleanupJobWorktree,
322
+ parseWorktreeListPorcelain,
323
+ reconcileWorktreesOnBoot,
324
+ // Test-only escape hatch for the in-memory concurrency counter.
325
+ _resetActiveWorktreeCountForTests(n = 0) { activeWorktreeCount = n; },
326
+ _getActiveWorktreeCountForTests() { return activeWorktreeCount; },
327
+ };
@@ -50,6 +50,7 @@ const VERDICT_LABELS = {
50
50
  pass_no_commit: 'PASS sentinel but no commit landed',
51
51
  pass_no_commit_already_shipped: 'PASS with no commit — deliverables already shipped',
52
52
  pass_no_commit_prior_run_verified: 'PASS with no commit — prior run of this slug already landed the work',
53
+ silent_no_op: 'no commit, clean tree — no evidence of work',
53
54
  };
54
55
 
55
56
  function humanVerdict(verdict) {
@@ -81,7 +82,7 @@ const PREVENTION_HINTS = {
81
82
  [FAILURE_CLASSES.NO_SENTINEL]:
82
83
  'End every run with a truthful `SCHEDULER_VERDICT: PASS`/`FAIL` sentinel as the literal last line, after the finish-protocol commit has landed.',
83
84
  [FAILURE_CLASSES.UNCOMMITTED]:
84
- 'Run `git add -A && git commit` as the final finish-protocol step before printing the verdict sentinel — never end a run with a dirty working tree.',
85
+ 'Stage the exact paths you created or modified — never a blanket/wildcard git-add of the whole tree, since the queue can run sibling jobs against this same working tree and a blanket add sweeps up their in-flight edits — and `git commit` as the final finish-protocol step before printing the verdict sentinel; never end a run with a dirty working tree.',
85
86
  [FAILURE_CLASSES.TRANSCRIPT_ERRORS]:
86
87
  'Recover or annotate every error within ~10 lines (e.g. `# expected/handled: <why>`) instead of leaving a bare Traceback near the end of the transcript.',
87
88
  [FAILURE_CLASSES.UNKNOWN]:
@@ -166,7 +167,7 @@ function classifyFailure({ verdict, logTail }) {
166
167
  }
167
168
  }
168
169
 
169
- if (verdict === 'uncommitted_changes') return FAILURE_CLASSES.UNCOMMITTED;
170
+ if (verdict === 'uncommitted_changes' || verdict === 'silent_no_op') return FAILURE_CLASSES.UNCOMMITTED;
170
171
  if (verdict === 'no_verdict_sentinel' || verdict === 'pass_no_commit') return FAILURE_CLASSES.NO_SENTINEL;
171
172
  if (verdict === 'transcript_errors' || verdict === 'verify_unavailable') return FAILURE_CLASSES.TRANSCRIPT_ERRORS;
172
173
  return FAILURE_CLASSES.UNKNOWN;