claude-code-session-manager 0.75.3 → 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-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.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-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
- package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.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/lib/activeSessions.cjs +116 -6
- 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 +269 -96
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +247 -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 +58 -3
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -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 +207 -1
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -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/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +25 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +64 -5
- package/src/main/ipcSchemas.cjs +69 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
- 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__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -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 +133 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -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__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +69 -2
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +514 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +520 -21
- package/src/main/lib/jobWorktree.cjs +2 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +370 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +106 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +103 -15
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +40 -7
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +54 -4
- package/src/main/lib/reaperHelpers.cjs +64 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +301 -55
- package/src/main/lib/schedulerConfig.cjs +99 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +162 -3
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1701 -130
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/seedSchedulerMcp.cjs +58 -4
- 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 +187 -3
- package/src/preload/index.cjs +9 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
- package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
- package/dist/assets/TagLibrary-E5CLeuVk.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
|
}
|
|
@@ -157,10 +202,21 @@ async function isGitRepo(cwd) {
|
|
|
157
202
|
}
|
|
158
203
|
}
|
|
159
204
|
|
|
160
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* True when `cwd`'s own working tree (not any worktree) has zero pending
|
|
207
|
+
* changes to TRACKED files (modified or staged). Untracked files are
|
|
208
|
+
* deliberately excluded (`--untracked-files=no`): an untracked file is never
|
|
209
|
+
* tracked WIP a job depends on, and `git worktree add` checks out only
|
|
210
|
+
* committed HEAD content into the new worktree — it never touches, copies, or
|
|
211
|
+
* is affected by the base tree's untracked files either way. Counting them as
|
|
212
|
+
* "dirty" here bought no safety: one stray scratch file in a project silently
|
|
213
|
+
* disabled isolation for every job in that project, permanently, since a
|
|
214
|
+
* failed-to-commit job leaves the tree dirty and re-triggers the same
|
|
215
|
+
* fallback for every subsequent job (RCA: PRD 1064, starry-night-ships).
|
|
216
|
+
*/
|
|
161
217
|
async function isBaseTreeClean(cwd) {
|
|
162
218
|
try {
|
|
163
|
-
const out = await execGit(['status', '--porcelain'], { cwd, timeout: 10_000 });
|
|
219
|
+
const out = await execGit(['status', '--porcelain', '--untracked-files=no'], { cwd, timeout: 10_000 });
|
|
164
220
|
return out.trim().length === 0;
|
|
165
221
|
} catch {
|
|
166
222
|
return false;
|
|
@@ -179,7 +235,16 @@ function branchNameFor(kind, key) {
|
|
|
179
235
|
return `${configFor(kind).branchPrefix}${key}`;
|
|
180
236
|
}
|
|
181
237
|
|
|
182
|
-
/**
|
|
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
|
+
*/
|
|
183
248
|
async function removeWorktreeDir(cwd, dir) {
|
|
184
249
|
try {
|
|
185
250
|
await execGit(['worktree', 'remove', '--force', dir], { cwd, timeout: 15_000 });
|
|
@@ -192,18 +257,238 @@ async function removeWorktreeDir(cwd, dir) {
|
|
|
192
257
|
} catch {
|
|
193
258
|
/* best-effort */
|
|
194
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 };
|
|
195
466
|
}
|
|
196
467
|
|
|
197
468
|
/**
|
|
198
469
|
* Create a linked worktree on a fresh branch checked out from the main
|
|
199
|
-
* tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd
|
|
200
|
-
* success, or `{ ok: false, reason }` — the reason is
|
|
201
|
-
* human-readable string meant to be logged verbatim so a
|
|
202
|
-
* 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.
|
|
474
|
+
*
|
|
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.
|
|
203
487
|
*
|
|
204
|
-
* Never throws: every failure mode (not a repo,
|
|
205
|
-
* error) is a normal, expected outcome for a project that
|
|
206
|
-
* — or currently can't support — isolation, not an
|
|
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.
|
|
207
492
|
*/
|
|
208
493
|
async function createWorktree({ kind, cwd, key }) {
|
|
209
494
|
configFor(kind); // throws on an unknown kind before anything else runs
|
|
@@ -213,12 +498,7 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
213
498
|
|
|
214
499
|
if (!(await isGitRepo(cwd))) return { ok: false, reason: 'not a git repository' };
|
|
215
500
|
|
|
216
|
-
|
|
217
|
-
// uncommitted WIP in `cwd` — a worktree only ever checks out committed
|
|
218
|
-
// HEAD content, so isolating into one here would silently drop that WIP
|
|
219
|
-
// from what it sees. Falling back to running in place is strictly safer
|
|
220
|
-
// than guessing.
|
|
221
|
-
if (!(await isBaseTreeClean(cwd))) return { ok: false, reason: 'base working tree has uncommitted changes' };
|
|
501
|
+
const baseWasClean = await isBaseTreeClean(cwd);
|
|
222
502
|
|
|
223
503
|
if (activeWorktreeCount[kind] >= getMaxConcurrentWorktrees(kind)) {
|
|
224
504
|
return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees(kind)} concurrent)` };
|
|
@@ -245,7 +525,23 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
245
525
|
activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
|
|
246
526
|
return { ok: false, reason: `git worktree add failed: ${(e && (e.stderrText || e.message)) || e}` };
|
|
247
527
|
}
|
|
248
|
-
|
|
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 };
|
|
249
545
|
}
|
|
250
546
|
|
|
251
547
|
/**
|
|
@@ -258,8 +554,16 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
258
554
|
* e.g. a genuine content conflict. On failure the branch is left un-merged
|
|
259
555
|
* and NOT deleted (see cleanupWorktree) so the work is recoverable, never
|
|
260
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.
|
|
261
565
|
*/
|
|
262
|
-
async function integrateBranch({ cwd, branch, key, kind }) {
|
|
566
|
+
async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
|
|
263
567
|
if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
|
|
264
568
|
let branchHead;
|
|
265
569
|
try {
|
|
@@ -277,6 +581,46 @@ async function integrateBranch({ cwd, branch, key, kind }) {
|
|
|
277
581
|
return { ok: true, integrated: false, reason: 'branch has no new commits' };
|
|
278
582
|
}
|
|
279
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
|
+
|
|
280
624
|
try {
|
|
281
625
|
await execGit(['merge', '--ff-only', branch], { cwd, timeout: 30_000 });
|
|
282
626
|
return { ok: true, integrated: true, fastForward: true };
|
|
@@ -316,6 +660,121 @@ async function cleanupWorktree({ kind, cwd, dir, branch, keepBranch }) {
|
|
|
316
660
|
activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
|
|
317
661
|
}
|
|
318
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Best-effort dump of a worktree's full outstanding diff (tracked
|
|
665
|
+
* modifications AND untracked files) to `outFile`, for a caller about to
|
|
666
|
+
* tear the worktree down. A job killed before its finish-protocol commit
|
|
667
|
+
* (rate limit, timeout, crash, hard kill) otherwise loses that work
|
|
668
|
+
* outright the moment `cleanupWorktree` removes the checkout — no branch,
|
|
669
|
+
* no stash, no patch survives it. `git add -A --intent-to-add` stages
|
|
670
|
+
* untracked paths (empty blobs) without touching their content, so the
|
|
671
|
+
* subsequent `git diff HEAD --binary` includes their full contents exactly
|
|
672
|
+
* like a tracked modification; the result is a patch a later `git apply`
|
|
673
|
+
* can consume verbatim.
|
|
674
|
+
*
|
|
675
|
+
* Never throws — this always runs immediately before teardown and must
|
|
676
|
+
* never block branch integration/cleanup or change a job's verdict. Writes
|
|
677
|
+
* nothing (and returns `{ ok: false }`) when the worktree is clean or on
|
|
678
|
+
* any git failure, so a successful run's teardown never leaves a 0-byte
|
|
679
|
+
* artifact behind.
|
|
680
|
+
*/
|
|
681
|
+
async function salvageWorktreeDiff({ cwd, outFile }) {
|
|
682
|
+
try {
|
|
683
|
+
const status = await execGit(['status', '--porcelain'], { cwd, timeout: 15_000 });
|
|
684
|
+
if (!status.trim()) return { ok: false };
|
|
685
|
+
try {
|
|
686
|
+
await execGit(['add', '-A', '--intent-to-add'], { cwd, timeout: 30_000 });
|
|
687
|
+
} catch {
|
|
688
|
+
// Best-effort — the diff below still captures whatever staged/tracked
|
|
689
|
+
// changes exist even if intent-to-add partially failed.
|
|
690
|
+
}
|
|
691
|
+
const patch = await execGit(['diff', 'HEAD', '--binary'], { cwd, timeout: 30_000 });
|
|
692
|
+
if (!patch || !patch.trim()) return { ok: false };
|
|
693
|
+
const { writeTextAtomic } = require('../config.cjs');
|
|
694
|
+
await writeTextAtomic(outFile, patch);
|
|
695
|
+
return { ok: true, bytes: Buffer.byteLength(patch, 'utf8') };
|
|
696
|
+
} catch {
|
|
697
|
+
return { ok: false };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
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
|
+
|
|
319
778
|
/** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
|
|
320
779
|
function parseWorktreeListPorcelain(text) {
|
|
321
780
|
const entries = [];
|
|
@@ -358,6 +817,14 @@ function keyFromBranch(kind, branch) {
|
|
|
358
817
|
* equivalent concept (a job worktree found at boot is, by definition, from a
|
|
359
818
|
* run that didn't finish) and typically omits `isLive`.
|
|
360
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
|
+
*
|
|
361
828
|
* Never throws — a project that isn't a git repo, or has no worktrees, is a
|
|
362
829
|
* silent no-op.
|
|
363
830
|
*/
|
|
@@ -366,6 +833,7 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
|
|
|
366
833
|
const isLive = typeof opts.isLive === 'function' ? opts.isLive : null;
|
|
367
834
|
const root = worktreeRootFor(kind);
|
|
368
835
|
const list = Array.isArray(cwds) ? cwds.filter(Boolean) : [];
|
|
836
|
+
let checkoutsRemoved = 0;
|
|
369
837
|
for (const cwd of list) {
|
|
370
838
|
if (!(await isGitRepo(cwd))) continue;
|
|
371
839
|
let out = '';
|
|
@@ -380,12 +848,29 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
|
|
|
380
848
|
const key = keyFromBranch(kind, entry.branch);
|
|
381
849
|
if (isLive && key && (await isLive(key, entry))) continue;
|
|
382
850
|
await removeWorktreeDir(cwd, entry.worktree);
|
|
851
|
+
checkoutsRemoved++;
|
|
383
852
|
if (entry.branch) {
|
|
384
853
|
try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
|
|
385
854
|
}
|
|
386
855
|
}
|
|
387
856
|
try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
|
|
388
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;
|
|
389
874
|
}
|
|
390
875
|
|
|
391
876
|
// ──────────────────────────────────────────── kind-scoped convenience wrappers
|
|
@@ -393,12 +878,18 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
|
|
|
393
878
|
async function createJobWorktree({ cwd, slug }) {
|
|
394
879
|
return createWorktree({ kind: 'job', cwd, key: slug });
|
|
395
880
|
}
|
|
396
|
-
async function integrateJobBranch({ cwd, branch, slug }) {
|
|
397
|
-
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 });
|
|
398
883
|
}
|
|
399
884
|
async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
|
|
400
885
|
return cleanupWorktree({ kind: 'job', cwd, dir, branch, keepBranch });
|
|
401
886
|
}
|
|
887
|
+
async function salvageJobWorktreeDiff({ dir, outFile }) {
|
|
888
|
+
return salvageWorktreeDiff({ cwd: dir, outFile });
|
|
889
|
+
}
|
|
890
|
+
async function salvageJobDirtyDelta({ cwd, paths, outFile }) {
|
|
891
|
+
return salvageDirtyDelta({ cwd, paths, outFile });
|
|
892
|
+
}
|
|
402
893
|
|
|
403
894
|
async function createEpicWorktree({ cwd, epicId }) {
|
|
404
895
|
return createWorktree({ kind: 'epic', cwd, key: epicId });
|
|
@@ -415,21 +906,29 @@ module.exports = {
|
|
|
415
906
|
worktreeRootFor,
|
|
416
907
|
isWorktreeDisabled,
|
|
417
908
|
getMaxConcurrentWorktrees,
|
|
909
|
+
getStaleSweepAgeMs,
|
|
418
910
|
isGitRepo,
|
|
419
911
|
isBaseTreeClean,
|
|
420
912
|
worktreeDirFor,
|
|
421
913
|
branchNameFor,
|
|
422
914
|
keyFromBranch,
|
|
423
915
|
createWorktree,
|
|
916
|
+
captureAndCarryBaseDiff,
|
|
424
917
|
integrateBranch,
|
|
425
918
|
cleanupWorktree,
|
|
919
|
+
salvageWorktreeDiff,
|
|
920
|
+
salvageDirtyDelta,
|
|
426
921
|
parseWorktreeListPorcelain,
|
|
427
922
|
reconcileWorktreesOnBoot,
|
|
923
|
+
sweepStaleWorktreeCheckouts,
|
|
924
|
+
mainTreeFromWorktreeGitFile,
|
|
428
925
|
// Job-kind convenience wrappers — same call shape jobWorktree.cjs has
|
|
429
926
|
// always exposed.
|
|
430
927
|
createJobWorktree,
|
|
431
928
|
integrateJobBranch,
|
|
432
929
|
cleanupJobWorktree,
|
|
930
|
+
salvageJobWorktreeDiff,
|
|
931
|
+
salvageJobDirtyDelta,
|
|
433
932
|
// Epic-kind convenience wrappers.
|
|
434
933
|
createEpicWorktree,
|
|
435
934
|
integrateEpicBranch,
|