pi-squad 0.16.3 → 0.16.4
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 +18 -12
- package/package.json +1 -1
- package/src/agent-pool.ts +123 -44
- package/src/index.ts +160 -177
- package/src/panel/message-view.ts +58 -50
- package/src/panel/squad-widget.ts +35 -10
- package/src/panel/task-list.ts +13 -3
- package/src/router.ts +37 -35
- package/src/scheduler.ts +254 -64
- package/src/store.ts +195 -1
- package/src/types.ts +24 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
14
14
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
15
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import type { TaskStatus } from "../types.js";
|
|
16
|
+
import type { TaskMessage, TaskStatus } from "../types.js";
|
|
17
17
|
import * as store from "../store.js";
|
|
18
18
|
|
|
19
19
|
function statusIcon(status: TaskStatus, th: Theme): string {
|
|
@@ -76,14 +76,33 @@ export function setupSquadWidget(
|
|
|
76
76
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
77
77
|
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
78
78
|
const elapsed = Date.now() - new Date(squad.created).getTime();
|
|
79
|
+
const taskMessages = new Map<string, TaskMessage[]>();
|
|
80
|
+
const recentOrchestratorByTask = new Map<string, TaskMessage>();
|
|
81
|
+
const recentMessageKeys: string[] = [];
|
|
82
|
+
for (const task of tasks) {
|
|
83
|
+
if (task.status !== "in_progress") continue;
|
|
84
|
+
const messages = store.loadMessages(state.squadId, task.id);
|
|
85
|
+
taskMessages.set(task.id, messages);
|
|
86
|
+
const latest = messages.at(-1);
|
|
87
|
+
recentMessageKeys.push(`${task.id}:${messages.length}:${latest?.id || latest?.ts || "none"}`);
|
|
88
|
+
const recentOrchestrator = [...messages.slice(-5)].reverse().find((message) =>
|
|
89
|
+
message.from === "orchestrator" &&
|
|
90
|
+
(message.type === "text" || message.type === "message" ||
|
|
91
|
+
message.type === "reply" || message.type === "mention"),
|
|
92
|
+
);
|
|
93
|
+
if (recentOrchestrator) recentOrchestratorByTask.set(task.id, recentOrchestrator);
|
|
94
|
+
}
|
|
79
95
|
|
|
80
96
|
const sIcon = squad.status === "done" ? th.fg("success", "✓")
|
|
81
97
|
: squad.status === "failed" ? th.fg("error", "✗")
|
|
82
98
|
: squad.status === "review" ? th.fg("warning", "◆")
|
|
83
99
|
: th.fg("warning", "⏳");
|
|
84
100
|
|
|
101
|
+
const orchestratorSignal = recentOrchestratorByTask.size > 0
|
|
102
|
+
? ` ${th.fg("accent", `✉ ${recentOrchestratorByTask.size > 1 ? recentOrchestratorByTask.size + " " : ""}ORCH`)}`
|
|
103
|
+
: "";
|
|
85
104
|
lines.push(
|
|
86
|
-
`${sIcon} ${th.fg("accent", "squad")} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
105
|
+
`${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
87
106
|
`${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
|
|
88
107
|
`${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
|
|
89
108
|
`${th.fg("dim", formatElapsed(elapsed))} ` +
|
|
@@ -111,13 +130,19 @@ export function setupSquadWidget(
|
|
|
111
130
|
const runningFor = task.started ? Date.now() - new Date(task.started).getTime() : 0;
|
|
112
131
|
const timeColor = runningFor > 180_000 ? "warning" : "dim";
|
|
113
132
|
line += ` ${th.fg(timeColor as any, formatElapsed(runningFor))}`;
|
|
114
|
-
const messages =
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
const messages = taskMessages.get(task.id) || [];
|
|
134
|
+
const recentOrchestrator = recentOrchestratorByTask.get(task.id);
|
|
135
|
+
if (recentOrchestrator) {
|
|
136
|
+
const preview = recentOrchestrator.text.split("\n")[0].slice(0, 24);
|
|
137
|
+
line += ` ${th.fg("accent", "← ORCH")} ${th.fg("dim", preview)}`;
|
|
138
|
+
} else {
|
|
139
|
+
const lastTool = [...messages].reverse().find(m => m.type === "tool");
|
|
140
|
+
if (lastTool) {
|
|
141
|
+
const rawDetail = (lastTool.args?.path || lastTool.args?.command || "").toString();
|
|
142
|
+
const detail = rawDetail.split("\n")[0]; // first line only
|
|
143
|
+
const toolStr = `→ ${lastTool.name || lastTool.text}`;
|
|
144
|
+
line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
|
|
145
|
+
}
|
|
121
146
|
}
|
|
122
147
|
} else if (task.status === "blocked") {
|
|
123
148
|
const blockers = task.depends.filter((d) => {
|
|
@@ -136,7 +161,7 @@ export function setupSquadWidget(
|
|
|
136
161
|
lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
|
|
137
162
|
}
|
|
138
163
|
|
|
139
|
-
const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}`;
|
|
164
|
+
const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
|
|
140
165
|
|
|
141
166
|
const statusText = squad.status === "done"
|
|
142
167
|
? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
|
package/src/panel/task-list.ts
CHANGED
|
@@ -200,15 +200,25 @@ export class TaskListView {
|
|
|
200
200
|
const recent = messages.slice(-3);
|
|
201
201
|
|
|
202
202
|
for (const msg of recent) {
|
|
203
|
-
|
|
203
|
+
const isConversation = msg.type === "text" || msg.type === "message" ||
|
|
204
|
+
msg.type === "reply" || msg.type === "mention";
|
|
205
|
+
if (msg.from === "orchestrator" && isConversation) {
|
|
206
|
+
const preview = msg.text.split("\n")[0];
|
|
207
|
+
lines.push(truncateToWidth(
|
|
208
|
+
` ${th.fg("accent", "ORCHESTRATOR")} ${th.fg("dim", `"${preview}"`)}`,
|
|
209
|
+
width,
|
|
210
|
+
"…",
|
|
211
|
+
));
|
|
212
|
+
} else if (msg.type === "tool") {
|
|
204
213
|
const toolStr = `→ ${msg.name || msg.text}`;
|
|
205
214
|
const rawArgs = (msg.args?.path || msg.args?.command || "").toString();
|
|
206
215
|
const argsStr = rawArgs.split("\n")[0]; // first line only
|
|
207
216
|
const preview = argsStr ? `${toolStr} ${argsStr}` : toolStr;
|
|
208
217
|
lines.push(truncateToWidth(` ${th.fg("muted", preview)}`, width, "…"));
|
|
209
|
-
} else if (
|
|
218
|
+
} else if (isConversation && msg.from !== "system") {
|
|
210
219
|
const preview = msg.text.split("\n")[0];
|
|
211
|
-
|
|
220
|
+
const sender = msg.from === "human" ? "YOU" : msg.from;
|
|
221
|
+
lines.push(truncateToWidth(` ${th.fg("dim", `${sender}: "${preview}"`)}`, width, "…"));
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
224
|
|
package/src/router.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* router.ts — @mention parsing and cross-agent message delivery.
|
|
3
3
|
*
|
|
4
4
|
* Parses assistant text for @agentname patterns.
|
|
5
|
-
* Routes messages to target
|
|
6
|
-
* or
|
|
5
|
+
* Routes messages to exact target tasks via steer() if running,
|
|
6
|
+
* or a durable task-owned mailbox for delivery on next spawn.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { AgentPool } from "./agent-pool.js";
|
|
@@ -83,30 +83,41 @@ export class Router {
|
|
|
83
83
|
text: message,
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.pool.steer(targetTaskId, steerMessage);
|
|
86
|
+
// Agent-name mentions are safe only when exactly one live task owns that
|
|
87
|
+
// role. Multiple concurrent same-role tasks must never receive guessed mail.
|
|
88
|
+
const liveTargets = store.loadAllTasks(this.squadId).filter(
|
|
89
|
+
(task) => task.agent === targetAgent && this.pool.isRunning(task.id),
|
|
90
|
+
);
|
|
91
|
+
const targetTaskId = liveTargets.length === 1 ? liveTargets[0].id : undefined;
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
store.
|
|
93
|
+
if (targetTaskId) {
|
|
94
|
+
const queued = store.queueTaskMessage(this.squadId, targetTaskId, {
|
|
96
95
|
ts: store.now(),
|
|
97
96
|
from: fromAgent,
|
|
98
97
|
type: "mention",
|
|
99
98
|
to: targetAgent,
|
|
100
99
|
text: message,
|
|
101
100
|
});
|
|
101
|
+
const steerMessage = `[squad] Message from @${fromAgent} (working on ${sourceTaskId}):\n${message}`;
|
|
102
|
+
void this.pool.steer(targetTaskId, steerMessage).then((delivered) => {
|
|
103
|
+
if (delivered) store.acknowledgeTaskMessages(this.squadId, targetTaskId, [queued.id]);
|
|
104
|
+
});
|
|
102
105
|
return false;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
const targetTasks = store.loadAllTasks(this.squadId).filter((task) => task.agent === targetAgent);
|
|
106
|
-
const
|
|
107
|
-
task.status === "
|
|
109
|
+
const futureTasks = targetTasks.filter((task) =>
|
|
110
|
+
task.status === "in_progress" || task.status === "pending" || task.status === "suspended" || task.status === "blocked",
|
|
108
111
|
);
|
|
109
|
-
|
|
112
|
+
const interrupted = futureTasks.filter((task) => task.status === "in_progress");
|
|
113
|
+
// Never guess between two tasks assigned to the same role. A uniquely
|
|
114
|
+
// interrupted task is authoritative; otherwise only one future task is safe.
|
|
115
|
+
const futureTask = interrupted.length === 1
|
|
116
|
+
? interrupted[0]
|
|
117
|
+
: futureTasks.length === 1
|
|
118
|
+
? futureTasks[0]
|
|
119
|
+
: undefined;
|
|
120
|
+
if (futureTasks.length === 0) {
|
|
110
121
|
const completed = targetTasks.filter((task) => task.status === "done" && task.output);
|
|
111
122
|
if (completed.length > 0) {
|
|
112
123
|
const durableReply = [
|
|
@@ -120,21 +131,20 @@ export class Router {
|
|
|
120
131
|
to: fromAgent,
|
|
121
132
|
text: durableReply,
|
|
122
133
|
};
|
|
123
|
-
store.
|
|
134
|
+
const queued = store.queueTaskMessage(this.squadId, sourceTaskId, reply);
|
|
124
135
|
if (this.pool.isRunning(sourceTaskId)) {
|
|
125
|
-
this.pool.steer(sourceTaskId, durableReply)
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
void this.pool.steer(sourceTaskId, durableReply).then((delivered) => {
|
|
137
|
+
if (delivered) store.acknowledgeTaskMessages(this.squadId, sourceTaskId, [queued.id]);
|
|
138
|
+
});
|
|
128
139
|
}
|
|
129
140
|
return true;
|
|
130
141
|
}
|
|
131
142
|
}
|
|
132
143
|
|
|
133
|
-
// Target has future work and may spawn again
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.pool.queueMessage(targetAgent, {
|
|
144
|
+
// Target has future work and may spawn again. Bind the message to exactly
|
|
145
|
+
// one durable task, never to the shared role name.
|
|
146
|
+
if (futureTask) {
|
|
147
|
+
store.queueTaskMessage(this.squadId, futureTask.id, {
|
|
138
148
|
ts: store.now(),
|
|
139
149
|
from: fromAgent,
|
|
140
150
|
type: "mention",
|
|
@@ -149,7 +159,7 @@ export class Router {
|
|
|
149
159
|
* Route a human message to an agent.
|
|
150
160
|
*/
|
|
151
161
|
routeHumanMessage(taskId: string, message: string): void {
|
|
152
|
-
store.
|
|
162
|
+
const queued = store.queueTaskMessage(this.squadId, taskId, {
|
|
153
163
|
ts: store.now(),
|
|
154
164
|
from: "human",
|
|
155
165
|
type: "message",
|
|
@@ -157,17 +167,9 @@ export class Router {
|
|
|
157
167
|
});
|
|
158
168
|
|
|
159
169
|
if (this.pool.isRunning(taskId)) {
|
|
160
|
-
this.pool.steer(taskId, `[squad] Human: ${message}`)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (task) {
|
|
164
|
-
this.pool.queueMessage(task.agent, {
|
|
165
|
-
ts: store.now(),
|
|
166
|
-
from: "human",
|
|
167
|
-
type: "message",
|
|
168
|
-
text: message,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
170
|
+
void this.pool.steer(taskId, `[squad] Human: ${message}`).then((delivered) => {
|
|
171
|
+
if (delivered) store.acknowledgeTaskMessages(this.squadId, taskId, [queued.id]);
|
|
172
|
+
});
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
175
|
|
package/src/scheduler.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import type { AgentDef, Squad, SquadConfig, Task, TaskMessage, TaskStatus } from "./types.js";
|
|
14
|
+
import type { AgentDef, Squad, SquadConfig, Task, TaskMailboxEntry, TaskMessage, TaskStatus } from "./types.js";
|
|
15
15
|
import { AgentPool, type AgentEvent } from "./agent-pool.js";
|
|
16
16
|
import { Monitor } from "./monitor.js";
|
|
17
17
|
import { Router } from "./router.js";
|
|
@@ -241,6 +241,32 @@ export class Scheduler {
|
|
|
241
241
|
|
|
242
242
|
const tasks = store.loadAllTasks(this.squadId);
|
|
243
243
|
|
|
244
|
+
// 0. Mailbox recovery is level-triggered from disk. If the previous
|
|
245
|
+
// process stopped after queueTaskMessage's atomic write but before status
|
|
246
|
+
// mutation/RPC delivery, reopen exactly that task on reconstruction.
|
|
247
|
+
let recoveredMailbox = false;
|
|
248
|
+
for (const task of tasks) {
|
|
249
|
+
if (this.pool.isRunning(task.id)) continue;
|
|
250
|
+
if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
|
|
251
|
+
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
252
|
+
const dependenciesDone = task.depends.every(
|
|
253
|
+
(depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
|
|
254
|
+
);
|
|
255
|
+
const nextStatus: TaskStatus = dependenciesDone ? "pending" : "blocked";
|
|
256
|
+
if (task.status !== nextStatus || task.completed !== null || task.error !== null) {
|
|
257
|
+
task.status = nextStatus;
|
|
258
|
+
task.completed = null;
|
|
259
|
+
task.error = null;
|
|
260
|
+
store.updateTaskStatus(this.squadId, task.id, nextStatus, { completed: null, error: null });
|
|
261
|
+
}
|
|
262
|
+
recoveredMailbox = true;
|
|
263
|
+
}
|
|
264
|
+
if (recoveredMailbox && squad.status !== "running") {
|
|
265
|
+
squad.status = "running";
|
|
266
|
+
delete squad.review;
|
|
267
|
+
store.saveSquad(squad);
|
|
268
|
+
}
|
|
269
|
+
|
|
244
270
|
// 1. Blocked → pending when all deps are done (respects autoUnblock config)
|
|
245
271
|
if (squad.config.autoUnblock) {
|
|
246
272
|
for (const task of tasks) {
|
|
@@ -323,7 +349,12 @@ export class Scheduler {
|
|
|
323
349
|
/** Get tasks that are ready to execute (pending + all deps done) */
|
|
324
350
|
private getReadyTasks(tasks: Task[]): Task[] {
|
|
325
351
|
return tasks.filter((task) => {
|
|
326
|
-
|
|
352
|
+
// A reconstructed scheduler has an empty process pool. Persisted
|
|
353
|
+
// in_progress means the previous process stopped mid-task, so resume its
|
|
354
|
+
// bound session instead of stranding it or creating a fresh context.
|
|
355
|
+
const needsProcess = task.status === "pending" ||
|
|
356
|
+
(task.status === "in_progress" && !this.pool.isRunning(task.id));
|
|
357
|
+
if (!needsProcess) return false;
|
|
327
358
|
return task.depends.every((depId) => {
|
|
328
359
|
const dep = tasks.find((t) => t.id === depId);
|
|
329
360
|
return dep?.status === "done";
|
|
@@ -376,9 +407,24 @@ export class Scheduler {
|
|
|
376
407
|
}
|
|
377
408
|
}
|
|
378
409
|
|
|
379
|
-
|
|
410
|
+
const resumeSession = store.loadTaskSession(this.squadId, task.id) ?? undefined;
|
|
411
|
+
const pendingMailbox = store.loadPendingTaskMessages(this.squadId, task.id);
|
|
412
|
+
// Legacy tasks predate task-owned Pi sessions. Their first durable spawn
|
|
413
|
+
// must receive the complete persisted history/output as a migration seed;
|
|
414
|
+
// otherwise reopening silently discards all prior context.
|
|
415
|
+
const legacyHistory = !resumeSession && (task.started !== null || task.completed !== null || task.output !== null)
|
|
416
|
+
? store.loadMessages(this.squadId, task.id)
|
|
417
|
+
: [];
|
|
418
|
+
const legacySeed = legacyHistory.length > 0 || (!resumeSession && task.output !== null)
|
|
419
|
+
? { messages: legacyHistory, output: task.output }
|
|
420
|
+
: undefined;
|
|
421
|
+
|
|
422
|
+
// Keep the original start time across resumes. A resumed/live process owns
|
|
423
|
+
// in_progress until Pi emits final agent_settled.
|
|
380
424
|
store.updateTaskStatus(this.squadId, task.id, "in_progress", {
|
|
381
|
-
started: store.now(),
|
|
425
|
+
started: task.started ?? store.now(),
|
|
426
|
+
completed: null,
|
|
427
|
+
error: null,
|
|
382
428
|
});
|
|
383
429
|
|
|
384
430
|
store.appendMessage(this.squadId, task.id, {
|
|
@@ -395,11 +441,13 @@ export class Scheduler {
|
|
|
395
441
|
agentName: task.agent,
|
|
396
442
|
});
|
|
397
443
|
|
|
398
|
-
//
|
|
399
|
-
|
|
444
|
+
// Context inheritance creates a session only on the task's first run.
|
|
445
|
+
// Every later run must reopen the immutable task binding.
|
|
446
|
+
const forkSessionFile = resumeSession ? undefined : this.resolveForkSession(task, squad, agentDef);
|
|
447
|
+
const taskSessionDir = store.getTaskSessionDir(this.squadId, task.id);
|
|
400
448
|
|
|
401
449
|
try {
|
|
402
|
-
await this.pool.spawn({
|
|
450
|
+
const agent = await this.pool.spawn({
|
|
403
451
|
taskId: task.id,
|
|
404
452
|
agentDef,
|
|
405
453
|
protocolOptions: {
|
|
@@ -408,19 +456,28 @@ export class Scheduler {
|
|
|
408
456
|
task,
|
|
409
457
|
agentDef,
|
|
410
458
|
modifiedFiles,
|
|
411
|
-
queuedMessages:
|
|
459
|
+
queuedMessages: pendingMailbox.map((entry) => entry.message),
|
|
412
460
|
},
|
|
413
461
|
cwd: squad.cwd,
|
|
414
462
|
skillPaths: this.skillPaths,
|
|
415
|
-
...(
|
|
416
|
-
? {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
},
|
|
421
|
-
}
|
|
422
|
-
: {}),
|
|
463
|
+
...(resumeSession
|
|
464
|
+
? { resumeSession }
|
|
465
|
+
: forkSessionFile
|
|
466
|
+
? { forkSession: { file: forkSessionFile, sessionDir: taskSessionDir } }
|
|
467
|
+
: { sessionDir: taskSessionDir }),
|
|
423
468
|
});
|
|
469
|
+
store.bindTaskSession(this.squadId, task.id, agent.session);
|
|
470
|
+
|
|
471
|
+
const accepted = await this.pool.prompt(
|
|
472
|
+
task.id,
|
|
473
|
+
this.buildTaskPrompt(task, Boolean(resumeSession), pendingMailbox, legacySeed),
|
|
474
|
+
);
|
|
475
|
+
if (!accepted) throw new Error(`Agent ${task.agent} did not accept the task prompt`);
|
|
476
|
+
store.acknowledgeTaskMessages(
|
|
477
|
+
this.squadId,
|
|
478
|
+
task.id,
|
|
479
|
+
pendingMailbox.map((entry) => entry.id),
|
|
480
|
+
);
|
|
424
481
|
} catch (error) {
|
|
425
482
|
this.handleTaskFailed(task.id, (error as Error).message);
|
|
426
483
|
}
|
|
@@ -545,6 +602,77 @@ export class Scheduler {
|
|
|
545
602
|
return sessionFile;
|
|
546
603
|
}
|
|
547
604
|
|
|
605
|
+
private buildTaskPrompt(
|
|
606
|
+
task: Task,
|
|
607
|
+
resumed: boolean,
|
|
608
|
+
entries: TaskMailboxEntry[],
|
|
609
|
+
legacySeed?: { messages: TaskMessage[]; output: string | null },
|
|
610
|
+
): string {
|
|
611
|
+
const lines = resumed
|
|
612
|
+
? [
|
|
613
|
+
`Resume your existing task: ${task.title}`,
|
|
614
|
+
"Continue from the durable Pi session context. Do not restart the task from scratch.",
|
|
615
|
+
]
|
|
616
|
+
: [
|
|
617
|
+
`Your task: ${task.title}`,
|
|
618
|
+
"",
|
|
619
|
+
task.description || "",
|
|
620
|
+
];
|
|
621
|
+
|
|
622
|
+
if (legacySeed) {
|
|
623
|
+
const pendingIds = new Set(entries.map((entry) => entry.id));
|
|
624
|
+
lines.push("", "Legacy task migration: the following persisted history is the complete prior context. Preserve it and continue from it.");
|
|
625
|
+
for (const message of legacySeed.messages) {
|
|
626
|
+
if (message.id && pendingIds.has(message.id)) continue;
|
|
627
|
+
lines.push("", `[${message.ts}] ${message.from} (${message.type}):`, message.text);
|
|
628
|
+
}
|
|
629
|
+
if (legacySeed.output !== null) {
|
|
630
|
+
lines.push("", "Prior durable task output:", legacySeed.output);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (entries.length > 0) {
|
|
635
|
+
lines.push("", "New durable messages for this task:");
|
|
636
|
+
for (const entry of entries) {
|
|
637
|
+
lines.push("", `[${entry.message.ts}] ${entry.message.from}:`, entry.message.text);
|
|
638
|
+
if (entry.message.expectsReply) {
|
|
639
|
+
lines.push("[Direct response required by main orchestrator]");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
lines.push("", "Read and act on every complete message above.");
|
|
643
|
+
} else if (resumed) {
|
|
644
|
+
lines.push("", "The previous process ended before final settlement. Continue the unfinished work and report the final result.");
|
|
645
|
+
}
|
|
646
|
+
return lines.join("\n");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private handleUnexpectedAgentExit(event: AgentEvent): void {
|
|
650
|
+
const exitCode = event.data?.exitCode ?? 1;
|
|
651
|
+
const turnCount = event.data?.turnCount ?? 0;
|
|
652
|
+
const stderr = event.data?.stderr || "";
|
|
653
|
+
const retryKey = `spawn-retry:${event.taskId}`;
|
|
654
|
+
if (!this.spawnRetries.has(retryKey)) {
|
|
655
|
+
this.spawnRetries.add(retryKey);
|
|
656
|
+
const reason = turnCount === 0
|
|
657
|
+
? "exited with 0 turns (likely rate limit or API error)"
|
|
658
|
+
: `exited before final agent_settled after ${turnCount} turns`;
|
|
659
|
+
logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
|
|
660
|
+
store.updateTaskStatus(this.squadId, event.taskId, "pending");
|
|
661
|
+
store.appendMessage(this.squadId, event.taskId, {
|
|
662
|
+
ts: store.now(),
|
|
663
|
+
from: "system",
|
|
664
|
+
type: "status",
|
|
665
|
+
text: `Agent ${reason}. Resuming the same task session...`,
|
|
666
|
+
});
|
|
667
|
+
setTimeout(() => {
|
|
668
|
+
if (this.running) this.scheduleReadyTasks();
|
|
669
|
+
}, 2000);
|
|
670
|
+
} else {
|
|
671
|
+
this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} before final agent_settled (retry exhausted). ${stderr}`);
|
|
672
|
+
}
|
|
673
|
+
this.updateContext();
|
|
674
|
+
}
|
|
675
|
+
|
|
548
676
|
// =========================================================================
|
|
549
677
|
// Event Handlers
|
|
550
678
|
// =========================================================================
|
|
@@ -638,50 +766,45 @@ export class Scheduler {
|
|
|
638
766
|
break;
|
|
639
767
|
}
|
|
640
768
|
|
|
641
|
-
|
|
642
|
-
|
|
769
|
+
case "agent_end": {
|
|
770
|
+
// Pi's low-level agent_end is not completion. AgentPool normally keeps
|
|
771
|
+
// it internal; if observed here, preserve in_progress. Only an actual
|
|
772
|
+
// child-process exit carries unexpectedExit and enters retry handling.
|
|
773
|
+
if (!event.data?.unexpectedExit) break;
|
|
774
|
+
this.handleUnexpectedAgentExit(event);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
case "agent_settled": {
|
|
779
|
+
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
780
|
+
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
781
|
+
// occurs before the task can become done.
|
|
782
|
+
if (store.loadPendingTaskMessages(this.squadId, event.taskId).length > 0) {
|
|
783
|
+
store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null });
|
|
784
|
+
store.appendMessage(this.squadId, event.taskId, {
|
|
785
|
+
ts: store.now(),
|
|
786
|
+
from: "system",
|
|
787
|
+
type: "status",
|
|
788
|
+
text: "Agent settled with pending durable messages; resuming the same task session",
|
|
789
|
+
});
|
|
790
|
+
if (this.running) void this.reconcile();
|
|
791
|
+
this.updateContext();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
643
795
|
const turnCount = event.data?.turnCount ?? 0;
|
|
644
796
|
const toolCallCount = event.data?.toolCallCount ?? 0;
|
|
645
|
-
|
|
646
|
-
// Tool use is strong evidence of work, but planning/review tasks can
|
|
647
|
-
// legitimately deliver a substantive report without calling a tool.
|
|
648
|
-
// Accept durable assistant output; the mandatory main-orchestrator
|
|
649
|
-
// review gate still decides whether the work satisfies the contract.
|
|
650
797
|
const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
|
|
651
798
|
.some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
|
|
652
799
|
const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
|
|
653
800
|
if (hadMeaningfulWork) {
|
|
654
801
|
this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
|
|
655
802
|
} else {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (!this.spawnRetries.has(retryKey)) {
|
|
661
|
-
this.spawnRetries.add(retryKey);
|
|
662
|
-
const stderr = event.data?.stderr || "";
|
|
663
|
-
const reason = turnCount === 0
|
|
664
|
-
? `exited with 0 turns (likely rate limit or API error)`
|
|
665
|
-
: `exited with ${turnCount} turns but no tool calls or substantive output`;
|
|
666
|
-
logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
|
|
667
|
-
store.updateTaskStatus(this.squadId, event.taskId, "pending");
|
|
668
|
-
store.appendMessage(this.squadId, event.taskId, {
|
|
669
|
-
ts: store.now(),
|
|
670
|
-
from: "system",
|
|
671
|
-
type: "status",
|
|
672
|
-
text: `Agent ${reason}. Retrying...`,
|
|
673
|
-
});
|
|
674
|
-
// Delay retry to let resources settle
|
|
675
|
-
setTimeout(() => {
|
|
676
|
-
if (this.running) this.scheduleReadyTasks();
|
|
677
|
-
}, 2000);
|
|
678
|
-
} else {
|
|
679
|
-
const stderr = event.data?.stderr || "";
|
|
680
|
-
this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} (retry exhausted). ${stderr}`);
|
|
681
|
-
}
|
|
682
|
-
this.updateContext();
|
|
803
|
+
this.handleUnexpectedAgentExit({
|
|
804
|
+
...event,
|
|
805
|
+
data: { ...event.data, unexpectedExit: true },
|
|
806
|
+
});
|
|
683
807
|
}
|
|
684
|
-
// Skip the updateContext() below — handled in the branches above
|
|
685
808
|
return;
|
|
686
809
|
}
|
|
687
810
|
|
|
@@ -1139,12 +1262,75 @@ export class Scheduler {
|
|
|
1139
1262
|
// External Actions
|
|
1140
1263
|
// =========================================================================
|
|
1141
1264
|
|
|
1142
|
-
/**
|
|
1265
|
+
/**
|
|
1266
|
+
* A reopened dependency invalidates every transitive descendant, including
|
|
1267
|
+
* descendants that had already completed. They retain history/output for
|
|
1268
|
+
* audit and durable-session continuation, but must rerun in dependency order.
|
|
1269
|
+
*/
|
|
1270
|
+
private async invalidateDescendants(reopenedTaskId: string): Promise<void> {
|
|
1271
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
1272
|
+
const byDependency = new Map<string, Task[]>();
|
|
1273
|
+
for (const task of tasks) {
|
|
1274
|
+
for (const dependency of task.depends) {
|
|
1275
|
+
const dependents = byDependency.get(dependency) ?? [];
|
|
1276
|
+
dependents.push(task);
|
|
1277
|
+
byDependency.set(dependency, dependents);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const queue = [...(byDependency.get(reopenedTaskId) ?? [])];
|
|
1282
|
+
const seen = new Set<string>();
|
|
1283
|
+
const kills: Promise<void>[] = [];
|
|
1284
|
+
while (queue.length > 0) {
|
|
1285
|
+
const descendant = queue.shift()!;
|
|
1286
|
+
if (seen.has(descendant.id)) continue;
|
|
1287
|
+
seen.add(descendant.id);
|
|
1288
|
+
queue.push(...(byDependency.get(descendant.id) ?? []));
|
|
1289
|
+
store.updateTaskStatus(this.squadId, descendant.id, "blocked", {
|
|
1290
|
+
completed: null,
|
|
1291
|
+
error: null,
|
|
1292
|
+
});
|
|
1293
|
+
store.appendMessage(this.squadId, descendant.id, {
|
|
1294
|
+
ts: store.now(),
|
|
1295
|
+
from: "system",
|
|
1296
|
+
type: "status",
|
|
1297
|
+
text: `Blocked — dependency ancestry reopened at ${reopenedTaskId}; prior result requires revalidation`,
|
|
1298
|
+
});
|
|
1299
|
+
if (this.pool.isRunning(descendant.id)) kills.push(this.pool.kill(descendant.id));
|
|
1300
|
+
}
|
|
1301
|
+
await Promise.all(kills);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private async reopenTaskForMessage(task: Task): Promise<void> {
|
|
1305
|
+
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
1306
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
1307
|
+
const dependenciesDone = task.depends.every(
|
|
1308
|
+
(depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
|
|
1309
|
+
);
|
|
1310
|
+
const nextStatus: TaskStatus = dependenciesDone ? "pending" : "blocked";
|
|
1311
|
+
store.updateTaskStatus(this.squadId, task.id, nextStatus, {
|
|
1312
|
+
error: null,
|
|
1313
|
+
completed: null,
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
const squad = store.loadSquad(this.squadId);
|
|
1317
|
+
if (squad && squad.status !== "running") {
|
|
1318
|
+
squad.status = "running";
|
|
1319
|
+
// Any prior acceptance/review applies to the pre-resume result. The
|
|
1320
|
+
// normal all-done path will require a fresh independent review.
|
|
1321
|
+
delete squad.review;
|
|
1322
|
+
store.saveSquad(squad);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/** Send a main-orchestrator request to one exact task and await its next reply. */
|
|
1143
1327
|
async sendHumanMessage(taskId: string, message: string, expectsReply = true): Promise<boolean> {
|
|
1144
1328
|
const task = store.loadTask(this.squadId, taskId);
|
|
1145
1329
|
if (!task) return false;
|
|
1146
1330
|
|
|
1147
|
-
|
|
1331
|
+
// Mailbox-first: a process/scheduler crash can occur at any later point
|
|
1332
|
+
// without losing or redirecting this message to another task of the role.
|
|
1333
|
+
const queued = store.queueTaskMessage(this.squadId, taskId, {
|
|
1148
1334
|
ts: store.now(),
|
|
1149
1335
|
from: "orchestrator",
|
|
1150
1336
|
type: "message",
|
|
@@ -1155,19 +1341,23 @@ export class Scheduler {
|
|
|
1155
1341
|
const request = expectsReply
|
|
1156
1342
|
? `[squad] Main orchestrator requests a direct response:\n${message}\n\nReply directly in your next assistant message. That complete message will be forwarded automatically to the main session.`
|
|
1157
1343
|
: `[squad] Main orchestrator message:\n${message}`;
|
|
1158
|
-
if (this.pool.isRunning(taskId)
|
|
1159
|
-
|
|
1344
|
+
if (this.pool.isRunning(taskId)) {
|
|
1345
|
+
if (await this.pool.steer(taskId, request)) {
|
|
1346
|
+
store.acknowledgeTaskMessages(this.squadId, taskId, [queued.id]);
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
// The process may still be completing its current run. Preserve
|
|
1350
|
+
// in_progress while it is live; agent_settled will observe the pending
|
|
1351
|
+
// mailbox and reopen the same session instead of marking done.
|
|
1352
|
+
if (this.pool.isRunning(taskId)) return false;
|
|
1160
1353
|
}
|
|
1161
1354
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
text: expectsReply ? `${message}\n\n[Direct response required by main orchestrator]` : message,
|
|
1169
|
-
expectsReply,
|
|
1170
|
-
});
|
|
1355
|
+
await this.reopenTaskForMessage(task);
|
|
1356
|
+
if (this.running) {
|
|
1357
|
+
await this.reconcile();
|
|
1358
|
+
return store.loadPendingTaskMessages(this.squadId, taskId)
|
|
1359
|
+
.every((entry) => entry.id !== queued.id);
|
|
1360
|
+
}
|
|
1171
1361
|
return false;
|
|
1172
1362
|
}
|
|
1173
1363
|
|