claude-code-session-manager 0.76.0 → 0.77.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/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/config.cjs +4 -1
- package/src/main/index.cjs +8 -1
- package/src/main/ipcSchemas.cjs +51 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +118 -2
- package/src/preload/index.cjs +7 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
|
@@ -77,6 +77,12 @@ const { execFile } = require('node:child_process');
|
|
|
77
77
|
// The job cap's assumptions (few, short-lived, quickly-reclaimed checkouts)
|
|
78
78
|
// simply don't transfer, so the epic kind gets a separately-configurable,
|
|
79
79
|
// much higher ceiling rather than sharing the job kind's default.
|
|
80
|
+
// staleSweepAgeEnv/defaultStaleSweepAgeMs back the on-disk orphan sweep
|
|
81
|
+
// (reconcileWorktreesOnBoot's root-level pass, below) — how old an on-disk
|
|
82
|
+
// checkout must be before it's reclaimed even though no caller-supplied cwd
|
|
83
|
+
// vouches for it. The job default (24h) matches a job's whole lifetime being
|
|
84
|
+
// minutes; the epic default (7d) matches an Epic legitimately living for
|
|
85
|
+
// days, same rationale as defaultMax above.
|
|
80
86
|
const KIND_CONFIG = {
|
|
81
87
|
job: {
|
|
82
88
|
root: path.join(os.tmpdir(), 'session-manager-job-worktrees'),
|
|
@@ -84,6 +90,8 @@ const KIND_CONFIG = {
|
|
|
84
90
|
disableEnv: 'SM_JOB_WORKTREE_DISABLE',
|
|
85
91
|
maxEnv: 'SM_JOB_WORKTREE_MAX',
|
|
86
92
|
defaultMax: 4,
|
|
93
|
+
staleSweepAgeEnv: 'SM_JOB_WORKTREE_STALE_MS',
|
|
94
|
+
defaultStaleSweepAgeMs: 24 * 60 * 60 * 1000,
|
|
87
95
|
},
|
|
88
96
|
epic: {
|
|
89
97
|
root: path.join(os.tmpdir(), 'session-manager-epic-worktrees'),
|
|
@@ -91,6 +99,8 @@ const KIND_CONFIG = {
|
|
|
91
99
|
disableEnv: 'SM_EPIC_WORKTREE_DISABLE',
|
|
92
100
|
maxEnv: 'SM_EPIC_WORKTREE_MAX',
|
|
93
101
|
defaultMax: 50,
|
|
102
|
+
staleSweepAgeEnv: 'SM_EPIC_WORKTREE_STALE_MS',
|
|
103
|
+
defaultStaleSweepAgeMs: 7 * 24 * 60 * 60 * 1000,
|
|
94
104
|
},
|
|
95
105
|
};
|
|
96
106
|
|
|
@@ -121,10 +131,40 @@ function isWorktreeDisabled(kind, cwd) {
|
|
|
121
131
|
return false;
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
/**
|
|
135
|
+
* The job-kind default alone is floored at sessionSlots' machine-wide `claude
|
|
136
|
+
* -p` cap (lazily required to avoid a load-order cycle): a job that fails to
|
|
137
|
+
* get session-slot capacity already waits for a slot to free up rather than
|
|
138
|
+
* degrading, so the worktree cap below it must never be tighter than the
|
|
139
|
+
* slot pool — otherwise raising slots to 10 in the UI silently strands 6 jobs
|
|
140
|
+
* without isolation, running in-place instead of waiting (the exact bug this
|
|
141
|
+
* PRD fixes). An explicit SM_JOB_WORKTREE_MAX still wins verbatim — a
|
|
142
|
+
* deliberate operator override, not a value this floor should touch.
|
|
143
|
+
*/
|
|
124
144
|
function getMaxConcurrentWorktrees(kind) {
|
|
125
145
|
const cfg = configFor(kind);
|
|
126
146
|
const raw = Number(process.env[cfg.maxEnv]);
|
|
127
|
-
|
|
147
|
+
if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
|
|
148
|
+
if (kind === 'job') {
|
|
149
|
+
const sessionSlots = require('./sessionSlots.cjs');
|
|
150
|
+
return Math.max(cfg.defaultMax, sessionSlots.totalSlots());
|
|
151
|
+
}
|
|
152
|
+
return cfg.defaultMax;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getStaleSweepAgeMs(kind) {
|
|
156
|
+
const cfg = configFor(kind);
|
|
157
|
+
const raw = Number(process.env[cfg.staleSweepAgeEnv]);
|
|
158
|
+
return Number.isFinite(raw) && raw > 0 ? raw : cfg.defaultStaleSweepAgeMs;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when resolved path `p` is `root` itself or nested under it — the one
|
|
162
|
+
* gate every deletion routine below must pass before touching disk, so a
|
|
163
|
+
* path-traversal-shaped key can never escape this kind's own tmpdir root. */
|
|
164
|
+
function isUnderRoot(p, root) {
|
|
165
|
+
const resolvedRoot = path.resolve(root);
|
|
166
|
+
const resolvedP = path.resolve(p);
|
|
167
|
+
return resolvedP === resolvedRoot || resolvedP.startsWith(resolvedRoot + path.sep);
|
|
128
168
|
}
|
|
129
169
|
|
|
130
170
|
// In-memory count of worktrees currently checked out by THIS process, kept
|
|
@@ -139,6 +179,11 @@ function execGit(args, { cwd, timeout = 20_000 } = {}) {
|
|
|
139
179
|
execFile('git', args, { cwd, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
140
180
|
if (err) {
|
|
141
181
|
err.stderrText = stderr;
|
|
182
|
+
// Some git subcommands (e.g. `diff --no-index`) exit non-zero to mean
|
|
183
|
+
// "found a difference", not "failed" — stdout still carries the real
|
|
184
|
+
// result in that case, so callers that need it can recover it off
|
|
185
|
+
// the rejected error rather than losing it.
|
|
186
|
+
err.stdoutText = stdout;
|
|
142
187
|
reject(err);
|
|
143
188
|
return;
|
|
144
189
|
}
|
|
@@ -190,7 +235,16 @@ function branchNameFor(kind, key) {
|
|
|
190
235
|
return `${configFor(kind).branchPrefix}${key}`;
|
|
191
236
|
}
|
|
192
237
|
|
|
193
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Best-effort teardown of one worktree checkout — never throws. Also rmdirs
|
|
240
|
+
* `dir`'s parent (the per-cwd hash directory `worktreeDirFor` nests every
|
|
241
|
+
* checkout under) when it's left empty, so a torn-down checkout doesn't
|
|
242
|
+
* leave a permanent empty leftover behind (RCA: PRD 1108 — 1125 of 1133
|
|
243
|
+
* on-disk hash dirs under the job root were exactly this, never reclaimed).
|
|
244
|
+
* Uses non-recursive `fs.rmdir`, which only succeeds on a truly empty
|
|
245
|
+
* directory, so a sibling checkout still living under the same hash dir can
|
|
246
|
+
* never be destroyed by this call.
|
|
247
|
+
*/
|
|
194
248
|
async function removeWorktreeDir(cwd, dir) {
|
|
195
249
|
try {
|
|
196
250
|
await execGit(['worktree', 'remove', '--force', dir], { cwd, timeout: 15_000 });
|
|
@@ -203,18 +257,238 @@ async function removeWorktreeDir(cwd, dir) {
|
|
|
203
257
|
} catch {
|
|
204
258
|
/* best-effort */
|
|
205
259
|
}
|
|
260
|
+
const parentDir = path.dirname(dir);
|
|
261
|
+
// Guard against ever rmdir-ing os.tmpdir() itself — every real caller's
|
|
262
|
+
// `dir` is `<kind root>/<hash>/<key>`, so `parentDir` is always the hash
|
|
263
|
+
// dir, never the tmpdir root, but this keeps the guarantee explicit rather
|
|
264
|
+
// than relying solely on call-site discipline.
|
|
265
|
+
if (parentDir === os.tmpdir()) return;
|
|
266
|
+
try {
|
|
267
|
+
const remaining = await fsp.readdir(parentDir);
|
|
268
|
+
if (remaining.length === 0) await fsp.rmdir(parentDir);
|
|
269
|
+
} catch {
|
|
270
|
+
// Not empty (a sibling checkout survives), doesn't exist, or a race —
|
|
271
|
+
// all fine to ignore.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Reads a linked worktree checkout's own `.git` file (a plain text pointer,
|
|
277
|
+
* not a directory, for any `git worktree add` checkout) to recover the path
|
|
278
|
+
* of the MAIN tree it's registered against, without needing a caller-
|
|
279
|
+
* supplied cwd. Content looks like `gitdir: /path/to/main/.git/worktrees/
|
|
280
|
+
* <name>`; the main tree is that path with the trailing `/.git/worktrees/
|
|
281
|
+
* <name>` segment stripped off.
|
|
282
|
+
*
|
|
283
|
+
* Round-trip verified before being trusted, mirroring the same defense
|
|
284
|
+
* `scripts/lib/activeSessions.cjs`'s `worktreeMainRootOf` already applies: a
|
|
285
|
+
* `.git` pointer file is a plain text file, so its content must not be
|
|
286
|
+
* trusted to redirect `git worktree remove --force` at an arbitrary path
|
|
287
|
+
* without the candidate main tree's OWN admin dir
|
|
288
|
+
* (`<main>/.git/worktrees/<name>/gitdir`) agreeing this checkout is really
|
|
289
|
+
* one of its registered worktrees. Returns null on any read/parse/mismatch —
|
|
290
|
+
* never throws.
|
|
291
|
+
*/
|
|
292
|
+
async function mainTreeFromWorktreeGitFile(dir) {
|
|
293
|
+
try {
|
|
294
|
+
const gitFile = path.join(dir, '.git');
|
|
295
|
+
const content = await fsp.readFile(gitFile, 'utf8');
|
|
296
|
+
const match = content.match(/^gitdir:\s*(.+)$/m);
|
|
297
|
+
if (!match) return null;
|
|
298
|
+
const gitdir = match[1].trim();
|
|
299
|
+
const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`;
|
|
300
|
+
const idx = gitdir.lastIndexOf(marker);
|
|
301
|
+
if (idx === -1) return null;
|
|
302
|
+
const candidateMain = gitdir.slice(0, idx);
|
|
303
|
+
const worktreeName = gitdir.slice(idx + marker.length).split(path.sep)[0];
|
|
304
|
+
if (!worktreeName) return null;
|
|
305
|
+
|
|
306
|
+
const adminGitdirFile = path.join(candidateMain, '.git', 'worktrees', worktreeName, 'gitdir');
|
|
307
|
+
const backRef = (await fsp.readFile(adminGitdirFile, 'utf8')).trim();
|
|
308
|
+
if (path.resolve(backRef) !== path.resolve(gitFile)) return null;
|
|
309
|
+
|
|
310
|
+
return candidateMain;
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Tear down one orphaned on-disk checkout that `sweepStaleWorktreeCheckouts`
|
|
318
|
+
* found with no caller-supplied cwd to run `git worktree remove` against.
|
|
319
|
+
* Recovers the main tree from the checkout's own `.git` file and removes the
|
|
320
|
+
* registration there first, so the main repo's `git worktree list` is never
|
|
321
|
+
* left with a stale entry pointing at a directory we just deleted. Falls back
|
|
322
|
+
* to a plain `rm -rf` (plus a best-effort `worktree prune` run from inside
|
|
323
|
+
* the checkout itself, in case git can still resolve it) when that lookup
|
|
324
|
+
* fails. Never throws.
|
|
325
|
+
*/
|
|
326
|
+
async function teardownOrphanedCheckout(dir) {
|
|
327
|
+
const mainTree = await mainTreeFromWorktreeGitFile(dir);
|
|
328
|
+
if (mainTree) {
|
|
329
|
+
try {
|
|
330
|
+
await execGit(['worktree', 'remove', '--force', dir], { cwd: mainTree, timeout: 15_000 });
|
|
331
|
+
} catch {
|
|
332
|
+
// Registration already gone, or the main tree no longer agrees this is
|
|
333
|
+
// one of its worktrees — fall through to the plain rm below either way.
|
|
334
|
+
}
|
|
335
|
+
try { await fsp.rm(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
336
|
+
try { await execGit(['worktree', 'prune'], { cwd: mainTree, timeout: 10_000 }); } catch { /* best-effort */ }
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
try { await execGit(['worktree', 'prune'], { cwd: dir, timeout: 10_000 }); } catch { /* best-effort */ }
|
|
340
|
+
try { await fsp.rm(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Sweeps this kind's ENTIRE on-disk worktree root directly — not limited to
|
|
345
|
+
* any caller-supplied list of cwds — for checkouts belonging to a project
|
|
346
|
+
* `reconcileWorktreesOnBoot` was never handed a cwd for (a project removed
|
|
347
|
+
* from Session Manager, or a stray leftover from before this sweep existed).
|
|
348
|
+
* The per-cwd loop in `reconcileWorktreesOnBoot` can only clean what it's
|
|
349
|
+
* told about; this covers the rest.
|
|
350
|
+
*
|
|
351
|
+
* A checkout younger than `staleAgeMs` (mtime-based) is NEVER touched — this
|
|
352
|
+
* is what keeps an in-progress job or Epic safe from a concurrent boot sweep.
|
|
353
|
+
* This intentionally does NOT accept an `isLive` predicate the way
|
|
354
|
+
* `reconcileWorktreesOnBoot`'s per-cwd pass does: that predicate is scoped to
|
|
355
|
+
* ONE project's own active-index.json per call, but this sweep walks EVERY
|
|
356
|
+
* project's checkouts under the shared kind root in one pass — applying one
|
|
357
|
+
* project's liveness answer to another project's checkout would be worse
|
|
358
|
+
* than no answer at all (a false "not live" for a foreign key would tear
|
|
359
|
+
* down a genuinely active Epic before its own project's turn ever runs this
|
|
360
|
+
* sweep). The age threshold (7 days for epics, by default) is the only
|
|
361
|
+
* safety margin here, deliberately.
|
|
362
|
+
*
|
|
363
|
+
* Every path visited is verified (`isUnderRoot`) to be nested under this
|
|
364
|
+
* kind's own root before any read or delete, so a maliciously-shaped on-disk
|
|
365
|
+
* name can never walk this sweep outside its own tmpdir root.
|
|
366
|
+
*
|
|
367
|
+
* Returns `{ checkoutsRemoved, emptyRootsRemoved }`. Never throws.
|
|
368
|
+
*/
|
|
369
|
+
async function sweepStaleWorktreeCheckouts(kind, opts = {}) {
|
|
370
|
+
const root = worktreeRootFor(kind);
|
|
371
|
+
const staleAgeMs = Number.isFinite(opts.staleAgeMs) && opts.staleAgeMs >= 0
|
|
372
|
+
? opts.staleAgeMs
|
|
373
|
+
: getStaleSweepAgeMs(kind);
|
|
374
|
+
const result = { checkoutsRemoved: 0, emptyRootsRemoved: 0 };
|
|
375
|
+
|
|
376
|
+
let hashEntries;
|
|
377
|
+
try {
|
|
378
|
+
hashEntries = await fsp.readdir(root, { withFileTypes: true });
|
|
379
|
+
} catch {
|
|
380
|
+
return result; // root doesn't exist yet (or unreadable) — nothing to sweep
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const hashEntry of hashEntries) {
|
|
384
|
+
if (!hashEntry.isDirectory()) continue;
|
|
385
|
+
const hashDir = path.join(root, hashEntry.name);
|
|
386
|
+
if (!isUnderRoot(hashDir, root)) continue;
|
|
387
|
+
|
|
388
|
+
let keyEntries;
|
|
389
|
+
try {
|
|
390
|
+
keyEntries = await fsp.readdir(hashDir, { withFileTypes: true });
|
|
391
|
+
} catch {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const keyEntry of keyEntries) {
|
|
396
|
+
if (!keyEntry.isDirectory()) continue;
|
|
397
|
+
const checkoutDir = path.join(hashDir, keyEntry.name);
|
|
398
|
+
if (!isUnderRoot(checkoutDir, root)) continue;
|
|
399
|
+
|
|
400
|
+
let stat;
|
|
401
|
+
try {
|
|
402
|
+
stat = await fsp.stat(checkoutDir);
|
|
403
|
+
} catch {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (Date.now() - stat.mtimeMs < staleAgeMs) continue; // still fresh — never touched
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
await teardownOrphanedCheckout(checkoutDir);
|
|
410
|
+
result.checkoutsRemoved++;
|
|
411
|
+
} catch {
|
|
412
|
+
/* best-effort */
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const remaining = await fsp.readdir(hashDir);
|
|
418
|
+
if (remaining.length === 0) {
|
|
419
|
+
await fsp.rmdir(hashDir);
|
|
420
|
+
result.emptyRootsRemoved++;
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
// Not empty (a fresh checkout survives, or one this pass just skipped),
|
|
424
|
+
// doesn't exist, or a race — all fine to ignore.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Capture the base tree's outstanding TRACKED diff (`git diff HEAD
|
|
433
|
+
* --binary` — read-only, never stages or touches `cwd`) and apply it inside
|
|
434
|
+
* the freshly created worktree `dir`, so a job started against a dirty base
|
|
435
|
+
* tree sees the SAME content it would have seen running in place, without
|
|
436
|
+
* the base tree ever being written to. Returns `{ ok: true, paths }` or
|
|
437
|
+
* `{ ok: false, reason }`; never throws.
|
|
438
|
+
*
|
|
439
|
+
* This is deliberately read-context, not work-product: the authoritative
|
|
440
|
+
* copy of the human's WIP stays in `cwd` the whole time. Nothing here ever
|
|
441
|
+
* `git add`s or commits the carried paths on the job's behalf, and nothing
|
|
442
|
+
* ever restores them back into `cwd` — they were never removed from it.
|
|
443
|
+
*/
|
|
444
|
+
async function captureAndCarryBaseDiff({ cwd, dir }) {
|
|
445
|
+
let paths = [];
|
|
446
|
+
let patch = '';
|
|
447
|
+
try {
|
|
448
|
+
const nameOut = await execGit(['diff', 'HEAD', '--name-only'], { cwd, timeout: 15_000 });
|
|
449
|
+
paths = nameOut.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
450
|
+
patch = await execGit(['diff', 'HEAD', '--binary'], { cwd, timeout: 30_000 });
|
|
451
|
+
} catch (e) {
|
|
452
|
+
return { ok: false, reason: `capturing base diff failed: ${(e && (e.stderrText || e.message)) || e}` };
|
|
453
|
+
}
|
|
454
|
+
if (!patch || !patch.trim()) return { ok: true, paths: [] };
|
|
455
|
+
|
|
456
|
+
const patchFile = path.join(os.tmpdir(), `sm-worktree-carry-${crypto.randomBytes(8).toString('hex')}.patch`);
|
|
457
|
+
try {
|
|
458
|
+
await fsp.writeFile(patchFile, patch, 'utf8');
|
|
459
|
+
await execGit(['apply', '--binary', patchFile], { cwd: dir, timeout: 30_000 });
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return { ok: false, reason: `git apply failed: ${(e && (e.stderrText || e.message)) || e}` };
|
|
462
|
+
} finally {
|
|
463
|
+
try { await fsp.rm(patchFile, { force: true }); } catch { /* best-effort */ }
|
|
464
|
+
}
|
|
465
|
+
return { ok: true, paths };
|
|
206
466
|
}
|
|
207
467
|
|
|
208
468
|
/**
|
|
209
469
|
* Create a linked worktree on a fresh branch checked out from the main
|
|
210
|
-
* tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd
|
|
211
|
-
* success, or `{ ok: false, reason }` — the reason is
|
|
212
|
-
* human-readable string meant to be logged verbatim so a
|
|
213
|
-
* in place is never silent.
|
|
470
|
+
* tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd,
|
|
471
|
+
* carriedPaths }` on success, or `{ ok: false, reason }` — the reason is
|
|
472
|
+
* always a short, human-readable string meant to be logged verbatim so a
|
|
473
|
+
* fallback to running in place is never silent.
|
|
214
474
|
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
475
|
+
* A dirty base tree (tracked modifications) no longer disables isolation —
|
|
476
|
+
* a worktree only ever checks out committed HEAD content, so the base
|
|
477
|
+
* tree's outstanding diff is captured and applied inside the fresh worktree
|
|
478
|
+
* (`captureAndCarryBaseDiff`) instead: the job sees the same content it
|
|
479
|
+
* would have seen running in place, while the shared base tree is never
|
|
480
|
+
* written to and stays untouchable for the run's whole duration. If that
|
|
481
|
+
* capture/apply fails for any reason (binary conflict, non-zero `git
|
|
482
|
+
* apply`), creation degrades to the OLD behaviour — the worktree is torn
|
|
483
|
+
* down and this returns `{ ok: false, reason: 'carry-over of base WIP
|
|
484
|
+
* failed: ...' }` — so this change can never turn a working fallback into a
|
|
485
|
+
* hard failure. `carriedPaths` (possibly empty) is threaded back so callers
|
|
486
|
+
* can exclude the human's carried WIP from branch integration.
|
|
487
|
+
*
|
|
488
|
+
* Never throws: every failure mode (not a repo, cap reached, carry-over
|
|
489
|
+
* failure, git error) is a normal, expected outcome for a project that
|
|
490
|
+
* hasn't opted into — or currently can't support — isolation, not an
|
|
491
|
+
* exceptional one.
|
|
218
492
|
*/
|
|
219
493
|
async function createWorktree({ kind, cwd, key }) {
|
|
220
494
|
configFor(kind); // throws on an unknown kind before anything else runs
|
|
@@ -224,12 +498,7 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
224
498
|
|
|
225
499
|
if (!(await isGitRepo(cwd))) return { ok: false, reason: 'not a git repository' };
|
|
226
500
|
|
|
227
|
-
|
|
228
|
-
// uncommitted WIP in `cwd` — a worktree only ever checks out committed
|
|
229
|
-
// HEAD content, so isolating into one here would silently drop that WIP
|
|
230
|
-
// from what it sees. Falling back to running in place is strictly safer
|
|
231
|
-
// than guessing.
|
|
232
|
-
if (!(await isBaseTreeClean(cwd))) return { ok: false, reason: 'base working tree has uncommitted changes' };
|
|
501
|
+
const baseWasClean = await isBaseTreeClean(cwd);
|
|
233
502
|
|
|
234
503
|
if (activeWorktreeCount[kind] >= getMaxConcurrentWorktrees(kind)) {
|
|
235
504
|
return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees(kind)} concurrent)` };
|
|
@@ -256,7 +525,23 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
256
525
|
activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
|
|
257
526
|
return { ok: false, reason: `git worktree add failed: ${(e && (e.stderrText || e.message)) || e}` };
|
|
258
527
|
}
|
|
259
|
-
|
|
528
|
+
|
|
529
|
+
let carriedPaths = [];
|
|
530
|
+
if (!baseWasClean) {
|
|
531
|
+
// Routed through module.exports (not the bare local function) so tests
|
|
532
|
+
// can substitute a failing carry-over without needing a real git-apply
|
|
533
|
+
// conflict fixture — see gitWorktree.test.cjs's carry-over-failure case.
|
|
534
|
+
const carry = await module.exports.captureAndCarryBaseDiff({ cwd, dir });
|
|
535
|
+
if (!carry.ok) {
|
|
536
|
+
await removeWorktreeDir(cwd, dir);
|
|
537
|
+
try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* best-effort */ }
|
|
538
|
+
activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
|
|
539
|
+
return { ok: false, reason: `carry-over of base WIP failed: ${carry.reason}` };
|
|
540
|
+
}
|
|
541
|
+
carriedPaths = carry.paths;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return { ok: true, dir, branch, baseCwd: cwd, carriedPaths };
|
|
260
545
|
}
|
|
261
546
|
|
|
262
547
|
/**
|
|
@@ -269,8 +554,16 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
269
554
|
* e.g. a genuine content conflict. On failure the branch is left un-merged
|
|
270
555
|
* and NOT deleted (see cleanupWorktree) so the work is recoverable, never
|
|
271
556
|
* silently discarded.
|
|
557
|
+
*
|
|
558
|
+
* `carriedPaths` (optional — from createWorktree's WIP carry-over) skips the
|
|
559
|
+
* merge entirely, returning `{ ok: true, integrated: false, reason:
|
|
560
|
+
* 'carried-wip-only' }`, when the branch's ONLY committed changes touch
|
|
561
|
+
* paths that were carried in as the human's base-tree WIP rather than the
|
|
562
|
+
* job's own work — landing such a commit would just re-apply the human's
|
|
563
|
+
* uncommitted edit back onto itself via a merge, and could conflict with
|
|
564
|
+
* the base tree still holding that same path dirty.
|
|
272
565
|
*/
|
|
273
|
-
async function integrateBranch({ cwd, branch, key, kind }) {
|
|
566
|
+
async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
|
|
274
567
|
if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
|
|
275
568
|
let branchHead;
|
|
276
569
|
try {
|
|
@@ -288,6 +581,46 @@ async function integrateBranch({ cwd, branch, key, kind }) {
|
|
|
288
581
|
return { ok: true, integrated: false, reason: 'branch has no new commits' };
|
|
289
582
|
}
|
|
290
583
|
|
|
584
|
+
if (Array.isArray(carriedPaths) && carriedPaths.length) {
|
|
585
|
+
try {
|
|
586
|
+
const changedOut = await execGit(['diff', `${mergeBase || 'HEAD'}..${branch}`, '--name-only'], { cwd, timeout: 10_000 });
|
|
587
|
+
const changed = changedOut.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
588
|
+
if (changed.length && changed.every((p) => carriedPaths.includes(p))) {
|
|
589
|
+
// Path membership alone is NOT enough: a job that legitimately edits
|
|
590
|
+
// the SAME file the base tree had carried-in WIP on (e.g. this
|
|
591
|
+
// repo's own scheduler.cjs churns queue.json/active-index.json while
|
|
592
|
+
// jobs run) would otherwise have its real commit misclassified as
|
|
593
|
+
// "just the carried WIP" and dropped — the caller treats
|
|
594
|
+
// `integrated: false` as safe-to-delete-the-branch, so that commit
|
|
595
|
+
// would be gone for good, worse than the ordinary merge-conflict
|
|
596
|
+
// path (branch kept, flagged for manual recovery) this shortcut is
|
|
597
|
+
// supposed to be a safe subset of. Content-verify: only when every
|
|
598
|
+
// changed path's committed blob on `branch` is byte-identical to
|
|
599
|
+
// what's STILL sitting dirty in `cwd` right now proves the job
|
|
600
|
+
// committed exactly the carried WIP and nothing more. Any mismatch
|
|
601
|
+
// (including a read failure — fail toward the safer default) falls
|
|
602
|
+
// through to the normal merge attempt below instead of skipping.
|
|
603
|
+
let allIdentical = true;
|
|
604
|
+
for (const p of changed) {
|
|
605
|
+
try {
|
|
606
|
+
const branchContent = await execGit(['show', `${branch}:${p}`], { cwd, timeout: 10_000 });
|
|
607
|
+
const baseContent = await fsp.readFile(path.join(cwd, p), 'utf8');
|
|
608
|
+
if (branchContent !== baseContent) { allIdentical = false; break; }
|
|
609
|
+
} catch {
|
|
610
|
+
allIdentical = false;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (allIdentical) {
|
|
615
|
+
return { ok: true, integrated: false, reason: 'carried-wip-only' };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
// Best-effort classification only — if the diff can't be read, fall
|
|
620
|
+
// through to the normal integration attempt below.
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
291
624
|
try {
|
|
292
625
|
await execGit(['merge', '--ff-only', branch], { cwd, timeout: 30_000 });
|
|
293
626
|
return { ok: true, integrated: true, fastForward: true };
|
|
@@ -365,6 +698,83 @@ async function salvageWorktreeDiff({ cwd, outFile }) {
|
|
|
365
698
|
}
|
|
366
699
|
}
|
|
367
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Best-effort dump of a caller-supplied DELTA of paths — never the whole
|
|
703
|
+
* tree — to `outFile`. This is the shared-tree counterpart to
|
|
704
|
+
* salvageWorktreeDiff above: a job that ran IN PLACE (no throwaway worktree
|
|
705
|
+
* checkout to diff and discard) shares `cwd` with a human's own WIP and any
|
|
706
|
+
* sibling job, so it must never touch anything outside the exact paths the
|
|
707
|
+
* caller says this job itself dirtied (typically: `git status` at exit,
|
|
708
|
+
* minus a pre-run baseline snapshot).
|
|
709
|
+
*
|
|
710
|
+
* Deliberately does NOT reuse salvageWorktreeDiff's `git add -A
|
|
711
|
+
* --intent-to-add` — that stages the ENTIRE tree's untracked files into the
|
|
712
|
+
* shared index, which is exactly the kind of blanket mutation this function
|
|
713
|
+
* exists to avoid. Instead: tracked/modified delta paths are read via `git
|
|
714
|
+
* diff HEAD --binary -- <paths>` (never stages anything), and untracked
|
|
715
|
+
* delta paths are read individually via `git diff --no-index --binary --
|
|
716
|
+
* /dev/null <path>` (also never touches the index) — `--no-index` exits
|
|
717
|
+
* non-zero to mean "found a difference", which execGit rejects, so its
|
|
718
|
+
* stdout is recovered off the rejected error via `err.stdoutText`.
|
|
719
|
+
*
|
|
720
|
+
* Never throws, and never mutates `cwd` in any way (no add/stash/reset/
|
|
721
|
+
* checkout/clean) — a caller running this against a tree shared with a
|
|
722
|
+
* human or a sibling job must be able to trust `git status --porcelain` and
|
|
723
|
+
* the stash list are byte-identical before and after. Writes nothing (and
|
|
724
|
+
* returns `{ ok: false }`) when none of the given paths are actually dirty,
|
|
725
|
+
* or on any git failure, so a run with nothing to salvage never leaves a
|
|
726
|
+
* 0-byte artifact behind.
|
|
727
|
+
*/
|
|
728
|
+
async function salvageDirtyDelta({ cwd, paths, outFile }) {
|
|
729
|
+
try {
|
|
730
|
+
const list = Array.isArray(paths) ? [...new Set(paths.filter(Boolean))] : [];
|
|
731
|
+
if (!list.length) return { ok: false };
|
|
732
|
+
|
|
733
|
+
const statusOut = await execGit(['status', '--porcelain', '--', ...list], { cwd, timeout: 15_000 }).catch(() => '');
|
|
734
|
+
const trackedPaths = [];
|
|
735
|
+
const untrackedPaths = [];
|
|
736
|
+
for (const line of String(statusOut || '').split('\n')) {
|
|
737
|
+
if (!line) continue;
|
|
738
|
+
const code = line.slice(0, 2);
|
|
739
|
+
const p = line.slice(3);
|
|
740
|
+
if (!p) continue;
|
|
741
|
+
if (code === '??') untrackedPaths.push(p);
|
|
742
|
+
else trackedPaths.push(p);
|
|
743
|
+
}
|
|
744
|
+
// Any requested path `git status` didn't report on (already clean by the
|
|
745
|
+
// time this runs — e.g. a race with a concurrent write) is simply
|
|
746
|
+
// skipped, never guessed at.
|
|
747
|
+
|
|
748
|
+
let patch = '';
|
|
749
|
+
if (trackedPaths.length) {
|
|
750
|
+
try {
|
|
751
|
+
const d = await execGit(['diff', 'HEAD', '--binary', '--', ...trackedPaths], { cwd, timeout: 30_000 });
|
|
752
|
+
if (d) patch += d;
|
|
753
|
+
} catch {
|
|
754
|
+
// Best-effort — a diff failure for the tracked set must not block
|
|
755
|
+
// salvaging the untracked set below.
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
for (const p of untrackedPaths) {
|
|
759
|
+
try {
|
|
760
|
+
const d = await execGit(['diff', '--no-index', '--binary', '--', '/dev/null', p], { cwd, timeout: 15_000 });
|
|
761
|
+
if (d) patch += d;
|
|
762
|
+
} catch (e) {
|
|
763
|
+
// git diff --no-index exits 1 (not an error) whenever it finds a
|
|
764
|
+
// difference, which is the expected outcome here every time — the
|
|
765
|
+
// real diff text is on stdout despite the non-zero exit.
|
|
766
|
+
if (e && typeof e.stdoutText === 'string' && e.stdoutText) patch += e.stdoutText;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (!patch.trim()) return { ok: false };
|
|
770
|
+
const { writeTextAtomic } = require('../config.cjs');
|
|
771
|
+
await writeTextAtomic(outFile, patch);
|
|
772
|
+
return { ok: true, bytes: Buffer.byteLength(patch, 'utf8') };
|
|
773
|
+
} catch {
|
|
774
|
+
return { ok: false };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
368
778
|
/** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
|
|
369
779
|
function parseWorktreeListPorcelain(text) {
|
|
370
780
|
const entries = [];
|
|
@@ -407,6 +817,14 @@ function keyFromBranch(kind, branch) {
|
|
|
407
817
|
* equivalent concept (a job worktree found at boot is, by definition, from a
|
|
408
818
|
* run that didn't finish) and typically omits `isLive`.
|
|
409
819
|
*
|
|
820
|
+
* After the per-cwd pass, this also runs `sweepStaleWorktreeCheckouts` —
|
|
821
|
+
* a second, root-directory-driven pass that is NOT limited to `cwds`, so a
|
|
822
|
+
* checkout belonging to a project no longer known to the caller (removed
|
|
823
|
+
* from Session Manager, or a leftover from before this sweep existed) still
|
|
824
|
+
* gets reclaimed once it's older than `opts.staleAgeMs` (default per-kind,
|
|
825
|
+
* see `KIND_CONFIG`). A one-shot log line reports the totals so a recurring
|
|
826
|
+
* leak stays visible (RCA: PRD 1108).
|
|
827
|
+
*
|
|
410
828
|
* Never throws — a project that isn't a git repo, or has no worktrees, is a
|
|
411
829
|
* silent no-op.
|
|
412
830
|
*/
|
|
@@ -415,6 +833,7 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
|
|
|
415
833
|
const isLive = typeof opts.isLive === 'function' ? opts.isLive : null;
|
|
416
834
|
const root = worktreeRootFor(kind);
|
|
417
835
|
const list = Array.isArray(cwds) ? cwds.filter(Boolean) : [];
|
|
836
|
+
let checkoutsRemoved = 0;
|
|
418
837
|
for (const cwd of list) {
|
|
419
838
|
if (!(await isGitRepo(cwd))) continue;
|
|
420
839
|
let out = '';
|
|
@@ -429,12 +848,29 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
|
|
|
429
848
|
const key = keyFromBranch(kind, entry.branch);
|
|
430
849
|
if (isLive && key && (await isLive(key, entry))) continue;
|
|
431
850
|
await removeWorktreeDir(cwd, entry.worktree);
|
|
851
|
+
checkoutsRemoved++;
|
|
432
852
|
if (entry.branch) {
|
|
433
853
|
try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
|
|
434
854
|
}
|
|
435
855
|
}
|
|
436
856
|
try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
|
|
437
857
|
}
|
|
858
|
+
|
|
859
|
+
let sweep = { checkoutsRemoved: 0, emptyRootsRemoved: 0 };
|
|
860
|
+
try {
|
|
861
|
+
sweep = await sweepStaleWorktreeCheckouts(kind, { staleAgeMs: opts.staleAgeMs });
|
|
862
|
+
} catch {
|
|
863
|
+
/* never let the orphan sweep take down boot reconciliation */
|
|
864
|
+
}
|
|
865
|
+
const totals = {
|
|
866
|
+
checkoutsRemoved: checkoutsRemoved + sweep.checkoutsRemoved,
|
|
867
|
+
emptyRootsRemoved: sweep.emptyRootsRemoved,
|
|
868
|
+
};
|
|
869
|
+
console.log(
|
|
870
|
+
`[gitWorktree] boot sweep (kind:${kind}): reclaimed ${totals.checkoutsRemoved} checkout(s), ` +
|
|
871
|
+
`${totals.emptyRootsRemoved} empty root dir(s)`
|
|
872
|
+
);
|
|
873
|
+
return totals;
|
|
438
874
|
}
|
|
439
875
|
|
|
440
876
|
// ──────────────────────────────────────────── kind-scoped convenience wrappers
|
|
@@ -442,8 +878,8 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
|
|
|
442
878
|
async function createJobWorktree({ cwd, slug }) {
|
|
443
879
|
return createWorktree({ kind: 'job', cwd, key: slug });
|
|
444
880
|
}
|
|
445
|
-
async function integrateJobBranch({ cwd, branch, slug }) {
|
|
446
|
-
return integrateBranch({ kind: 'job', cwd, branch, key: slug });
|
|
881
|
+
async function integrateJobBranch({ cwd, branch, slug, carriedPaths }) {
|
|
882
|
+
return integrateBranch({ kind: 'job', cwd, branch, key: slug, carriedPaths });
|
|
447
883
|
}
|
|
448
884
|
async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
|
|
449
885
|
return cleanupWorktree({ kind: 'job', cwd, dir, branch, keepBranch });
|
|
@@ -451,6 +887,9 @@ async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
|
|
|
451
887
|
async function salvageJobWorktreeDiff({ dir, outFile }) {
|
|
452
888
|
return salvageWorktreeDiff({ cwd: dir, outFile });
|
|
453
889
|
}
|
|
890
|
+
async function salvageJobDirtyDelta({ cwd, paths, outFile }) {
|
|
891
|
+
return salvageDirtyDelta({ cwd, paths, outFile });
|
|
892
|
+
}
|
|
454
893
|
|
|
455
894
|
async function createEpicWorktree({ cwd, epicId }) {
|
|
456
895
|
return createWorktree({ kind: 'epic', cwd, key: epicId });
|
|
@@ -467,23 +906,29 @@ module.exports = {
|
|
|
467
906
|
worktreeRootFor,
|
|
468
907
|
isWorktreeDisabled,
|
|
469
908
|
getMaxConcurrentWorktrees,
|
|
909
|
+
getStaleSweepAgeMs,
|
|
470
910
|
isGitRepo,
|
|
471
911
|
isBaseTreeClean,
|
|
472
912
|
worktreeDirFor,
|
|
473
913
|
branchNameFor,
|
|
474
914
|
keyFromBranch,
|
|
475
915
|
createWorktree,
|
|
916
|
+
captureAndCarryBaseDiff,
|
|
476
917
|
integrateBranch,
|
|
477
918
|
cleanupWorktree,
|
|
478
919
|
salvageWorktreeDiff,
|
|
920
|
+
salvageDirtyDelta,
|
|
479
921
|
parseWorktreeListPorcelain,
|
|
480
922
|
reconcileWorktreesOnBoot,
|
|
923
|
+
sweepStaleWorktreeCheckouts,
|
|
924
|
+
mainTreeFromWorktreeGitFile,
|
|
481
925
|
// Job-kind convenience wrappers — same call shape jobWorktree.cjs has
|
|
482
926
|
// always exposed.
|
|
483
927
|
createJobWorktree,
|
|
484
928
|
integrateJobBranch,
|
|
485
929
|
cleanupJobWorktree,
|
|
486
930
|
salvageJobWorktreeDiff,
|
|
931
|
+
salvageJobDirtyDelta,
|
|
487
932
|
// Epic-kind convenience wrappers.
|
|
488
933
|
createEpicWorktree,
|
|
489
934
|
integrateEpicBranch,
|
|
@@ -63,6 +63,7 @@ module.exports = {
|
|
|
63
63
|
integrateJobBranch: gitWorktree.integrateJobBranch,
|
|
64
64
|
cleanupJobWorktree: gitWorktree.cleanupJobWorktree,
|
|
65
65
|
salvageJobWorktreeDiff: gitWorktree.salvageJobWorktreeDiff,
|
|
66
|
+
salvageJobDirtyDelta: gitWorktree.salvageJobDirtyDelta,
|
|
66
67
|
parseWorktreeListPorcelain: gitWorktree.parseWorktreeListPorcelain,
|
|
67
68
|
reconcileWorktreesOnBoot: (cwds) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND }),
|
|
68
69
|
// Test-only escape hatch for the in-memory concurrency counter.
|