pi-crew 0.9.20 → 0.9.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-meta.json +4725 -4677
- package/dist/index.mjs +2060 -1760
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
- package/src/extension/cross-extension-rpc.ts +15 -2
- package/src/extension/team-tool/run.ts +33 -4
- package/src/runtime/background-runner.ts +7 -0
- package/src/runtime/dynamic-workflow-context.ts +3 -3
- package/src/runtime/task-runner.ts +4 -4
- package/src/runtime/team-runner.ts +130 -1
- package/src/runtime/tool-output-pruner.ts +5 -3
- package/src/state/event-log.ts +18 -0
- package/src/state/types.ts +5 -0
- package/src/utils/token-counter.ts +67 -0
- package/src/worktree/worktree-manager.ts +365 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
1
|
+
import { execFile, execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
5
6
|
import { loadConfig } from "../config/config.ts";
|
|
6
7
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
7
8
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
@@ -20,6 +21,8 @@ export interface PreparedTaskWorkspace {
|
|
|
20
21
|
syntheticPaths?: string[];
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
23
26
|
export interface WorktreeDiffStat {
|
|
24
27
|
filesChanged: number;
|
|
25
28
|
insertions: number;
|
|
@@ -69,6 +72,52 @@ function git(cwd: string, args: string[]): string {
|
|
|
69
72
|
}).trim();
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/** Build the sanitized env object shared by all git operations. */
|
|
76
|
+
function gitEnv(): Record<string, string> {
|
|
77
|
+
return {
|
|
78
|
+
...sanitizeEnvSecrets(process.env, {
|
|
79
|
+
allowList: [
|
|
80
|
+
"PATH",
|
|
81
|
+
"HOME",
|
|
82
|
+
"USER",
|
|
83
|
+
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
84
|
+
"SHELL",
|
|
85
|
+
"TERM",
|
|
86
|
+
"LANG",
|
|
87
|
+
"LC_ALL",
|
|
88
|
+
"LC_COLLATE",
|
|
89
|
+
"LC_CTYPE",
|
|
90
|
+
"LC_MESSAGES",
|
|
91
|
+
"XDG_CONFIG_HOME",
|
|
92
|
+
"XDG_DATA_HOME",
|
|
93
|
+
"XDG_CACHE_HOME",
|
|
94
|
+
"NVM_BIN",
|
|
95
|
+
"NVM_DIR",
|
|
96
|
+
"NODE_PATH",
|
|
97
|
+
"GIT_CONFIG_GLOBAL",
|
|
98
|
+
"GIT_CONFIG_SYSTEM",
|
|
99
|
+
"GIT_AUTHOR_NAME",
|
|
100
|
+
"GIT_AUTHOR_EMAIL",
|
|
101
|
+
"GIT_COMMITTER_NAME",
|
|
102
|
+
"GIT_COMMITTER_EMAIL",
|
|
103
|
+
],
|
|
104
|
+
}),
|
|
105
|
+
LANG: "en_US.UTF-8",
|
|
106
|
+
LC_ALL: "en_US.UTF-8",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function gitAsync(cwd: string, args: string[]): Promise<string> {
|
|
111
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
112
|
+
cwd,
|
|
113
|
+
encoding: "utf-8",
|
|
114
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
115
|
+
env: gitEnv(),
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
});
|
|
118
|
+
return stdout.trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
72
121
|
// Dots are removed from branch names since they are used in path construction,
|
|
73
122
|
// and dots could cause ambiguity with relative path handling on some platforms.
|
|
74
123
|
// Branch names themselves support dots in git, but we strip them for safe path use.
|
|
@@ -92,6 +141,41 @@ export function assertCleanLeader(repoRoot: string): void {
|
|
|
92
141
|
}
|
|
93
142
|
}
|
|
94
143
|
|
|
144
|
+
// --- Async versions ---
|
|
145
|
+
|
|
146
|
+
/** Cache for findGitRoot results keyed by cwd. Cleared per-run. */
|
|
147
|
+
const _gitRootCache = new Map<string, string>();
|
|
148
|
+
|
|
149
|
+
/** Clear the findGitRoot cache. Call at the start of each team run. */
|
|
150
|
+
export function clearGitRootCache(): void {
|
|
151
|
+
_gitRootCache.clear();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function findGitRootAsync(cwd: string): Promise<string> {
|
|
155
|
+
const cached = _gitRootCache.get(cwd);
|
|
156
|
+
if (cached) return cached;
|
|
157
|
+
const root = await gitAsync(cwd, ["rev-parse", "--show-toplevel"]);
|
|
158
|
+
_gitRootCache.set(cwd, root);
|
|
159
|
+
return root;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Cache for assertCleanLeader results keyed by repoRoot. Cleared per-run. */
|
|
163
|
+
const _cleanLeaderCache = new Set<string>();
|
|
164
|
+
|
|
165
|
+
/** Clear the assertCleanLeader cache. Call at the start of each team run. */
|
|
166
|
+
export function clearCleanLeaderCache(): void {
|
|
167
|
+
_cleanLeaderCache.clear();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function assertCleanLeaderAsync(repoRoot: string): Promise<void> {
|
|
171
|
+
if (_cleanLeaderCache.has(repoRoot)) return;
|
|
172
|
+
const status = await gitAsync(repoRoot, ["status", "--porcelain"]);
|
|
173
|
+
if (status.trim()) {
|
|
174
|
+
throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
|
|
175
|
+
}
|
|
176
|
+
_cleanLeaderCache.add(repoRoot);
|
|
177
|
+
}
|
|
178
|
+
|
|
95
179
|
function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boolean {
|
|
96
180
|
const source = path.join(repoRoot, "node_modules");
|
|
97
181
|
const target = path.join(worktreePath, "node_modules");
|
|
@@ -348,6 +432,40 @@ function pruneStaleWorktrees(repoRoot: string): void {
|
|
|
348
432
|
}
|
|
349
433
|
}
|
|
350
434
|
|
|
435
|
+
async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ local: boolean; remoteOnly: boolean }> {
|
|
436
|
+
let local = false;
|
|
437
|
+
try {
|
|
438
|
+
await gitAsync(repoRoot, ["rev-parse", "--verify", `refs/heads/${branch}`]);
|
|
439
|
+
local = true;
|
|
440
|
+
} catch {}
|
|
441
|
+
if (local) return { local: true, remoteOnly: false };
|
|
442
|
+
// Check remote-tracking branch
|
|
443
|
+
try {
|
|
444
|
+
const out = (
|
|
445
|
+
await execFileAsync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], {
|
|
446
|
+
cwd: repoRoot,
|
|
447
|
+
encoding: "utf-8",
|
|
448
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
449
|
+
windowsHide: true,
|
|
450
|
+
})
|
|
451
|
+
).stdout.trim();
|
|
452
|
+
return { local: false, remoteOnly: out.length > 0 };
|
|
453
|
+
} catch {
|
|
454
|
+
return { local: false, remoteOnly: false };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function pruneStaleWorktreesAsync(repoRoot: string): Promise<void> {
|
|
459
|
+
try {
|
|
460
|
+
await execFileAsync("git", ["worktree", "prune"], {
|
|
461
|
+
cwd: repoRoot,
|
|
462
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
463
|
+
});
|
|
464
|
+
} catch {
|
|
465
|
+
/* best-effort */
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
351
469
|
/**
|
|
352
470
|
* Normalize and validate seed paths — ensure all paths stay within repoRoot.
|
|
353
471
|
* Rejects path traversal (../) and absolute paths.
|
|
@@ -582,6 +700,144 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
|
|
|
582
700
|
};
|
|
583
701
|
}
|
|
584
702
|
|
|
703
|
+
/** Async version of prepareTaskWorkspace — yields the event loop during git operations. */
|
|
704
|
+
export async function prepareTaskWorkspaceAsync(
|
|
705
|
+
manifest: TeamRunManifest,
|
|
706
|
+
task: TeamTaskState,
|
|
707
|
+
stepSeedPaths?: string[],
|
|
708
|
+
): Promise<PreparedTaskWorkspace> {
|
|
709
|
+
if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd };
|
|
710
|
+
const repoRoot = await findGitRootAsync(manifest.cwd);
|
|
711
|
+
const loadedConfig = loadConfig(manifest.cwd);
|
|
712
|
+
if (loadedConfig.config.requireCleanWorktreeLeader !== false) await assertCleanLeaderAsync(repoRoot);
|
|
713
|
+
const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
|
|
714
|
+
const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
|
|
715
|
+
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
716
|
+
let resolvedWorktreeRoot = worktreeRoot;
|
|
717
|
+
try {
|
|
718
|
+
const r = fs.realpathSync.native(worktreeRoot);
|
|
719
|
+
resolvedWorktreeRoot = r.startsWith("\\\\?\\") ? r.slice(4) : r;
|
|
720
|
+
} catch {
|
|
721
|
+
try {
|
|
722
|
+
resolvedWorktreeRoot = fs.realpathSync(worktreeRoot);
|
|
723
|
+
} catch {
|
|
724
|
+
/* keep as-is */
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const sanitizedTaskId = sanitizeBranchPart(task.id);
|
|
728
|
+
const worktreePath = path.join(resolvedWorktreeRoot, sanitizedTaskId);
|
|
729
|
+
const branch = `pi-crew/${sanitizeBranchPart(manifest.runId)}/${sanitizeBranchPart(task.id)}`;
|
|
730
|
+
let worktreeExists = false;
|
|
731
|
+
try {
|
|
732
|
+
const worktreeList = await gitAsync(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
733
|
+
const normalizedWtPath =
|
|
734
|
+
process.platform === "win32"
|
|
735
|
+
? (() => {
|
|
736
|
+
try {
|
|
737
|
+
const r = fs.realpathSync.native(worktreePath);
|
|
738
|
+
return r.startsWith("\\\\?\\") ? r.slice(4) : r;
|
|
739
|
+
} catch {
|
|
740
|
+
return worktreePath;
|
|
741
|
+
}
|
|
742
|
+
})()
|
|
743
|
+
.replace(/\\/g, "/")
|
|
744
|
+
.toLowerCase()
|
|
745
|
+
: worktreePath;
|
|
746
|
+
worktreeExists = worktreeList.split("\n").some((line) => {
|
|
747
|
+
const trimmed = line.trim();
|
|
748
|
+
const matchPath = trimmed.startsWith("worktree ") ? trimmed.slice(9) : trimmed;
|
|
749
|
+
if (process.platform === "win32") {
|
|
750
|
+
return matchPath.replace(/\\/g, "/").toLowerCase() === normalizedWtPath;
|
|
751
|
+
}
|
|
752
|
+
return matchPath === worktreePath;
|
|
753
|
+
});
|
|
754
|
+
} catch {
|
|
755
|
+
worktreeExists = false;
|
|
756
|
+
}
|
|
757
|
+
if (worktreeExists) {
|
|
758
|
+
let currentBranch: string;
|
|
759
|
+
try {
|
|
760
|
+
currentBranch = await gitAsync(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
761
|
+
} catch (gitError) {
|
|
762
|
+
throw new Error(
|
|
763
|
+
`Existing worktree at ${worktreePath} is not a valid git repository; cannot verify branch: ${gitError instanceof Error ? gitError.message : String(gitError)}`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
if (currentBranch !== branch) {
|
|
767
|
+
throw new Error(`Existing worktree branch mismatch at ${worktreePath}: expected '${branch}', got '${currentBranch}'.`);
|
|
768
|
+
}
|
|
769
|
+
const dirtyStatus = await gitAsync(worktreePath, ["status", "--porcelain"]);
|
|
770
|
+
if (dirtyStatus.trim()) {
|
|
771
|
+
logInternalError(
|
|
772
|
+
"worktree.reused.dirty",
|
|
773
|
+
new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath}`),
|
|
774
|
+
`runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
|
|
775
|
+
);
|
|
776
|
+
await gitAsync(worktreePath, ["checkout", "--", "."]);
|
|
777
|
+
await gitAsync(worktreePath, ["clean", "-fd"]);
|
|
778
|
+
}
|
|
779
|
+
const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
|
|
780
|
+
const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
|
|
781
|
+
if (mergedReused.length > 0) {
|
|
782
|
+
overlaySeedPaths(repoRoot, worktreePath, mergedReused);
|
|
783
|
+
}
|
|
784
|
+
// Re-validate leader is still clean before reusing
|
|
785
|
+
// Note: this intentionally skips the cache to re-check the live state.
|
|
786
|
+
_cleanLeaderCache.delete(repoRoot);
|
|
787
|
+
await assertCleanLeaderAsync(repoRoot);
|
|
788
|
+
return { cwd: worktreePath, worktreePath, branch, reused: true };
|
|
789
|
+
}
|
|
790
|
+
await pruneStaleWorktreesAsync(repoRoot);
|
|
791
|
+
const exists = await branchExistsAsync(repoRoot, branch);
|
|
792
|
+
let worktreeCreated = false;
|
|
793
|
+
try {
|
|
794
|
+
if (exists.local) {
|
|
795
|
+
await gitAsync(repoRoot, ["worktree", "add", worktreePath, branch]);
|
|
796
|
+
} else {
|
|
797
|
+
if (exists.remoteOnly) {
|
|
798
|
+
logInternalError(
|
|
799
|
+
"worktree.branchRemoteOnly",
|
|
800
|
+
new Error(`Branch '${branch}' exists only on remote; creating local from HEAD instead of tracking remote.`),
|
|
801
|
+
`branch=${branch}`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
await gitAsync(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
|
|
805
|
+
}
|
|
806
|
+
worktreeCreated = true;
|
|
807
|
+
} catch (error) {
|
|
808
|
+
if (fs.existsSync(worktreePath)) {
|
|
809
|
+
try {
|
|
810
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
811
|
+
} catch {
|
|
812
|
+
/* best-effort cleanup */
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
816
|
+
if (/already checked out|is already used by worktree/i.test(msg)) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`Branch '${branch}' is checked out at another worktree. Run \`team cleanup runId=${manifest.runId} force=true\` or manually remove the conflicting worktree.`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
|
|
824
|
+
const nodeModulesLinked =
|
|
825
|
+
loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
|
|
826
|
+
const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
|
|
827
|
+
const merged = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
|
|
828
|
+
if (merged.length > 0) {
|
|
829
|
+
overlaySeedPaths(repoRoot, worktreePath, merged);
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
cwd: worktreePath,
|
|
833
|
+
worktreePath,
|
|
834
|
+
branch,
|
|
835
|
+
reused: false,
|
|
836
|
+
nodeModulesLinked,
|
|
837
|
+
syntheticPaths,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
585
841
|
export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat {
|
|
586
842
|
try {
|
|
587
843
|
const diffStat = git(worktreePath, ["diff", "--stat"]);
|
|
@@ -601,6 +857,25 @@ export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat
|
|
|
601
857
|
}
|
|
602
858
|
}
|
|
603
859
|
|
|
860
|
+
export async function captureWorktreeDiffStatAsync(worktreePath: string): Promise<WorktreeDiffStat> {
|
|
861
|
+
try {
|
|
862
|
+
const diffStat = await gitAsync(worktreePath, ["diff", "--stat"]);
|
|
863
|
+
const numstat = await gitAsync(worktreePath, ["diff", "--numstat"]);
|
|
864
|
+
let filesChanged = 0;
|
|
865
|
+
let insertions = 0;
|
|
866
|
+
let deletions = 0;
|
|
867
|
+
for (const line of numstat.split(/\r?\n/).filter(Boolean)) {
|
|
868
|
+
const [add, del] = line.split(/\s+/);
|
|
869
|
+
filesChanged += 1;
|
|
870
|
+
insertions += Number(add) || 0;
|
|
871
|
+
deletions += Number(del) || 0;
|
|
872
|
+
}
|
|
873
|
+
return { filesChanged, insertions, deletions, diffStat };
|
|
874
|
+
} catch {
|
|
875
|
+
return { filesChanged: 0, insertions: 0, deletions: 0, diffStat: "" };
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
604
879
|
export function captureWorktreeDiff(worktreePath: string): string {
|
|
605
880
|
try {
|
|
606
881
|
return git(worktreePath, ["diff", "--stat"]) + "\n\n" + git(worktreePath, ["diff"]);
|
|
@@ -610,6 +885,16 @@ export function captureWorktreeDiff(worktreePath: string): string {
|
|
|
610
885
|
}
|
|
611
886
|
}
|
|
612
887
|
|
|
888
|
+
export async function captureWorktreeDiffAsync(worktreePath: string): Promise<string> {
|
|
889
|
+
try {
|
|
890
|
+
const [stat, full] = await Promise.all([gitAsync(worktreePath, ["diff", "--stat"]), gitAsync(worktreePath, ["diff"])]);
|
|
891
|
+
return stat + "\n\n" + full;
|
|
892
|
+
} catch (error) {
|
|
893
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
894
|
+
return `Failed to capture worktree diff: ${message}`;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
613
898
|
/**
|
|
614
899
|
* round-17 P2-4: Create an isolated git worktree for a single DWF agent call.
|
|
615
900
|
*
|
|
@@ -644,6 +929,29 @@ export function prepareAgentWorktree(manifest: TeamRunManifest, agentId: string)
|
|
|
644
929
|
}
|
|
645
930
|
}
|
|
646
931
|
|
|
932
|
+
/** Async version of prepareAgentWorktree — yields the event loop during git operations. */
|
|
933
|
+
export async function prepareAgentWorktreeAsync(manifest: TeamRunManifest, agentId: string): Promise<PreparedTaskWorkspace | undefined> {
|
|
934
|
+
try {
|
|
935
|
+
const repoRoot = await findGitRootAsync(manifest.cwd);
|
|
936
|
+
const loadedConfig = loadConfig(manifest.cwd);
|
|
937
|
+
if (loadedConfig.config.requireCleanWorktreeLeader !== false) await assertCleanLeaderAsync(repoRoot);
|
|
938
|
+
const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
|
|
939
|
+
const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
|
|
940
|
+
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
941
|
+
const sanitizedAgentId = sanitizeBranchPart(agentId);
|
|
942
|
+
const worktreePath = path.join(worktreeRoot, sanitizedAgentId);
|
|
943
|
+
const branch = `pi-crew/${sanitizedRunId}/${sanitizedAgentId}`;
|
|
944
|
+
await pruneStaleWorktreesAsync(repoRoot);
|
|
945
|
+
await gitAsync(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
|
|
946
|
+
const nodeModulesLinked =
|
|
947
|
+
loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
|
|
948
|
+
return { cwd: worktreePath, worktreePath, branch, nodeModulesLinked };
|
|
949
|
+
} catch {
|
|
950
|
+
// Graceful fallback: no git repo, dirty leader, or git error → run normally.
|
|
951
|
+
return undefined;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
647
955
|
/**
|
|
648
956
|
* round-17 P2-4: Remove a DWF agent worktree after the agent completes.
|
|
649
957
|
*
|
|
@@ -697,3 +1005,59 @@ export function cleanupAgentWorktree(manifest: TeamRunManifest, worktreePath: st
|
|
|
697
1005
|
logInternalError("worktree.agent-cleanup.prune", error, `worktreePath=${worktreePath}`);
|
|
698
1006
|
}
|
|
699
1007
|
}
|
|
1008
|
+
|
|
1009
|
+
/** Async version of cleanupAgentWorktree — caches findGitRoot and yields during git operations. */
|
|
1010
|
+
export async function cleanupAgentWorktreeAsync(manifest: TeamRunManifest, worktreePath: string, branch?: string): Promise<void> {
|
|
1011
|
+
// Capture diff as artifact (best-effort).
|
|
1012
|
+
try {
|
|
1013
|
+
const diff = await captureWorktreeDiffAsync(worktreePath);
|
|
1014
|
+
if (diff.trim() && !diff.startsWith("Failed to capture worktree diff")) {
|
|
1015
|
+
writeArtifact(manifest.artifactsRoot, {
|
|
1016
|
+
kind: "diff",
|
|
1017
|
+
relativePath: `wf/worktree-diff-${Date.now()}-${randomBytes(2).toString("hex")}.diff`,
|
|
1018
|
+
content: diff,
|
|
1019
|
+
producer: "dynamic-workflow",
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
logInternalError("worktree.agent-cleanup.diff", error, `worktreePath=${worktreePath}`);
|
|
1024
|
+
}
|
|
1025
|
+
// Resolve repoRoot once and reuse (cache optimization).
|
|
1026
|
+
let repoRoot: string | undefined;
|
|
1027
|
+
try {
|
|
1028
|
+
repoRoot = await findGitRootAsync(manifest.cwd);
|
|
1029
|
+
} catch {
|
|
1030
|
+
// Cannot resolve repoRoot — fall back to fs.rm for the worktree.
|
|
1031
|
+
try {
|
|
1032
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
1033
|
+
} catch (rmError) {
|
|
1034
|
+
logInternalError("worktree.agent-cleanup.rm", rmError, `worktreePath=${worktreePath}`);
|
|
1035
|
+
}
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
// Remove worktree (best-effort). Try git first, then fall back to fs.rm.
|
|
1039
|
+
try {
|
|
1040
|
+
await gitAsync(repoRoot, ["worktree", "remove", "--force", worktreePath]);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
logInternalError("worktree.agent-cleanup.remove", error, `worktreePath=${worktreePath}`);
|
|
1043
|
+
try {
|
|
1044
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
1045
|
+
} catch (rmError) {
|
|
1046
|
+
logInternalError("worktree.agent-cleanup.rm", rmError, `worktreePath=${worktreePath}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// Delete the ephemeral agent branch (best-effort).
|
|
1050
|
+
if (branch) {
|
|
1051
|
+
try {
|
|
1052
|
+
await gitAsync(repoRoot, ["branch", "-D", branch]);
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
logInternalError("worktree.agent-cleanup.branch", error, `branch=${branch}`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// Prune stale worktree refs (best-effort).
|
|
1058
|
+
try {
|
|
1059
|
+
await gitAsync(repoRoot, ["worktree", "prune"]);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
logInternalError("worktree.agent-cleanup.prune", error, `worktreePath=${worktreePath}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|