@ran-sh/dsh-crew 1.9.1 → 1.10.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "1.9.1",
3
+ "version": "1.10.1",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.9.1",
3
+ "version": "1.10.1",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -29,7 +29,8 @@
29
29
  "build:client": "tsdown src/client/entry.tsx --format cjs --platform browser --target es2022 --tsconfig tsconfig.client.json --deps.never-bundle react --deps.never-bundle react/jsx-runtime --deps.never-bundle react-dom --deps.never-bundle react-dom/client --out-dir .client-build --clean --logLevel warn && tsdown src/client/quick-entry.tsx --format cjs --platform browser --target es2022 --tsconfig tsconfig.client.json --deps.never-bundle react --deps.never-bundle react/jsx-runtime --deps.never-bundle react-dom --deps.never-bundle react-dom/client --out-dir .client-build --no-clean --logLevel warn && node scripts/build-client.mjs",
30
30
  "verify:npm-install": "node scripts/verify-npm-install.mjs",
31
31
  "verify:npm-install:official": "node scripts/verify-npm-install.mjs --with-official-dsh",
32
- "verify:official-bridge": "node scripts/verify-official-bridge-e2e.mjs --diagnostic-only"
32
+ "verify:official-bridge": "node scripts/verify-official-bridge-e2e.mjs --diagnostic-only",
33
+ "test": "node --test \"test/*.test.mjs\""
33
34
  },
34
35
  "dependencies": {
35
36
  "@modelcontextprotocol/sdk": "^1.25.4",
@@ -8,7 +8,12 @@ import { readHistoryState, writeHistoryState, historyPending, publicHistoryState
8
8
  import { readSessionOrigins } from '../session-origins.mjs';
9
9
  import { defaultWorktreeRoot } from '../workspace-isolation.mjs';
10
10
 
11
- export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
11
+ // `readOrigins` is injectable because the provenance ledger it reads by default
12
+ // is machine-global, and the plan revision hashes the whole set: anything that
13
+ // appends to that file between a preview and its execute invalidates the plan.
14
+ // That is correct in production, where the ledger is this machine's own record,
15
+ // but a caller that cannot control the ledger cannot control its own revisions.
16
+ export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now, readOrigins = readSessionOrigins }) {
12
17
  const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
13
18
  const plans = new Map();
14
19
  let entering = false;
@@ -67,7 +72,7 @@ export function createHistoryService({ crewRoot, agents, persistence, runtimeId,
67
72
  });
68
73
  // A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
69
74
  const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
70
- const crewSessionIds = [...readSessionOrigins()];
75
+ const crewSessionIds = [...readOrigins()];
71
76
  const crewSet = new Set(crewSessionIds);
72
77
  for (const row of sessions) if (crewSet.has(row.id)) row.crew = true;
73
78
  // The isolated-workspace marker: the hub stamps every worktree session with a
package/src/hub/entry.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  import { apply as applyHub, inject, name, WorkerRegistry } from './index.mjs';
11
11
  import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
12
12
  import { getProcessAdaptiveHealthStore } from '../adaptive-routing.mjs';
13
- import { claimReleaseInUse } from '../release-in-use.mjs';
13
+ import { claimReleaseInUse, clearReleaseClaim } from '../release-in-use.mjs';
14
14
 
15
15
  const RUNTIME_PATH = '/_dsh/dsh-crew/runtime';
16
16
  const ADAPTIVE_OBSERVER_INSTALLED = Symbol.for('@ran-sh/dsh-crew/adaptive-observer-installed');
@@ -97,8 +97,37 @@ export async function apply(ctx) {
97
97
  // modules lazily with a cache-busting query, so deleting the release under a
98
98
  // running process would break those routes with no way to recover but a
99
99
  // restart. Retention reads these claims and leaves a live release alone.
100
- claimReleaseInUse();
101
- registerRuntimeEndpoint(ctx);
102
- installAdaptiveHealthObserver();
103
- return applyHub(ctx);
100
+ //
101
+ // A claim that could not be written is worth saying out loud: retention cannot
102
+ // see an unclaimed release, so this process is then the one that a later update
103
+ // may delete from under itself.
104
+ const claimFile = claimReleaseInUse();
105
+ if (!claimFile) {
106
+ ctx.logger?.warn?.('dsh-crew: could not record this release as in use; a later update may prune it while this Hub is running');
107
+ }
108
+ try {
109
+ registerRuntimeEndpoint(ctx);
110
+ installAdaptiveHealthObserver();
111
+ const disposeHub = await applyHub(ctx);
112
+ // Release the claim on disposal, and only once teardown has actually
113
+ // finished: clearing it first would say "this release is unused" while the
114
+ // Hub is still running. Nothing else may remove a claim file — the reader
115
+ // deliberately never unlinks one, because it cannot tell its object from a
116
+ // successor published at the same name — but the process that owns a claim
117
+ // may remove its own, and it removes *this* claim rather than any other this
118
+ // process happens to hold.
119
+ return async () => {
120
+ try { if (typeof disposeHub === 'function') await disposeHub(); }
121
+ // Only a claim this mount actually holds. A null handle means the claim was
122
+ // never acquired, and clearing something anyway could remove a sibling
123
+ // mount's protection.
124
+ finally { if (claimFile) { try { clearReleaseClaim({ file: claimFile }); } catch { /* best effort */ } } }
125
+ };
126
+ } catch (error) {
127
+ // No disposer will ever be returned for a failed mount, so the claim has to
128
+ // be released here or the release stays pinned by a process that never
129
+ // provided anything.
130
+ if (claimFile) { try { clearReleaseClaim({ file: claimFile }); } catch { /* best effort */ } }
131
+ throw error;
132
+ }
104
133
  }
@@ -42,7 +42,7 @@ import { homedir } from 'node:os';
42
42
  import * as realInstaller from './install.mjs';
43
43
  import { samePayloadContent, capturePayloadContent } from './payload-content.mjs';
44
44
  import { crewDshHome, crewProfileDir } from './install.mjs';
45
- import { liveReleaseClaims } from '../release-in-use.mjs';
45
+ import { releaseClaimsState } from '../release-in-use.mjs';
46
46
  import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
47
47
  import {
48
48
  ensureOfficialWebIntegration,
@@ -1084,14 +1084,26 @@ const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
1084
1084
  function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
1085
1085
  const pointer = readCurrentPointer({ home });
1086
1086
  const releasesDir = crewReleasesDir({ home });
1087
- if (!existsSync(releasesDir)) return;
1087
+ if (!existsSync(releasesDir)) return [];
1088
1088
  const removed = [];
1089
1089
  // A running Hub keeps executing the release it started from and re-reads
1090
1090
  // several modules from disk on every request, so removing that release breaks
1091
1091
  // those routes with no recovery but a restart. Protect what live processes
1092
1092
  // claim, not just what the pointer names.
1093
- const claimed = liveReleaseClaims({ home }).map((dir) => resolve(dir));
1094
- const live = new Set([...(protect ? [resolve(protect)] : []), ...claimed]);
1093
+ //
1094
+ // Liveness has to be known, not merely unrefuted: if the claim directory
1095
+ // cannot be read, "no live claims" is an absence of evidence, and deleting on
1096
+ // it reproduces the outage this protection exists to prevent. Pruning is
1097
+ // optional and a skipped pass costs disk; guessing wrong costs a broken Hub.
1098
+ const claims = releaseClaimsState({ home });
1099
+ if (!claims.reliable) {
1100
+ // Say so rather than passing over it in silence: nothing is pruned until the
1101
+ // claim directory can be read again, and an operator who never hears about
1102
+ // that finds out when the disk fills.
1103
+ process.emitWarning(`dsh-crew: release liveness could not be read (${claims.error ?? 'unknown cause'}); skipping release pruning this pass`);
1104
+ return removed;
1105
+ }
1106
+ const live = new Set([...(protect ? [resolve(protect)] : []), ...claims.live.map((dir) => resolve(dir))]);
1095
1107
  const dirs = readdirSync(releasesDir)
1096
1108
  .map((name) => join(releasesDir, name))
1097
1109
  .filter((dir) => (!pointer || dir !== pointer.path) && !live.has(resolve(dir)));
@@ -16,13 +16,21 @@
16
16
  // release with a live claim. Liveness is decided by the pid, not by the file, so
17
17
  // a process that dies without cleaning up cannot pin a release forever.
18
18
 
19
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { randomBytes } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
20
21
  import { homedir } from 'node:os';
21
22
  import { dirname, join, resolve } from 'node:path';
22
23
  import { fileURLToPath } from 'node:url';
23
24
 
24
25
  export const IN_USE_DIRNAME = 'in-use';
25
26
 
27
+ // A claim file is `<pid>.json` or `<pid>-<nonce>.json`. The PID alone is not
28
+ // enough to identify a claim: two mounts inside one process would publish to the
29
+ // same pathname, and whichever disposed first would remove the other's
30
+ // protection. A claim is owned by the mount that wrote it, so it needs a name
31
+ // that says which one that was.
32
+ const CLAIM_NAME_RE = /^(\d+)(?:-[0-9a-f]+)?\.json$/;
33
+
26
34
  export function releaseInUseDir({ home = homedir() } = {}) {
27
35
  return join(home, '.config', 'dsh-crew', 'app', IN_USE_DIRNAME);
28
36
  }
@@ -55,8 +63,13 @@ function isAlive(pid) {
55
63
 
56
64
  /**
57
65
  * Record that this process is running `releasePath`. Best effort: failing to
58
- * claim must never stop the Hub from starting, because the cost of a missed
59
- * claim is a possible retention of one extra release.
66
+ * claim must never stop the Hub from starting.
67
+ *
68
+ * A null return is a real degradation, not a footnote. Retention cannot see a
69
+ * release that was never claimed, so an unclaimable release is one that a later
70
+ * update may delete from under this process — the 500-answering routes this
71
+ * module exists to prevent. Callers should surface it; `releaseClaimsState`
72
+ * gives retention the matching fail-closed signal.
60
73
  */
61
74
  export function claimReleaseInUse({ moduleUrl = import.meta.url, releasePath, home = homedir(), pid = process.pid, now = Date.now() } = {}) {
62
75
  try {
@@ -64,47 +77,122 @@ export function claimReleaseInUse({ moduleUrl = import.meta.url, releasePath, ho
64
77
  if (!target) return null;
65
78
  const dir = releaseInUseDir({ home });
66
79
  mkdirSync(dir, { recursive: true });
67
- const file = join(dir, `${pid}.json`);
68
- writeFileSync(file, JSON.stringify({ pid, release: resolve(target), claimed_at: now }) + '\n');
80
+ // The nonce makes this claim the property of this call. Clearing by PID would
81
+ // let one mount remove a sibling mount's only protection.
82
+ const nonce = randomBytes(8).toString('hex');
83
+ const file = join(dir, `${pid}-${nonce}.json`);
84
+ // Write then rename: a reader that lists the directory can otherwise see this
85
+ // filename while its JSON is still empty or half-written, and an unparseable
86
+ // claim is indistinguishable from a dead one.
87
+ const pending = `${file}.tmp`;
88
+ writeFileSync(pending, JSON.stringify({ pid, nonce, release: resolve(target), claimed_at: now }) + '\n');
89
+ renameSync(pending, file);
69
90
  return file;
70
91
  } catch { return null; }
71
92
  }
72
93
 
94
+ /** The unbranded per-PID claim path, as written by releases before claims were per-mount. */
73
95
  export function releaseClaimFile({ home = homedir(), pid = process.pid } = {}) {
74
96
  return join(releaseInUseDir({ home }), `${pid}.json`);
75
97
  }
76
98
 
77
99
  export function releaseClaimInUse({ home = homedir(), pid = process.pid } = {}) {
78
- const file = releaseClaimFile({ home, pid });
79
- return existsSync(file) || false;
100
+ let names;
101
+ try { names = readdirSync(releaseInUseDir({ home })); } catch { return false; }
102
+ return names.some((name) => CLAIM_NAME_RE.exec(name)?.[1] === String(pid));
103
+ }
104
+
105
+ /**
106
+ * Remove the claim whose handle `claimReleaseInUse` returned.
107
+ *
108
+ * A missing handle is not permission to remove something: without one there is
109
+ * no way to know which claim this caller owns, and guessing by PID can remove a
110
+ * sibling mount's protection. Legacy unbranded claims are removed by
111
+ * `clearLegacyReleaseClaim`, which says so in its name.
112
+ */
113
+ export function clearReleaseClaim({ file = null } = {}) {
114
+ if (!file) return false;
115
+ try { rmSync(file, { force: true }); return true; } catch { return false; }
80
116
  }
81
117
 
82
- export function clearReleaseClaim({ home = homedir(), pid = process.pid } = {}) {
118
+ /**
119
+ * Remove the unbranded `<pid>.json` claim written by releases before claims were
120
+ * per-mount. Separate from `clearReleaseClaim` because it can take a claim this
121
+ * caller did not write, and that should never happen by falling through a
122
+ * missing argument.
123
+ */
124
+ export function clearLegacyReleaseClaim({ home = homedir(), pid = process.pid } = {}) {
83
125
  try { rmSync(releaseClaimFile({ home, pid }), { force: true }); return true; } catch { return false; }
84
126
  }
85
127
 
86
128
  /**
87
- * Releases currently held by a live process. Dead claims are removed as they are
88
- * encountered, so the directory cannot accumulate stale files.
129
+ * Releases currently held by a live process, plus whether that answer is
130
+ * trustworthy.
131
+ *
132
+ * `reliable: false` means liveness could not be determined, so an empty `live`
133
+ * list is not evidence that nothing is running. A caller that deletes releases
134
+ * must treat unknown as "do not delete": reading an unreadable claim directory
135
+ * as "no claims" is exactly how a release disappears from under a running Hub,
136
+ * and the damage is to the process, not to the files.
137
+ *
138
+ * Nothing is deleted here. A claim is only ever a protection, so a reaper that
139
+ * unlinks one has to be certain the object it inspected is still the object it
140
+ * is removing — and it cannot be, because the pathname outlives the claim and a
141
+ * process restarting with a reused PID publishes a new file at the same one.
142
+ * Checking liveness and then unlinking is a check-then-act over a name, and
143
+ * losing that race deletes a live process's protection. Stale files therefore
144
+ * stay; a Hub clears its own claim on a clean shutdown, and one file per
145
+ * hard-killed process is not worth a race over a safety mechanism.
89
146
  */
90
- export function liveReleaseClaims({ home = homedir(), alive = isAlive } = {}) {
147
+ export function releaseClaimsState({ home = homedir(), alive = isAlive } = {}) {
91
148
  const dir = releaseInUseDir({ home });
92
149
  let names;
93
- // A state directory that is unreadable — or replaced by a file — must read as
94
- // "no claims" rather than throwing: this runs inside install, and failing it
95
- // would block an update over a problem that only affects pruning.
96
- try { names = readdirSync(dir); } catch { return []; }
150
+ try { names = readdirSync(dir); } catch (error) {
151
+ // A directory that does not exist yet reliably holds no claims; any other
152
+ // failure — permissions, a file where the directory should be — does not.
153
+ if (error?.code === 'ENOENT') return { live: [], reliable: true };
154
+ return { live: [], reliable: false, error: String(error?.message ?? error) };
155
+ }
97
156
  const live = [];
157
+ let unknown = null;
98
158
  for (const name of names) {
99
159
  if (!name.endsWith('.json')) continue;
100
160
  const file = join(dir, name);
101
- let record;
102
- try { record = JSON.parse(readFileSync(file, 'utf8')); } catch { record = null; }
103
- if (record && Number.isInteger(record.pid) && typeof record.release === 'string' && alive(record.pid)) {
104
- live.push(resolve(record.release));
161
+ // The filename carries the PID, and the name is complete before any content
162
+ // exists — so a torn write still says whose claim it was, which is what
163
+ // keeps one old unusable file from disabling pruning forever.
164
+ const pidFromName = CLAIM_NAME_RE.exec(name) ? Number.parseInt(name, 10) : null;
165
+
166
+ let parsed = null;
167
+ try { parsed = JSON.parse(readFileSync(file, 'utf8')); } catch { parsed = null; }
168
+
169
+ const wellFormed = parsed && typeof parsed === 'object'
170
+ && Number.isInteger(parsed.pid) && parsed.pid > 0
171
+ && typeof parsed.release === 'string' && parsed.release !== ''
172
+ && parsed.pid === pidFromName;
173
+
174
+ if (wellFormed) {
175
+ // A well-formed claim for a process that is gone simply protects nothing;
176
+ // it is inert, not a reason to distrust the rest.
177
+ if (alive(parsed.pid)) live.push(resolve(parsed.release));
105
178
  continue;
106
179
  }
107
- try { rmSync(file, { force: true }); } catch { /* best effort */ }
180
+
181
+ // Anything else is not evidence of a dead process. Syntax that happens to
182
+ // parse is not a schema: `{}` and `{"pid":123}` say nothing about liveness.
183
+ // The filename is the only identity left, and a claim whose named process is
184
+ // positively gone is inert for the same reason.
185
+ if (pidFromName !== null && !alive(pidFromName)) continue;
186
+ // No usable PID, or the PID may still be running. A reused PID is
187
+ // indistinguishable from the original here, so this stays unknown rather
188
+ // than guessing; that is a fail-closed condition, not a resolved one.
189
+ unknown ??= `unusable claim: ${file}`;
108
190
  }
109
- return [...new Set(live)];
191
+ if (unknown) return { live: [...new Set(live)], reliable: false, error: unknown };
192
+ return { live: [...new Set(live)], reliable: true };
193
+ }
194
+
195
+ /** Releases currently held by a live process. See `releaseClaimsState`. */
196
+ export function liveReleaseClaims(opts = {}) {
197
+ return releaseClaimsState(opts).live;
110
198
  }
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '1.9.1';
39
+ export const RUNTIME_VERSION = '1.10.1';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -11,10 +11,10 @@
11
11
  // lock blocks cleanup.
12
12
 
13
13
  import { execFile } from 'node:child_process';
14
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
15
15
  import { readFile, lstat } from 'node:fs/promises';
16
16
  import { tmpdir } from 'node:os';
17
- import { join, resolve, basename } from 'node:path';
17
+ import { join, resolve, basename, dirname } from 'node:path';
18
18
  import { randomBytes, createHash } from 'node:crypto';
19
19
  import { promisify } from 'node:util';
20
20
  import { isSensitivePath, parseChanges, DIFF_LIMIT, GIT_TIMEOUT_MS } from './workspace-audit.mjs';
@@ -31,17 +31,22 @@ export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
31
31
  export const MAX_PARALLEL_CAP = 16;
32
32
  export const DEFAULT_MAX_PARALLEL = 3;
33
33
  // Worktree names read `Crew_YYYYMMDD_HHMMSS_<purpose>` so an operator can tell
34
- // from the directory alone when a job ran and what it was for. The legacy
35
- // prefix stays recognised as ours, so worktrees created by an earlier release
36
- // are still adopted and cleaned up rather than orphaned.
34
+ // from the directory alone when a job ran and what it was for. Worktrees from
35
+ // an earlier release used `dsh-crew-<job>-<hex>` and stay recognised, so old
36
+ // trees are still adopted and cleaned up rather than orphaned.
37
+ //
38
+ // The shapes are matched exactly rather than by prefix, because the prefix
39
+ // alone is not proof of ownership: cleanup `--force`-deletes what it adopts,
40
+ // and a directory a user happened to name Crew_manual-testing or
41
+ // dsh-crew-backup would go with its uncommitted contents.
37
42
  const WORKTREE_PREFIX = 'Crew_';
38
- const LEGACY_WORKTREE_PREFIXES = Object.freeze(['dsh-crew-']);
43
+ const WORKTREE_NAME_RE = /^Crew_\d{8}_\d{6}_[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:-\d+)?$/;
44
+ const LEGACY_WORKTREE_RE = /^dsh-crew-[A-Za-z0-9._-]+-[0-9a-f]{8}$/;
39
45
 
40
46
  /** Whether a directory name is a worktree Crew created. */
41
47
  export function isCrewWorktreeName(name) {
42
48
  const value = String(name ?? '');
43
- return value.startsWith(WORKTREE_PREFIX)
44
- || LEGACY_WORKTREE_PREFIXES.some((prefix) => value.startsWith(prefix));
49
+ return WORKTREE_NAME_RE.test(value) || LEGACY_WORKTREE_RE.test(value);
45
50
  }
46
51
 
47
52
  async function defaultRunner(args, { cwd }) {
@@ -80,23 +85,27 @@ function stamp(at) {
80
85
  + `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
81
86
  }
82
87
 
83
- /**
84
- * `Crew_<date>_<time>_<purpose>`, with a numeric suffix only when that name is
85
- * already taken. Parallel jobs can start inside the same second, so the suffix is
86
- * what keeps the name unique without putting random noise in every name.
87
- */
88
88
  /**
89
89
  * `Crew_<date>_<time>_<purpose>`, reserved by creating the directory.
90
90
  *
91
91
  * The reservation is a mkdir, not a lookup: two jobs starting in the same second
92
92
  * both probe before either has created anything, so a check-then-act name would
93
93
  * hand them the same path and one `git worktree add` would fail. mkdir
94
- * fails on EEXIST, which makes the suffix loop race-free.
94
+ * fails on EEXIST, which makes the suffix loop race-free, and a numeric suffix
95
+ * is added only when the name is already taken.
95
96
  */
96
- function reserveWorktreeDir({ root, purpose, at }) {
97
- mkdirSync(root, { recursive: true });
97
+ export function reserveWorktreeDir({ root, purpose, at }) {
98
+ try {
99
+ mkdirSync(root, { recursive: true });
100
+ } catch (error) {
101
+ return { ok: false, error: `cannot create worktree root: ${error?.message ?? error}` };
102
+ }
103
+ // Truncate before trimming. Trimming first lets a 32-character slice end on
104
+ // the separator it just created, and the resulting name fails
105
+ // `isCrewWorktreeName` — so Crew would create a worktree it no longer
106
+ // recognises as its own, and stale pruning would never adopt it again.
98
107
  const safe = String(purpose ?? 'job').replace(/[^A-Za-z0-9]+/g, '-')
99
- .replace(/^-+|-+$/g, '').slice(0, PURPOSE_MAX) || 'job';
108
+ .slice(0, PURPOSE_MAX).replace(/^-+|-+$/g, '') || 'job';
100
109
  const base = `${WORKTREE_PREFIX}${stamp(at)}_${safe}`;
101
110
  for (let n = 1; n <= 100; n += 1) {
102
111
  const name = n === 1 ? base : `${base}-${n}`;
@@ -114,6 +123,138 @@ export function defaultWorktreeRoot() {
114
123
  return join(tmpdir(), 'dsh-crew-worktrees');
115
124
  }
116
125
 
126
+ // ---------- ownership record ----------
127
+ //
128
+ // A name is not proof that Crew made a worktree. `dsh-crew-backup-deadbeef`
129
+ // satisfies the legacy grammar, and `detached` describes HEAD rather than who
130
+ // created the tree, so both can be true of a worktree a user made — and cleanup
131
+ // `--force` deletes what it adopts. Crew therefore records what it creates,
132
+ // beside the worktrees it creates it in, and only ever adopts what a valid
133
+ // record claims. Anything else that looks like a leftover is reported for a
134
+ // human instead of removed.
135
+ //
136
+ // The record lives under the worktree root rather than in the Crew home so it
137
+ // travels with the trees it describes: if the root is wiped, the trees are gone
138
+ // with it and there is nothing left to adopt.
139
+ //
140
+ // A record is evidence only when it can be read and matches: its shape is
141
+ // validated, its name has to be this worktree's name, and its repository has to
142
+ // be the repository being scanned. An unreadable, truncated, malformed or
143
+ // mismatched record reads as "not owned", which withholds deletion rather than
144
+ // granting it — the one direction where being wrong is survivable.
145
+
146
+ const OWNED_DIRNAME = '.crew-owned';
147
+ const OWNED_SCHEMA = 2;
148
+ // Stored inside the worktree's git administrative directory, which git destroys
149
+ // with the worktree.
150
+ const INCARNATION_FILE = 'crew-owned';
151
+
152
+ function ownedMarkerPath(worktreePath) {
153
+ const abs = resolve(worktreePath);
154
+ return join(dirname(abs), OWNED_DIRNAME, `${basename(abs)}.json`);
155
+ }
156
+
157
+ /**
158
+ * Prove which *incarnation* of a worktree this is.
159
+ *
160
+ * A path can be reused. Git removes a worktree and later another one is created
161
+ * in the same place — same name, same repository — so a record keyed on those
162
+ * alone can be inherited by a successor that Crew never created, and inherited
163
+ * again after Crew's own cleanup if releasing the record failed. The worktree's
164
+ * git administrative directory does not have that problem: git deletes it along
165
+ * with the worktree, so a value kept there cannot outlive the tree it describes.
166
+ */
167
+ async function gitDirOf(run, cwd) {
168
+ const abs = await runGit(run, ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd });
169
+ if (abs.ok && abs.stdout.trim()) return resolve(abs.stdout.trim());
170
+ const rel = await runGit(run, ['rev-parse', '--git-dir'], { cwd });
171
+ if (!rel.ok || !rel.stdout.trim()) return null;
172
+ return resolve(cwd, rel.stdout.trim());
173
+ }
174
+
175
+ /** The nonce this worktree incarnation was stamped with, if any. */
176
+ async function incarnationOf(run, worktreePath) {
177
+ const gitDir = await gitDirOf(run, worktreePath);
178
+ if (!gitDir) return null;
179
+ let nonce;
180
+ try { nonce = readFileSync(join(gitDir, INCARNATION_FILE), 'utf8').trim(); } catch { return null; }
181
+ return nonce ? { gitDir, nonce } : null;
182
+ }
183
+
184
+ /** Stamp a freshly created worktree so its record cannot be inherited later. */
185
+ async function claimIncarnation(run, worktreePath) {
186
+ const gitDir = await gitDirOf(run, worktreePath);
187
+ if (!gitDir) return null;
188
+ const nonce = randomBytes(16).toString('hex');
189
+ try { writeFileSync(join(gitDir, INCARNATION_FILE), `${nonce}\n`); } catch { return null; }
190
+ return { gitDir, nonce };
191
+ }
192
+
193
+ function recordOwnership({ worktreePath, repo, incarnation, head, purpose, at }) {
194
+ const marker = ownedMarkerPath(worktreePath);
195
+ const record = {
196
+ schemaVersion: OWNED_SCHEMA,
197
+ name: basename(resolve(worktreePath)),
198
+ worktree: pathIdentity(worktreePath),
199
+ // Binding the record to a repository is what stops a stale record from
200
+ // licensing a delete for a worktree of some other repository that later
201
+ // occupies the same path. The identity is the git *common* directory, not
202
+ // `--show-toplevel`: that returns the linked worktree's own path when run
203
+ // inside one, so it names different directories for the same repository
204
+ // depending on where it is asked.
205
+ repo: pathIdentity(repo),
206
+ git_dir: pathIdentity(incarnation.gitDir),
207
+ nonce: incarnation.nonce,
208
+ // The revision Crew left the worktree at. An operator who commits or checks
209
+ // out something else has taken the worktree over, and moving HEAD is how
210
+ // that shows up: being on a branch is only one way to do it.
211
+ head,
212
+ purpose: purpose ?? null,
213
+ created_at: at ?? Date.now(),
214
+ };
215
+ try {
216
+ mkdirSync(dirname(marker), { recursive: true });
217
+ const pending = `${marker}.${process.pid}.tmp`;
218
+ writeFileSync(pending, JSON.stringify(record) + '\n');
219
+ renameSync(pending, marker);
220
+ return true;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ function releaseOwnership({ worktreePath }) {
227
+ try { rmSync(ownedMarkerPath(worktreePath), { force: true }); return true; } catch { return false; }
228
+ }
229
+
230
+ /**
231
+ * The validated ownership record for `worktreePath`, or null when there is no
232
+ * usable evidence that Crew created *this* worktree in `repo`.
233
+ *
234
+ * This answers identity only — was it Crew's worktree? — and deliberately not
235
+ * state, which is a question for the caller: `head` and `detached` describe what
236
+ * the worktree looks like now, and a worktree whose operator has moved HEAD is
237
+ * still Crew's worktree, just no longer a disposable one. All of shape, name,
238
+ * path, repository and incarnation have to match; every failure reads as "not
239
+ * owned", which withholds deletion rather than granting it.
240
+ */
241
+ function readOwnership({ worktreePath, repo, incarnation }) {
242
+ let raw;
243
+ try { raw = readFileSync(ownedMarkerPath(worktreePath), 'utf8'); } catch { return null; }
244
+ let record;
245
+ try { record = JSON.parse(raw); } catch { return null; }
246
+ if (!record || typeof record !== 'object' || record.schemaVersion !== OWNED_SCHEMA) return null;
247
+ if (typeof record.name !== 'string' || record.name !== basename(resolve(worktreePath))) return null;
248
+ if (typeof record.worktree !== 'string' || record.worktree !== pathIdentity(worktreePath)) return null;
249
+ // An unknown identity on either side cannot be matched, so it cannot be
250
+ // adopted: an unverifiable record is not evidence.
251
+ if (!repo || typeof record.repo !== 'string' || record.repo !== pathIdentity(repo)) return null;
252
+ if (!incarnation) return null;
253
+ if (typeof record.nonce !== 'string' || record.nonce !== incarnation.nonce) return null;
254
+ if (typeof record.git_dir !== 'string' || record.git_dir !== pathIdentity(incarnation.gitDir)) return null;
255
+ return record;
256
+ }
257
+
117
258
  /**
118
259
  * Resolve repository root + HEAD. Dirty detection is advisory: inability to
119
260
  * read `git status` must not turn an otherwise valid repository into a hard
@@ -140,6 +281,10 @@ export async function inspectRepository({ cwd, git, runner } = {}) {
140
281
 
141
282
  export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevision, at, root = defaultWorktreeRoot(), git } = {}) {
142
283
  const run = git ?? defaultRunner;
284
+ // A relative root would be resolved against this process's cwd for the
285
+ // mkdir reservation but against the repo root for the git invocation, so the
286
+ // two could land in different places. Anchor it once.
287
+ root = resolve(root);
143
288
  const repo = await inspectRepository({ cwd, git: run });
144
289
  if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
145
290
  const rev = baseRevision ?? repo.baseRevision;
@@ -148,11 +293,44 @@ export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevisio
148
293
  const { dir, name } = reserved;
149
294
  const res = await runGit(run, ['worktree', 'add', '--detach', dir, rev], { cwd: repo.repoRoot });
150
295
  if (!res.ok) {
151
- // Release the name so a retry is not blocked by an empty directory.
152
- try { rmSync(dir, { recursive: true, force: true }); } catch {}
153
- return { ok: false, reason: res.reason, error: res.error };
296
+ // A nonzero exit does not mean git did nothing: a failing post-checkout hook
297
+ // leaves the worktree registered and populated, and a transient Windows lock
298
+ // can defeat the unregister. Go through the same verified, retrying cleanup
299
+ // the normal path uses, and report a blocked reservation rather than
300
+ // pretending the name was released — a stranded registration makes every
301
+ // later attempt on this name fail with no way for the caller to tell why.
302
+ const released = await cleanupIsolatedWorkspace({ worktreePath: dir, repoRoot: repo.repoRoot, git: run, reservation: true });
303
+ return {
304
+ ok: false,
305
+ reason: res.reason,
306
+ error: res.error,
307
+ ...(released.ok ? {} : { cleanupBlocked: true, cleanupError: released.error }),
308
+ };
154
309
  }
155
- return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name };
310
+ // Recorded only after git succeeded, so a failed creation never leaves a claim
311
+ // on a tree that does not exist. A record that cannot be written costs the
312
+ // automatic prune of this one worktree; it never costs the worktree itself.
313
+ //
314
+ // `owned` is only true when a record that `readOwnership` would actually
315
+ // accept was written: a record missing its repository or incarnation identity
316
+ // can never authorize cleanup, and reporting it as owned would be a leak
317
+ // dressed up as success.
318
+ // The revision Git actually left the worktree at, not the string we asked for:
319
+ // a caller may pass a branch or tag, and recording that name would never match
320
+ // the commit OID `worktree list` reports, so the worktree could never be
321
+ // recognized as untouched.
322
+ const headRes = await runGit(run, ['rev-parse', 'HEAD'], { cwd: dir });
323
+ const created = headRes.ok ? headRes.stdout.trim() : '';
324
+
325
+ const commonDir = await commonDirOf(run, repo.repoRoot);
326
+ const incarnation = commonDir ? await claimIncarnation(run, dir) : null;
327
+ // All four identities are required, and an unresolvable HEAD is not a fallback
328
+ // to the requested string: recording a symbolic name would produce a record
329
+ // that can never match, which is a worktree that can never be cleaned up
330
+ // reported as owned.
331
+ const owned = Boolean(commonDir && incarnation && created)
332
+ && recordOwnership({ worktreePath: dir, repo: commonDir, incarnation, head: created, purpose, at });
333
+ return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name, owned };
156
334
  }
157
335
 
158
336
  function splitFirstTab(line) {
@@ -328,30 +506,114 @@ function trackedIn(nameStatus, untracked) {
328
506
  return out;
329
507
  }
330
508
 
509
+ /**
510
+ * Parse `git worktree list --porcelain` into records. Git documents that the
511
+ * main working tree is the first record; `detached` marks the ones Crew creates,
512
+ * since every Crew worktree is added with `--detach`.
513
+ */
514
+ function parseWorktrees(stdout) {
515
+ const out = [];
516
+ for (const block of String(stdout ?? '').split('\n\n')) {
517
+ const lines = block.split('\n');
518
+ const path = lines.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
519
+ if (!path) continue;
520
+ out.push({
521
+ path: resolve(path),
522
+ detached: lines.includes('detached'),
523
+ head: lines.find((line) => line.startsWith('HEAD '))?.slice('HEAD '.length)?.trim() ?? null,
524
+ });
525
+ }
526
+ return out;
527
+ }
528
+
331
529
  async function mainRepoRoot(run, worktreePath) {
332
- const common = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd: worktreePath });
333
- if (!common.ok) return null;
334
- const dir = String(common.stdout ?? '').trim();
335
- if (!dir) return null;
336
- return resolve(dir, '..');
530
+ // The main working tree is the first record of `git worktree list`; deriving
531
+ // it from `--git-common-dir` breaks for a bare main repository, where that
532
+ // path is the bare repository itself and its parent is not a git directory at
533
+ // all. The first record is the same ordering guarantee the stale-list scan
534
+ // already relies on.
535
+ const list = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: worktreePath });
536
+ if (!list.ok) return null;
537
+ return parseWorktrees(list.stdout)[0]?.path ?? null;
337
538
  }
338
539
 
339
540
  const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
340
541
 
542
+ /**
543
+ * A path identity safe to compare for equality.
544
+ *
545
+ * `realpathSync` returns the on-disk spelling, so two aliases of one directory —
546
+ * a symlinked repo root, a mapped drive, a differently cased path — collapse to
547
+ * the same string without guessing. Lower-casing on Windows used to stand in for
548
+ * this, but Windows supports per-directory case sensitivity, where `Foo` and
549
+ * `foo` really are two directories and folding them together would license a
550
+ * delete against the wrong repository.
551
+ */
341
552
  const pathIdentity = (value) => {
342
553
  const resolved = resolve(String(value));
343
- return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
554
+ try { return realpathSync.native ? realpathSync.native(resolved) : realpathSync(resolved); }
555
+ catch { return resolved; }
344
556
  };
345
557
 
346
558
  async function worktreeRegistered({ worktreePath, git, cwd }) {
347
559
  const res = await runGit(git, ['worktree', 'list', '--porcelain'], { cwd });
348
560
  if (!res.ok) return null;
349
561
  const target = pathIdentity(worktreePath);
350
- for (const block of String(res.stdout).split('\n\n')) {
351
- const path = block.split('\n').find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
352
- if (path && pathIdentity(path) === target) return true;
353
- }
354
- return false;
562
+ return parseWorktrees(res.stdout).some((record) => pathIdentity(record.path) === target);
563
+ }
564
+
565
+ /**
566
+ * The repository's common git directory as seen from `cwd`, or null when git
567
+ * cannot say. `--path-format=absolute` needs git 2.31+, so the relative form is
568
+ * resolved against `cwd` for older installations.
569
+ */
570
+ async function commonDirOf(run, cwd) {
571
+ const abs = await runGit(run, ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd });
572
+ if (abs.ok && abs.stdout.trim()) return resolve(abs.stdout.trim());
573
+ const rel = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd });
574
+ if (!rel.ok || !rel.stdout.trim()) return null;
575
+ return resolve(cwd, rel.stdout.trim());
576
+ }
577
+
578
+ /**
579
+ * Whether `worktreePath` carries git metadata for the same repository as `root`.
580
+ *
581
+ * A directory name is not proof of ownership, and the filesystem fallback below
582
+ * force-deletes what it adopts — so the last resort asks git which repository the
583
+ * directory belongs to and refuses when the answer is unavailable or different.
584
+ * An unrelated directory that merely happens to be named like a Crew worktree
585
+ * therefore survives.
586
+ */
587
+ async function sameRepository(run, worktreePath, root) {
588
+ const [a, b] = await Promise.all([commonDirOf(run, worktreePath), commonDirOf(run, root)]);
589
+ return Boolean(a && b && pathIdentity(a) === pathIdentity(b));
590
+ }
591
+
592
+ function emptyDirectory(path) {
593
+ try { return readdirSync(path).length === 0; } catch { return false; }
594
+ }
595
+
596
+ /**
597
+ * Why `worktreePath` is no longer the worktree the caller validated, or null when
598
+ * it still is.
599
+ *
600
+ * Checking ownership and then deleting by pathname is check-then-act: whatever
601
+ * occupies the path when the delete runs is what gets deleted. Re-reading the
602
+ * incarnation, registration, HEAD and detached state immediately before the
603
+ * removal narrows that window to the removal itself.
604
+ */
605
+ async function ownershipDrift(run, worktreePath, expect) {
606
+ const incarnation = await incarnationOf(run, worktreePath);
607
+ if (!incarnation) return 'its incarnation stamp is gone';
608
+ if (incarnation.nonce !== expect.incarnation?.nonce) return 'its incarnation nonce changed';
609
+ if (pathIdentity(incarnation.gitDir) !== pathIdentity(expect.incarnation?.gitDir)) return 'its git directory changed';
610
+ const listed = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: worktreePath });
611
+ if (!listed.ok) return 'it is no longer registered';
612
+ const record = parseWorktrees(listed.stdout).find((r) => pathIdentity(r.path) === pathIdentity(worktreePath));
613
+ if (!record) return 'it is no longer registered';
614
+ if (!record.detached) return 'it is no longer detached';
615
+ if (expect.head && record.head !== expect.head) return `its HEAD moved from ${expect.head} to ${record.head}`;
616
+ return null;
355
617
  }
356
618
 
357
619
  /**
@@ -372,36 +634,140 @@ export async function cleanupIsolatedWorkspace({
372
634
  git,
373
635
  retries = WORKTREE_CLEANUP_RETRIES,
374
636
  backoffMs = WORKTREE_CLEANUP_BACKOFF_MS,
637
+ reservation = false,
638
+ // Callers that own the worktree (the Hub allocated it, this process holds the
639
+ // job) may force it away. Callers that merely recognised it — background
640
+ // pruning — must not: forcing discards any changes the tree now holds, which is
641
+ // precisely what a takeover looks like.
642
+ force = true,
643
+ // What the caller validated before deciding to delete. Re-checked here, because
644
+ // deciding on one observation and acting on a pathname is a different object.
645
+ expect = null,
375
646
  } = {}) {
376
647
  const run = git ?? defaultRunner;
377
648
  if (!worktreePath) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path required' };
378
649
  const root = repoRoot ?? (await mainRepoRoot(run, worktreePath));
379
650
  const owned = isCrewWorktreeName(basename(resolve(worktreePath)));
380
651
 
652
+ if (expect) {
653
+ const drift = await ownershipDrift(run, worktreePath, expect);
654
+ if (drift) {
655
+ return { ok: false, reason: WORKTREE_LOCKED, error: `refusing to remove ${worktreePath}: ${drift}`, cleanupBlocked: true };
656
+ }
657
+ }
658
+
659
+ if (!force) {
660
+ // Measured, not assumed: git refuses modified, staged and untracked files
661
+ // without `--force`, but it removes a worktree whose only remaining content
662
+ // is *ignored* — build output, a log, local config — and takes that content
663
+ // with it. Ask about ignored files directly, and treat an unanswerable
664
+ // question as a reason not to delete.
665
+ const ignored = await runGit(run, ['ls-files', '--others', '--ignored', '--exclude-standard'], { cwd: worktreePath });
666
+ if (!ignored.ok || ignored.stdout.trim() !== '') {
667
+ return {
668
+ ok: false,
669
+ reason: WORKTREE_LOCKED,
670
+ error: `refusing to remove ${worktreePath} without --force: it holds files that are not Crew's, or its contents could not be read`,
671
+ cleanupBlocked: true,
672
+ };
673
+ }
674
+ // And the index flags, which are the same class of hole one level down: a
675
+ // tracked file with `assume-unchanged` or `skip-worktree` set is invisible to
676
+ // `status`, to the untracked query and to the ignored query alike, and git
677
+ // removes the worktree without complaint — taking the operator's edit with
678
+ // it. Measured on git 2.47.3. In `ls-files -v` a lowercase letter means
679
+ // assume-unchanged and `S` means skip-worktree; the ordinary state is `H`.
680
+ const flags = await runGit(run, ['ls-files', '-v'], { cwd: worktreePath });
681
+ const hidden = flags.ok
682
+ ? flags.stdout.split('\n').find((line) => /^([a-z]|S)/.test(line)) ?? null
683
+ : 'unknown';
684
+ if (!flags.ok || hidden) {
685
+ return {
686
+ ok: false,
687
+ reason: WORKTREE_LOCKED,
688
+ error: `refusing to remove ${worktreePath} without --force: its index carries assume-unchanged or skip-worktree entries, which hide a modification from every status query`,
689
+ cleanupBlocked: true,
690
+ };
691
+ }
692
+ }
693
+
381
694
  if (root) {
382
- // Preferred path: `git worktree remove --force` (removes registration and
383
- // directory atomically). Retry bounded times to recover transient locks.
695
+ // Preferred path: `git worktree remove` (removes registration and directory
696
+ // atomically). Retry bounded times to recover transient locks.
697
+ //
698
+ // The ownership re-check above narrows the window between deciding and
699
+ // deleting, but it does not close it: git has no "remove only if HEAD and
700
+ // incarnation still equal X" primitive, so a worktree that changes in the
701
+ // moments between the two is removed anyway. That residual is accepted and
702
+ // stated rather than papered over; closing it needs a lease on the worktree,
703
+ // not another read.
704
+ const args = force
705
+ ? ['worktree', 'remove', '--force', worktreePath]
706
+ : ['worktree', 'remove', worktreePath];
384
707
  for (let attempt = 0; attempt < retries; attempt += 1) {
385
- const res = await runGit(run, ['worktree', 'remove', '--force', worktreePath], { cwd: root });
386
- if (res.ok) return { ok: true, removed: true, actions: [`removed worktree ${worktreePath}`] };
708
+ const res = await runGit(run, args, { cwd: root });
709
+ if (res.ok) {
710
+ const released = releaseOwnership({ worktreePath });
711
+ return {
712
+ ok: true,
713
+ removed: true,
714
+ actions: [`removed worktree ${worktreePath}`],
715
+ // The worktree is gone but its record is not: worth saying, because the
716
+ // record is what would have authorized a later cleanup.
717
+ ...(released ? {} : { ownershipRecordStale: true }),
718
+ };
719
+ }
387
720
  if (attempt < retries - 1) await sleep(backoffMs);
388
721
  }
722
+ if (!force) {
723
+ // Non-forced removal failed: git is refusing, or the tree is locked. The
724
+ // filesystem fallback deletes unconditionally, so it must not run here.
725
+ return { ok: false, reason: WORKTREE_LOCKED, error: `git declined to remove ${worktreePath} without --force`, cleanupBlocked: true };
726
+ }
389
727
  }
390
728
 
391
729
  // Last resort, only for Crew-owned disposable paths: remove the directory and
392
730
  // verify the git registration actually went away before claiming success.
731
+ //
732
+ // Ownership here is not the name alone. An earlier revision adopted anything
733
+ // whose basename matched, so a directory the user had named (or a worktree they
734
+ // had made) could be force-deleted for resembling Crew's.
393
735
  if (owned && root && pathIdentity(worktreePath) !== pathIdentity(root)) {
736
+ // Ask git BEFORE deleting anything. The reverse order — delete, then check —
737
+ // destroys the files of a worktree git still tracks and then reports the
738
+ // registration stranded, which is the outcome the caller was avoiding.
739
+ const registered = await worktreeRegistered({ worktreePath, git: run, cwd: root });
740
+ if (registered === true) {
741
+ return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after git refused to remove it: ${worktreePath}`, cleanupBlocked: true };
742
+ }
743
+ // The empty directory this call itself reserved is the one path that needs no
744
+ // repository provenance; anything else must belong to this repository.
745
+ const ownEmptyReservation = reservation && emptyDirectory(worktreePath);
746
+ const adoptable = ownEmptyReservation || (registered === false && await sameRepository(run, worktreePath, root));
747
+ if (!adoptable) {
748
+ return {
749
+ ok: false,
750
+ reason: WORKTREE_LOCKED,
751
+ error: `refusing to delete ${worktreePath}: not a worktree of ${root} (remove it manually if it is disposable)`,
752
+ cleanupBlocked: true,
753
+ };
754
+ }
394
755
  try {
395
- rmSync(worktreePath, { recursive: true, force: true });
756
+ // A reservation is empty by construction, so remove it non-recursively: if
757
+ // something appeared in it, failing is better than deleting that too.
758
+ if (ownEmptyReservation) rmdirSync(worktreePath);
759
+ else rmSync(worktreePath, { recursive: true, force: true });
396
760
  } catch (err) {
397
761
  return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed: ${err?.message ?? String(err)}`, cleanupBlocked: true };
398
762
  }
399
- const registered = root ? await worktreeRegistered({ worktreePath, git: run, cwd: root }) : null;
400
- if (registered === false && !existsSync(worktreePath)) {
401
- return { ok: true, removed: true, actions: [`cleaned worktree files ${worktreePath}`] };
402
- }
403
- if (registered === true) {
404
- return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after cleanup: ${worktreePath}`, cleanupBlocked: true };
763
+ if (!existsSync(worktreePath)) {
764
+ const released = releaseOwnership({ worktreePath });
765
+ return {
766
+ ok: true,
767
+ removed: true,
768
+ actions: [`cleaned worktree files ${worktreePath}`],
769
+ ...(released ? {} : { ownershipRecordStale: true }),
770
+ };
405
771
  }
406
772
  return { ok: false, reason: WORKTREE_LOCKED, error: `could not verify worktree removal for ${worktreePath}`, cleanupBlocked: true };
407
773
  }
@@ -409,36 +775,161 @@ export async function cleanupIsolatedWorkspace({
409
775
  return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed while ${worktreePath} remains (${root ? 'not a Crew-owned disposable path' : 'main repository root unresolvable'})`, cleanupBlocked: true };
410
776
  }
411
777
 
412
- export async function staleWorktrees({ git, allowed = [] } = {}) {
778
+ /**
779
+ * Split the linked worktrees of this repository into:
780
+ *
781
+ * - `stale`: Crew recorded creating it and it is still in the disposable state
782
+ * Crew leaves behind, so it may be removed automatically;
783
+ * - `retained`: Crew recorded creating it, but an operator has since attached a
784
+ * branch or checked something out, so it is no longer Crew's to discard;
785
+ * - `unowned`: Crew cannot show it created it at all.
786
+ *
787
+ * Only the first is ever deleted. The other two are reported so a human decides.
788
+ * Returned paths are canonical: git reports the spelling it resolved when the
789
+ * worktree was added, which on Windows can expand an 8.3 temp name (`RUNNER~1`)
790
+ * into its long form, so a caller comparing its own path string against the
791
+ * answer would miss — and a miss here decides whether a live worktree looks
792
+ * like a leftover.
793
+ */
794
+ async function worktreeCandidates({ git, allowed = [] } = {}) {
413
795
  const run = git ?? defaultRunner;
414
- const set = new Set(allowed.map((p) => resolve(p)));
796
+ const set = new Set(allowed.map((p) => pathIdentity(p)));
415
797
  const stale = [];
798
+ const retained = [];
799
+ const unowned = [];
416
800
  try {
417
801
  const root = await inspectRepository({ cwd: allowed[0] ?? process.cwd(), git: run });
418
802
  if (root.ok) {
419
803
  const res = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: root.repoRoot });
420
804
  if (res.ok) {
421
- for (const block of String(res.stdout).split('\n\n')) {
422
- const path = block.split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length)?.trim();
423
- if (!path) continue;
424
- const abs = resolve(path);
425
- if (isCrewWorktreeName(basename(abs)) && !set.has(abs)) stale.push(abs);
805
+ // `git worktree list` puts the main working tree first. Inspecting from
806
+ // inside a linked worktree reports that worktree as the top level, so the
807
+ // repo root cannot be matched by path — the position in the list is what
808
+ // identifies the main tree.
809
+ const records = parseWorktrees(res.stdout);
810
+ const repo = await commonDirOf(run, root.repoRoot);
811
+ for (let index = 1; index < records.length; index += 1) {
812
+ const { path: abs, detached, head } = records[index];
813
+ // A repo whose directory happens to start with a Crew prefix (say, a
814
+ // checkout named dsh-crew-something) is not a disposable worktree, and
815
+ // treating it as stale would have the cleanup path fighting over it.
816
+ if (!isCrewWorktreeName(basename(abs)) || set.has(pathIdentity(abs))) continue;
817
+ const incarnation = await incarnationOf(run, abs);
818
+ const owned = readOwnership({ worktreePath: abs, repo, incarnation });
819
+ if (!owned) {
820
+ // The name is not proof: `dsh-crew-backup-deadbeef` is a name a user
821
+ // could plausibly pick. No valid record, no deletion.
822
+ unowned.push(pathIdentity(abs));
823
+ } else if (detached && owned.head === head) {
824
+ // Crew's worktree, untouched since Crew left it there. The incarnation
825
+ // and HEAD travel with it so the removal can re-check them rather
826
+ // than trusting that nothing changed since this scan.
827
+ stale.push({ path: pathIdentity(abs), incarnation, head });
828
+ } else {
829
+ // Crew's worktree, but no longer a disposable one. Being on a branch
830
+ // is only one way an operator takes a worktree over; committing on a
831
+ // detached HEAD, or checking out something else, moves it just as
832
+ // far out of Crew's hands.
833
+ retained.push(pathIdentity(abs));
834
+ }
426
835
  }
427
836
  }
428
837
  }
429
838
  } catch {}
430
- return stale;
839
+ return { stale, retained, unowned };
431
840
  }
432
841
 
433
- export async function pruneWorktrees({ git, allowed = [] } = {}) {
842
+ /** Crew-created worktrees that are still disposable and belong to no active job. */
843
+ export async function staleWorktrees({ git, allowed = [] } = {}) {
844
+ return (await worktreeCandidates({ git, allowed })).stale.map((entry) => entry.path);
845
+ }
846
+
847
+ /** Linked worktrees that resemble Crew's but carry no usable record of it. */
848
+ export async function unownedWorktrees({ git, allowed = [] } = {}) {
849
+ return (await worktreeCandidates({ git, allowed })).unowned;
850
+ }
851
+
852
+ /** Crew-created worktrees an operator has since taken over; never removed. */
853
+ export async function retainedWorktrees({ git, allowed = [] } = {}) {
854
+ return (await worktreeCandidates({ git, allowed })).retained;
855
+ }
856
+
857
+ /**
858
+ * Report what Crew could clean up, and only delete when explicitly asked.
859
+ *
860
+ * `remove` defaults to false, and the default is the point. Deleting from a
861
+ * background scan has been shown insufficient three separate ways: git removes a
862
+ * worktree whose only content is ignored; it removes one whose tracked file
863
+ * carries `assume-unchanged` or `skip-worktree`; and between validating a
864
+ * worktree and deleting it, the worktree can change. Each was measured against
865
+ * real git rather than reasoned about, and each ends with an operator's work
866
+ * gone. Nothing in Crew calls this path, so the automatic capability buys nothing
867
+ * and risks the one thing Crew must not lose.
868
+ *
869
+ * A caller that has decided to remove a specific worktree should pass
870
+ * `remove: true`, or better, use `cleanupIsolatedWorkspace` for a worktree it
871
+ * directly owns. Unattended deletion would need a lease on the worktree, not
872
+ * another pre-delete observation.
873
+ */
874
+ export async function pruneWorktrees({ git, allowed = [], remove = false } = {}) {
434
875
  const run = git ?? defaultRunner;
435
- const stale = await staleWorktrees({ git: run, allowed });
876
+ const { stale, retained, unowned } = await worktreeCandidates({ git: run, allowed });
436
877
  const actions = [];
437
- for (const w of stale) {
438
- const r = await cleanupIsolatedWorkspace({ worktreePath: w, git: run });
439
- actions.push(...(r.actions ?? [r.error ?? `stale worktree ${w}`]));
878
+
879
+ if (!remove) {
880
+ for (const entry of stale) actions.push(`stale Crew worktree, remove explicitly if it is finished with: ${entry.path}`);
881
+ for (const w of unowned) actions.push(`not Crew-owned, left in place: ${w}`);
882
+ for (const w of retained) actions.push(`taken over by an operator, left in place: ${w}`);
883
+ return {
884
+ ok: true,
885
+ reportOnly: true,
886
+ removed: 0,
887
+ candidates: stale.map((entry) => entry.path),
888
+ failed: [],
889
+ unowned,
890
+ retained,
891
+ staleRecords: [],
892
+ actions,
893
+ };
440
894
  }
441
- return { ok: true, removed: stale.length, actions };
895
+
896
+ const failed = [];
897
+ const staleRecords = [];
898
+ let removed = 0;
899
+ for (const entry of stale) {
900
+ // Not forced, and re-checked against what was scanned: background pruning
901
+ // recognised this worktree from a name and a record, and neither survives the
902
+ // tree itself being taken over. `--force` would discard whatever an operator
903
+ // had put there; ownershipDrift would not notice if they had already.
904
+ const r = await cleanupIsolatedWorkspace({
905
+ worktreePath: entry.path,
906
+ git: run,
907
+ force: false,
908
+ expect: { incarnation: entry.incarnation, head: entry.head },
909
+ });
910
+ // A locked worktree is left on disk, so counting it as removed would tell an
911
+ // operator the machine is clean when it is not.
912
+ if (r.ok && r.removed) {
913
+ removed += 1;
914
+ actions.push(...(r.actions ?? []));
915
+ if (r.ownershipRecordStale) staleRecords.push(entry.path);
916
+ } else {
917
+ failed.push(entry.path);
918
+ actions.push(`failed to remove ${entry.path}: ${r.error ?? 'unknown reason'}`);
919
+ }
920
+ }
921
+ for (const w of unowned) actions.push(`not Crew-owned, left in place: ${w}`);
922
+ for (const w of retained) actions.push(`taken over by an operator, left in place: ${w}`);
923
+ return {
924
+ ok: failed.length === 0,
925
+ removed,
926
+ failed,
927
+ unowned,
928
+ retained,
929
+ staleRecords,
930
+ actions,
931
+ ...(failed.length ? { cleanupBlocked: true } : {}),
932
+ };
442
933
  }
443
934
 
444
935
  export function clampMaxParallel(raw) {