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.
@@ -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
+ }