pi-crew 0.9.19 → 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/CHANGELOG.md +28 -0
- 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 +162 -131
- package/src/extension/rpc-hmac.ts +236 -0
- package/src/extension/team-tool/run.ts +33 -4
- package/src/runtime/background-runner.ts +7 -0
- package/src/runtime/child-pi.ts +62 -81
- package/src/runtime/cross-extension-rpc.ts +22 -3
- 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/tools/safe-bash-extension.ts +2 -2
- package/src/tools/safe-bash.ts +100 -0
- package/src/utils/env-filter.ts +49 -0
- package/src/utils/token-counter.ts +67 -0
- package/src/worktree/worktree-manager.ts +365 -1
- package/docs/A +0 -358
- package/docs/M-A +0 -357
- package/docs/Y +0 -357
- package/docs/a +0 -357
- package/docs/aA +0 -358
- package/docs//303/242mmaAAA/303/242 +0 -357
package/src/utils/env-filter.ts
CHANGED
|
@@ -21,10 +21,59 @@ const KNOWN_PROVIDER_KEYS = new Set([
|
|
|
21
21
|
"ZERODEV_API_KEY",
|
|
22
22
|
]);
|
|
23
23
|
|
|
24
|
+
// Map from provider prefix (lowercase) to env var names.
|
|
25
|
+
// Used by providerEnvKeys() to scope API keys per task.
|
|
26
|
+
const PROVIDER_ENV_KEY_MAP: Record<string, string[]> = {
|
|
27
|
+
minimax: ["MINIMAX_API_KEY", "MINIMAX_GROUP_ID"],
|
|
28
|
+
openai: ["OPENAI_API_KEY", "OPENAI_ORG_ID"],
|
|
29
|
+
anthropic: ["ANTHROPIC_API_KEY"],
|
|
30
|
+
google: ["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_LANGUAGE_API_KEY"],
|
|
31
|
+
gemini: ["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_LANGUAGE_API_KEY"],
|
|
32
|
+
azure: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"],
|
|
33
|
+
"azure-openai": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"],
|
|
34
|
+
aws: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
|
|
35
|
+
bedrock: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
|
|
36
|
+
zai: ["ZEU_API_KEY"],
|
|
37
|
+
zerodev: ["ZERODEV_API_KEY"],
|
|
38
|
+
};
|
|
39
|
+
|
|
24
40
|
function isKnownProviderKey(key: string): boolean {
|
|
25
41
|
return KNOWN_PROVIDER_KEYS.has(key);
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Extract provider prefix from a model ID string and return the corresponding
|
|
46
|
+
* env var names. Returns empty array for unknown/custom providers or invalid input.
|
|
47
|
+
*
|
|
48
|
+
* @param modelId - Model in "provider/modelId" format (e.g. "openai/gpt-4o")
|
|
49
|
+
* @returns Array of env var names needed for this provider
|
|
50
|
+
*/
|
|
51
|
+
export function providerEnvKeys(modelId: string | undefined): string[] {
|
|
52
|
+
if (!modelId) return [];
|
|
53
|
+
const separatorIndex = modelId.indexOf("/");
|
|
54
|
+
if (separatorIndex <= 0) return [];
|
|
55
|
+
const provider = modelId.substring(0, separatorIndex).toLowerCase();
|
|
56
|
+
return PROVIDER_ENV_KEY_MAP[provider] ?? [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a scoped allowlist that includes system vars + only the provider keys
|
|
61
|
+
* relevant to the assigned model (and its fallback chain).
|
|
62
|
+
*
|
|
63
|
+
* @param baseAllowList - Non-provider system vars to always include
|
|
64
|
+
* @param models - Model IDs to include provider keys for (primary + fallbacks)
|
|
65
|
+
* @returns Combined allowlist with scoped provider keys
|
|
66
|
+
*/
|
|
67
|
+
export function buildScopedAllowList(baseAllowList: string[], models: (string | undefined)[]): string[] {
|
|
68
|
+
const providerKeys = new Set<string>();
|
|
69
|
+
for (const model of models) {
|
|
70
|
+
for (const key of providerEnvKeys(model)) {
|
|
71
|
+
providerKeys.add(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...baseAllowList, ...providerKeys];
|
|
75
|
+
}
|
|
76
|
+
|
|
28
77
|
export interface SanitizeEnvOptions {
|
|
29
78
|
/** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
|
|
30
79
|
allowList?: string[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight token counter for estimating token counts in text.
|
|
3
|
+
*
|
|
4
|
+
* Provides a more accurate estimate than the naive char/4 heuristic by
|
|
5
|
+
* distinguishing word characters from punctuation. Real LLM tokenizers
|
|
6
|
+
* (BPE) typically count alphanumeric runs as ~1 token per ~4 characters,
|
|
7
|
+
* while individual punctuation characters are often separate tokens.
|
|
8
|
+
*
|
|
9
|
+
* Performance: O(n) single-pass, ~1ms for 10KB text, no external deps.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function isWhitespace(c: number): boolean {
|
|
13
|
+
return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isAlphanumeric(c: number): boolean {
|
|
17
|
+
return (
|
|
18
|
+
(c >= 0x30 && c <= 0x39) || // 0-9
|
|
19
|
+
(c >= 0x41 && c <= 0x5a) || // A-Z
|
|
20
|
+
(c >= 0x61 && c <= 0x7a) || // a-z
|
|
21
|
+
c === 0x5f // _
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Estimate token count for a string using a single-pass O(n) scan.
|
|
27
|
+
*
|
|
28
|
+
* Algorithm:
|
|
29
|
+
* 1. Walk text char-by-char with charCodeAt (fast, no allocations).
|
|
30
|
+
* 2. Count alphabetic chars (each ~1 token per 4 chars, like BPE prose).
|
|
31
|
+
* 3. Count punctuation chars separately (each = 1 token, since operators
|
|
32
|
+
* and punctuation typically tokenize as separate units in BPE).
|
|
33
|
+
* 4. Estimate: ceil(alpha / 4) + punct
|
|
34
|
+
*
|
|
35
|
+
* Why this beats char/4:
|
|
36
|
+
* - char/4 undercounts operators in code-heavy content (treats `=>` as ~3
|
|
37
|
+
* chars/token when BPE gives ~1-2 tokens per operator).
|
|
38
|
+
* - char/4 also miscounts short punctuation-only segments.
|
|
39
|
+
* - This formula weights punctuation at 1 token each, matching BPE's
|
|
40
|
+
* tendency to tokenize operators, brackets, and symbols individually.
|
|
41
|
+
*
|
|
42
|
+
* Accuracy: typically within ±10-15% of actual BPE token counts for both
|
|
43
|
+
* English prose and code, beating the char/4 heuristic (which can be
|
|
44
|
+
* 30%+ off for code-heavy content).
|
|
45
|
+
*
|
|
46
|
+
* @param text Input text to estimate tokens for.
|
|
47
|
+
* @returns Estimated token count.
|
|
48
|
+
*/
|
|
49
|
+
export function countTokens(text: string): number {
|
|
50
|
+
if (!text || text.length === 0) return 0;
|
|
51
|
+
|
|
52
|
+
let alpha = 0;
|
|
53
|
+
let punct = 0;
|
|
54
|
+
const len = text.length;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < len; i++) {
|
|
57
|
+
const c = text.charCodeAt(i);
|
|
58
|
+
if (isWhitespace(c)) continue;
|
|
59
|
+
if (isAlphanumeric(c)) {
|
|
60
|
+
alpha++;
|
|
61
|
+
} else {
|
|
62
|
+
punct++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return Math.ceil(alpha / 4) + punct;
|
|
67
|
+
}
|
|
@@ -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
|
+
}
|