@quintinshaw/pi-dynamic-workflows 1.4.0 → 1.5.0
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/README.md +4 -1
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +6 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/workflow.d.ts +1 -0
- package/dist/workflow.js +18 -2
- package/dist/worktree.d.ts +25 -0
- package/dist/worktree.js +61 -0
- package/package.json +1 -1
- package/src/agent.ts +8 -2
- package/src/index.ts +3 -0
- package/src/workflow.ts +17 -3
- package/src/worktree.ts +76 -0
package/README.md
CHANGED
|
@@ -99,6 +99,7 @@ return { inventory, summary }
|
|
|
99
99
|
| `phase` | string | Override the current phase for this agent |
|
|
100
100
|
| `schema` | object | JSON Schema for structured output |
|
|
101
101
|
| `model` | string | Run this agent on a specific model — `provider/modelId` or a bare `modelId` |
|
|
102
|
+
| `isolation` | `"worktree"` | Run this agent in its own throwaway git worktree (parallel edits without conflict) |
|
|
102
103
|
| `timeoutMs` | number | Override the default 5-minute agent timeout |
|
|
103
104
|
|
|
104
105
|
Models can also be set per phase via `meta.phases[].model`. Precedence is `opts.model` > phase model > session default; an unknown model logs a warning and falls back to the default.
|
|
@@ -135,6 +136,7 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
135
136
|
- **Real per-agent / per-phase model routing** — `opts.model` and `meta.phases[].model` actually select the model (resolved against your authed model registry), with graceful fallback
|
|
136
137
|
- **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
|
|
137
138
|
- **Resume** — each agent result is journaled by a deterministic call index; resuming replays the unchanged prefix from cache (no re-run, no tokens) and runs only new or edited calls live
|
|
139
|
+
- **Worktree isolation** — `isolation: "worktree"` runs an agent in its own git worktree on a throwaway branch, so parallel agents can edit the same files without conflict; the worktree is torn down after (results are not auto-merged), and it falls back to a logged no-op outside a git repo
|
|
138
140
|
- **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
|
|
139
141
|
- **Live progress + token/cost display**, `Esc` to abort
|
|
140
142
|
- **Log persistence** to `.pi/workflows/runs/`
|
|
@@ -143,8 +145,9 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
143
145
|
|
|
144
146
|
Tracked toward closer parity with Claude Code dynamic workflows:
|
|
145
147
|
|
|
146
|
-
- **
|
|
148
|
+
- **Bundled `/deep-research`** and `/adversarial-review` workflows
|
|
147
149
|
- **Saved workflows** as `/<name>` slash commands
|
|
150
|
+
- **Nested `workflow()`** to compose saved workflows inline
|
|
148
151
|
|
|
149
152
|
## How it works
|
|
150
153
|
|
package/dist/agent.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
|
|
|
38
38
|
model?: string;
|
|
39
39
|
/** Called with the resolved model id once known (for display/telemetry). */
|
|
40
40
|
onModelResolved?: (modelId: string) => void;
|
|
41
|
+
/** Run this agent in a different working directory (e.g. an isolated worktree). */
|
|
42
|
+
cwd?: string;
|
|
41
43
|
}
|
|
42
44
|
export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema ? Static<TSchemaDef> : string;
|
|
43
45
|
export declare class WorkflowAgent {
|
package/dist/agent.js
CHANGED
|
@@ -39,7 +39,11 @@ export class WorkflowAgent {
|
|
|
39
39
|
}
|
|
40
40
|
async run(prompt, options = {}) {
|
|
41
41
|
const capture = { called: false, value: undefined };
|
|
42
|
-
|
|
42
|
+
// Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
|
|
43
|
+
// since tools capture their cwd at construction and can't be relocated.
|
|
44
|
+
const runCwd = options.cwd ?? this.cwd;
|
|
45
|
+
const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
|
|
46
|
+
const customTools = [...baseTools, ...(options.tools ?? [])];
|
|
43
47
|
if (options.schema) {
|
|
44
48
|
customTools.push(createStructuredOutputTool({ schema: options.schema, capture }));
|
|
45
49
|
}
|
|
@@ -57,7 +61,7 @@ export class WorkflowAgent {
|
|
|
57
61
|
}
|
|
58
62
|
const agentDir = getAgentDir();
|
|
59
63
|
const { session } = await createAgentSession({
|
|
60
|
-
cwd:
|
|
64
|
+
cwd: runCwd,
|
|
61
65
|
agentDir,
|
|
62
66
|
sessionManager: SessionManager.inMemory(),
|
|
63
67
|
// Use real SettingsManager to inherit user's default provider/model settings.
|
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type { PersistedRunState, RunPersistence, RunStatus } from "./run-persist
|
|
|
18
18
|
export { createRunPersistence, generateRunId } from "./run-persistence.js";
|
|
19
19
|
export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
|
|
20
20
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
21
|
-
export type { AgentOptions, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
|
|
21
|
+
export type { AgentOptions, JournalEntry, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
|
|
22
22
|
export { parseWorkflowScript, runWorkflow } from "./workflow.js";
|
|
23
23
|
export { registerWorkflowCommands } from "./workflow-commands.js";
|
|
24
24
|
export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
|
|
@@ -27,3 +27,5 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
|
|
|
27
27
|
export { createWorkflowStorage } from "./workflow-saved.js";
|
|
28
28
|
export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
|
|
29
29
|
export { createWorkflowTool } from "./workflow-tool.js";
|
|
30
|
+
export type { Worktree } from "./worktree.js";
|
|
31
|
+
export { createWorktree, removeWorktree } from "./worktree.js";
|
package/dist/index.js
CHANGED
|
@@ -14,3 +14,4 @@ export { registerWorkflowCommands } from "./workflow-commands.js";
|
|
|
14
14
|
export { WorkflowManager } from "./workflow-manager.js";
|
|
15
15
|
export { createWorkflowStorage } from "./workflow-saved.js";
|
|
16
16
|
export { createWorkflowTool } from "./workflow-tool.js";
|
|
17
|
+
export { createWorktree, removeWorktree } from "./worktree.js";
|
package/dist/workflow.d.ts
CHANGED
package/dist/workflow.js
CHANGED
|
@@ -6,6 +6,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
|
|
|
6
6
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
7
7
|
import { createWorkflowLogger } from "./logger.js";
|
|
8
8
|
import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
9
|
+
import { createWorktree, removeWorktree } from "./worktree.js";
|
|
9
10
|
const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
|
|
10
11
|
export async function runWorkflow(script, options = {}) {
|
|
11
12
|
const started = Date.now();
|
|
@@ -15,6 +16,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
15
16
|
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
16
17
|
const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
17
18
|
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
19
|
+
const baseCwd = options.cwd ?? process.cwd();
|
|
18
20
|
// Initialize logger
|
|
19
21
|
const logger = createWorkflowLogger({
|
|
20
22
|
runId,
|
|
@@ -88,6 +90,14 @@ export async function runWorkflow(script, options = {}) {
|
|
|
88
90
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
89
91
|
const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
|
|
90
92
|
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
93
|
+
// Optional per-agent worktree isolation (deterministic name -> stable resume keys).
|
|
94
|
+
let worktree;
|
|
95
|
+
if (agentOptions.isolation === "worktree") {
|
|
96
|
+
worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
|
|
97
|
+
if (!worktree.isolated)
|
|
98
|
+
log(`isolation ignored for "${label}" (${worktree.reason})`);
|
|
99
|
+
}
|
|
100
|
+
const runCwd = worktree?.isolated ? worktree.cwd : undefined;
|
|
91
101
|
// Captured from the subagent's real session usage; falls back to an
|
|
92
102
|
// estimate when the provider reports no usage (total === 0).
|
|
93
103
|
let usage;
|
|
@@ -111,6 +121,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
111
121
|
signal: options.signal,
|
|
112
122
|
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
113
123
|
model: modelSpec,
|
|
124
|
+
cwd: runCwd,
|
|
114
125
|
onUsage: (u) => {
|
|
115
126
|
usage = u;
|
|
116
127
|
},
|
|
@@ -118,7 +129,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
118
129
|
throwIfAborted();
|
|
119
130
|
const tokens = recordTokens(result);
|
|
120
131
|
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
121
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
132
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
|
|
122
133
|
return result;
|
|
123
134
|
}
|
|
124
135
|
catch (error) {
|
|
@@ -127,13 +138,18 @@ export async function runWorkflow(script, options = {}) {
|
|
|
127
138
|
const workflowError = wrapError(error, { agentLabel: label });
|
|
128
139
|
logger.error(`agent ${label} failed: ${workflowError.message}`);
|
|
129
140
|
const tokens = recordTokens(null);
|
|
130
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
|
|
141
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
|
|
131
142
|
// Return null for recoverable errors
|
|
132
143
|
if (workflowError.recoverable) {
|
|
133
144
|
return null;
|
|
134
145
|
}
|
|
135
146
|
throw workflowError;
|
|
136
147
|
}
|
|
148
|
+
finally {
|
|
149
|
+
// Always tear down the worktree, even on timeout/abort.
|
|
150
|
+
if (worktree?.isolated)
|
|
151
|
+
await removeWorktree(worktree);
|
|
152
|
+
}
|
|
137
153
|
});
|
|
138
154
|
};
|
|
139
155
|
const parallel = async (thunks) => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
|
|
3
|
+
* it runs in a throwaway worktree on its own branch so parallel agents can edit the
|
|
4
|
+
* same files without conflict. Results are NOT auto-merged — the path is surfaced for
|
|
5
|
+
* the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
|
|
6
|
+
*/
|
|
7
|
+
export interface Worktree {
|
|
8
|
+
/** True when a real worktree was created; false means "ran in the shared tree". */
|
|
9
|
+
isolated: boolean;
|
|
10
|
+
/** cwd the agent should run in (worktree path when isolated, else the base cwd). */
|
|
11
|
+
cwd: string;
|
|
12
|
+
branch?: string;
|
|
13
|
+
/** Repo root the worktree was added to (for teardown). */
|
|
14
|
+
repoRoot?: string;
|
|
15
|
+
/** Why isolation was skipped, when isolated === false. */
|
|
16
|
+
reason?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
|
|
20
|
+
* `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
|
|
21
|
+
* never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createWorktree(baseCwd: string, name: string): Promise<Worktree>;
|
|
24
|
+
/** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
|
|
25
|
+
export declare function removeWorktree(wt: Worktree): Promise<void>;
|
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
|
|
3
|
+
* it runs in a throwaway worktree on its own branch so parallel agents can edit the
|
|
4
|
+
* same files without conflict. Results are NOT auto-merged — the path is surfaced for
|
|
5
|
+
* the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
function slug(name) {
|
|
12
|
+
return (name
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
15
|
+
.replace(/^-+|-+$/g, "")
|
|
16
|
+
.slice(0, 32) || "agent");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
|
|
20
|
+
* `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
|
|
21
|
+
* never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
|
|
22
|
+
*/
|
|
23
|
+
export async function createWorktree(baseCwd, name) {
|
|
24
|
+
const id = slug(name);
|
|
25
|
+
let repoRoot;
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
|
|
28
|
+
repoRoot = stdout.trim();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
|
|
32
|
+
}
|
|
33
|
+
const path = join(repoRoot, ".pi", "worktrees", id);
|
|
34
|
+
const branch = `pi/wf/${id}`;
|
|
35
|
+
try {
|
|
36
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
|
|
37
|
+
return { isolated: true, cwd: path, branch, repoRoot };
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
|
|
44
|
+
export async function removeWorktree(wt) {
|
|
45
|
+
if (!wt.isolated || !wt.repoRoot)
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// already gone / locked — fall through
|
|
52
|
+
}
|
|
53
|
+
if (wt.branch) {
|
|
54
|
+
try {
|
|
55
|
+
await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// branch already deleted
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
|
|
|
54
54
|
model?: string;
|
|
55
55
|
/** Called with the resolved model id once known (for display/telemetry). */
|
|
56
56
|
onModelResolved?: (modelId: string) => void;
|
|
57
|
+
/** Run this agent in a different working directory (e.g. an isolated worktree). */
|
|
58
|
+
cwd?: string;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema
|
|
@@ -105,7 +107,11 @@ export class WorkflowAgent {
|
|
|
105
107
|
options: AgentRunOptions<TSchemaDef> = {},
|
|
106
108
|
): Promise<AgentRunResult<TSchemaDef>> {
|
|
107
109
|
const capture: StructuredOutputCapture<any> = { called: false, value: undefined };
|
|
108
|
-
|
|
110
|
+
// Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
|
|
111
|
+
// since tools capture their cwd at construction and can't be relocated.
|
|
112
|
+
const runCwd = options.cwd ?? this.cwd;
|
|
113
|
+
const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
|
|
114
|
+
const customTools: ToolDefinition[] = [...baseTools, ...(options.tools ?? [])];
|
|
109
115
|
|
|
110
116
|
if (options.schema) {
|
|
111
117
|
customTools.push(createStructuredOutputTool({ schema: options.schema, capture }) as unknown as ToolDefinition);
|
|
@@ -125,7 +131,7 @@ export class WorkflowAgent {
|
|
|
125
131
|
|
|
126
132
|
const agentDir = getAgentDir();
|
|
127
133
|
const { session } = await createAgentSession({
|
|
128
|
-
cwd:
|
|
134
|
+
cwd: runCwd,
|
|
129
135
|
agentDir,
|
|
130
136
|
sessionManager: SessionManager.inMemory(),
|
|
131
137
|
// Use real SettingsManager to inherit user's default provider/model settings.
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./str
|
|
|
41
41
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
42
42
|
export type {
|
|
43
43
|
AgentOptions,
|
|
44
|
+
JournalEntry,
|
|
44
45
|
WorkflowMeta,
|
|
45
46
|
WorkflowMetaPhase,
|
|
46
47
|
WorkflowRunOptions,
|
|
@@ -54,3 +55,5 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
|
|
|
54
55
|
export { createWorkflowStorage } from "./workflow-saved.js";
|
|
55
56
|
export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
|
|
56
57
|
export { createWorkflowTool } from "./workflow-tool.js";
|
|
58
|
+
export type { Worktree } from "./worktree.js";
|
|
59
|
+
export { createWorktree, removeWorktree } from "./worktree.js";
|
package/src/workflow.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
|
|
|
9
9
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
10
10
|
import { createWorkflowLogger } from "./logger.js";
|
|
11
11
|
import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
12
|
+
import { createWorktree, removeWorktree, type Worktree } from "./worktree.js";
|
|
12
13
|
|
|
13
14
|
export interface WorkflowMetaPhase {
|
|
14
15
|
title: string;
|
|
@@ -54,7 +55,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
54
55
|
onLog?: (message: string) => void;
|
|
55
56
|
onPhase?: (title: string) => void;
|
|
56
57
|
onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
|
|
57
|
-
onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number }) => void;
|
|
58
|
+
onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number; worktree?: string }) => void;
|
|
58
59
|
onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -116,6 +117,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
116
117
|
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
117
118
|
const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
118
119
|
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
120
|
+
const baseCwd = options.cwd ?? process.cwd();
|
|
119
121
|
|
|
120
122
|
// Initialize logger
|
|
121
123
|
const logger = createWorkflowLogger({
|
|
@@ -211,6 +213,14 @@ export async function runWorkflow<T = unknown>(
|
|
|
211
213
|
|
|
212
214
|
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
213
215
|
|
|
216
|
+
// Optional per-agent worktree isolation (deterministic name -> stable resume keys).
|
|
217
|
+
let worktree: Worktree | undefined;
|
|
218
|
+
if (agentOptions.isolation === "worktree") {
|
|
219
|
+
worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
|
|
220
|
+
if (!worktree.isolated) log(`isolation ignored for "${label}" (${worktree.reason})`);
|
|
221
|
+
}
|
|
222
|
+
const runCwd = worktree?.isolated ? worktree.cwd : undefined;
|
|
223
|
+
|
|
214
224
|
// Captured from the subagent's real session usage; falls back to an
|
|
215
225
|
// estimate when the provider reports no usage (total === 0).
|
|
216
226
|
let usage: AgentUsage | undefined;
|
|
@@ -237,6 +247,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
237
247
|
signal: options.signal,
|
|
238
248
|
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
239
249
|
model: modelSpec,
|
|
250
|
+
cwd: runCwd,
|
|
240
251
|
onUsage: (u: AgentUsage) => {
|
|
241
252
|
usage = u;
|
|
242
253
|
},
|
|
@@ -249,7 +260,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
249
260
|
|
|
250
261
|
const tokens = recordTokens(result);
|
|
251
262
|
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
252
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
263
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
|
|
253
264
|
return result;
|
|
254
265
|
} catch (error) {
|
|
255
266
|
if (options.signal?.aborted) throw error;
|
|
@@ -257,13 +268,16 @@ export async function runWorkflow<T = unknown>(
|
|
|
257
268
|
const workflowError = wrapError(error, { agentLabel: label });
|
|
258
269
|
logger.error(`agent ${label} failed: ${workflowError.message}`);
|
|
259
270
|
const tokens = recordTokens(null);
|
|
260
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
|
|
271
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
|
|
261
272
|
|
|
262
273
|
// Return null for recoverable errors
|
|
263
274
|
if (workflowError.recoverable) {
|
|
264
275
|
return null;
|
|
265
276
|
}
|
|
266
277
|
throw workflowError;
|
|
278
|
+
} finally {
|
|
279
|
+
// Always tear down the worktree, even on timeout/abort.
|
|
280
|
+
if (worktree?.isolated) await removeWorktree(worktree);
|
|
267
281
|
}
|
|
268
282
|
});
|
|
269
283
|
};
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
|
|
3
|
+
* it runs in a throwaway worktree on its own branch so parallel agents can edit the
|
|
4
|
+
* same files without conflict. Results are NOT auto-merged — the path is surfaced for
|
|
5
|
+
* the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
export interface Worktree {
|
|
15
|
+
/** True when a real worktree was created; false means "ran in the shared tree". */
|
|
16
|
+
isolated: boolean;
|
|
17
|
+
/** cwd the agent should run in (worktree path when isolated, else the base cwd). */
|
|
18
|
+
cwd: string;
|
|
19
|
+
branch?: string;
|
|
20
|
+
/** Repo root the worktree was added to (for teardown). */
|
|
21
|
+
repoRoot?: string;
|
|
22
|
+
/** Why isolation was skipped, when isolated === false. */
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function slug(name: string): string {
|
|
27
|
+
return (
|
|
28
|
+
name
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
31
|
+
.replace(/^-+|-+$/g, "")
|
|
32
|
+
.slice(0, 32) || "agent"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
|
|
38
|
+
* `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
|
|
39
|
+
* never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
|
|
40
|
+
*/
|
|
41
|
+
export async function createWorktree(baseCwd: string, name: string): Promise<Worktree> {
|
|
42
|
+
const id = slug(name);
|
|
43
|
+
let repoRoot: string;
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
|
|
46
|
+
repoRoot = stdout.trim();
|
|
47
|
+
} catch {
|
|
48
|
+
return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const path = join(repoRoot, ".pi", "worktrees", id);
|
|
52
|
+
const branch = `pi/wf/${id}`;
|
|
53
|
+
try {
|
|
54
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
|
|
55
|
+
return { isolated: true, cwd: path, branch, repoRoot };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
|
|
62
|
+
export async function removeWorktree(wt: Worktree): Promise<void> {
|
|
63
|
+
if (!wt.isolated || !wt.repoRoot) return;
|
|
64
|
+
try {
|
|
65
|
+
await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
|
|
66
|
+
} catch {
|
|
67
|
+
// already gone / locked — fall through
|
|
68
|
+
}
|
|
69
|
+
if (wt.branch) {
|
|
70
|
+
try {
|
|
71
|
+
await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
|
|
72
|
+
} catch {
|
|
73
|
+
// branch already deleted
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|