@fradser/pi-agent-teams 0.5.2 → 0.6.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/.memory/MEMORY.md +3 -1
- package/.memory/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +18 -0
- package/.memory/team-centric-design.md +21 -0
- package/README.md +99 -62
- package/agents/observer.md +1 -1
- package/agents/reviewer.md +4 -2
- package/agents/specialist.md +1 -1
- package/agents/worker.md +3 -3
- package/package.json +3 -3
- package/src/activity.ts +86 -0
- package/src/agents.ts +58 -21
- package/src/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +237 -0
- package/src/guidance.ts +81 -57
- package/src/index.ts +119 -38
- package/src/spawner.ts +217 -393
- package/src/state.ts +253 -568
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +946 -0
- package/src/tools.ts +181 -118
- package/src/types.ts +157 -155
- package/src/ui.ts +327 -268
- package/src/worker.ts +172 -31
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/src/run-machine.ts +0 -573
- package/src/terminal.ts +0 -34
package/src/run-machine.ts
DELETED
|
@@ -1,573 +0,0 @@
|
|
|
1
|
-
/** Run scheduling, worker lifecycle, persistence, and session resource limits. */
|
|
2
|
-
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { resolveAgent } from "./agents";
|
|
6
|
-
import { isWorkerEvent } from "./worker";
|
|
7
|
-
import {
|
|
8
|
-
cancelBlockedDependents, cancelNode, cancelRun, clearWorkerRunEvents, clearStateDirty, isStateDirty,
|
|
9
|
-
deliverToLeader, failRunTimeout, findSharedWorkspaceWriteConflict, getNode,
|
|
10
|
-
getRun, getState, listNodes, listRuns, markNodeRunning, markStateDirty,
|
|
11
|
-
readyPendingNodes, receiveWorkerMessage, runningNodeCount, settleRun, setNodeSpawnInfo,
|
|
12
|
-
SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus,
|
|
13
|
-
} from "./state";
|
|
14
|
-
import {
|
|
15
|
-
CancellationIntents, buildAutonomousPrompt, finishReportedWorker, isCompletedWorkerExit,
|
|
16
|
-
POST_REPORT_GRACE_MS, spawnPiWorker, terminateWorker,
|
|
17
|
-
type WorkerProcessResult,
|
|
18
|
-
} from "./spawner";
|
|
19
|
-
import { buildNodeTerminalResult } from "./terminal";
|
|
20
|
-
import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree";
|
|
21
|
-
import { readWorkerEvents, removeWorkerOutbox, stateFilePath, workerOutboxPath, writeStateFile } from "./statefile";
|
|
22
|
-
|
|
23
|
-
export interface DispatchCtx {
|
|
24
|
-
ui: ExtensionContext["ui"];
|
|
25
|
-
sessionManager?: { getSessionFile(): string | undefined };
|
|
26
|
-
cwd?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const MAX_SESSION_WORKERS = 8;
|
|
30
|
-
const DEFAULT_NODE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
31
|
-
const LIVE_POLL_MS = 500;
|
|
32
|
-
const cancellationIntents = new CancellationIntents();
|
|
33
|
-
const reportedWorkerShutdowns = new Set<string>();
|
|
34
|
-
let liveStateFile: string | undefined;
|
|
35
|
-
let livePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
36
|
-
let machineCtx: DispatchCtx | undefined;
|
|
37
|
-
let sendUpdate: (subject: string, body: string, runId?: string) => void = () => {};
|
|
38
|
-
let notifyChange: () => void = () => {};
|
|
39
|
-
|
|
40
|
-
export function initRunMachine(ctx: DispatchCtx, stateFile: string, hooks: { sendUpdate: typeof sendUpdate; notifyChange: () => void }): void {
|
|
41
|
-
machineCtx = ctx;
|
|
42
|
-
liveStateFile = stateFile;
|
|
43
|
-
sendUpdate = hooks.sendUpdate;
|
|
44
|
-
notifyChange = hooks.notifyChange;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function ensureRunContext(ctx: DispatchCtx): void {
|
|
48
|
-
machineCtx = ctx;
|
|
49
|
-
if (!liveStateFile && ctx.sessionManager) {
|
|
50
|
-
liveStateFile = stateFilePath(ctx.sessionManager.getSessionFile(), ctx.cwd ?? process.cwd());
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function shutdownRunMachine(): void {
|
|
55
|
-
if (livePollTimer) clearInterval(livePollTimer);
|
|
56
|
-
livePollTimer = undefined;
|
|
57
|
-
liveStateFile = undefined;
|
|
58
|
-
machineCtx = undefined;
|
|
59
|
-
sendUpdate = () => {};
|
|
60
|
-
notifyChange = () => {};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function currentStateFile(): string {
|
|
64
|
-
if (!liveStateFile) throw new Error("Agent Teams session state is unavailable.");
|
|
65
|
-
return liveStateFile;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function flushStateSnapshot(): void {
|
|
69
|
-
if (!liveStateFile || !isStateDirty()) return;
|
|
70
|
-
try {
|
|
71
|
-
writeStateFile(liveStateFile, getState());
|
|
72
|
-
clearStateDirty();
|
|
73
|
-
} catch {
|
|
74
|
-
// Keep the dirty bit set so the next poll retries the snapshot.
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function publishStateSnapshot(): void {
|
|
79
|
-
applyWorkerEvents();
|
|
80
|
-
flushStateSnapshot();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Apply complete, validated event records from every running node's outbox. */
|
|
84
|
-
export function applyWorkerEvents(): void {
|
|
85
|
-
const stateFile = currentStateFile();
|
|
86
|
-
const state = getState();
|
|
87
|
-
for (const run of Object.values(state.runs)) {
|
|
88
|
-
for (const node of Object.values(run.nodes)) {
|
|
89
|
-
const spawn = node.spawn;
|
|
90
|
-
if (!spawn || spawn.status !== "running") continue;
|
|
91
|
-
const spawnId = spawn.spawnId;
|
|
92
|
-
const outboxKey = `${node.workerKey}:${spawnId}`;
|
|
93
|
-
const outbox = workerOutboxPath(stateFile, node.workerKey, spawnId);
|
|
94
|
-
const { events, nextOffset } = readWorkerEvents(outbox, state.workerEventOffsets[outboxKey] ?? 0);
|
|
95
|
-
state.workerEventOffsets[outboxKey] = nextOffset;
|
|
96
|
-
for (const value of events) {
|
|
97
|
-
if (!isWorkerEvent(value) || state.workerEventIds[`${spawnId}:${value.id}`]) continue;
|
|
98
|
-
const event = value;
|
|
99
|
-
if (event.worker !== node.workerKey || event.spawnId !== spawnId) continue;
|
|
100
|
-
if (event.type === "message") {
|
|
101
|
-
state.workerEventIds[`${spawnId}:${event.id}`] = spawnId;
|
|
102
|
-
receiveWorkerMessage({
|
|
103
|
-
id: event.id,
|
|
104
|
-
worker: node.workerKey,
|
|
105
|
-
spawnId,
|
|
106
|
-
type: "message",
|
|
107
|
-
subject: event.subject,
|
|
108
|
-
body: event.body,
|
|
109
|
-
});
|
|
110
|
-
if (event.status && !["completed", "failed", "cancelled"].includes(node.status)) {
|
|
111
|
-
if (event.status === "completed") {
|
|
112
|
-
updateNodeStatus(run.id, node.id, "completed", event.body, undefined);
|
|
113
|
-
requestReportedWorkerShutdown(node.workerKey, spawnId);
|
|
114
|
-
} else if (event.status === "failed") {
|
|
115
|
-
updateNodeStatus(run.id, node.id, "failed", undefined, event.body);
|
|
116
|
-
requestReportedWorkerShutdown(node.workerKey, spawnId);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** End a process that already sent a terminal report, without changing its result to cancelled. */
|
|
127
|
-
function requestReportedWorkerShutdown(workerKey: string, spawnId: string): void {
|
|
128
|
-
if (reportedWorkerShutdowns.has(spawnId)) return;
|
|
129
|
-
reportedWorkerShutdowns.add(spawnId);
|
|
130
|
-
void finishReportedWorker(workerKey, POST_REPORT_GRACE_MS)
|
|
131
|
-
.catch(() => false)
|
|
132
|
-
.finally(() => {
|
|
133
|
-
reportedWorkerShutdowns.delete(spawnId);
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Persist final node state before compacting an exhausted per-spawn outbox. */
|
|
138
|
-
function compactFinishedNodeRun(stateFile: string, workerKey: string, spawnId: string): void {
|
|
139
|
-
try {
|
|
140
|
-
// First persist the final board while its event cursor still points past
|
|
141
|
-
// every applied record. A crash here preserves replay protection.
|
|
142
|
-
writeStateFile(stateFile, getState());
|
|
143
|
-
clearWorkerRunEvents(workerKey, spawnId);
|
|
144
|
-
writeStateFile(stateFile, getState());
|
|
145
|
-
clearStateDirty();
|
|
146
|
-
removeWorkerOutbox(stateFile, workerKey, spawnId);
|
|
147
|
-
} catch {
|
|
148
|
-
// Best-effort compaction — the in-memory board is authoritative.
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
export function ensureLivePoll(): void {
|
|
154
|
-
const running = listNodes().some((node) => node.status === "running");
|
|
155
|
-
if (running && !livePollTimer && liveStateFile) {
|
|
156
|
-
livePollTimer = setInterval(() => {
|
|
157
|
-
try {
|
|
158
|
-
applyWorkerEvents();
|
|
159
|
-
enforceRunTimeouts();
|
|
160
|
-
flushStateSnapshot();
|
|
161
|
-
notifyChange();
|
|
162
|
-
ensureLivePoll();
|
|
163
|
-
} catch {
|
|
164
|
-
// Never let a poll error break the extension.
|
|
165
|
-
}
|
|
166
|
-
}, LIVE_POLL_MS);
|
|
167
|
-
} else if (!running && livePollTimer) {
|
|
168
|
-
clearInterval(livePollTimer);
|
|
169
|
-
livePollTimer = undefined;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Fail runs whose run-level hard wall-clock cap was exceeded. */
|
|
174
|
-
function enforceRunTimeouts(): void {
|
|
175
|
-
for (const run of listRuns()) {
|
|
176
|
-
if (run.status !== "running" || !run.deadlineAt || Date.now() < run.deadlineAt) continue;
|
|
177
|
-
const failed = failRunTimeout(run.id, `Run timed out after ${Math.round((run.timeoutMs ?? 0) / 1000)}s.`);
|
|
178
|
-
if (!failed.ok) continue;
|
|
179
|
-
for (const nodeId of failed.runningNodeIds) {
|
|
180
|
-
// Spawn was cleared by failRunTimeout, so terminate by the stable workerKey
|
|
181
|
-
// (a no-op when the child already closed).
|
|
182
|
-
const node = getNode(run.id, nodeId);
|
|
183
|
-
if (node) void terminateWorker(node.workerKey).catch(() => false);
|
|
184
|
-
}
|
|
185
|
-
onRunSettled(run.id);
|
|
186
|
-
// failRunTimeout clears spawns, so those nodes' close events skip their
|
|
187
|
-
// finalize path — release write-deferred nodes in OTHER runs here.
|
|
188
|
-
if (machineCtx) scheduleAllRuns(machineCtx);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// ── Run dispatch machinery ────────────────────────────────────────
|
|
194
|
-
|
|
195
|
-
/** Compact run summary for tool returns and follow-ups. When the run has a
|
|
196
|
-
* synthesized __summary node result, that is shown instead of per-node
|
|
197
|
-
* headlines (no truncation heuristics). */
|
|
198
|
-
export function buildRunSummary(runId: string): string {
|
|
199
|
-
const run = getRun(runId);
|
|
200
|
-
if (!run) return `Run ${runId} not found.`;
|
|
201
|
-
const nodes = Object.values(run.nodes).filter((node) => node.id !== SUMMARY_NODE_ID);
|
|
202
|
-
const counts = Object.values(run.nodes).reduce<Record<string, number>>((acc, node) => {
|
|
203
|
-
acc[node.status] = (acc[node.status] ?? 0) + 1;
|
|
204
|
-
return acc;
|
|
205
|
-
}, {});
|
|
206
|
-
const lines = [
|
|
207
|
-
`## Run [${run.id}] ${run.status}`,
|
|
208
|
-
`${Object.keys(run.nodes).length} node(s): ${Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ")}`,
|
|
209
|
-
"",
|
|
210
|
-
];
|
|
211
|
-
if (run.summary) {
|
|
212
|
-
lines.push(run.summary, "");
|
|
213
|
-
} else if (nodes.length === 1) {
|
|
214
|
-
const node = nodes[0];
|
|
215
|
-
const deliverable = node.result?.trim() || node.errorMessage?.trim();
|
|
216
|
-
if (deliverable) lines.push(deliverable, "");
|
|
217
|
-
} else {
|
|
218
|
-
for (const node of nodes) {
|
|
219
|
-
const deliverable = node.result?.trim() || node.errorMessage?.trim();
|
|
220
|
-
if (deliverable) {
|
|
221
|
-
lines.push(`### [${node.id}] (${node.agent}):`, deliverable, "");
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
for (const node of nodes) {
|
|
226
|
-
lines.push(`- [${node.id}] ${node.status} (${node.agent})`);
|
|
227
|
-
}
|
|
228
|
-
return lines.join("\n");
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Called once a run reaches a terminal status. Idempotent: the leader summary
|
|
232
|
-
* and follow-up fire only on the first settled observation (a run can transition
|
|
233
|
-
* through settleRun once per node close). */
|
|
234
|
-
export function onRunSettled(runId: string): void {
|
|
235
|
-
const run = getRun(runId);
|
|
236
|
-
if (!run) return;
|
|
237
|
-
if (run.settledMessageSent) return;
|
|
238
|
-
run.settledMessageSent = true;
|
|
239
|
-
markStateDirty();
|
|
240
|
-
const summary = buildRunSummary(runId);
|
|
241
|
-
deliverToLeader({ from: run.id, subject: `Run ${run.status}`, body: summary, runId: runId });
|
|
242
|
-
if (run.background && !run.completionNotified) {
|
|
243
|
-
// One follow-up only when no other delivery path (wait/foreground gather)
|
|
244
|
-
// has already consumed the run's completion.
|
|
245
|
-
run.completionNotified = true;
|
|
246
|
-
markStateDirty();
|
|
247
|
-
sendUpdate(`Run ${run.status}`, summary, runId);
|
|
248
|
-
}
|
|
249
|
-
publishStateSnapshot();
|
|
250
|
-
notifyChange();
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Start ready nodes of a run up to its concurrency budget. Root nodes start
|
|
255
|
-
* immediately; downstream nodes auto-start when their dependencies complete.
|
|
256
|
-
*/
|
|
257
|
-
export function scheduleRun(runId: string, ctx: DispatchCtx): void {
|
|
258
|
-
const run = getRun(runId);
|
|
259
|
-
if (!run || run.status !== "running") return;
|
|
260
|
-
const settled = settleRun(runId);
|
|
261
|
-
if (settled !== "running") {
|
|
262
|
-
onRunSettled(runId);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
const sessionBudget = MAX_SESSION_WORKERS - listNodes().filter((node) => node.status === "running").length;
|
|
266
|
-
const budget = Math.min(run.concurrency - runningNodeCount(runId), sessionBudget);
|
|
267
|
-
if (budget <= 0) return;
|
|
268
|
-
const ready = readyPendingNodes(run);
|
|
269
|
-
let started = 0;
|
|
270
|
-
for (const node of ready) {
|
|
271
|
-
if (started >= budget) break;
|
|
272
|
-
if (!run.worktree) {
|
|
273
|
-
const conflict = findSharedWorkspaceWriteConflict(runId, node.id);
|
|
274
|
-
if (conflict) continue; // deferred until the overlapping writer finishes
|
|
275
|
-
}
|
|
276
|
-
startNode(runId, node.id, ctx);
|
|
277
|
-
started++;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Re-schedule every running run. Write-conflict detection spans runs, so a
|
|
283
|
-
* node reaching a terminal state in one run can release a deferred write node
|
|
284
|
-
* in a DIFFERENT run — every termination path schedules all runs, not just
|
|
285
|
-
* its own.
|
|
286
|
-
*/
|
|
287
|
-
export function scheduleAllRuns(ctx: DispatchCtx): void {
|
|
288
|
-
for (const run of [...listRuns()].sort((left, right) => left.createdAt - right.createdAt)) {
|
|
289
|
-
if (run.status === "running") scheduleRun(run.id, ctx);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Cancel one node (and its not-yet-started dependents) and terminate its live
|
|
295
|
-
* workers with SIGTERM→SIGKILL. Shared by the teammate_cancel tool and the
|
|
296
|
-
* /teammate console's `x` key so both paths leave identical state.
|
|
297
|
-
*/
|
|
298
|
-
export async function cancelNodeAndTerminate(
|
|
299
|
-
runId: string,
|
|
300
|
-
nodeId: string,
|
|
301
|
-
ctx: DispatchCtx,
|
|
302
|
-
): Promise<{ ok: true; stopped: number } | { ok: false; error: string }> {
|
|
303
|
-
const cancelled = cancelNode(runId, nodeId);
|
|
304
|
-
if (!cancelled.ok) return { ok: false, error: cancelled.error ?? "Failed to cancel node." };
|
|
305
|
-
for (const id of cancelled.runningNodeIds) {
|
|
306
|
-
const node = getNode(runId, id);
|
|
307
|
-
if (!node?.spawn) continue;
|
|
308
|
-
const spawnId = node.spawn.spawnId;
|
|
309
|
-
if (cancellationIntents.begin(spawnId)) {
|
|
310
|
-
const terminated = await terminateWorker(node.workerKey);
|
|
311
|
-
cancellationIntents.resolve(spawnId, terminated);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
publishStateSnapshot();
|
|
315
|
-
scheduleAllRuns(ctx);
|
|
316
|
-
notifyChange();
|
|
317
|
-
return { ok: true, stopped: cancelled.runningNodeIds.length };
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
export async function cancelRunAndTerminate(
|
|
321
|
-
runId: string,
|
|
322
|
-
ctx: DispatchCtx,
|
|
323
|
-
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
324
|
-
const run = getRun(runId);
|
|
325
|
-
if (!run) return { ok: false, error: `Run "${runId}" not found.` };
|
|
326
|
-
if (run.status !== "running") return { ok: false, error: `Run "${runId}" is already ${run.status}.` };
|
|
327
|
-
const cancellation = cancelRun(runId);
|
|
328
|
-
if (!cancellation.ok) return { ok: false, error: cancellation.error ?? "Failed to cancel run." };
|
|
329
|
-
run.status = "cancelled";
|
|
330
|
-
run.finishedAt = Date.now();
|
|
331
|
-
run.updatedAt = Date.now();
|
|
332
|
-
run.completionNotified = true;
|
|
333
|
-
markStateDirty();
|
|
334
|
-
for (const nodeId of cancellation.runningNodeIds) {
|
|
335
|
-
const node = getNode(runId, nodeId);
|
|
336
|
-
const spawnId = node?.spawn?.spawnId;
|
|
337
|
-
if (!node?.spawn || !spawnId || !cancellationIntents.begin(spawnId)) continue;
|
|
338
|
-
const terminated = await terminateWorker(node.workerKey);
|
|
339
|
-
cancellationIntents.resolve(spawnId, terminated);
|
|
340
|
-
}
|
|
341
|
-
publishStateSnapshot();
|
|
342
|
-
onRunSettled(runId);
|
|
343
|
-
scheduleAllRuns(ctx);
|
|
344
|
-
notifyChange();
|
|
345
|
-
return { ok: true };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Spawn one node's worker process. Always asynchronous; the node settles via
|
|
349
|
-
* finalizeNode when the child closes. */
|
|
350
|
-
export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void {
|
|
351
|
-
const run = getRun(runId);
|
|
352
|
-
const node = run?.nodes[nodeId];
|
|
353
|
-
if (!run || !node || node.status !== "pending") return;
|
|
354
|
-
const agent = resolveAgent(node.agent, run.cwd);
|
|
355
|
-
const stateFile = liveStateFile ?? (ctx.sessionManager ? stateFilePath(ctx.sessionManager.getSessionFile(), run.cwd) : "");
|
|
356
|
-
if (!agent || !stateFile) {
|
|
357
|
-
updateNodeStatus(runId, nodeId, "failed", undefined, agent ? "Shared state file unavailable." : `Agent "${node.agent}" not found.`);
|
|
358
|
-
cancelBlockedDependents(runId, nodeId);
|
|
359
|
-
scheduleRun(runId, ctx);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const spawnId = randomUUID();
|
|
364
|
-
markNodeRunning(runId, nodeId);
|
|
365
|
-
|
|
366
|
-
// Optional git worktree isolation: run the node on its own branch.
|
|
367
|
-
let worktree: ReturnType<typeof createWorktree> | undefined;
|
|
368
|
-
if (run.worktree) {
|
|
369
|
-
worktree = createWorktree(run.cwd, `${runId}-${nodeId}`);
|
|
370
|
-
if ("error" in worktree) {
|
|
371
|
-
updateNodeStatus(runId, nodeId, "failed", undefined, `Cannot isolate node: ${worktree.error}`);
|
|
372
|
-
cancelBlockedDependents(runId, nodeId);
|
|
373
|
-
scheduleRun(runId, ctx);
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
const workerCwd = worktree && !("error" in worktree) ? worktree.cwd : run.cwd;
|
|
378
|
-
const workerKey = node.workerKey;
|
|
379
|
-
const outboxFile = workerOutboxPath(stateFile, workerKey, spawnId);
|
|
380
|
-
const workerEnv = {
|
|
381
|
-
PI_TEAMMATE_WORKER_NAME: workerKey,
|
|
382
|
-
PI_TEAMMATE_TASK_ID: node.id,
|
|
383
|
-
PI_TEAMMATE_SPAWN_ID: spawnId,
|
|
384
|
-
PI_TEAMMATE_OUTBOX_FILE: outboxFile,
|
|
385
|
-
};
|
|
386
|
-
setNodeSpawnInfo(runId, nodeId, {
|
|
387
|
-
spawnId,
|
|
388
|
-
pid: 0,
|
|
389
|
-
status: "running",
|
|
390
|
-
startedAt: Date.now(),
|
|
391
|
-
isolation: run.worktree ? "worktree" : "none",
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
const timeoutMs = node.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
395
|
-
const upstream = node.dependsOn
|
|
396
|
-
.map((depId) => run.nodes[depId])
|
|
397
|
-
.filter((dep): dep is NonNullable<typeof dep> => Boolean(dep))
|
|
398
|
-
.map((dep) => {
|
|
399
|
-
const body = dep.result?.trim() || dep.errorMessage?.trim() || `${dep.status} with no written result.`;
|
|
400
|
-
return `--- ${dep.id} (${dep.agent}, ${dep.status}) ---\n${body}`;
|
|
401
|
-
});
|
|
402
|
-
const description = [
|
|
403
|
-
buildAutonomousPrompt({
|
|
404
|
-
name: `${runId}/${nodeId} (${node.agent})`,
|
|
405
|
-
role: node.agent,
|
|
406
|
-
prompt: agent.prompt,
|
|
407
|
-
taskId: nodeId,
|
|
408
|
-
timeoutSec: Math.round(timeoutMs / 1000),
|
|
409
|
-
}),
|
|
410
|
-
"",
|
|
411
|
-
"=== TASK ===",
|
|
412
|
-
`Access: ${node.access}`,
|
|
413
|
-
`Paths: ${node.paths.join(", ")}`,
|
|
414
|
-
node.prompt,
|
|
415
|
-
...(upstream.length > 0 ? ["", "=== UPSTREAM HANDOFF ===", ...upstream] : []),
|
|
416
|
-
].join("\n");
|
|
417
|
-
|
|
418
|
-
const finalizeNode = (result: WorkerProcessResult, cancelled = false) => {
|
|
419
|
-
// A stale close from an older spawn must not affect this node's newer spawn.
|
|
420
|
-
if (getNode(runId, nodeId)?.spawn?.spawnId !== spawnId) {
|
|
421
|
-
if (worktree && !("error" in worktree)) cleanupWorktree(worktree);
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
// Drain validated worker events before recording the final process outcome.
|
|
425
|
-
applyWorkerEvents();
|
|
426
|
-
let patchText = "";
|
|
427
|
-
if (worktree && !("error" in worktree)) {
|
|
428
|
-
const diff = captureWorktreeDiff(worktree);
|
|
429
|
-
if (diff.patch.trim()) {
|
|
430
|
-
patchText = `\n\n=== Worktree changes ===\n${diff.diffStat}\n\n${diff.patch}`;
|
|
431
|
-
}
|
|
432
|
-
cleanupWorktree(worktree);
|
|
433
|
-
}
|
|
434
|
-
const nodeNow = getNode(runId, nodeId);
|
|
435
|
-
const reportedTerminalStatus = nodeNow?.status === "completed" || nodeNow?.status === "failed"
|
|
436
|
-
? nodeNow.status
|
|
437
|
-
: undefined;
|
|
438
|
-
const completedAfterFinalResponse = nodeNow?.spawn?.finalResponse === true;
|
|
439
|
-
const workerReportedFailure = reportedTerminalStatus === "failed";
|
|
440
|
-
const completedAfterShutdown = (reportedTerminalStatus === "completed" || completedAfterFinalResponse)
|
|
441
|
-
&& (result.signal === "SIGTERM" || result.exitCode === 128 + 15 || result.timedOut);
|
|
442
|
-
const ok = isCompletedWorkerExit(
|
|
443
|
-
result,
|
|
444
|
-
reportedTerminalStatus === "completed" || completedAfterFinalResponse,
|
|
445
|
-
) && !workerReportedFailure && !cancelled;
|
|
446
|
-
setNodeSpawnInfo(runId, nodeId, {
|
|
447
|
-
spawnId: spawnId,
|
|
448
|
-
pid: result.pid,
|
|
449
|
-
status: ok ? "completed" : "failed",
|
|
450
|
-
startedAt: nodeNow?.spawn?.startedAt ?? Date.now(),
|
|
451
|
-
finishedAt: Date.now(),
|
|
452
|
-
exitCode: result.exitCode ?? undefined,
|
|
453
|
-
stdout: (result.stdout + patchText).trim() ? result.stdout + patchText : undefined,
|
|
454
|
-
stderr: ok ? undefined : result.stderr,
|
|
455
|
-
usage: result.usage,
|
|
456
|
-
timedOut: result.timedOut,
|
|
457
|
-
isolation: run.worktree ? "worktree" : "none",
|
|
458
|
-
error: ok
|
|
459
|
-
? undefined
|
|
460
|
-
: result.timedOut
|
|
461
|
-
? `Worker timed out after ${Math.round(timeoutMs / 1000)}s.`
|
|
462
|
-
: result.signal
|
|
463
|
-
? `Worker was terminated by ${result.signal}.`
|
|
464
|
-
: workerReportedFailure
|
|
465
|
-
? nodeNow?.errorMessage ?? "Worker reported task failure."
|
|
466
|
-
: `Worker exited with code ${result.exitCode ?? "unknown"}.`,
|
|
467
|
-
});
|
|
468
|
-
reportedWorkerShutdowns.delete(spawnId);
|
|
469
|
-
// A successful summary node becomes the run's headline result.
|
|
470
|
-
if (ok && node.id === SUMMARY_NODE_ID) {
|
|
471
|
-
const settledRun = getRun(runId);
|
|
472
|
-
if (settledRun) {
|
|
473
|
-
settledRun.summary = nodeNow?.result ?? result.stdout;
|
|
474
|
-
markStateDirty();
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
const terminalSubject = cancelled ? "Node cancelled" : ok ? "Node completed" : "Node failed";
|
|
478
|
-
const terminalBody = buildNodeTerminalResult({
|
|
479
|
-
runId,
|
|
480
|
-
nodeId,
|
|
481
|
-
agent: node.agent,
|
|
482
|
-
result,
|
|
483
|
-
nodeResult: nodeNow?.result,
|
|
484
|
-
nodeError: nodeNow?.errorMessage,
|
|
485
|
-
cancelled,
|
|
486
|
-
completedAfterShutdown,
|
|
487
|
-
patchText,
|
|
488
|
-
});
|
|
489
|
-
deliverToLeader({ from: workerKey, subject: terminalSubject, body: terminalBody, runId: runId });
|
|
490
|
-
if (cancelled) {
|
|
491
|
-
// A cancelled node keeps its process outcome but not a misleading error.
|
|
492
|
-
const cleared = getNode(runId, nodeId);
|
|
493
|
-
if (cleared) cleared.errorMessage = undefined;
|
|
494
|
-
updateNodeStatus(runId, nodeId, "cancelled", nodeNow?.result, undefined);
|
|
495
|
-
} else if (!ok) {
|
|
496
|
-
// A failed node cancels its not-yet-started transitive dependents.
|
|
497
|
-
cancelBlockedDependents(runId, nodeId);
|
|
498
|
-
}
|
|
499
|
-
compactFinishedNodeRun(stateFile, workerKey, spawnId);
|
|
500
|
-
scheduleAllRuns(ctx);
|
|
501
|
-
notifyChange();
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
const finish = (result: WorkerProcessResult) => {
|
|
505
|
-
if (cancellationIntents.defer(spawnId, (cancelled) => finalizeNode(result, cancelled))) return;
|
|
506
|
-
finalizeNode(result);
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
const spawnFailure = (error: Error | string) => {
|
|
510
|
-
if (getNode(runId, nodeId)?.spawn?.spawnId !== spawnId) return;
|
|
511
|
-
setNodeSpawnInfo(runId, nodeId, {
|
|
512
|
-
spawnId: spawnId,
|
|
513
|
-
pid: 0,
|
|
514
|
-
status: "failed",
|
|
515
|
-
startedAt: node.spawn?.startedAt ?? Date.now(),
|
|
516
|
-
finishedAt: Date.now(),
|
|
517
|
-
isolation: run.worktree ? "worktree" : "none",
|
|
518
|
-
error: typeof error === "string" ? error : error.message,
|
|
519
|
-
});
|
|
520
|
-
reportedWorkerShutdowns.delete(spawnId);
|
|
521
|
-
deliverToLeader({
|
|
522
|
-
from: workerKey,
|
|
523
|
-
subject: "Node failed",
|
|
524
|
-
body: `Node [${runId}/${nodeId}] could not start.\nError: ${typeof error === "string" ? error : error.message}`,
|
|
525
|
-
runId: runId,
|
|
526
|
-
});
|
|
527
|
-
cancelBlockedDependents(runId, nodeId);
|
|
528
|
-
compactFinishedNodeRun(stateFile, workerKey, spawnId);
|
|
529
|
-
if (worktree && !("error" in worktree)) discardWorktree(worktree);
|
|
530
|
-
scheduleAllRuns(ctx);
|
|
531
|
-
notifyChange();
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
const started = spawnPiWorker({
|
|
535
|
-
workerName: workerKey,
|
|
536
|
-
description,
|
|
537
|
-
model: node.model ?? agent.model,
|
|
538
|
-
tools: agent.tools,
|
|
539
|
-
cwd: workerCwd,
|
|
540
|
-
env: workerEnv,
|
|
541
|
-
timeoutMs,
|
|
542
|
-
onUpdate: (progress) => {
|
|
543
|
-
updateNodeSpawnProgress(runId, nodeId, spawnId, {
|
|
544
|
-
liveText: progress.text,
|
|
545
|
-
activeTool: progress.activeTool,
|
|
546
|
-
liveThinking: progress.liveThinking,
|
|
547
|
-
turns: progress.turns,
|
|
548
|
-
finalResponse: progress.finalResponse,
|
|
549
|
-
});
|
|
550
|
-
if (progress.finalResponse) requestReportedWorkerShutdown(workerKey, spawnId);
|
|
551
|
-
notifyChange();
|
|
552
|
-
},
|
|
553
|
-
onExit: (result) => finish(result),
|
|
554
|
-
onError: (error) => spawnFailure(error),
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
if ("error" in started) {
|
|
558
|
-
spawnFailure(started.error);
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
setNodeSpawnInfo(runId, nodeId, {
|
|
563
|
-
spawnId: spawnId,
|
|
564
|
-
pid: started.pid,
|
|
565
|
-
status: "running",
|
|
566
|
-
startedAt: node.spawn?.startedAt ?? Date.now(),
|
|
567
|
-
isolation: run.worktree ? "worktree" : "none",
|
|
568
|
-
});
|
|
569
|
-
ensureLivePoll();
|
|
570
|
-
publishStateSnapshot();
|
|
571
|
-
notifyChange();
|
|
572
|
-
}
|
|
573
|
-
|
package/src/terminal.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
export interface NodeTerminalResultInput {
|
|
2
|
-
runId: string;
|
|
3
|
-
nodeId: string;
|
|
4
|
-
agent: string;
|
|
5
|
-
result: {
|
|
6
|
-
stdout: string;
|
|
7
|
-
stderr: string;
|
|
8
|
-
signal: NodeJS.Signals | null;
|
|
9
|
-
timedOut: boolean;
|
|
10
|
-
};
|
|
11
|
-
nodeResult?: string;
|
|
12
|
-
nodeError?: string;
|
|
13
|
-
cancelled: boolean;
|
|
14
|
-
completedAfterShutdown?: boolean;
|
|
15
|
-
patchText: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function buildNodeTerminalResult(input: NodeTerminalResultInput): string {
|
|
19
|
-
const status = input.cancelled
|
|
20
|
-
? "cancelled"
|
|
21
|
-
: input.completedAfterShutdown
|
|
22
|
-
? "completed"
|
|
23
|
-
: input.result.timedOut
|
|
24
|
-
? "timed out"
|
|
25
|
-
: input.result.signal
|
|
26
|
-
? `terminated by ${input.result.signal}`
|
|
27
|
-
: input.nodeError
|
|
28
|
-
? "failed"
|
|
29
|
-
: "completed";
|
|
30
|
-
const summary = [input.nodeResult, input.nodeError, input.result.stdout, input.result.stderr]
|
|
31
|
-
.find((value) => value?.trim()) ?? "No worker summary was produced.";
|
|
32
|
-
const worktree = input.patchText ? "\nWorktree diff captured; inspect the node result before integration." : "";
|
|
33
|
-
return `Node [${input.runId}/${input.nodeId}] ${status} — agent ${input.agent}.\n${summary}${worktree}`;
|
|
34
|
-
}
|