claude-code-session-manager 0.72.0 → 0.73.1
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/{AgentLibrary-CXw7Z5Iw.js → AgentLibrary-BKWnsq_l.js} +1 -1
- package/dist/assets/{History-D64U_aDf.js → History-C0X8QPtj.js} +1 -1
- package/dist/assets/{Hooks-BFVkM6QH.js → Hooks-CPlN3FZJ.js} +1 -1
- package/dist/assets/{HostBilko-BRERoCra.js → HostBilko-D89tQlRT.js} +1 -1
- package/dist/assets/{Library-C6shVsjv.js → Library-CPZItZXp.js} +1 -1
- package/dist/assets/{ListDetail-dXWSZuDV.js → ListDetail-BOPSKXb8.js} +1 -1
- package/dist/assets/{MarkdownEditor-D_akyPJ2.js → MarkdownEditor-COrqn6pG.js} +1 -1
- package/dist/assets/{McpServers-DxROkYGa.js → McpServers-CAJoECwZ.js} +1 -1
- package/dist/assets/{Memory-OqUyTRrr.js → Memory-k4nLGHes.js} +1 -1
- package/dist/assets/{Panel-Biw98xRO.js → Panel-5RBhh85Z.js} +1 -1
- package/dist/assets/{Permissions-CIdZOzK8.js → Permissions-saePXW0J.js} +1 -1
- package/dist/assets/{Plugins-Dt147wEE.js → Plugins-Dga7FHIF.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BUikIHf4.js → ProvenanceBadge-CHhfNxeY.js} +1 -1
- package/dist/assets/{SaveBar-BoHRDHlp.js → SaveBar-Ccf-qQGF.js} +1 -1
- package/dist/assets/{Scheduler-CipmAcy7.js → Scheduler-IP4JRLZM.js} +1 -1
- package/dist/assets/{ScopeSwitcher-CkCwUYU5.js → ScopeSwitcher-D6dgDA6U.js} +1 -1
- package/dist/assets/Settings-eu8Xc1Hi.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BuSuA3B7.js → SkillReferenceGraph-Bw-Hr4Sl.js} +1 -1
- package/dist/assets/{Skills-BM8w5BGV.js → Skills-BYLJsGLc.js} +1 -1
- package/dist/assets/{SystemPrompt-BWOdtfze.js → SystemPrompt-BGRCOhlY.js} +1 -1
- package/dist/assets/{TagLibrary-cn3JECC5.js → TagLibrary-COX_bxyZ.js} +1 -1
- package/dist/assets/{TiptapBody-BwoXKY8J.js → TiptapBody-S2ygespF.js} +1 -1
- package/dist/assets/{Toggle-BlNVxkq-.js → Toggle-Cbz7S9OY.js} +1 -1
- package/dist/assets/{index-BpzvONtX.js → index-BoVqP3BD.js} +679 -679
- package/dist/assets/{index-DDpA9yBe.css → index-D9C8N3pn.css} +1 -1
- package/dist/assets/{settingsSchema-DePBZnaw.js → settingsSchema-PIoojYg_.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +10 -1
- package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +125 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +156 -0
- package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +65 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/index.cjs +6 -0
- package/src/main/ipcSchemas.cjs +35 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +193 -0
- package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +136 -0
- package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +130 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +115 -0
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +79 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +400 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +126 -0
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +82 -0
- package/src/main/lib/epicSpawnCwd.cjs +95 -0
- package/src/main/lib/epicWorktreeBoot.cjs +49 -0
- package/src/main/lib/epicWorktreeMerge.cjs +63 -0
- package/src/main/lib/epicWorktreeMint.cjs +54 -0
- package/src/main/lib/epicWorktreeProjectConfig.cjs +102 -0
- package/src/main/lib/gitWorktree.cjs +446 -0
- package/src/main/lib/jobWorktree.cjs +36 -293
- package/src/main/lib/promptSessionSchema.cjs +15 -0
- package/src/main/lib/queueStore.cjs +18 -0
- package/src/main/lib/terminalRunOutcome.cjs +14 -2
- package/src/main/pty.cjs +11 -1
- package/src/main/queueOps.cjs +40 -12
- package/src/main/scheduler.cjs +14 -0
- package/src/preload/api.d.ts +59 -0
- package/src/preload/index.cjs +22 -0
- package/dist/assets/Settings-CmU_7z2e.js +0 -3
|
@@ -2,28 +2,28 @@
|
|
|
2
2
|
* jobWorktree.cjs — per-job `git worktree` isolation for concurrent scheduler
|
|
3
3
|
* runs (PRD 994).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* from every sibling job and from the human's own interactive session in the
|
|
13
|
-
* same repo.
|
|
5
|
+
* As of PRD 1032 this is a thin, byte-for-byte-compatible wrapper over the
|
|
6
|
+
* generalized primitives in gitWorktree.cjs (kind: 'job') — every export
|
|
7
|
+
* below keeps its original call shape so scheduler.cjs and any other
|
|
8
|
+
* existing caller need zero changes. New callers wanting the same isolation
|
|
9
|
+
* for a different kind of unit of work (e.g. an Epic session — kind: 'epic')
|
|
10
|
+
* should use gitWorktree.cjs's `create/integrate/cleanupEpicWorktree`
|
|
11
|
+
* directly rather than adding a second copy here.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
13
|
+
* Problem this solves: once the queue genuinely runs several jobs wide in
|
|
14
|
+
* one project, N headless `claude -p` processes edit the SAME working tree
|
|
15
|
+
* simultaneously. Observed live on 2026-08-02 with only two concurrent
|
|
16
|
+
* writers: a full test run failed on another job's half-written renderer
|
|
17
|
+
* files, a commit had to be hand-staged to avoid sweeping a sibling job's
|
|
18
|
+
* WIP, and a commit was silently rewritten to a new SHA by a concurrent
|
|
19
|
+
* rebase. This module gives each job its own linked worktree — its edits,
|
|
20
|
+
* its test runs, its commit — isolated from every sibling job and from the
|
|
21
|
+
* human's own interactive session in the same repo.
|
|
16
22
|
*
|
|
17
|
-
*
|
|
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.
|
|
23
|
+
* ---------- the ops-root hazard (read before touching cwd plumbing) ----------
|
|
25
24
|
*
|
|
26
|
-
*
|
|
25
|
+
* See gitWorktree.cjs's own header comment for the full writeup — the rule
|
|
26
|
+
* carries forward unchanged: this module never changes `job.cwd`. It hands
|
|
27
27
|
* back a SEPARATE `execCwd` (the worktree directory) that scheduler.cjs uses
|
|
28
28
|
* ONLY as the spawned child process's `cwd` spawn option. Every PRD-path
|
|
29
29
|
* resolution, queue read/write, and run-log path in scheduler.cjs keeps using
|
|
@@ -46,282 +46,25 @@
|
|
|
46
46
|
*/
|
|
47
47
|
'use strict';
|
|
48
48
|
|
|
49
|
-
const
|
|
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
|
-
}
|
|
49
|
+
const gitWorktree = require('./gitWorktree.cjs');
|
|
277
50
|
|
|
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
|
-
}
|
|
51
|
+
const KIND = 'job';
|
|
309
52
|
|
|
310
53
|
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,
|
|
54
|
+
WORKTREE_ROOT: gitWorktree.worktreeRootFor(KIND),
|
|
55
|
+
DEFAULT_MAX_CONCURRENT_WORKTREES: gitWorktree.KIND_CONFIG[KIND].defaultMax,
|
|
56
|
+
isWorktreeDisabled: () => gitWorktree.isWorktreeDisabled(KIND),
|
|
57
|
+
getMaxConcurrentWorktrees: () => gitWorktree.getMaxConcurrentWorktrees(KIND),
|
|
58
|
+
isGitRepo: gitWorktree.isGitRepo,
|
|
59
|
+
isBaseTreeClean: gitWorktree.isBaseTreeClean,
|
|
60
|
+
worktreeDirFor: (cwd, slug) => gitWorktree.worktreeDirFor(KIND, cwd, slug),
|
|
61
|
+
branchNameFor: (slug) => gitWorktree.branchNameFor(KIND, slug),
|
|
62
|
+
createJobWorktree: gitWorktree.createJobWorktree,
|
|
63
|
+
integrateJobBranch: gitWorktree.integrateJobBranch,
|
|
64
|
+
cleanupJobWorktree: gitWorktree.cleanupJobWorktree,
|
|
65
|
+
parseWorktreeListPorcelain: gitWorktree.parseWorktreeListPorcelain,
|
|
66
|
+
reconcileWorktreesOnBoot: (cwds) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND }),
|
|
324
67
|
// Test-only escape hatch for the in-memory concurrency counter.
|
|
325
|
-
_resetActiveWorktreeCountForTests(n = 0) {
|
|
326
|
-
_getActiveWorktreeCountForTests() { return
|
|
68
|
+
_resetActiveWorktreeCountForTests(n = 0) { gitWorktree._resetActiveWorktreeCountForTests(KIND, n); },
|
|
69
|
+
_getActiveWorktreeCountForTests() { return gitWorktree._getActiveWorktreeCountForTests(KIND); },
|
|
327
70
|
};
|
|
@@ -55,6 +55,17 @@ const EpicIntakeSectionSchema = z.object({
|
|
|
55
55
|
source: z.string().optional(),
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
// Mirrors PromptSession's `worktree` field (src/renderer/state/
|
|
59
|
+
// promptSessions.ts) — this Epic's isolated `git worktree` checkout, when
|
|
60
|
+
// one exists. `baseCwd` is always the Epic's real owning-project cwd, never
|
|
61
|
+
// the worktree dir itself (see gitWorktree.cjs's "ops-root hazard" comment).
|
|
62
|
+
const EpicWorktreeSchema = z.object({
|
|
63
|
+
dir: z.string(),
|
|
64
|
+
branch: z.string(),
|
|
65
|
+
baseCwd: z.string(),
|
|
66
|
+
status: z.enum(['active', 'needs_merge_resolution', 'merged', 'disabled']),
|
|
67
|
+
});
|
|
68
|
+
|
|
58
69
|
// Mirrors PromptSession (src/renderer/state/promptSessions.ts:29-76).
|
|
59
70
|
const PromptSessionSchema = z.object({
|
|
60
71
|
id: z.string(),
|
|
@@ -73,6 +84,9 @@ const PromptSessionSchema = z.object({
|
|
|
73
84
|
// rendering the flat `openingPrompt` as a single block (see
|
|
74
85
|
// ChatTranscriptTurn.tsx's EpicIntakeCard).
|
|
75
86
|
sections: z.array(EpicIntakeSectionSchema).optional(),
|
|
87
|
+
// Passthrough only as of PRD 1032 — nothing populates this yet; the next
|
|
88
|
+
// PRD in this chain wires Epic worktree minting.
|
|
89
|
+
worktree: EpicWorktreeSchema.optional(),
|
|
76
90
|
});
|
|
77
91
|
|
|
78
92
|
/**
|
|
@@ -97,5 +111,6 @@ module.exports = {
|
|
|
97
111
|
EpicSourceSchema,
|
|
98
112
|
EpicTagSchema,
|
|
99
113
|
EpicIntakeSectionSchema,
|
|
114
|
+
EpicWorktreeSchema,
|
|
100
115
|
assertValidPromptSession,
|
|
101
116
|
};
|
|
@@ -40,8 +40,26 @@ const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager',
|
|
|
40
40
|
const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
|
|
41
41
|
const STATE_SUBPATH = ['session-manager-operations', 'scheduler', 'state'];
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* A project's `session-manager-operations/scheduler/state/` directory.
|
|
45
|
+
*
|
|
46
|
+
* The cwd MUST be absolute. `path.join()` happily accepts a relative one and
|
|
47
|
+
* silently resolves it against `process.cwd()`, which turns a bad caller into
|
|
48
|
+
* a whole ops root materialized in the wrong place — observed live on
|
|
49
|
+
* 2026-08-13 as five stray `session-manager-operations/scheduler/state/
|
|
50
|
+
* queue.json` trees under source directories (`src/main/lib/`,
|
|
51
|
+
* `src/renderer/state/`, …), each created by a caller that passed a
|
|
52
|
+
* repo-relative path like `src/main/lib`. That is the same ops-root hazard
|
|
53
|
+
* gitWorktree.cjs's header comment describes, reached from the other end: not
|
|
54
|
+
* a worktree dir substituted for a project cwd, but a relative fragment
|
|
55
|
+
* accepted as one. Fail closed — a caller that cannot name an absolute
|
|
56
|
+
* project cwd has no business writing that project's queue.
|
|
57
|
+
*/
|
|
43
58
|
function projectStateDir(cwd) {
|
|
44
59
|
if (!cwd || typeof cwd !== 'string') throw new Error('projectStateDir: cwd is required');
|
|
60
|
+
if (!path.isAbsolute(cwd)) {
|
|
61
|
+
throw new Error(`projectStateDir: cwd must be an absolute path, got "${cwd}"`);
|
|
62
|
+
}
|
|
45
63
|
return path.join(cwd, ...STATE_SUBPATH);
|
|
46
64
|
}
|
|
47
65
|
|
|
@@ -67,7 +67,16 @@ function latestTerminalOutcomeForSlug(slug, { runsDir, fsImpl = fs } = {}) {
|
|
|
67
67
|
try {
|
|
68
68
|
meta = JSON.parse(fsImpl.readFileSync(metaPath, 'utf8'));
|
|
69
69
|
} catch {
|
|
70
|
-
|
|
70
|
+
// An unreadable/half-written sidecar in THIS dir says nothing about the
|
|
71
|
+
// older ones — fall through to the next-newest candidate rather than
|
|
72
|
+
// abandoning the scan. Returning null here made the caller treat the
|
|
73
|
+
// slug as having no terminal run at all, which resurrects and re-runs
|
|
74
|
+
// an already-completed PRD (the anti-resurrection guard silently
|
|
75
|
+
// switching itself off on the one input it exists to survive). This is
|
|
76
|
+
// also what makes MAX_DIRS_SCANNED mean what its name says: before,
|
|
77
|
+
// every branch below returned on the FIRST candidate, so the newest dir
|
|
78
|
+
// was the only one ever consulted.
|
|
79
|
+
continue;
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
// meta.json's finishedAt is an epoch-ms number; callers (e.g. the
|
|
@@ -86,7 +95,10 @@ function latestTerminalOutcomeForSlug(slug, { runsDir, fsImpl = fs } = {}) {
|
|
|
86
95
|
try {
|
|
87
96
|
verdicts = JSON.parse(fsImpl.readFileSync(verdictsPath, 'utf8'));
|
|
88
97
|
} catch {
|
|
89
|
-
|
|
98
|
+
// Same reasoning as the meta read above: a run whose verdicts sidecar
|
|
99
|
+
// never landed (killed between the two writes) must not erase an older,
|
|
100
|
+
// complete terminal record for this slug.
|
|
101
|
+
continue;
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
if (COMPLETED_EQUIVALENT_VERDICTS.has(verdicts.verdict)) {
|
package/src/main/pty.cjs
CHANGED
|
@@ -21,6 +21,7 @@ const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
|
|
|
21
21
|
const { checkInsideHome } = require('./lib/insideHome.cjs');
|
|
22
22
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
23
23
|
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
24
|
+
const { resolveEpicSpawnCwd } = require('./lib/epicSpawnCwd.cjs');
|
|
24
25
|
|
|
25
26
|
// Absolute path to the installed package root (src/main/ -> ../../), shown in
|
|
26
27
|
// the remediation message so the user can cd there and rebuild.
|
|
@@ -159,6 +160,15 @@ class PtyManager {
|
|
|
159
160
|
|
|
160
161
|
const shell = process.env.SHELL || '/bin/bash';
|
|
161
162
|
console.log('[pty] spawning shell', shell);
|
|
163
|
+
// Tab ID = claudeSessionId (see CLAUDE.md), so for an Epic-attached tab
|
|
164
|
+
// this resolves the Epic's own record in `cwd`'s active-index.json and
|
|
165
|
+
// returns its isolated worktree dir when one exists — `cwd` itself is
|
|
166
|
+
// NEVER repointed; only this actual PTY spawn option is. See
|
|
167
|
+
// epicSpawnCwd.cjs's header comment (the ops-root hazard) for why.
|
|
168
|
+
const spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: tabId });
|
|
169
|
+
if (spawnCwd !== cwd) {
|
|
170
|
+
console.log(`[pty] tabId=${tabId} isolated in worktree ${spawnCwd}`);
|
|
171
|
+
}
|
|
162
172
|
let proc;
|
|
163
173
|
try {
|
|
164
174
|
// Interactive login shell so aliases / nvm / PATH resolve correctly.
|
|
@@ -166,7 +176,7 @@ class PtyManager {
|
|
|
166
176
|
name: 'xterm-256color',
|
|
167
177
|
cols,
|
|
168
178
|
rows,
|
|
169
|
-
cwd,
|
|
179
|
+
cwd: spawnCwd,
|
|
170
180
|
env,
|
|
171
181
|
});
|
|
172
182
|
} catch (err) {
|
package/src/main/queueOps.cjs
CHANGED
|
@@ -287,19 +287,44 @@ async function lintAll() {
|
|
|
287
287
|
// ────────────────────────────────────────────── archive
|
|
288
288
|
|
|
289
289
|
/**
|
|
290
|
-
* Move
|
|
290
|
+
* Move <srcDir>/<slug>.md → <srcDir>/../prds-archived/<ISO>/<slug>.md.
|
|
291
291
|
* Atomic rename. Path containment checks both source and destination.
|
|
292
292
|
* Never deletes — always reversible from prds-archived/.
|
|
293
293
|
*/
|
|
294
|
-
|
|
294
|
+
/**
|
|
295
|
+
* The archive directory a PRD found in `srcDir` belongs in: `prds-archived/`
|
|
296
|
+
* SIBLING to its own `prds/`, which for a per-project PRD is
|
|
297
|
+
* `<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds-archived/`.
|
|
298
|
+
*
|
|
299
|
+
* PRD sources are per-project and per-Epic (CLAUDE.md's TAB → EPIC → PRD
|
|
300
|
+
* model); the machine-level `~/.claude/session-manager/scheduled-plans/` tree
|
|
301
|
+
* holds only run logs and PRD_AUTHORING.md. Archiving therefore has to follow
|
|
302
|
+
* the source, not a fixed global constant — otherwise a manual archive yanks a
|
|
303
|
+
* PRD out of its Epic and strands it in the retired global tree, splitting one
|
|
304
|
+
* Epic's history across two locations (observed 2026-08-13 with
|
|
305
|
+
* 1035-epic-worktree-ui-surfacing, whose three chain siblings sat in the Epic's
|
|
306
|
+
* own prds-archived/ while it landed under scheduled-plans/). A PRD still in
|
|
307
|
+
* the legacy flat `PRDS_DIR` resolves to `ROOT/prds-archived` here, which is
|
|
308
|
+
* exactly the old behavior — so that case is unchanged.
|
|
309
|
+
*/
|
|
310
|
+
function archiveDirForSource(srcDir, ts) {
|
|
311
|
+
return path.join(path.dirname(srcDir), 'prds-archived', ts);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function archiveOne(slug, ts) {
|
|
295
315
|
if (!SLUG_RE.test(slug)) return { ok: false, slug, error: 'invalid slug' };
|
|
296
316
|
const srcDir = await findPrdDir(slug);
|
|
297
317
|
if (!srcDir) return { ok: false, slug, error: 'not found in any PRDs dir' };
|
|
298
318
|
const src = path.resolve(path.join(srcDir, `${slug}.md`));
|
|
299
319
|
if (!src.startsWith(srcDir + path.sep)) return { ok: false, slug, error: 'path escape (src)' };
|
|
320
|
+
// Destination is derived per-slug (not once for the whole batch): a single
|
|
321
|
+
// archive call can span several projects/Epics, and each PRD has to land
|
|
322
|
+
// beside its own source.
|
|
323
|
+
const archiveDir = archiveDirForSource(srcDir, ts);
|
|
300
324
|
const dst = path.resolve(path.join(archiveDir, `${slug}.md`));
|
|
301
|
-
if (!dst.startsWith(
|
|
325
|
+
if (!dst.startsWith(path.resolve(archiveDir) + path.sep)) return { ok: false, slug, error: 'path escape (dst)' };
|
|
302
326
|
try {
|
|
327
|
+
await fsp.mkdir(archiveDir, { recursive: true });
|
|
303
328
|
await fsp.rename(src, dst);
|
|
304
329
|
return { ok: true, slug, archivedTo: dst };
|
|
305
330
|
} catch (e) {
|
|
@@ -321,17 +346,14 @@ async function archiveMany(slugs) {
|
|
|
321
346
|
if (!Array.isArray(slugs) || slugs.length === 0) {
|
|
322
347
|
return { ok: true, archived: 0, archivedTo: null, results: [] };
|
|
323
348
|
}
|
|
349
|
+
// One timestamp for the whole batch (so a multi-slug archive reads as one
|
|
350
|
+
// event), but each slug's destination DIRECTORY is resolved from its own
|
|
351
|
+
// source inside archiveOne — see archiveDirForSource. mkdir moved in there
|
|
352
|
+
// for the same reason: there is no single batch-wide dir to pre-create.
|
|
324
353
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
325
|
-
const archiveDir = path.join(PRDS_ARCHIVE_DIR, ts);
|
|
326
|
-
try {
|
|
327
|
-
await fsp.mkdir(archiveDir, { recursive: true });
|
|
328
|
-
} catch (e) {
|
|
329
|
-
logs.writeLine({ level: 'error', scope: 'queueOps', message: 'archiveMany: mkdir failed', meta: { error: e?.message } });
|
|
330
|
-
return { ok: false, archived: 0, archivedTo: null, results: [], error: e?.message ?? 'mkdir failed' };
|
|
331
|
-
}
|
|
332
354
|
const results = [];
|
|
333
355
|
for (const slug of slugs) {
|
|
334
|
-
results.push(await archiveOne(slug,
|
|
356
|
+
results.push(await archiveOne(slug, ts));
|
|
335
357
|
}
|
|
336
358
|
const archived = results.filter((r) => r.ok).length;
|
|
337
359
|
const archivedSlugs = results.filter((r) => r.ok).map((r) => r.slug);
|
|
@@ -340,7 +362,12 @@ async function archiveMany(slugs) {
|
|
|
340
362
|
logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'archiveMany: retireCompletedSlugs failed', meta: { error: e?.message } });
|
|
341
363
|
});
|
|
342
364
|
}
|
|
343
|
-
|
|
365
|
+
// archivedTo reports where the batch actually landed. With per-source
|
|
366
|
+
// destinations that is only well-defined when every move agreed on one dir
|
|
367
|
+
// (the common single-project case); a genuinely cross-project batch reports
|
|
368
|
+
// null rather than naming one project's dir as if it covered them all.
|
|
369
|
+
const dirs = new Set(results.filter((r) => r.ok).map((r) => path.dirname(r.archivedTo)));
|
|
370
|
+
return { ok: true, archived, archivedTo: dirs.size === 1 ? [...dirs][0] : null, results };
|
|
344
371
|
}
|
|
345
372
|
|
|
346
373
|
// ────────────────────────────────────────────── auto-archive completed PRDs
|
|
@@ -597,6 +624,7 @@ module.exports = {
|
|
|
597
624
|
retagMany,
|
|
598
625
|
PRDS_DIR,
|
|
599
626
|
PRDS_ARCHIVE_DIR,
|
|
627
|
+
archiveDirForSource,
|
|
600
628
|
candidatePrdsDirs,
|
|
601
629
|
findPrdDir,
|
|
602
630
|
};
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -124,6 +124,7 @@ function resolveOriginSessionId(cwd, epicId) {
|
|
|
124
124
|
}
|
|
125
125
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
126
126
|
const jobWorktree = require('./lib/jobWorktree.cjs');
|
|
127
|
+
const { reconcileEpicWorktreesOnBoot } = require('./lib/epicWorktreeBoot.cjs');
|
|
127
128
|
const queueStore = require('./lib/queueStore.cjs');
|
|
128
129
|
const { splitFrontmatter, parsePrdFile, serializePrdFile } = require('./lib/prdFrontmatter.cjs');
|
|
129
130
|
const { migratePrds, consolidateFlatPrds, legacyAdoptExistingPrds } = require('./lib/prdMigration.cjs');
|
|
@@ -4967,6 +4968,19 @@ async function init() {
|
|
|
4967
4968
|
console.error('[scheduler] boot worktree reconciliation failed', e?.message);
|
|
4968
4969
|
}
|
|
4969
4970
|
|
|
4971
|
+
// Epic worktree boot reconciliation (PRD 1033): same sweep, epic kind —
|
|
4972
|
+
// reaps only a worktree whose owning Epic is no longer 'active' in that
|
|
4973
|
+
// project's own active-index.json (see epicWorktreeBoot.cjs). Reuses the
|
|
4974
|
+
// same known-cwd set as the job sweep above; best-effort, never blocks
|
|
4975
|
+
// the rest of boot.
|
|
4976
|
+
try {
|
|
4977
|
+
const worktreeCwds = new Set(bootSnap.jobs.map((j) => j.cwd).filter(Boolean));
|
|
4978
|
+
worktreeCwds.add(DEFAULT_PROJECT_CWD);
|
|
4979
|
+
await reconcileEpicWorktreesOnBoot([...worktreeCwds]);
|
|
4980
|
+
} catch (e) {
|
|
4981
|
+
console.error('[scheduler] boot epic-worktree reconciliation failed', e?.message);
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4970
4984
|
const { immediate: immediateSlugs, deferred: deferredSlugs } = partitionBootOrphans(bootSnap.jobs);
|
|
4971
4985
|
const bootOutcomes = new Map();
|
|
4972
4986
|
for (const j of bootSnap.jobs) {
|