pi-agent-fleet 0.3.0 → 0.4.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/src/controller.ts CHANGED
@@ -166,12 +166,15 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
166
166
  const prompt = await readFile(join(fleet.fleetRoot, "workers", nodeId, "prompt.md"), "utf-8");
167
167
  const sessionDir = join(fleet.fleetRoot, "workers", nodeId);
168
168
  const effort = worker.effort ?? fleet.spec.config.effort ?? "medium";
169
+ const worktreeCwd = worker?.worktree || worker?.id === "fleet-integrator"
170
+ ? join(fleet.fleetRoot, "worktrees", nodeId)
171
+ : ctx.cwd;
169
172
  try {
170
173
  return await runWorker({
171
174
  nodeId,
172
175
  worker: workerWithResolvedModel(worker, resolvedModel),
173
176
  prompt,
174
- repoCwd: ctx.cwd,
177
+ repoCwd: worktreeCwd,
175
178
  sessionDir,
176
179
  thinkingLevel: effort,
177
180
  sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
@@ -200,9 +203,12 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
200
203
  const state = await runFleet({
201
204
  spec: fleet.spec,
202
205
  fleetRoot: fleet.fleetRoot,
206
+ baseRepo: ctx.cwd,
203
207
  repoCwd: (nodeId) => {
204
208
  const worker = fleet.spec.workers.find((w) => w.id === nodeId);
205
- return worker?.worktree ? join(fleet.fleetRoot, "worktrees", nodeId) : ctx.cwd;
209
+ return worker?.worktree || worker?.id === "fleet-integrator"
210
+ ? join(fleet.fleetRoot, "worktrees", nodeId)
211
+ : ctx.cwd;
206
212
  },
207
213
  spawn,
208
214
  killSwitch: fleet.killSwitch,
package/src/dag.ts CHANGED
@@ -49,6 +49,47 @@ export function getDependents(spec: FleetSpec, nodeId: string): string[] {
49
49
  return spec.workers.filter((w) => w.depends_on.includes(nodeId)).map((w) => w.id);
50
50
  }
51
51
 
52
+ function findWorktreeOwnershipConflicts(workers: WorkerSpec[]): string[] {
53
+ const worktrees = workers.filter((w) => w.worktree);
54
+ const claims = new Map<string, string[]>();
55
+ for (const w of worktrees) {
56
+ for (const o of w.outputs) {
57
+ if (!o.path.startsWith("output/")) {
58
+ const list = claims.get(o.path) ?? [];
59
+ list.push(w.id);
60
+ claims.set(o.path, list);
61
+ }
62
+ }
63
+ }
64
+ const errors: string[] = [];
65
+ const byId = new Map(workers.map((w) => [w.id, w]));
66
+ for (const [path, ids] of claims) {
67
+ if (ids.length < 2) continue;
68
+ for (let i = 0; i < ids.length; i++) {
69
+ for (let j = i + 1; j < ids.length; j++) {
70
+ const a = ids[i], b = ids[j];
71
+ const aBeforeB = byId.get(a)!.depends_on.includes(b);
72
+ const bBeforeA = byId.get(b)!.depends_on.includes(a);
73
+ if (!aBeforeB && !bBeforeA) {
74
+ errors.push(`worktree ownership conflict: "${path}" claimed by "${a}" and "${b}" without ordered handoff`);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ return errors;
80
+ }
81
+
82
+ function hasIntegratorPath(workers: WorkerSpec[]): boolean {
83
+ const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
84
+ if (worktreeIds.size < 2) return true;
85
+ return workers.some((w) => {
86
+ if (w.worktree) return false;
87
+ const deps = new Set(w.depends_on);
88
+ for (const id of worktreeIds) if (!deps.has(id)) return false;
89
+ return true;
90
+ });
91
+ }
92
+
52
93
  export function validateFleetSpec(
53
94
  raw: unknown,
54
95
  ): { ok: true; spec: FleetSpec; layers: string[][] } | { ok: false; errors: string[] } {
@@ -115,6 +156,23 @@ export function validateFleetSpec(
115
156
  }
116
157
  }
117
158
 
159
+ const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
160
+ if (worktreeIds.size >= 2) {
161
+ if (!workers.some((w) => w.id === "fleet-integrator")) {
162
+ workers.push({
163
+ id: "fleet-integrator",
164
+ type: "code-run",
165
+ task: `Merge worktree branches in order: ${[...worktreeIds].join(", ")}. Verify the combined repo is consistent and commit if needed.`,
166
+ depends_on: [...worktreeIds],
167
+ outputs: [],
168
+ });
169
+ }
170
+ if (!hasIntegratorPath(workers)) {
171
+ errors.push("multiple worktree workers require an integrator that depends on all worktree workers");
172
+ }
173
+ errors.push(...findWorktreeOwnershipConflicts(workers));
174
+ }
175
+
118
176
  let loopConfig: LoopConfig | undefined;
119
177
  if (cfg.loop !== undefined && cfg.loop !== null) {
120
178
  if (typeof cfg.loop !== "object" || Array.isArray(cfg.loop)) {
package/src/scheduler.ts CHANGED
@@ -4,12 +4,14 @@ import { verifyOutputs } from "./contracts.js";
4
4
  import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
5
5
  import { TERMINAL_NODE_STATUSES } from "./types.js";
6
6
  import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict, WorkerSpec } from "./types.js";
7
+ import { commitWorktree, createWorktree, prepareIntegratorWorktree } from "./worktree.js";
7
8
 
8
9
  export type SpawnFn = (nodeId: string) => Promise<{ ok: boolean; turns: number; tokens: number; cost?: number; error?: string }>;
9
10
 
10
11
  export interface RunFleetOpts {
11
12
  spec: FleetSpec;
12
13
  fleetRoot: string;
14
+ baseRepo?: string;
13
15
  repoCwd: string | ((nodeId: string) => string);
14
16
  spawn: SpawnFn;
15
17
  onNodeChange?: (nodeId: string, s: NodeState) => void;
@@ -72,6 +74,41 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
72
74
  const repoCwdFor = (nodeId: string): string =>
73
75
  typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
74
76
 
77
+ const baseRepo = (): string | undefined =>
78
+ opts.baseRepo ?? (typeof opts.repoCwd === "string" ? opts.repoCwd : undefined);
79
+
80
+ function orderedWorktreeBranches(spec: FleetSpec): string[] {
81
+ const ids = spec.workers.filter((w) => w.worktree).map((w) => w.id);
82
+ const set = new Set(ids);
83
+ const indeg = new Map(ids.map((id) => [id, 0]));
84
+ const rev = new Map(ids.map((id) => [id, [] as string[]]));
85
+ for (const w of spec.workers) {
86
+ if (!set.has(w.id)) continue;
87
+ for (const d of w.depends_on) {
88
+ if (set.has(d)) {
89
+ indeg.set(w.id, (indeg.get(w.id) ?? 0) + 1);
90
+ rev.get(d)?.push(w.id);
91
+ }
92
+ }
93
+ }
94
+ const sorted: string[] = [];
95
+ let current = ids.filter((id) => indeg.get(id) === 0);
96
+ while (current.length > 0) {
97
+ sorted.push(...current);
98
+ const next: string[] = [];
99
+ for (const id of current) {
100
+ for (const m of rev.get(id) ?? []) {
101
+ const v = (indeg.get(m) ?? 0) - 1;
102
+ indeg.set(m, v);
103
+ if (v === 0) next.push(m);
104
+ }
105
+ }
106
+ current = next;
107
+ }
108
+ if (sorted.length !== ids.length) sorted.push(...ids.filter((id) => !sorted.includes(id)));
109
+ return sorted.map((id) => `fleet/${spec.fleet_name}/${id}`);
110
+ }
111
+
75
112
  const runPass = async (): Promise<void> => {
76
113
  while (true) {
77
114
  // auto-initialize workers inserted into the spec after the run started
@@ -122,6 +159,61 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
122
159
  const depsDone = w.depends_on.every((d) => state.nodes[d]?.status === "completed");
123
160
  if (!depsDone) continue;
124
161
  slots--;
162
+
163
+ if (w.worktree) {
164
+ const repo = baseRepo();
165
+ if (!repo) {
166
+ await patch(w.id, {
167
+ status: "failed",
168
+ ended_at: new Date().toISOString(),
169
+ status_note: "worktree worker requires a baseRepo",
170
+ });
171
+ continue;
172
+ }
173
+ try {
174
+ await createWorktree({
175
+ baseRepo: repo,
176
+ fleetName: spec.fleet_name,
177
+ nodeId: w.id,
178
+ fleetRoot,
179
+ });
180
+ } catch (e) {
181
+ const msg = e instanceof Error ? e.message : String(e);
182
+ await patch(w.id, {
183
+ status: "failed",
184
+ ended_at: new Date().toISOString(),
185
+ status_note: `worktree creation failed: ${msg}`,
186
+ });
187
+ continue;
188
+ }
189
+ }
190
+
191
+ if (w.id === "fleet-integrator") {
192
+ const repo = baseRepo();
193
+ if (!repo) {
194
+ await patch(w.id, {
195
+ status: "failed",
196
+ ended_at: new Date().toISOString(),
197
+ status_note: "integrator requires a baseRepo",
198
+ });
199
+ continue;
200
+ }
201
+ const prep = await prepareIntegratorWorktree({
202
+ baseRepo: repo,
203
+ fleetName: spec.fleet_name,
204
+ fleetRoot,
205
+ branches: orderedWorktreeBranches(spec),
206
+ });
207
+ if (!prep.ok) {
208
+ await patch(w.id, {
209
+ status: "failed",
210
+ ended_at: new Date().toISOString(),
211
+ status_note: prep.conflict,
212
+ });
213
+ continue;
214
+ }
215
+ }
216
+
125
217
  await patch(w.id, { status: "running", started_at: new Date().toISOString() });
126
218
  const p = opts.spawn(w.id).then(async (res) => {
127
219
  if (opts.killSwitch?.killed) return;
@@ -133,6 +225,27 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
133
225
  await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
134
226
  return;
135
227
  }
228
+ if (w.worktree) {
229
+ try {
230
+ await commitWorktree({
231
+ worktreePath: repoCwdFor(w.id),
232
+ nodeId: w.id,
233
+ fleetName: spec.fleet_name,
234
+ iteration: state.iteration,
235
+ });
236
+ } catch (e) {
237
+ const msg = e instanceof Error ? e.message : String(e);
238
+ await patch(w.id, {
239
+ status: "failed",
240
+ ended_at: new Date().toISOString(),
241
+ turns: res.turns,
242
+ tokens: res.tokens,
243
+ cost_usd_estimate: res.cost ?? 0,
244
+ status_note: `commit failed: ${msg}`,
245
+ });
246
+ return;
247
+ }
248
+ }
136
249
  const contract = await verifyOutputs({
137
250
  workerDir: `${fleetRoot}/workers/${w.id}`,
138
251
  repoCwd: repoCwdFor(w.id),
@@ -0,0 +1,75 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileP = promisify(execFile);
7
+
8
+ export interface CreateWorktreeOpts {
9
+ baseRepo: string;
10
+ fleetName: string;
11
+ nodeId: string;
12
+ fleetRoot: string;
13
+ }
14
+
15
+ export async function createWorktree(opts: CreateWorktreeOpts): Promise<string> {
16
+ const path = join(opts.fleetRoot, "worktrees", opts.nodeId);
17
+ const branch = `fleet/${opts.fleetName}/${opts.nodeId}`;
18
+ await mkdir(path, { recursive: true });
19
+ await removeWorktree(path, opts.baseRepo);
20
+ await execFileP("git", ["worktree", "add", "-b", branch, path], { cwd: opts.baseRepo });
21
+ return path;
22
+ }
23
+
24
+ export interface CommitWorktreeOpts {
25
+ worktreePath: string;
26
+ nodeId: string;
27
+ fleetName: string;
28
+ iteration: number;
29
+ }
30
+
31
+ export async function commitWorktree(opts: CommitWorktreeOpts): Promise<void> {
32
+ await execFileP("git", ["add", "-A"], { cwd: opts.worktreePath });
33
+ try {
34
+ await execFileP(
35
+ "git",
36
+ ["commit", "-m", `fleet: ${opts.fleetName} ${opts.nodeId} iteration ${opts.iteration}`],
37
+ { cwd: opts.worktreePath },
38
+ );
39
+ } catch (e) {
40
+ const msg = e instanceof Error ? e.message : String(e);
41
+ if (msg.includes("nothing to commit")) return;
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ export interface PrepareIntegratorOpts {
47
+ baseRepo: string;
48
+ fleetName: string;
49
+ fleetRoot: string;
50
+ branches: string[];
51
+ }
52
+
53
+ export async function prepareIntegratorWorktree(
54
+ opts: PrepareIntegratorOpts,
55
+ ): Promise<{ path: string; ok: boolean; conflict?: string }> {
56
+ const path = join(opts.fleetRoot, "worktrees", "fleet-integrator");
57
+ await removeWorktree(path, opts.baseRepo);
58
+ await mkdir(path, { recursive: true });
59
+ await execFileP("git", ["worktree", "add", "-b", `fleet/${opts.fleetName}/fleet-integrator`, path], {
60
+ cwd: opts.baseRepo,
61
+ });
62
+ for (const branch of opts.branches) {
63
+ try {
64
+ await execFileP("git", ["merge", "--no-ff", "--no-edit", branch], { cwd: path });
65
+ } catch (e) {
66
+ const msg = e instanceof Error ? e.message : String(e);
67
+ return { path, ok: false, conflict: `merge ${branch} failed: ${msg}` };
68
+ }
69
+ }
70
+ return { path, ok: true };
71
+ }
72
+
73
+ export async function removeWorktree(path: string, baseRepo: string): Promise<void> {
74
+ await execFileP("git", ["worktree", "remove", "--force", path], { cwd: baseRepo }).catch(() => {});
75
+ }