@fradser/pi-agent-teams 0.5.1 → 0.5.3
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/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +17 -0
- package/README.md +3 -1
- package/index.ts +1 -0
- package/package.json +4 -3
- package/src/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +170 -0
- package/src/guidance.ts +12 -9
- package/src/index.ts +33 -16
- package/src/run-machine.ts +99 -73
- package/src/spawner.ts +142 -97
- package/src/state.ts +40 -42
- package/src/tools.ts +63 -4
- package/src/types.ts +48 -10
- package/src/ui.ts +56 -73
- package/src/worker.ts +4 -1
package/src/run-machine.ts
CHANGED
|
@@ -2,25 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { resolveAgent } from "./agents";
|
|
7
6
|
import { isWorkerEvent } from "./worker";
|
|
8
7
|
import {
|
|
9
8
|
cancelBlockedDependents, cancelNode, cancelRun, clearWorkerRunEvents, clearStateDirty, isStateDirty,
|
|
10
|
-
deliverToLeader,
|
|
9
|
+
deliverToLeader, findSharedWorkspaceWriteConflict, getNode,
|
|
11
10
|
getRun, getState, listNodes, listRuns, markNodeRunning, markStateDirty,
|
|
12
|
-
readyPendingNodes, receiveWorkerMessage,
|
|
13
|
-
SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus,
|
|
11
|
+
readyPendingNodes, receiveWorkerMessage, runningNodeCount, settleRun, setNodeSpawnInfo,
|
|
12
|
+
SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus, validateStructuredOutput,
|
|
14
13
|
} from "./state";
|
|
15
14
|
import {
|
|
16
|
-
CancellationIntents, buildAutonomousPrompt, finishReportedWorker, isCompletedWorkerExit,
|
|
17
|
-
POST_REPORT_GRACE_MS, spawnPiWorker,
|
|
15
|
+
CancellationIntents, buildAutonomousPrompt, DEFAULT_TURN_BUDGET, finishReportedWorker, isCompletedWorkerExit,
|
|
16
|
+
POST_REPORT_GRACE_MS, spawnPiWorker, terminateWorker,
|
|
18
17
|
type WorkerProcessResult,
|
|
19
18
|
} from "./spawner";
|
|
20
19
|
import { buildNodeTerminalResult } from "./terminal";
|
|
21
20
|
import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree";
|
|
22
21
|
import { readWorkerEvents, removeWorkerOutbox, stateFilePath, workerOutboxPath, writeStateFile } from "./statefile";
|
|
23
|
-
import type { Run } from "./types";
|
|
24
22
|
|
|
25
23
|
export interface DispatchCtx {
|
|
26
24
|
ui: ExtensionContext["ui"];
|
|
@@ -29,25 +27,21 @@ export interface DispatchCtx {
|
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
export const MAX_SESSION_WORKERS = 8;
|
|
32
|
-
const DEFAULT_NODE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
33
30
|
const LIVE_POLL_MS = 500;
|
|
34
31
|
const cancellationIntents = new CancellationIntents();
|
|
35
32
|
const reportedWorkerShutdowns = new Set<string>();
|
|
36
33
|
let liveStateFile: string | undefined;
|
|
37
34
|
let livePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
38
|
-
let machineCtx: DispatchCtx | undefined;
|
|
39
35
|
let sendUpdate: (subject: string, body: string, runId?: string) => void = () => {};
|
|
40
36
|
let notifyChange: () => void = () => {};
|
|
41
37
|
|
|
42
|
-
export function initRunMachine(
|
|
43
|
-
machineCtx = ctx;
|
|
38
|
+
export function initRunMachine(_ctx: DispatchCtx, stateFile: string, hooks: { sendUpdate: typeof sendUpdate; notifyChange: () => void }): void {
|
|
44
39
|
liveStateFile = stateFile;
|
|
45
40
|
sendUpdate = hooks.sendUpdate;
|
|
46
41
|
notifyChange = hooks.notifyChange;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
export function ensureRunContext(ctx: DispatchCtx): void {
|
|
50
|
-
machineCtx = ctx;
|
|
51
45
|
if (!liveStateFile && ctx.sessionManager) {
|
|
52
46
|
liveStateFile = stateFilePath(ctx.sessionManager.getSessionFile(), ctx.cwd ?? process.cwd());
|
|
53
47
|
}
|
|
@@ -57,16 +51,47 @@ export function shutdownRunMachine(): void {
|
|
|
57
51
|
if (livePollTimer) clearInterval(livePollTimer);
|
|
58
52
|
livePollTimer = undefined;
|
|
59
53
|
liveStateFile = undefined;
|
|
60
|
-
machineCtx = undefined;
|
|
61
54
|
sendUpdate = () => {};
|
|
62
55
|
notifyChange = () => {};
|
|
63
56
|
}
|
|
64
57
|
|
|
58
|
+
function resolveInputBinding(run: import("./types").Run, node: import("./types").Node, source: string): string {
|
|
59
|
+
const separator = source.indexOf("#");
|
|
60
|
+
const dependencyId = separator >= 0 ? source.slice(0, separator) : source;
|
|
61
|
+
if (!node.dependsOn.includes(dependencyId)) return "(rejected: source is not a dependency)";
|
|
62
|
+
const dependency = run.nodes[dependencyId];
|
|
63
|
+
if (!dependency) return "(rejected: dependency not found)";
|
|
64
|
+
const value = dependency.structuredOutput ?? dependency.namedOutputs ?? dependency.result;
|
|
65
|
+
if (separator < 0) return JSON.stringify(value);
|
|
66
|
+
const pointer = source.slice(separator + 1).replace(/^\/json/, "");
|
|
67
|
+
if (!pointer || pointer === "") return JSON.stringify(value);
|
|
68
|
+
let current: unknown = value;
|
|
69
|
+
for (const part of pointer.split("/").filter(Boolean).map((item) => item.replaceAll("~1", "/").replaceAll("~0", "~"))) {
|
|
70
|
+
if (!current || typeof current !== "object") return "(missing)";
|
|
71
|
+
current = (current as Record<string, unknown>)[part];
|
|
72
|
+
}
|
|
73
|
+
return JSON.stringify(current);
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
function currentStateFile(): string {
|
|
66
77
|
if (!liveStateFile) throw new Error("Agent Teams session state is unavailable.");
|
|
67
78
|
return liveStateFile;
|
|
68
79
|
}
|
|
69
80
|
|
|
81
|
+
function sendNodeFollowUp(run: import("./types").Run, node: import("./types").Node, subject: string, body: string): void {
|
|
82
|
+
if (!run.background || node.id === SUMMARY_NODE_ID || node.nodeFollowUpSent) return;
|
|
83
|
+
node.nodeFollowUpSent = true;
|
|
84
|
+
markStateDirty();
|
|
85
|
+
sendUpdate(`Teammate ${node.id} ${terminalVerb(subject)}`, body, run.id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function terminalVerb(subject: string): string {
|
|
89
|
+
if (subject === "Node completed") return "completed";
|
|
90
|
+
if (subject === "Node failed") return "failed";
|
|
91
|
+
if (subject === "Node cancelled") return "was cancelled";
|
|
92
|
+
return subject.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
function flushStateSnapshot(): void {
|
|
71
96
|
if (!liveStateFile || !isStateDirty()) return;
|
|
72
97
|
try {
|
|
@@ -108,13 +133,25 @@ export function applyWorkerEvents(): void {
|
|
|
108
133
|
type: "message",
|
|
109
134
|
subject: event.subject,
|
|
110
135
|
body: event.body,
|
|
136
|
+
status: event.status,
|
|
137
|
+
data: event.data,
|
|
111
138
|
});
|
|
139
|
+
if (event.data?.kind === "named_output" && event.data.name && typeof event.data.value === "string") {
|
|
140
|
+
node.namedOutputs = { ...node.namedOutputs, [event.data.name]: event.data.value };
|
|
141
|
+
markStateDirty();
|
|
142
|
+
}
|
|
143
|
+
if (event.data?.kind === "output" && event.data.output !== undefined && !validateStructuredOutput(event.data.output)) {
|
|
144
|
+
node.structuredOutput = event.data.output;
|
|
145
|
+
markStateDirty();
|
|
146
|
+
}
|
|
112
147
|
if (event.status && !["completed", "failed", "cancelled"].includes(node.status)) {
|
|
113
148
|
if (event.status === "completed") {
|
|
114
149
|
updateNodeStatus(run.id, node.id, "completed", event.body, undefined);
|
|
150
|
+
sendNodeFollowUp(run, node, "Node completed", event.body);
|
|
115
151
|
requestReportedWorkerShutdown(node.workerKey, spawnId);
|
|
116
152
|
} else if (event.status === "failed") {
|
|
117
153
|
updateNodeStatus(run.id, node.id, "failed", undefined, event.body);
|
|
154
|
+
sendNodeFollowUp(run, node, "Node failed", event.body);
|
|
118
155
|
requestReportedWorkerShutdown(node.workerKey, spawnId);
|
|
119
156
|
}
|
|
120
157
|
}
|
|
@@ -158,7 +195,6 @@ export function ensureLivePoll(): void {
|
|
|
158
195
|
livePollTimer = setInterval(() => {
|
|
159
196
|
try {
|
|
160
197
|
applyWorkerEvents();
|
|
161
|
-
enforceRunTimeouts();
|
|
162
198
|
flushStateSnapshot();
|
|
163
199
|
notifyChange();
|
|
164
200
|
ensureLivePoll();
|
|
@@ -172,32 +208,8 @@ export function ensureLivePoll(): void {
|
|
|
172
208
|
}
|
|
173
209
|
}
|
|
174
210
|
|
|
175
|
-
/** Fail runs whose run-level hard wall-clock cap was exceeded. */
|
|
176
|
-
function enforceRunTimeouts(): void {
|
|
177
|
-
for (const run of listRuns()) {
|
|
178
|
-
if (run.status !== "running" || !run.deadlineAt || Date.now() < run.deadlineAt) continue;
|
|
179
|
-
const failed = failRunTimeout(run.id, `Run timed out after ${Math.round((run.timeoutMs ?? 0) / 1000)}s.`);
|
|
180
|
-
if (!failed.ok) continue;
|
|
181
|
-
for (const nodeId of failed.runningNodeIds) {
|
|
182
|
-
// Spawn was cleared by failRunTimeout, so terminate by the stable workerKey
|
|
183
|
-
// (a no-op when the child already closed).
|
|
184
|
-
const node = getNode(run.id, nodeId);
|
|
185
|
-
if (node) void terminateWorker(node.workerKey).catch(() => false);
|
|
186
|
-
}
|
|
187
|
-
onRunSettled(run.id, machineCtx);
|
|
188
|
-
// failRunTimeout clears spawns, so those nodes' close events skip their
|
|
189
|
-
// finalize path — release write-deferred nodes in OTHER runs here.
|
|
190
|
-
if (machineCtx) scheduleAllRuns(machineCtx);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
211
|
// ── Run dispatch machinery ────────────────────────────────────────
|
|
196
212
|
|
|
197
|
-
function sleep(ms: number): Promise<void> {
|
|
198
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
213
|
/** Compact run summary for tool returns and follow-ups. When the run has a
|
|
202
214
|
* synthesized __summary node result, that is shown instead of per-node
|
|
203
215
|
* headlines (no truncation heuristics). */
|
|
@@ -209,11 +221,8 @@ export function buildRunSummary(runId: string): string {
|
|
|
209
221
|
acc[node.status] = (acc[node.status] ?? 0) + 1;
|
|
210
222
|
return acc;
|
|
211
223
|
}, {});
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
`${Object.keys(run.nodes).length} node(s): ${Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ")}`,
|
|
215
|
-
"",
|
|
216
|
-
];
|
|
224
|
+
const countsStr = Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ");
|
|
225
|
+
const lines = [`Run [${run.id}] ${countsStr}`, ""];
|
|
217
226
|
if (run.summary) {
|
|
218
227
|
lines.push(run.summary, "");
|
|
219
228
|
} else if (nodes.length === 1) {
|
|
@@ -228,17 +237,13 @@ export function buildRunSummary(runId: string): string {
|
|
|
228
237
|
}
|
|
229
238
|
}
|
|
230
239
|
}
|
|
231
|
-
for (const node of nodes) {
|
|
232
|
-
lines.push(`- [${node.id}] ${node.status} (${node.agent})`);
|
|
233
|
-
}
|
|
234
|
-
lines.push("", "Console: /teammate");
|
|
235
240
|
return lines.join("\n");
|
|
236
241
|
}
|
|
237
242
|
|
|
238
243
|
/** Called once a run reaches a terminal status. Idempotent: the leader summary
|
|
239
244
|
* and follow-up fire only on the first settled observation (a run can transition
|
|
240
245
|
* through settleRun once per node close). */
|
|
241
|
-
export function onRunSettled(runId: string
|
|
246
|
+
export function onRunSettled(runId: string): void {
|
|
242
247
|
const run = getRun(runId);
|
|
243
248
|
if (!run) return;
|
|
244
249
|
if (run.settledMessageSent) return;
|
|
@@ -266,7 +271,7 @@ export function scheduleRun(runId: string, ctx: DispatchCtx): void {
|
|
|
266
271
|
if (!run || run.status !== "running") return;
|
|
267
272
|
const settled = settleRun(runId);
|
|
268
273
|
if (settled !== "running") {
|
|
269
|
-
onRunSettled(runId
|
|
274
|
+
onRunSettled(runId);
|
|
270
275
|
return;
|
|
271
276
|
}
|
|
272
277
|
const sessionBudget = MAX_SESSION_WORKERS - listNodes().filter((node) => node.status === "running").length;
|
|
@@ -333,9 +338,6 @@ export async function cancelRunAndTerminate(
|
|
|
333
338
|
if (run.status !== "running") return { ok: false, error: `Run "${runId}" is already ${run.status}.` };
|
|
334
339
|
const cancellation = cancelRun(runId);
|
|
335
340
|
if (!cancellation.ok) return { ok: false, error: cancellation.error ?? "Failed to cancel run." };
|
|
336
|
-
run.status = "cancelled";
|
|
337
|
-
run.finishedAt = Date.now();
|
|
338
|
-
run.updatedAt = Date.now();
|
|
339
341
|
run.completionNotified = true;
|
|
340
342
|
markStateDirty();
|
|
341
343
|
for (const nodeId of cancellation.runningNodeIds) {
|
|
@@ -346,7 +348,13 @@ export async function cancelRunAndTerminate(
|
|
|
346
348
|
cancellationIntents.resolve(spawnId, terminated);
|
|
347
349
|
}
|
|
348
350
|
publishStateSnapshot();
|
|
349
|
-
|
|
351
|
+
if (!cancellation.runningNodeIds.some((nodeId) => getNode(runId, nodeId)?.status === "running")) {
|
|
352
|
+
run.status = "cancelled";
|
|
353
|
+
run.finishedAt = Date.now();
|
|
354
|
+
run.updatedAt = Date.now();
|
|
355
|
+
markStateDirty();
|
|
356
|
+
onRunSettled(runId);
|
|
357
|
+
}
|
|
350
358
|
scheduleAllRuns(ctx);
|
|
351
359
|
notifyChange();
|
|
352
360
|
return { ok: true };
|
|
@@ -360,10 +368,18 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
360
368
|
if (!run || !node || node.status !== "pending") return;
|
|
361
369
|
const agent = resolveAgent(node.agent, run.cwd);
|
|
362
370
|
const stateFile = liveStateFile ?? (ctx.sessionManager ? stateFilePath(ctx.sessionManager.getSessionFile(), run.cwd) : "");
|
|
363
|
-
|
|
364
|
-
updateNodeStatus(runId, nodeId, "failed", undefined,
|
|
371
|
+
const failBeforeSpawn = (error: string): void => {
|
|
372
|
+
updateNodeStatus(runId, nodeId, "failed", undefined, error);
|
|
373
|
+
const failedNode = getNode(runId, nodeId);
|
|
374
|
+
const subject = "Node failed";
|
|
375
|
+
const body = `Node [${runId}/${nodeId}] could not start.\nError: ${error}`;
|
|
376
|
+
deliverToLeader({ from: node.workerKey, subject, body, runId });
|
|
377
|
+
if (failedNode) sendNodeFollowUp(run, failedNode, subject, body);
|
|
365
378
|
cancelBlockedDependents(runId, nodeId);
|
|
366
379
|
scheduleRun(runId, ctx);
|
|
380
|
+
};
|
|
381
|
+
if (!agent || !stateFile) {
|
|
382
|
+
failBeforeSpawn(agent ? "Shared state file unavailable." : `Agent "${node.agent}" not found.`);
|
|
367
383
|
return;
|
|
368
384
|
}
|
|
369
385
|
|
|
@@ -375,9 +391,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
375
391
|
if (run.worktree) {
|
|
376
392
|
worktree = createWorktree(run.cwd, `${runId}-${nodeId}`);
|
|
377
393
|
if ("error" in worktree) {
|
|
378
|
-
|
|
379
|
-
cancelBlockedDependents(runId, nodeId);
|
|
380
|
-
scheduleRun(runId, ctx);
|
|
394
|
+
failBeforeSpawn(`Cannot isolate node: ${worktree.error}`);
|
|
381
395
|
return;
|
|
382
396
|
}
|
|
383
397
|
}
|
|
@@ -396,15 +410,24 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
396
410
|
status: "running",
|
|
397
411
|
startedAt: Date.now(),
|
|
398
412
|
isolation: run.worktree ? "worktree" : "none",
|
|
413
|
+
mode: node.mode,
|
|
399
414
|
});
|
|
400
415
|
|
|
401
|
-
const
|
|
416
|
+
const turnBudget = node.turnBudget ?? DEFAULT_TURN_BUDGET;
|
|
417
|
+
const bindingLines = node.inputBindings
|
|
418
|
+
? Object.entries(node.inputBindings).map(([name, source]) => `Input ${name}: ${resolveInputBinding(run, node, source)}`)
|
|
419
|
+
: [];
|
|
402
420
|
const upstream = node.dependsOn
|
|
403
421
|
.map((depId) => run.nodes[depId])
|
|
404
422
|
.filter((dep): dep is NonNullable<typeof dep> => Boolean(dep))
|
|
423
|
+
.filter((dep) => !node.forkContext || node.forkContext.includes(dep.id))
|
|
405
424
|
.map((dep) => {
|
|
406
425
|
const body = dep.result?.trim() || dep.errorMessage?.trim() || `${dep.status} with no written result.`;
|
|
407
|
-
|
|
426
|
+
const named = dep.namedOutputs && Object.keys(dep.namedOutputs).length > 0
|
|
427
|
+
? `\nNamed outputs: ${JSON.stringify(dep.namedOutputs)}`
|
|
428
|
+
: "";
|
|
429
|
+
const structured = dep.structuredOutput === undefined ? "" : `\nStructured output: ${JSON.stringify(dep.structuredOutput)}`;
|
|
430
|
+
return `--- ${dep.id} (${dep.agent}, ${dep.status}) ---\n${body}${named}${structured}`;
|
|
408
431
|
});
|
|
409
432
|
const description = [
|
|
410
433
|
buildAutonomousPrompt({
|
|
@@ -412,7 +435,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
412
435
|
role: node.agent,
|
|
413
436
|
prompt: agent.prompt,
|
|
414
437
|
taskId: nodeId,
|
|
415
|
-
|
|
438
|
+
turnBudget,
|
|
416
439
|
}),
|
|
417
440
|
"",
|
|
418
441
|
"=== TASK ===",
|
|
@@ -420,6 +443,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
420
443
|
`Paths: ${node.paths.join(", ")}`,
|
|
421
444
|
node.prompt,
|
|
422
445
|
...(upstream.length > 0 ? ["", "=== UPSTREAM HANDOFF ===", ...upstream] : []),
|
|
446
|
+
...(bindingLines.length > 0 ? ["", "=== NAMED INPUTS ===", ...bindingLines] : []),
|
|
423
447
|
].join("\n");
|
|
424
448
|
|
|
425
449
|
const finalizeNode = (result: WorkerProcessResult, cancelled = false) => {
|
|
@@ -445,7 +469,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
445
469
|
const completedAfterFinalResponse = nodeNow?.spawn?.finalResponse === true;
|
|
446
470
|
const workerReportedFailure = reportedTerminalStatus === "failed";
|
|
447
471
|
const completedAfterShutdown = (reportedTerminalStatus === "completed" || completedAfterFinalResponse)
|
|
448
|
-
&&
|
|
472
|
+
&& result.signal === "SIGTERM";
|
|
449
473
|
const ok = isCompletedWorkerExit(
|
|
450
474
|
result,
|
|
451
475
|
reportedTerminalStatus === "completed" || completedAfterFinalResponse,
|
|
@@ -460,12 +484,11 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
460
484
|
stdout: (result.stdout + patchText).trim() ? result.stdout + patchText : undefined,
|
|
461
485
|
stderr: ok ? undefined : result.stderr,
|
|
462
486
|
usage: result.usage,
|
|
463
|
-
timedOut: result.timedOut,
|
|
464
487
|
isolation: run.worktree ? "worktree" : "none",
|
|
465
488
|
error: ok
|
|
466
489
|
? undefined
|
|
467
|
-
: result.
|
|
468
|
-
? `Worker
|
|
490
|
+
: result.turnBudgetExceeded
|
|
491
|
+
? `Worker exceeded its turn budget of ${turnBudget} turn(s).`
|
|
469
492
|
: result.signal
|
|
470
493
|
? `Worker was terminated by ${result.signal}.`
|
|
471
494
|
: workerReportedFailure
|
|
@@ -486,7 +509,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
486
509
|
runId,
|
|
487
510
|
nodeId,
|
|
488
511
|
agent: node.agent,
|
|
489
|
-
result,
|
|
512
|
+
result: { ...result, timedOut: result.turnBudgetExceeded ?? false },
|
|
490
513
|
nodeResult: nodeNow?.result,
|
|
491
514
|
nodeError: nodeNow?.errorMessage,
|
|
492
515
|
cancelled,
|
|
@@ -494,6 +517,8 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
494
517
|
patchText,
|
|
495
518
|
});
|
|
496
519
|
deliverToLeader({ from: workerKey, subject: terminalSubject, body: terminalBody, runId: runId });
|
|
520
|
+
const settledNode = getNode(runId, nodeId);
|
|
521
|
+
if (!cancelled && settledNode) sendNodeFollowUp(run, settledNode, terminalSubject, terminalBody);
|
|
497
522
|
if (cancelled) {
|
|
498
523
|
// A cancelled node keeps its process outcome but not a misleading error.
|
|
499
524
|
const cleared = getNode(runId, nodeId);
|
|
@@ -525,12 +550,11 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
525
550
|
error: typeof error === "string" ? error : error.message,
|
|
526
551
|
});
|
|
527
552
|
reportedWorkerShutdowns.delete(spawnId);
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
});
|
|
553
|
+
const subject = "Node failed";
|
|
554
|
+
const body = `Node [${runId}/${nodeId}] could not start.\nError: ${typeof error === "string" ? error : error.message}`;
|
|
555
|
+
deliverToLeader({ from: workerKey, subject, body, runId: runId });
|
|
556
|
+
const failedNode = getNode(runId, nodeId);
|
|
557
|
+
if (failedNode) sendNodeFollowUp(run, failedNode, subject, body);
|
|
534
558
|
cancelBlockedDependents(runId, nodeId);
|
|
535
559
|
compactFinishedNodeRun(stateFile, workerKey, spawnId);
|
|
536
560
|
if (worktree && !("error" in worktree)) discardWorktree(worktree);
|
|
@@ -540,12 +564,13 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
540
564
|
|
|
541
565
|
const started = spawnPiWorker({
|
|
542
566
|
workerName: workerKey,
|
|
567
|
+
mode: node.mode,
|
|
543
568
|
description,
|
|
544
569
|
model: node.model ?? agent.model,
|
|
545
570
|
tools: agent.tools,
|
|
546
571
|
cwd: workerCwd,
|
|
547
572
|
env: workerEnv,
|
|
548
|
-
|
|
573
|
+
turnBudget,
|
|
549
574
|
onUpdate: (progress) => {
|
|
550
575
|
updateNodeSpawnProgress(runId, nodeId, spawnId, {
|
|
551
576
|
liveText: progress.text,
|
|
@@ -572,6 +597,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
572
597
|
status: "running",
|
|
573
598
|
startedAt: node.spawn?.startedAt ?? Date.now(),
|
|
574
599
|
isolation: run.worktree ? "worktree" : "none",
|
|
600
|
+
mode: node.mode,
|
|
575
601
|
});
|
|
576
602
|
ensureLivePoll();
|
|
577
603
|
publishStateSnapshot();
|