@ran-sh/dsh-crew 0.3.5 → 0.3.7
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/.claude-plugin/plugin.json +8 -8
- package/.mcp.json +8 -8
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +125 -180
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +125 -180
- package/agents/ds-flash.md +26 -26
- package/agents/ds-pro.md +32 -32
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +22 -22
- package/codex/agents/ds-flash.toml +30 -30
- package/codex/agents/ds-pro.toml +31 -31
- package/codex/agents/ds-reviewer.toml +28 -28
- package/codex/agents/ds-worker.toml +28 -28
- package/codex/prompts/dsh-config.md +3 -3
- package/codex/prompts/dsh-status.md +1 -1
- package/commands/config.md +11 -11
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +5 -5
- package/cordis.patch.yml +4 -4
- package/lib/client.js +3446 -2765
- package/package.json +131 -131
- package/scripts/build-client.mjs +28 -28
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/setup.mjs +284 -284
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-installer-fix.mjs +26 -26
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/collapsible-sections.mjs +55 -0
- package/src/client/entry.tsx +236 -236
- package/src/client/index.tsx +1213 -1120
- package/src/config-readiness.mjs +59 -59
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +239 -239
- package/src/failure-classification.mjs +172 -172
- package/src/hub/entry.mjs +98 -98
- package/src/hub-client.mjs +132 -132
- package/src/hub-compatibility.mjs +49 -49
- package/src/i18n.mjs +19 -19
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +462 -462
- package/src/install/install.mjs +451 -451
- package/src/install/npx-lifecycle.mjs +1055 -1019
- package/src/mcp-runtime.mjs +257 -257
- package/src/model-catalog.mjs +173 -173
- package/src/model-routing.mjs +391 -391
- package/src/policy.mjs +197 -197
- package/src/readiness-matrix.mjs +169 -169
- package/src/runtime-controls.mjs +90 -90
- package/src/runtime-identity.mjs +108 -108
- package/src/server.mjs +477 -477
- package/src/status-shard.mjs +52 -52
- package/src/structured-error-code.mjs +38 -38
- package/src/vision-route.mjs +138 -138
- package/src/workflow-runtime.mjs +573 -573
- package/src/workflow.mjs +160 -160
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-isolation.mjs +365 -365
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/worker.cordis.yml +77 -77
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
// Git worktree isolation for parallel coding jobs. Every isolated coding job
|
|
2
|
-
// runs in its own detached worktree at a captured base revision, so concurrent
|
|
3
|
-
// workers never write the same mutable working tree. The Main Agent receives
|
|
4
|
-
// an auditable change candidate (bounded, redacted patch + name status) and
|
|
5
|
-
// decides accept / reject / revise from there.
|
|
6
|
-
//
|
|
7
|
-
// Discipline: NEVER touches the primary working tree with reset / stash /
|
|
8
|
-
// clean / checkout — the primary tree may stay dirty. All worktree mutation
|
|
9
|
-
// targets the allocated worktree dir only. Windows-safe: node:path only, no
|
|
10
|
-
// shell string building, execFile(args array), and clear errors when a file
|
|
11
|
-
// lock blocks cleanup.
|
|
12
|
-
|
|
13
|
-
import { execFile } from 'node:child_process';
|
|
14
|
-
import { existsSync, rmSync } from 'node:fs';
|
|
15
|
-
import { readFile } from 'node:fs/promises';
|
|
16
|
-
import { tmpdir } from 'node:os';
|
|
17
|
-
import { join, resolve, basename } from 'node:path';
|
|
18
|
-
import { randomBytes, createHash } from 'node:crypto';
|
|
19
|
-
import { promisify } from 'node:util';
|
|
20
|
-
import { isSensitivePath, parseChanges, DIFF_LIMIT, GIT_TIMEOUT_MS } from './workspace-audit.mjs';
|
|
21
|
-
|
|
22
|
-
const execFileAsync = promisify(execFile);
|
|
23
|
-
|
|
24
|
-
export const NOT_GIT_REPOSITORY = 'NOT_GIT_REPOSITORY';
|
|
25
|
-
export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
|
|
26
|
-
export const GIT_TIMEOUT = 'GIT_TIMEOUT';
|
|
27
|
-
export const GIT_ERROR = 'GIT_ERROR';
|
|
28
|
-
export const WORKTREE_LOCKED = 'WORKTREE_LOCKED';
|
|
29
|
-
export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
|
|
30
|
-
export const MAX_PARALLEL_CAP = 16;
|
|
31
|
-
export const DEFAULT_MAX_PARALLEL = 3;
|
|
32
|
-
const WORKTREE_PREFIX = 'dsh-crew-';
|
|
33
|
-
|
|
34
|
-
async function defaultRunner(args, { cwd }) {
|
|
35
|
-
try {
|
|
36
|
-
const out = await execFileAsync('git', args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
37
|
-
return { code: 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
|
|
38
|
-
} catch (error) {
|
|
39
|
-
const missing = error?.code === 'ENOENT' || /spawn git ENOENT/i.test(error?.message ?? '');
|
|
40
|
-
if (!missing) return { code: error?.code ?? error?.status ?? 1, stdout: '', stderr: error?.stderr ?? error?.message ?? String(error) };
|
|
41
|
-
return { code: -1, stdout: '', stderr: 'spawn git ENOENT' };
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function runGit(runner, args, opts) {
|
|
46
|
-
try {
|
|
47
|
-
const r = await runner(args, opts);
|
|
48
|
-
const stderr = r.stderr ?? '';
|
|
49
|
-
if (/not a git repository/i.test(stderr)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: stderr.trim() };
|
|
50
|
-
if (r.code != null && r.code !== 0) return { ok: false, reason: r.code === -1 ? GIT_NOT_FOUND : GIT_ERROR, code: r.code, error: stderr.trim() || 'git exited non-zero' };
|
|
51
|
-
return { ok: true, code: r.code, stdout: r.stdout ?? '', stderr };
|
|
52
|
-
} catch (err) {
|
|
53
|
-
const msg = err?.message ?? String(err);
|
|
54
|
-
if (err?.code === 'ETIMEDOUT' || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: msg };
|
|
55
|
-
if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
|
|
56
|
-
return { ok: false, reason: NOT_GIT_REPOSITORY, error: msg };
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function worktreeName(jobId) {
|
|
61
|
-
const safe = String(jobId ?? '').replace(/[^A-Za-z0-9._-]/g, '-') || 'job';
|
|
62
|
-
return `${WORKTREE_PREFIX}${safe}-${randomBytes(4).toString('hex')}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function defaultWorktreeRoot() {
|
|
66
|
-
return join(tmpdir(), 'dsh-crew-worktrees');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Resolve repository root + HEAD. Dirty detection is advisory: inability to
|
|
71
|
-
* read `git status` must not turn an otherwise valid repository into a hard
|
|
72
|
-
* isolation failure. `dirty=null` means unknown.
|
|
73
|
-
*/
|
|
74
|
-
export async function inspectRepository({ cwd, git, runner } = {}) {
|
|
75
|
-
const run = git ?? runner ?? defaultRunner;
|
|
76
|
-
if (!cwd) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'cwd required' };
|
|
77
|
-
const [root, head, status] = await Promise.all([
|
|
78
|
-
runGit(run, ['rev-parse', '--show-toplevel'], { cwd }),
|
|
79
|
-
runGit(run, ['rev-parse', 'HEAD'], { cwd }),
|
|
80
|
-
runGit(run, ['status', '--porcelain', '-uall'], { cwd }),
|
|
81
|
-
]);
|
|
82
|
-
if (!root.ok) return { ok: false, reason: root.reason, error: root.error };
|
|
83
|
-
if (!head.ok) return { ok: false, reason: head.reason, error: head.error };
|
|
84
|
-
return {
|
|
85
|
-
ok: true,
|
|
86
|
-
repoRoot: resolve(root.stdout.trim()),
|
|
87
|
-
baseRevision: head.stdout.trim(),
|
|
88
|
-
headRevision: head.stdout.trim(),
|
|
89
|
-
dirty: status.ok ? status.stdout.trim() !== '' : null,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export async function createIsolatedWorkspace({ cwd, jobId, baseRevision, root = defaultWorktreeRoot(), git } = {}) {
|
|
94
|
-
const run = git ?? defaultRunner;
|
|
95
|
-
const repo = await inspectRepository({ cwd, git: run });
|
|
96
|
-
if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
|
|
97
|
-
const rev = baseRevision ?? repo.baseRevision;
|
|
98
|
-
const dir = join(root, worktreeName(jobId ?? repo.baseRevision));
|
|
99
|
-
const res = await runGit(run, ['worktree', 'add', '--detach', dir, rev], { cwd: repo.repoRoot });
|
|
100
|
-
if (!res.ok) return { ok: false, reason: res.reason, error: res.error };
|
|
101
|
-
return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name: basename(dir) };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function splitFirstTab(line) {
|
|
105
|
-
const i = line.indexOf('\t');
|
|
106
|
-
return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limit }) {
|
|
110
|
-
const tracked = [];
|
|
111
|
-
const sensitive = new Set();
|
|
112
|
-
for (const line of String(nameStatus ?? '').split('\n')) {
|
|
113
|
-
const trimmed = line.trim();
|
|
114
|
-
if (!trimmed) continue;
|
|
115
|
-
const [, rest] = splitFirstTab(trimmed);
|
|
116
|
-
const involved = rest.split('\t').filter(Boolean).map((p) => p.replace(/\\/g, '/'));
|
|
117
|
-
if (involved.length === 0) continue;
|
|
118
|
-
if (involved.some(isSensitivePath)) {
|
|
119
|
-
for (const p of involved) sensitive.add(p);
|
|
120
|
-
} else {
|
|
121
|
-
for (const p of involved) if (!tracked.includes(p)) tracked.push(p);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
let out = '';
|
|
126
|
-
const redacted = [];
|
|
127
|
-
const incompleteReasons = [];
|
|
128
|
-
if (tracked.length > 0) {
|
|
129
|
-
const r = await runGit(run, ['diff', '--binary', base, '--', ...tracked], { cwd });
|
|
130
|
-
if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
|
|
131
|
-
out += r.stdout;
|
|
132
|
-
}
|
|
133
|
-
for (const p of sensitive) {
|
|
134
|
-
redacted.push(p);
|
|
135
|
-
out += `[REDACTED SENSITIVE FILE: ${p}]\n`;
|
|
136
|
-
}
|
|
137
|
-
if (redacted.length > 0) incompleteReasons.push('sensitive_content_redacted');
|
|
138
|
-
|
|
139
|
-
for (const p of untracked) {
|
|
140
|
-
if (isSensitivePath(p)) {
|
|
141
|
-
redacted.push(p);
|
|
142
|
-
out += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
|
|
143
|
-
if (!incompleteReasons.includes('sensitive_content_redacted')) incompleteReasons.push('sensitive_content_redacted');
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
const abs = join(cwd, ...p.split('/'));
|
|
147
|
-
if (!existsSync(abs)) {
|
|
148
|
-
out += `[UNTRACKED FILE: ${p}]\n`;
|
|
149
|
-
incompleteReasons.push(`untracked_missing:${p}`);
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
const next = await safeNewFilePatch(p, abs);
|
|
153
|
-
out += next.patch;
|
|
154
|
-
if (!next.complete) incompleteReasons.push(next.reason ?? `untracked_unreplayable:${p}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const truncated = Buffer.byteLength(out, 'utf8') > limit;
|
|
158
|
-
if (truncated) {
|
|
159
|
-
out = Buffer.from(out, 'utf8').subarray(0, limit).toString('utf8');
|
|
160
|
-
incompleteReasons.push('patch_truncated');
|
|
161
|
-
}
|
|
162
|
-
const complete = incompleteReasons.length === 0;
|
|
163
|
-
return { failed: false, patch: out, truncated, redacted, complete, incompleteReasons };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function safeNewFilePatch(relPath, absPath) {
|
|
167
|
-
try {
|
|
168
|
-
const buf = await readFile(absPath);
|
|
169
|
-
if (buf.includes(0)) return { patch: `[NEW BINARY FILE: ${relPath} (${buf.length} bytes)]\n`, complete: false, reason: `binary_untracked:${relPath}` };
|
|
170
|
-
const lines = buf.toString('utf8').split(/\r?\n/);
|
|
171
|
-
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
172
|
-
if (lines.length === 0) return { patch: `[NEW EMPTY FILE: ${relPath}]\n`, complete: true };
|
|
173
|
-
const body = lines.map((l) => `+${l}`).join('\n');
|
|
174
|
-
return {
|
|
175
|
-
patch: `diff --git a/${relPath} b/${relPath}\nnew file mode 100644\n--- /dev/null\n+++ b/${relPath}\n@@ -0,0 +1,${lines.length} @@\n${body}\n`,
|
|
176
|
-
complete: true,
|
|
177
|
-
};
|
|
178
|
-
} catch {
|
|
179
|
-
return { patch: `[UNTRACKED FILE: ${relPath}]\n`, complete: false, reason: `untracked_read_failed:${relPath}` };
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function candidateFingerprint({ base, nameStatus, patch }) {
|
|
184
|
-
return createHash('sha256').update(`${base}\n${nameStatus ?? ''}\n${patch ?? ''}`).digest('hex');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function captureCandidate({ worktreePath, baseRevision, git, limit = DIFF_LIMIT } = {}) {
|
|
188
|
-
const run = git ?? defaultRunner;
|
|
189
|
-
if (!worktreePath || !existsSync(worktreePath)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path missing' };
|
|
190
|
-
const baseCheck = baseRevision ? await runGit(run, ['rev-parse', '--verify', `${baseRevision}^{commit}`], { cwd: worktreePath }) : { ok: true };
|
|
191
|
-
if (baseRevision && !baseCheck.ok) return { ok: false, reason: CANDIDATE_CAPTURE_FAILED, error: `invalid base revision ${baseRevision}: ${(baseCheck.error ?? '').trim()}` };
|
|
192
|
-
const base = baseRevision ?? (await runGit(run, ['rev-parse', 'HEAD'], { cwd: worktreePath })).stdout.trim();
|
|
193
|
-
|
|
194
|
-
const [nameStatus, statRel, status, head] = await Promise.all([
|
|
195
|
-
runGit(run, ['diff', '--name-status', base], { cwd: worktreePath }),
|
|
196
|
-
runGit(run, ['diff', '--stat', base], { cwd: worktreePath }),
|
|
197
|
-
runGit(run, ['status', '--porcelain', '-uall'], { cwd: worktreePath }),
|
|
198
|
-
runGit(run, ['rev-parse', 'HEAD'], { cwd: worktreePath }),
|
|
199
|
-
]);
|
|
200
|
-
for (const r of [nameStatus, statRel, status]) if (!r.ok) return { ok: false, reason: r.reason, error: r.error };
|
|
201
|
-
|
|
202
|
-
const changes = parseChanges(nameStatus.stdout, status.stdout);
|
|
203
|
-
const untracked = changes.untracked;
|
|
204
|
-
const built = await buildCandidatePatch(run, { cwd: worktreePath, base, nameStatus: nameStatus.stdout, untracked, limit });
|
|
205
|
-
if (built.failed) return { ok: false, reason: built.reason ?? CANDIDATE_CAPTURE_FAILED, error: built.error };
|
|
206
|
-
|
|
207
|
-
const nameStatusOut = nameStatus.stdout;
|
|
208
|
-
return {
|
|
209
|
-
ok: true,
|
|
210
|
-
kind: 'git-worktree',
|
|
211
|
-
base_revision: base,
|
|
212
|
-
committed_head: head.ok ? head.stdout.trim() : null,
|
|
213
|
-
worktree_path: worktreePath,
|
|
214
|
-
changed_files: [...new Set([...trackedIn(nameStatusOut, untracked), ...built.redacted, ...untracked])],
|
|
215
|
-
name_status: nameStatusOut,
|
|
216
|
-
diff_stat: statRel.stdout,
|
|
217
|
-
patch: built.patch,
|
|
218
|
-
patch_truncated: built.truncated,
|
|
219
|
-
sensitive_paths_redacted: built.redacted,
|
|
220
|
-
untracked_files: untracked,
|
|
221
|
-
complete: built.complete,
|
|
222
|
-
replayable: built.complete,
|
|
223
|
-
incomplete_reasons: built.incompleteReasons,
|
|
224
|
-
fingerprint: candidateFingerprint({ base, nameStatus: nameStatusOut, patch: built.patch }),
|
|
225
|
-
candidate_commit: null,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function trackedIn(nameStatus, untracked) {
|
|
230
|
-
const out = [];
|
|
231
|
-
for (const line of String(nameStatus ?? '').split('\n')) {
|
|
232
|
-
const trimmed = line.trim();
|
|
233
|
-
if (!trimmed) continue;
|
|
234
|
-
const [, rest] = splitFirstTab(trimmed);
|
|
235
|
-
const involved = rest.split('\t').filter(Boolean).map((p) => p.replace(/\\/g, '/'));
|
|
236
|
-
for (const p of involved) if (!out.includes(p) && !untracked.includes(p)) out.push(p);
|
|
237
|
-
}
|
|
238
|
-
return out;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async function mainRepoRoot(run, worktreePath) {
|
|
242
|
-
const common = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd: worktreePath });
|
|
243
|
-
if (!common.ok) return null;
|
|
244
|
-
const dir = String(common.stdout ?? '').trim();
|
|
245
|
-
if (!dir) return null;
|
|
246
|
-
return resolve(dir, '..');
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
250
|
-
|
|
251
|
-
const pathIdentity = (value) => {
|
|
252
|
-
const resolved = resolve(String(value));
|
|
253
|
-
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
async function worktreeRegistered({ worktreePath, git, cwd }) {
|
|
257
|
-
const res = await runGit(git, ['worktree', 'list', '--porcelain'], { cwd });
|
|
258
|
-
if (!res.ok) return null;
|
|
259
|
-
const target = pathIdentity(worktreePath);
|
|
260
|
-
for (const block of String(res.stdout).split('\n\n')) {
|
|
261
|
-
const path = block.split('\n').find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
|
|
262
|
-
if (path && pathIdentity(path) === target) return true;
|
|
263
|
-
}
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Bounded, truthful cleanup of a Crew-owned disposable worktree. Transient
|
|
269
|
-
* Windows locks (index lock, AV scan, lingering handle) are retried with a
|
|
270
|
-
* small backoff; a persistent failure surfaces `cleanupBlocked: true` with the
|
|
271
|
-
* real reason and is never reported as removed. The filesystem fallback only
|
|
272
|
-
* runs for Crew-owned worktree paths and claims success only after it verifies
|
|
273
|
-
* the registration is gone and no directory remains — success is never claimed
|
|
274
|
-
* while a worktree stays registered or on disk.
|
|
275
|
-
*/
|
|
276
|
-
export const WORKTREE_CLEANUP_RETRIES = 3;
|
|
277
|
-
export const WORKTREE_CLEANUP_BACKOFF_MS = 150;
|
|
278
|
-
|
|
279
|
-
export async function cleanupIsolatedWorkspace({
|
|
280
|
-
worktreePath,
|
|
281
|
-
repoRoot,
|
|
282
|
-
git,
|
|
283
|
-
retries = WORKTREE_CLEANUP_RETRIES,
|
|
284
|
-
backoffMs = WORKTREE_CLEANUP_BACKOFF_MS,
|
|
285
|
-
} = {}) {
|
|
286
|
-
const run = git ?? defaultRunner;
|
|
287
|
-
if (!worktreePath) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path required' };
|
|
288
|
-
const root = repoRoot ?? (await mainRepoRoot(run, worktreePath));
|
|
289
|
-
const owned = basename(resolve(worktreePath)).startsWith(WORKTREE_PREFIX);
|
|
290
|
-
|
|
291
|
-
if (root) {
|
|
292
|
-
// Preferred path: `git worktree remove --force` (removes registration and
|
|
293
|
-
// directory atomically). Retry bounded times to recover transient locks.
|
|
294
|
-
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
295
|
-
const res = await runGit(run, ['worktree', 'remove', '--force', worktreePath], { cwd: root });
|
|
296
|
-
if (res.ok) return { ok: true, removed: true, actions: [`removed worktree ${worktreePath}`] };
|
|
297
|
-
if (attempt < retries - 1) await sleep(backoffMs);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Last resort, only for Crew-owned disposable paths: remove the directory and
|
|
302
|
-
// verify the git registration actually went away before claiming success.
|
|
303
|
-
if (owned && root && pathIdentity(worktreePath) !== pathIdentity(root)) {
|
|
304
|
-
try {
|
|
305
|
-
rmSync(worktreePath, { recursive: true, force: true });
|
|
306
|
-
} catch (err) {
|
|
307
|
-
return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed: ${err?.message ?? String(err)}`, cleanupBlocked: true };
|
|
308
|
-
}
|
|
309
|
-
const registered = root ? await worktreeRegistered({ worktreePath, git: run, cwd: root }) : null;
|
|
310
|
-
if (registered === false && !existsSync(worktreePath)) {
|
|
311
|
-
return { ok: true, removed: true, actions: [`cleaned worktree files ${worktreePath}`] };
|
|
312
|
-
}
|
|
313
|
-
if (registered === true) {
|
|
314
|
-
return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after cleanup: ${worktreePath}`, cleanupBlocked: true };
|
|
315
|
-
}
|
|
316
|
-
return { ok: false, reason: WORKTREE_LOCKED, error: `could not verify worktree removal for ${worktreePath}`, cleanupBlocked: true };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
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 };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
export async function staleWorktrees({ git, allowed = [] } = {}) {
|
|
323
|
-
const run = git ?? defaultRunner;
|
|
324
|
-
const set = new Set(allowed.map((p) => resolve(p)));
|
|
325
|
-
const stale = [];
|
|
326
|
-
try {
|
|
327
|
-
const root = await inspectRepository({ cwd: allowed[0] ?? process.cwd(), git: run });
|
|
328
|
-
if (root.ok) {
|
|
329
|
-
const res = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: root.repoRoot });
|
|
330
|
-
if (res.ok) {
|
|
331
|
-
for (const block of String(res.stdout).split('\n\n')) {
|
|
332
|
-
const path = block.split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length)?.trim();
|
|
333
|
-
if (!path) continue;
|
|
334
|
-
const abs = resolve(path);
|
|
335
|
-
if (basename(abs).startsWith(WORKTREE_PREFIX) && !set.has(abs)) stale.push(abs);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
} catch {}
|
|
340
|
-
return stale;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
export async function pruneWorktrees({ git, allowed = [] } = {}) {
|
|
344
|
-
const run = git ?? defaultRunner;
|
|
345
|
-
const stale = await staleWorktrees({ git: run, allowed });
|
|
346
|
-
const actions = [];
|
|
347
|
-
for (const w of stale) {
|
|
348
|
-
const r = await cleanupIsolatedWorkspace({ worktreePath: w, git: run });
|
|
349
|
-
actions.push(...(r.actions ?? [r.error ?? `stale worktree ${w}`]));
|
|
350
|
-
}
|
|
351
|
-
return { ok: true, removed: stale.length, actions };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
export function clampMaxParallel(raw) {
|
|
355
|
-
const n = Number.isInteger(raw) ? raw : DEFAULT_MAX_PARALLEL;
|
|
356
|
-
if (n < 1) return 1;
|
|
357
|
-
if (n > MAX_PARALLEL_CAP) return MAX_PARALLEL_CAP;
|
|
358
|
-
return n;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
export function concurrencyGate({ maxParallel = DEFAULT_MAX_PARALLEL, active = 0 } = {}) {
|
|
362
|
-
const cap = clampMaxParallel(maxParallel);
|
|
363
|
-
const ok = active < cap;
|
|
364
|
-
return { ok, active, maxParallel: cap, blocked: !ok };
|
|
365
|
-
}
|
|
1
|
+
// Git worktree isolation for parallel coding jobs. Every isolated coding job
|
|
2
|
+
// runs in its own detached worktree at a captured base revision, so concurrent
|
|
3
|
+
// workers never write the same mutable working tree. The Main Agent receives
|
|
4
|
+
// an auditable change candidate (bounded, redacted patch + name status) and
|
|
5
|
+
// decides accept / reject / revise from there.
|
|
6
|
+
//
|
|
7
|
+
// Discipline: NEVER touches the primary working tree with reset / stash /
|
|
8
|
+
// clean / checkout — the primary tree may stay dirty. All worktree mutation
|
|
9
|
+
// targets the allocated worktree dir only. Windows-safe: node:path only, no
|
|
10
|
+
// shell string building, execFile(args array), and clear errors when a file
|
|
11
|
+
// lock blocks cleanup.
|
|
12
|
+
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join, resolve, basename } from 'node:path';
|
|
18
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import { isSensitivePath, parseChanges, DIFF_LIMIT, GIT_TIMEOUT_MS } from './workspace-audit.mjs';
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
export const NOT_GIT_REPOSITORY = 'NOT_GIT_REPOSITORY';
|
|
25
|
+
export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
|
|
26
|
+
export const GIT_TIMEOUT = 'GIT_TIMEOUT';
|
|
27
|
+
export const GIT_ERROR = 'GIT_ERROR';
|
|
28
|
+
export const WORKTREE_LOCKED = 'WORKTREE_LOCKED';
|
|
29
|
+
export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
|
|
30
|
+
export const MAX_PARALLEL_CAP = 16;
|
|
31
|
+
export const DEFAULT_MAX_PARALLEL = 3;
|
|
32
|
+
const WORKTREE_PREFIX = 'dsh-crew-';
|
|
33
|
+
|
|
34
|
+
async function defaultRunner(args, { cwd }) {
|
|
35
|
+
try {
|
|
36
|
+
const out = await execFileAsync('git', args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
37
|
+
return { code: 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const missing = error?.code === 'ENOENT' || /spawn git ENOENT/i.test(error?.message ?? '');
|
|
40
|
+
if (!missing) return { code: error?.code ?? error?.status ?? 1, stdout: '', stderr: error?.stderr ?? error?.message ?? String(error) };
|
|
41
|
+
return { code: -1, stdout: '', stderr: 'spawn git ENOENT' };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runGit(runner, args, opts) {
|
|
46
|
+
try {
|
|
47
|
+
const r = await runner(args, opts);
|
|
48
|
+
const stderr = r.stderr ?? '';
|
|
49
|
+
if (/not a git repository/i.test(stderr)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: stderr.trim() };
|
|
50
|
+
if (r.code != null && r.code !== 0) return { ok: false, reason: r.code === -1 ? GIT_NOT_FOUND : GIT_ERROR, code: r.code, error: stderr.trim() || 'git exited non-zero' };
|
|
51
|
+
return { ok: true, code: r.code, stdout: r.stdout ?? '', stderr };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const msg = err?.message ?? String(err);
|
|
54
|
+
if (err?.code === 'ETIMEDOUT' || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: msg };
|
|
55
|
+
if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
|
|
56
|
+
return { ok: false, reason: NOT_GIT_REPOSITORY, error: msg };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function worktreeName(jobId) {
|
|
61
|
+
const safe = String(jobId ?? '').replace(/[^A-Za-z0-9._-]/g, '-') || 'job';
|
|
62
|
+
return `${WORKTREE_PREFIX}${safe}-${randomBytes(4).toString('hex')}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function defaultWorktreeRoot() {
|
|
66
|
+
return join(tmpdir(), 'dsh-crew-worktrees');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve repository root + HEAD. Dirty detection is advisory: inability to
|
|
71
|
+
* read `git status` must not turn an otherwise valid repository into a hard
|
|
72
|
+
* isolation failure. `dirty=null` means unknown.
|
|
73
|
+
*/
|
|
74
|
+
export async function inspectRepository({ cwd, git, runner } = {}) {
|
|
75
|
+
const run = git ?? runner ?? defaultRunner;
|
|
76
|
+
if (!cwd) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'cwd required' };
|
|
77
|
+
const [root, head, status] = await Promise.all([
|
|
78
|
+
runGit(run, ['rev-parse', '--show-toplevel'], { cwd }),
|
|
79
|
+
runGit(run, ['rev-parse', 'HEAD'], { cwd }),
|
|
80
|
+
runGit(run, ['status', '--porcelain', '-uall'], { cwd }),
|
|
81
|
+
]);
|
|
82
|
+
if (!root.ok) return { ok: false, reason: root.reason, error: root.error };
|
|
83
|
+
if (!head.ok) return { ok: false, reason: head.reason, error: head.error };
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
repoRoot: resolve(root.stdout.trim()),
|
|
87
|
+
baseRevision: head.stdout.trim(),
|
|
88
|
+
headRevision: head.stdout.trim(),
|
|
89
|
+
dirty: status.ok ? status.stdout.trim() !== '' : null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function createIsolatedWorkspace({ cwd, jobId, baseRevision, root = defaultWorktreeRoot(), git } = {}) {
|
|
94
|
+
const run = git ?? defaultRunner;
|
|
95
|
+
const repo = await inspectRepository({ cwd, git: run });
|
|
96
|
+
if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
|
|
97
|
+
const rev = baseRevision ?? repo.baseRevision;
|
|
98
|
+
const dir = join(root, worktreeName(jobId ?? repo.baseRevision));
|
|
99
|
+
const res = await runGit(run, ['worktree', 'add', '--detach', dir, rev], { cwd: repo.repoRoot });
|
|
100
|
+
if (!res.ok) return { ok: false, reason: res.reason, error: res.error };
|
|
101
|
+
return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name: basename(dir) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function splitFirstTab(line) {
|
|
105
|
+
const i = line.indexOf('\t');
|
|
106
|
+
return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limit }) {
|
|
110
|
+
const tracked = [];
|
|
111
|
+
const sensitive = new Set();
|
|
112
|
+
for (const line of String(nameStatus ?? '').split('\n')) {
|
|
113
|
+
const trimmed = line.trim();
|
|
114
|
+
if (!trimmed) continue;
|
|
115
|
+
const [, rest] = splitFirstTab(trimmed);
|
|
116
|
+
const involved = rest.split('\t').filter(Boolean).map((p) => p.replace(/\\/g, '/'));
|
|
117
|
+
if (involved.length === 0) continue;
|
|
118
|
+
if (involved.some(isSensitivePath)) {
|
|
119
|
+
for (const p of involved) sensitive.add(p);
|
|
120
|
+
} else {
|
|
121
|
+
for (const p of involved) if (!tracked.includes(p)) tracked.push(p);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let out = '';
|
|
126
|
+
const redacted = [];
|
|
127
|
+
const incompleteReasons = [];
|
|
128
|
+
if (tracked.length > 0) {
|
|
129
|
+
const r = await runGit(run, ['diff', '--binary', base, '--', ...tracked], { cwd });
|
|
130
|
+
if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
|
|
131
|
+
out += r.stdout;
|
|
132
|
+
}
|
|
133
|
+
for (const p of sensitive) {
|
|
134
|
+
redacted.push(p);
|
|
135
|
+
out += `[REDACTED SENSITIVE FILE: ${p}]\n`;
|
|
136
|
+
}
|
|
137
|
+
if (redacted.length > 0) incompleteReasons.push('sensitive_content_redacted');
|
|
138
|
+
|
|
139
|
+
for (const p of untracked) {
|
|
140
|
+
if (isSensitivePath(p)) {
|
|
141
|
+
redacted.push(p);
|
|
142
|
+
out += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
|
|
143
|
+
if (!incompleteReasons.includes('sensitive_content_redacted')) incompleteReasons.push('sensitive_content_redacted');
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const abs = join(cwd, ...p.split('/'));
|
|
147
|
+
if (!existsSync(abs)) {
|
|
148
|
+
out += `[UNTRACKED FILE: ${p}]\n`;
|
|
149
|
+
incompleteReasons.push(`untracked_missing:${p}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const next = await safeNewFilePatch(p, abs);
|
|
153
|
+
out += next.patch;
|
|
154
|
+
if (!next.complete) incompleteReasons.push(next.reason ?? `untracked_unreplayable:${p}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const truncated = Buffer.byteLength(out, 'utf8') > limit;
|
|
158
|
+
if (truncated) {
|
|
159
|
+
out = Buffer.from(out, 'utf8').subarray(0, limit).toString('utf8');
|
|
160
|
+
incompleteReasons.push('patch_truncated');
|
|
161
|
+
}
|
|
162
|
+
const complete = incompleteReasons.length === 0;
|
|
163
|
+
return { failed: false, patch: out, truncated, redacted, complete, incompleteReasons };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function safeNewFilePatch(relPath, absPath) {
|
|
167
|
+
try {
|
|
168
|
+
const buf = await readFile(absPath);
|
|
169
|
+
if (buf.includes(0)) return { patch: `[NEW BINARY FILE: ${relPath} (${buf.length} bytes)]\n`, complete: false, reason: `binary_untracked:${relPath}` };
|
|
170
|
+
const lines = buf.toString('utf8').split(/\r?\n/);
|
|
171
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
172
|
+
if (lines.length === 0) return { patch: `[NEW EMPTY FILE: ${relPath}]\n`, complete: true };
|
|
173
|
+
const body = lines.map((l) => `+${l}`).join('\n');
|
|
174
|
+
return {
|
|
175
|
+
patch: `diff --git a/${relPath} b/${relPath}\nnew file mode 100644\n--- /dev/null\n+++ b/${relPath}\n@@ -0,0 +1,${lines.length} @@\n${body}\n`,
|
|
176
|
+
complete: true,
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
return { patch: `[UNTRACKED FILE: ${relPath}]\n`, complete: false, reason: `untracked_read_failed:${relPath}` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function candidateFingerprint({ base, nameStatus, patch }) {
|
|
184
|
+
return createHash('sha256').update(`${base}\n${nameStatus ?? ''}\n${patch ?? ''}`).digest('hex');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function captureCandidate({ worktreePath, baseRevision, git, limit = DIFF_LIMIT } = {}) {
|
|
188
|
+
const run = git ?? defaultRunner;
|
|
189
|
+
if (!worktreePath || !existsSync(worktreePath)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path missing' };
|
|
190
|
+
const baseCheck = baseRevision ? await runGit(run, ['rev-parse', '--verify', `${baseRevision}^{commit}`], { cwd: worktreePath }) : { ok: true };
|
|
191
|
+
if (baseRevision && !baseCheck.ok) return { ok: false, reason: CANDIDATE_CAPTURE_FAILED, error: `invalid base revision ${baseRevision}: ${(baseCheck.error ?? '').trim()}` };
|
|
192
|
+
const base = baseRevision ?? (await runGit(run, ['rev-parse', 'HEAD'], { cwd: worktreePath })).stdout.trim();
|
|
193
|
+
|
|
194
|
+
const [nameStatus, statRel, status, head] = await Promise.all([
|
|
195
|
+
runGit(run, ['diff', '--name-status', base], { cwd: worktreePath }),
|
|
196
|
+
runGit(run, ['diff', '--stat', base], { cwd: worktreePath }),
|
|
197
|
+
runGit(run, ['status', '--porcelain', '-uall'], { cwd: worktreePath }),
|
|
198
|
+
runGit(run, ['rev-parse', 'HEAD'], { cwd: worktreePath }),
|
|
199
|
+
]);
|
|
200
|
+
for (const r of [nameStatus, statRel, status]) if (!r.ok) return { ok: false, reason: r.reason, error: r.error };
|
|
201
|
+
|
|
202
|
+
const changes = parseChanges(nameStatus.stdout, status.stdout);
|
|
203
|
+
const untracked = changes.untracked;
|
|
204
|
+
const built = await buildCandidatePatch(run, { cwd: worktreePath, base, nameStatus: nameStatus.stdout, untracked, limit });
|
|
205
|
+
if (built.failed) return { ok: false, reason: built.reason ?? CANDIDATE_CAPTURE_FAILED, error: built.error };
|
|
206
|
+
|
|
207
|
+
const nameStatusOut = nameStatus.stdout;
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
kind: 'git-worktree',
|
|
211
|
+
base_revision: base,
|
|
212
|
+
committed_head: head.ok ? head.stdout.trim() : null,
|
|
213
|
+
worktree_path: worktreePath,
|
|
214
|
+
changed_files: [...new Set([...trackedIn(nameStatusOut, untracked), ...built.redacted, ...untracked])],
|
|
215
|
+
name_status: nameStatusOut,
|
|
216
|
+
diff_stat: statRel.stdout,
|
|
217
|
+
patch: built.patch,
|
|
218
|
+
patch_truncated: built.truncated,
|
|
219
|
+
sensitive_paths_redacted: built.redacted,
|
|
220
|
+
untracked_files: untracked,
|
|
221
|
+
complete: built.complete,
|
|
222
|
+
replayable: built.complete,
|
|
223
|
+
incomplete_reasons: built.incompleteReasons,
|
|
224
|
+
fingerprint: candidateFingerprint({ base, nameStatus: nameStatusOut, patch: built.patch }),
|
|
225
|
+
candidate_commit: null,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function trackedIn(nameStatus, untracked) {
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const line of String(nameStatus ?? '').split('\n')) {
|
|
232
|
+
const trimmed = line.trim();
|
|
233
|
+
if (!trimmed) continue;
|
|
234
|
+
const [, rest] = splitFirstTab(trimmed);
|
|
235
|
+
const involved = rest.split('\t').filter(Boolean).map((p) => p.replace(/\\/g, '/'));
|
|
236
|
+
for (const p of involved) if (!out.includes(p) && !untracked.includes(p)) out.push(p);
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function mainRepoRoot(run, worktreePath) {
|
|
242
|
+
const common = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd: worktreePath });
|
|
243
|
+
if (!common.ok) return null;
|
|
244
|
+
const dir = String(common.stdout ?? '').trim();
|
|
245
|
+
if (!dir) return null;
|
|
246
|
+
return resolve(dir, '..');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
250
|
+
|
|
251
|
+
const pathIdentity = (value) => {
|
|
252
|
+
const resolved = resolve(String(value));
|
|
253
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
async function worktreeRegistered({ worktreePath, git, cwd }) {
|
|
257
|
+
const res = await runGit(git, ['worktree', 'list', '--porcelain'], { cwd });
|
|
258
|
+
if (!res.ok) return null;
|
|
259
|
+
const target = pathIdentity(worktreePath);
|
|
260
|
+
for (const block of String(res.stdout).split('\n\n')) {
|
|
261
|
+
const path = block.split('\n').find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
|
|
262
|
+
if (path && pathIdentity(path) === target) return true;
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Bounded, truthful cleanup of a Crew-owned disposable worktree. Transient
|
|
269
|
+
* Windows locks (index lock, AV scan, lingering handle) are retried with a
|
|
270
|
+
* small backoff; a persistent failure surfaces `cleanupBlocked: true` with the
|
|
271
|
+
* real reason and is never reported as removed. The filesystem fallback only
|
|
272
|
+
* runs for Crew-owned worktree paths and claims success only after it verifies
|
|
273
|
+
* the registration is gone and no directory remains — success is never claimed
|
|
274
|
+
* while a worktree stays registered or on disk.
|
|
275
|
+
*/
|
|
276
|
+
export const WORKTREE_CLEANUP_RETRIES = 3;
|
|
277
|
+
export const WORKTREE_CLEANUP_BACKOFF_MS = 150;
|
|
278
|
+
|
|
279
|
+
export async function cleanupIsolatedWorkspace({
|
|
280
|
+
worktreePath,
|
|
281
|
+
repoRoot,
|
|
282
|
+
git,
|
|
283
|
+
retries = WORKTREE_CLEANUP_RETRIES,
|
|
284
|
+
backoffMs = WORKTREE_CLEANUP_BACKOFF_MS,
|
|
285
|
+
} = {}) {
|
|
286
|
+
const run = git ?? defaultRunner;
|
|
287
|
+
if (!worktreePath) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path required' };
|
|
288
|
+
const root = repoRoot ?? (await mainRepoRoot(run, worktreePath));
|
|
289
|
+
const owned = basename(resolve(worktreePath)).startsWith(WORKTREE_PREFIX);
|
|
290
|
+
|
|
291
|
+
if (root) {
|
|
292
|
+
// Preferred path: `git worktree remove --force` (removes registration and
|
|
293
|
+
// directory atomically). Retry bounded times to recover transient locks.
|
|
294
|
+
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
295
|
+
const res = await runGit(run, ['worktree', 'remove', '--force', worktreePath], { cwd: root });
|
|
296
|
+
if (res.ok) return { ok: true, removed: true, actions: [`removed worktree ${worktreePath}`] };
|
|
297
|
+
if (attempt < retries - 1) await sleep(backoffMs);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Last resort, only for Crew-owned disposable paths: remove the directory and
|
|
302
|
+
// verify the git registration actually went away before claiming success.
|
|
303
|
+
if (owned && root && pathIdentity(worktreePath) !== pathIdentity(root)) {
|
|
304
|
+
try {
|
|
305
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed: ${err?.message ?? String(err)}`, cleanupBlocked: true };
|
|
308
|
+
}
|
|
309
|
+
const registered = root ? await worktreeRegistered({ worktreePath, git: run, cwd: root }) : null;
|
|
310
|
+
if (registered === false && !existsSync(worktreePath)) {
|
|
311
|
+
return { ok: true, removed: true, actions: [`cleaned worktree files ${worktreePath}`] };
|
|
312
|
+
}
|
|
313
|
+
if (registered === true) {
|
|
314
|
+
return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after cleanup: ${worktreePath}`, cleanupBlocked: true };
|
|
315
|
+
}
|
|
316
|
+
return { ok: false, reason: WORKTREE_LOCKED, error: `could not verify worktree removal for ${worktreePath}`, cleanupBlocked: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
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 };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function staleWorktrees({ git, allowed = [] } = {}) {
|
|
323
|
+
const run = git ?? defaultRunner;
|
|
324
|
+
const set = new Set(allowed.map((p) => resolve(p)));
|
|
325
|
+
const stale = [];
|
|
326
|
+
try {
|
|
327
|
+
const root = await inspectRepository({ cwd: allowed[0] ?? process.cwd(), git: run });
|
|
328
|
+
if (root.ok) {
|
|
329
|
+
const res = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: root.repoRoot });
|
|
330
|
+
if (res.ok) {
|
|
331
|
+
for (const block of String(res.stdout).split('\n\n')) {
|
|
332
|
+
const path = block.split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length)?.trim();
|
|
333
|
+
if (!path) continue;
|
|
334
|
+
const abs = resolve(path);
|
|
335
|
+
if (basename(abs).startsWith(WORKTREE_PREFIX) && !set.has(abs)) stale.push(abs);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} catch {}
|
|
340
|
+
return stale;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function pruneWorktrees({ git, allowed = [] } = {}) {
|
|
344
|
+
const run = git ?? defaultRunner;
|
|
345
|
+
const stale = await staleWorktrees({ git: run, allowed });
|
|
346
|
+
const actions = [];
|
|
347
|
+
for (const w of stale) {
|
|
348
|
+
const r = await cleanupIsolatedWorkspace({ worktreePath: w, git: run });
|
|
349
|
+
actions.push(...(r.actions ?? [r.error ?? `stale worktree ${w}`]));
|
|
350
|
+
}
|
|
351
|
+
return { ok: true, removed: stale.length, actions };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function clampMaxParallel(raw) {
|
|
355
|
+
const n = Number.isInteger(raw) ? raw : DEFAULT_MAX_PARALLEL;
|
|
356
|
+
if (n < 1) return 1;
|
|
357
|
+
if (n > MAX_PARALLEL_CAP) return MAX_PARALLEL_CAP;
|
|
358
|
+
return n;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function concurrencyGate({ maxParallel = DEFAULT_MAX_PARALLEL, active = 0 } = {}) {
|
|
362
|
+
const cap = clampMaxParallel(maxParallel);
|
|
363
|
+
const ok = active < cap;
|
|
364
|
+
return { ok, active, maxParallel: cap, blocked: !ok };
|
|
365
|
+
}
|