@fradser/pi-agent-teams 0.7.0 → 0.8.2
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/.memory/MEMORY.md +1 -0
- package/.memory/follow-up-queue.md +10 -6
- package/.memory/team-centric-design.md +3 -3
- package/README.md +48 -22
- package/package.json +2 -2
- package/references/agent-roles.md +8 -2
- package/src/agents.ts +100 -6
- package/src/follow-up-queue.ts +84 -21
- package/src/guidance.ts +141 -44
- package/src/index.ts +91 -56
- package/src/picker-keys.ts +30 -0
- package/src/spawner.ts +63 -3
- package/src/state.ts +266 -26
- package/src/statefile.ts +14 -7
- package/src/team-machine.ts +1012 -106
- package/src/tool-render.ts +48 -0
- package/src/tools.ts +120 -78
- package/src/types.ts +70 -7
- package/src/ui.ts +204 -52
- package/src/worker.ts +182 -19
- package/src/worktree.ts +46 -9
package/src/worker.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import type
|
|
9
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { detailField, eventToolLifecycle } from "@fradser/pi-kit";
|
|
11
|
+
import { emptyToolCall, renderLifecycleResult } from "./tool-render.ts";
|
|
10
12
|
import { livingTeammates, listTasks } from "./state.ts";
|
|
11
13
|
import { appendInboxMessage, appendWorkerEvent, createTaskIntent, readBoardFile, readRoster } from "./statefile.ts";
|
|
12
14
|
import {
|
|
@@ -19,6 +21,46 @@ import {
|
|
|
19
21
|
type BoardTask,
|
|
20
22
|
} from "./types.ts";
|
|
21
23
|
|
|
24
|
+
const BOARD_DISCLOSURE_TOOLS = ["task_list", "task_claim", "task_submit"] as const;
|
|
25
|
+
type BoardDisclosure = "none" | "notice" | "claimed";
|
|
26
|
+
|
|
27
|
+
export interface WorkerToolDisclosure {
|
|
28
|
+
update(prompt: string): void;
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
33
|
+
let state: BoardDisclosure = "none";
|
|
34
|
+
const apply = () => {
|
|
35
|
+
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
36
|
+
const active = pi.getActiveTools();
|
|
37
|
+
const withoutBoardControls = active.filter((tool) => !BOARD_DISCLOSURE_TOOLS.includes(tool as typeof BOARD_DISCLOSURE_TOOLS[number]));
|
|
38
|
+
const revealed = state === "notice"
|
|
39
|
+
? ["task_list", "task_claim"]
|
|
40
|
+
: state === "claimed"
|
|
41
|
+
? ["task_submit"]
|
|
42
|
+
: [];
|
|
43
|
+
pi.setActiveTools([...withoutBoardControls, ...revealed]);
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
update(prompt) {
|
|
47
|
+
const binding = workerBinding();
|
|
48
|
+
const rosterEntry = binding
|
|
49
|
+
? readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)
|
|
50
|
+
: undefined;
|
|
51
|
+
const assignment = rosterEntry?.assignment;
|
|
52
|
+
if (assignment?.kind === "board" && !assignment.closed) state = "claimed";
|
|
53
|
+
else if (!assignment && prompt.includes("=== BOARD NOTICE ===")) state = "notice";
|
|
54
|
+
else state = "none";
|
|
55
|
+
apply();
|
|
56
|
+
},
|
|
57
|
+
reset() {
|
|
58
|
+
state = "none";
|
|
59
|
+
apply();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
22
64
|
export interface WorkerBinding {
|
|
23
65
|
worker: string;
|
|
24
66
|
spawnId: string;
|
|
@@ -63,18 +105,54 @@ function dependenciesMet(task: BoardTask, tasks: Map<string, BoardTask>): boolea
|
|
|
63
105
|
return task.dependsOn.every((dep) => tasks.get(dep)?.status === "completed");
|
|
64
106
|
}
|
|
65
107
|
|
|
108
|
+
function taskCounts(tasks: BoardTask[], byId: Map<string, BoardTask>): string {
|
|
109
|
+
const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
|
|
110
|
+
for (const task of tasks) counts[task.status] += 1;
|
|
111
|
+
const claimable = tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId)).length;
|
|
112
|
+
return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) · claimed=${counts.claimed} · completed=${counts.completed} · superseded=${counts.superseded}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resourcesConflict(left: readonly string[] | undefined, right: readonly string[] | undefined): boolean {
|
|
116
|
+
return (left ?? []).some((a) => (right ?? []).some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function claimRejection(binding: WorkerBinding, task: BoardTask): string | undefined {
|
|
120
|
+
const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
|
|
121
|
+
if (self?.assignment) {
|
|
122
|
+
const state = self.assignment.closed ? "closed pending leader reopen" : "active";
|
|
123
|
+
return `You already own ${state} ${self.assignment.kind} assignment "${self.assignment.id}". Finish it through its matching protocol before claiming board work.`;
|
|
124
|
+
}
|
|
125
|
+
const conflict = readRoster(binding.rosterFile).find((entry) => entry.name !== binding.worker
|
|
126
|
+
&& entry.assignment && !entry.assignment.closed
|
|
127
|
+
&& resourcesConflict(task.resources, entry.assignment.resources));
|
|
128
|
+
if (conflict) {
|
|
129
|
+
return `Task "${task.id}" conflicts with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".`;
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function taskStatusLabel(task: BoardTask, tasks: Map<string, BoardTask>): string {
|
|
135
|
+
if (task.status === "pending") return dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
|
|
136
|
+
return task.status;
|
|
137
|
+
}
|
|
138
|
+
|
|
66
139
|
export function renderTaskBoard(tasks: BoardTask[]): string {
|
|
67
|
-
if (tasks.length === 0) return "(the task board is empty)";
|
|
68
140
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
69
|
-
|
|
141
|
+
const lines = [`BOARD · current session`, `SUMMARY · ${taskCounts(tasks, byId)}`];
|
|
142
|
+
if (tasks.length === 0) return `${lines.join("\n")}\nTASKS\n(no tasks)`;
|
|
143
|
+
lines.push("TASKS");
|
|
144
|
+
for (const task of tasks) {
|
|
70
145
|
const deps = task.dependsOn.length > 0
|
|
71
|
-
? `
|
|
146
|
+
? ` · depends=${task.dependsOn.join(",")} (${dependenciesMet(task, byId) ? "met" : "blocked"})`
|
|
72
147
|
: "";
|
|
73
|
-
const holder = task.claimedBy ? `
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
148
|
+
const holder = task.claimedBy ? ` · claimant=@${task.claimedBy}` : "";
|
|
149
|
+
lines.push(`- ${task.id} · ${taskStatusLabel(task, byId)} · ${task.subject}${holder}${deps}`);
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderRoster(roster: string): string {
|
|
155
|
+
return `ROSTER\n${roster || "(none)"}`;
|
|
78
156
|
}
|
|
79
157
|
|
|
80
158
|
/** One registration shared by leader and worker; binding chooses the data source. */
|
|
@@ -83,8 +161,18 @@ export function registerTaskListTool(pi: ExtensionAPI): void {
|
|
|
83
161
|
name: "task_list",
|
|
84
162
|
promptSnippet: "Read the shared task board",
|
|
85
163
|
label: "Task Board",
|
|
86
|
-
description: "Read board
|
|
164
|
+
description: "Read the current session board as grouped task state plus a compact roster. Pending tasks with met dependencies are claimable.",
|
|
87
165
|
parameters: TaskListParams,
|
|
166
|
+
renderShell: "self",
|
|
167
|
+
renderCall: emptyToolCall,
|
|
168
|
+
renderResult(result, options, theme, context) {
|
|
169
|
+
const count = detailField<number>(result.details, "count") ?? 0;
|
|
170
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
171
|
+
"board",
|
|
172
|
+
`${count} task${count === 1 ? "" : "s"}`,
|
|
173
|
+
{ label: "listed" },
|
|
174
|
+
));
|
|
175
|
+
},
|
|
88
176
|
async execute() {
|
|
89
177
|
const binding = workerBinding();
|
|
90
178
|
const tasks = binding ? loadBoardTasks(binding) : listTasks();
|
|
@@ -92,21 +180,41 @@ export function registerTaskListTool(pi: ExtensionAPI): void {
|
|
|
92
180
|
const roster = binding
|
|
93
181
|
? readRoster(binding.rosterFile)
|
|
94
182
|
.filter((entry) => entry.status !== "stopped")
|
|
95
|
-
.map((entry) => `@${entry.name} (${entry.agent}, ${entry.status})`)
|
|
183
|
+
.map((entry) => `@${entry.name} (${entry.agent}, ${entry.status}${entry.assignment ? `, ${entry.assignment.kind} ${entry.assignment.id}` : ""})`)
|
|
96
184
|
.join("\n")
|
|
97
185
|
: livingTeammates().map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
|
|
98
|
-
|
|
186
|
+
const leaderHint = !binding && !roster && tasks.some((task) => task.status === "pending")
|
|
187
|
+
? "NEXT · leader: teammate_spawn; workers then use task_claim"
|
|
188
|
+
: tasks.some((task) => task.status === "pending")
|
|
189
|
+
? "NEXT · workers use task_claim for pending/claimable tasks"
|
|
190
|
+
: "NEXT · no claimable work";
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: "text", text: `${renderTaskBoard(tasks)}\n${leaderHint}\n\n${renderRoster(roster)}` }],
|
|
193
|
+
details: { count: tasks.length },
|
|
194
|
+
};
|
|
99
195
|
},
|
|
100
196
|
});
|
|
101
197
|
}
|
|
102
198
|
|
|
103
|
-
export function registerWorkerCapabilities(pi: ExtensionAPI):
|
|
199
|
+
export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
200
|
+
const disclosure = createWorkerToolDisclosure(pi);
|
|
104
201
|
pi.registerTool({
|
|
105
202
|
name: "send_message",
|
|
106
203
|
promptSnippet: "Send a message to the leader or a teammate",
|
|
107
204
|
label: "Send Message",
|
|
108
205
|
description: "The only messaging primitive. Use to=\"leader\" for reports; use a teammate name for direct peer mail. status is valid only for leader reports.",
|
|
109
206
|
parameters: SendMessageParams,
|
|
207
|
+
renderShell: "self",
|
|
208
|
+
renderCall: emptyToolCall,
|
|
209
|
+
renderResult(result, options, theme, context) {
|
|
210
|
+
const to = String((context.args as { to?: string }).to ?? "");
|
|
211
|
+
|
|
212
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
213
|
+
"message",
|
|
214
|
+
detailField<"steered" | "queued">(result.details, "outcome") ?? "queued",
|
|
215
|
+
{ label: `to @${to}` },
|
|
216
|
+
));
|
|
217
|
+
},
|
|
110
218
|
async execute(_toolCallId, params) {
|
|
111
219
|
const binding = workerBinding();
|
|
112
220
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
@@ -118,10 +226,15 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
118
226
|
spawnId: binding.spawnId,
|
|
119
227
|
body: params.message,
|
|
120
228
|
status: params.status,
|
|
229
|
+
timestamp: Date.now(),
|
|
121
230
|
});
|
|
122
|
-
return {
|
|
123
|
-
|
|
124
|
-
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text", text: params.status
|
|
233
|
+
? `MESSAGING\nREPORT · to=leader · status=${params.status}\nNEXT · harness will deliver this report`
|
|
234
|
+
: 'MESSAGING\nREPORT · to=leader · status=in_progress\nNEXT · send status="completed" or status="failed" to end the assignment' }],
|
|
235
|
+
details: { to: LEADER_RECIPIENT, status: params.status ?? "in_progress", outcome: "queued" },
|
|
236
|
+
terminate: params.status === "completed" || params.status === "failed",
|
|
237
|
+
};
|
|
125
238
|
}
|
|
126
239
|
if (params.status) throw new Error('status is valid only when to="leader".');
|
|
127
240
|
if (params.to === binding.worker) throw new Error("You are already the recipient — no need to message yourself.");
|
|
@@ -135,7 +248,10 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
135
248
|
subject: messageTitle(params.message),
|
|
136
249
|
body: params.message,
|
|
137
250
|
});
|
|
138
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: `MESSAGING\nQUEUED · to=@${params.to}\nNEXT · harness will route the inbox message into a recipient turn` }],
|
|
253
|
+
details: { to: params.to, outcome: "queued" },
|
|
254
|
+
};
|
|
139
255
|
},
|
|
140
256
|
});
|
|
141
257
|
|
|
@@ -143,6 +259,16 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
143
259
|
|
|
144
260
|
pi.registerTool({
|
|
145
261
|
name: "task_claim",
|
|
262
|
+
renderShell: "self",
|
|
263
|
+
renderCall: emptyToolCall,
|
|
264
|
+
renderResult(result, options, theme, context) {
|
|
265
|
+
const taskId = String((context.args as { taskId?: string }).taskId ?? "first claimable");
|
|
266
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
267
|
+
"board",
|
|
268
|
+
taskId,
|
|
269
|
+
{ label: "claim queued" },
|
|
270
|
+
));
|
|
271
|
+
},
|
|
146
272
|
promptSnippet: "Self-claim a pending board task",
|
|
147
273
|
label: "Claim Task",
|
|
148
274
|
description: "Atomically claim a pending task whose dependencies are met. Omit taskId to claim the first claimable task.",
|
|
@@ -160,6 +286,11 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
160
286
|
}
|
|
161
287
|
for (const task of candidates) {
|
|
162
288
|
if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
|
|
289
|
+
const rejected = claimRejection(binding, task);
|
|
290
|
+
if (rejected) {
|
|
291
|
+
if (params.taskId) throw new Error(rejected);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
163
294
|
const won = createTaskIntent(binding.claimsDir, task.id, {
|
|
164
295
|
taskId: task.id,
|
|
165
296
|
worker: binding.worker,
|
|
@@ -167,7 +298,10 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
167
298
|
timestamp: Date.now(),
|
|
168
299
|
});
|
|
169
300
|
if (won) {
|
|
170
|
-
return {
|
|
301
|
+
return {
|
|
302
|
+
content: [{ type: "text", text: `BOARD · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness Claim accepted feedback; do not start work until it arrives` }],
|
|
303
|
+
details: { taskId: task.id, subject: task.subject, worker: binding.worker },
|
|
304
|
+
};
|
|
171
305
|
}
|
|
172
306
|
if (params.taskId) throw new Error(`Task "${task.id}" was claimed by someone else first.`);
|
|
173
307
|
}
|
|
@@ -177,6 +311,17 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
177
311
|
|
|
178
312
|
pi.registerTool({
|
|
179
313
|
name: "task_submit",
|
|
314
|
+
renderShell: "self",
|
|
315
|
+
renderCall: emptyToolCall,
|
|
316
|
+
renderResult(result, options, theme, context) {
|
|
317
|
+
const taskId = String((context.args as { taskId?: string }).taskId ?? "task");
|
|
318
|
+
const status = String((context.args as { status?: string }).status ?? "submitted");
|
|
319
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
320
|
+
"board",
|
|
321
|
+
taskId,
|
|
322
|
+
{ label: status },
|
|
323
|
+
));
|
|
324
|
+
},
|
|
180
325
|
promptSnippet: "Submit a claimed task outcome",
|
|
181
326
|
label: "Submit Task",
|
|
182
327
|
description: "Submit a task you claimed. completed runs its verify gate; failed releases the task back to the board.",
|
|
@@ -193,7 +338,25 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
193
338
|
timestamp: Date.now(),
|
|
194
339
|
});
|
|
195
340
|
if (!won) throw new Error(`A submission for "${params.taskId}" is already pending.`);
|
|
196
|
-
|
|
341
|
+
disclosure.reset();
|
|
342
|
+
const task = loadBoardTasks(binding).find((candidate) => candidate.id === params.taskId);
|
|
343
|
+
const roleVerify = process.env.PI_TEAMMATE_VERIFY_DEFAULT?.trim();
|
|
344
|
+
const verify = params.status === "completed"
|
|
345
|
+
? task?.verify
|
|
346
|
+
? "VERIFY · queued (task gate)"
|
|
347
|
+
: roleVerify
|
|
348
|
+
? "VERIFY · queued (role gate)"
|
|
349
|
+
: "VERIFY · none configured"
|
|
350
|
+
: "VERIFY · skipped (failed submission)";
|
|
351
|
+
const next = params.status === "completed"
|
|
352
|
+
? "NEXT · wait for the harness result"
|
|
353
|
+
: "NEXT · task returns to pending";
|
|
354
|
+
return {
|
|
355
|
+
content: [{ type: "text", text: `BOARD · current session\nSUBMITTED · ${params.taskId} · ${params.status}\n${verify}\n${next}` }],
|
|
356
|
+
details: { taskId: params.taskId, status: params.status, verify: Boolean(task?.verify || roleVerify) },
|
|
357
|
+
};
|
|
197
358
|
},
|
|
198
359
|
});
|
|
360
|
+
|
|
361
|
+
return disclosure;
|
|
199
362
|
}
|
package/src/worktree.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Layout: `<repoRoot>/.pi/worktrees/teammate-<taskId>/`, branch
|
|
6
6
|
* `teammate/<taskId>`. On completion the worker's diff is captured against
|
|
7
|
-
* the base commit, then the worktree
|
|
7
|
+
* the base commit, then the worktree directory is removed while the branch
|
|
8
|
+
* stays so captured work remains retrievable.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -104,15 +105,50 @@ export function captureWorktreeDiff(setup: WorktreeSetup): WorktreeDiffResult {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
108
|
+
* Commit any remaining worktree changes onto the branch. Staging alone dies
|
|
109
|
+
* with the worktree directory; only a commit makes the kept branch carry the
|
|
110
|
+
* captured work. A clean tree succeeds without a commit.
|
|
109
111
|
*/
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
112
|
+
function commitRemainingWork(setup: WorktreeSetup): { ok: boolean; error?: string } {
|
|
113
|
+
const add = runGit(setup.path, ["add", "-A"]);
|
|
114
|
+
if (add.status !== 0) return { ok: false, error: add.stderr.trim() || "git add failed" };
|
|
115
|
+
const result = spawnSync("git", ["-C", setup.path, "commit", "-m", "agent-teams: capture teammate work"], { encoding: "utf-8" });
|
|
116
|
+
if (result.status === 0) return { ok: true };
|
|
117
|
+
const output = `${result.stderr ?? ""}${result.stdout ?? ""}`;
|
|
118
|
+
if (/nothing to commit/i.test(output)) return { ok: true };
|
|
119
|
+
return { ok: false, error: output.trim() || "git commit failed" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove the worktree directory but keep the branch: remaining changes are
|
|
124
|
+
* committed first so the captured diff stays retrievable with
|
|
125
|
+
* `git diff <baseCommit>..<branch>` after cleanup. Pass `{ deleteBranch: true }`
|
|
126
|
+
* for teardowns where no work was captured (a failed spawn). When the commit
|
|
127
|
+
* fails in keep-branch mode the directory is PRESERVED and returned in the
|
|
128
|
+
* error — removing it would destroy the only copy of the work. Errors are
|
|
129
|
+
* returned rather than thrown so task completion is never blocked by cleanup
|
|
130
|
+
* failure.
|
|
131
|
+
*/
|
|
132
|
+
export function cleanupWorktree(
|
|
133
|
+
setup: WorktreeSetup,
|
|
134
|
+
options?: { deleteBranch?: boolean },
|
|
135
|
+
): { ok: boolean; error?: string } {
|
|
113
136
|
const errors: string[] = [];
|
|
137
|
+
if (!options?.deleteBranch) {
|
|
138
|
+
const commit = commitRemainingWork(setup);
|
|
139
|
+
if (!commit.ok) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error: `commit: ${commit.error}; worktree left in place at ${setup.path} so the work is not lost`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const remove = runGit(setup.repoRoot, ["worktree", "remove", "--force", setup.path]);
|
|
114
147
|
if (remove.status !== 0) errors.push(`worktree remove: ${remove.stderr.trim()}`);
|
|
115
|
-
if (
|
|
148
|
+
if (options?.deleteBranch) {
|
|
149
|
+
const branch = runGit(setup.repoRoot, ["branch", "-D", setup.branch]);
|
|
150
|
+
if (branch.status !== 0) errors.push(`branch delete: ${branch.stderr.trim()}`);
|
|
151
|
+
}
|
|
116
152
|
try {
|
|
117
153
|
runGit(setup.repoRoot, ["worktree", "prune"]);
|
|
118
154
|
} catch {
|
|
@@ -121,7 +157,8 @@ export function cleanupWorktree(setup: WorktreeSetup): { ok: boolean; error?: st
|
|
|
121
157
|
return errors.length > 0 ? { ok: false, error: errors.join("; ") } : { ok: true };
|
|
122
158
|
}
|
|
123
159
|
|
|
124
|
-
/** Best-effort cleanup used when a spawn fails before the worker starts
|
|
160
|
+
/** Best-effort cleanup used when a spawn fails before the worker starts:
|
|
161
|
+
* nothing was captured, so the empty branch goes too. */
|
|
125
162
|
export function discardWorktree(setup: WorktreeSetup): void {
|
|
126
|
-
cleanupWorktree(setup);
|
|
163
|
+
cleanupWorktree(setup, { deleteBranch: true });
|
|
127
164
|
}
|