pi-squad 0.1.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/LICENSE +21 -0
- package/README.md +244 -0
- package/package.json +30 -0
- package/src/agent-pool.ts +445 -0
- package/src/agents/_defaults/architect.json +9 -0
- package/src/agents/_defaults/backend.json +9 -0
- package/src/agents/_defaults/debugger.json +9 -0
- package/src/agents/_defaults/devops.json +9 -0
- package/src/agents/_defaults/docs.json +9 -0
- package/src/agents/_defaults/frontend.json +9 -0
- package/src/agents/_defaults/fullstack.json +9 -0
- package/src/agents/_defaults/planner.json +9 -0
- package/src/agents/_defaults/qa.json +9 -0
- package/src/agents/_defaults/researcher.json +9 -0
- package/src/agents/_defaults/security.json +9 -0
- package/src/index.ts +1121 -0
- package/src/monitor.ts +204 -0
- package/src/panel/message-view.ts +232 -0
- package/src/panel/squad-panel.ts +383 -0
- package/src/panel/task-list.ts +264 -0
- package/src/planner.ts +275 -0
- package/src/protocol.ts +265 -0
- package/src/router.ts +207 -0
- package/src/scheduler.ts +732 -0
- package/src/skills/collaboration/SKILL.md +39 -0
- package/src/skills/squad-protocol/SKILL.md +65 -0
- package/src/skills/verification/SKILL.md +64 -0
- package/src/store.ts +458 -0
- package/src/supervisor.ts +143 -0
- package/src/types.ts +210 -0
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler.ts — Dependency DAG resolution, concurrency control, task lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* The scheduler is the core engine. It:
|
|
5
|
+
* - Resolves which tasks are ready (all deps done)
|
|
6
|
+
* - Spawns agents up to maxConcurrency
|
|
7
|
+
* - Auto-unblocks dependents when tasks complete
|
|
8
|
+
* - Kills agents when tasks become re-blocked
|
|
9
|
+
* - Detects squad completion
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AgentDef, Squad, SquadConfig, Task, TaskMessage, TaskStatus } from "./types.js";
|
|
13
|
+
import { AgentPool, type AgentEvent } from "./agent-pool.js";
|
|
14
|
+
import { Monitor } from "./monitor.js";
|
|
15
|
+
import { Router } from "./router.js";
|
|
16
|
+
import * as store from "./store.js";
|
|
17
|
+
import { buildAgentSystemPrompt } from "./protocol.js";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Types
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
export type SchedulerEventType =
|
|
24
|
+
| "task_started"
|
|
25
|
+
| "task_completed"
|
|
26
|
+
| "task_failed"
|
|
27
|
+
| "task_blocked"
|
|
28
|
+
| "task_unblocked"
|
|
29
|
+
| "squad_completed"
|
|
30
|
+
| "squad_failed"
|
|
31
|
+
| "escalation"
|
|
32
|
+
| "activity";
|
|
33
|
+
|
|
34
|
+
export interface SchedulerEvent {
|
|
35
|
+
type: SchedulerEventType;
|
|
36
|
+
squadId: string;
|
|
37
|
+
taskId?: string;
|
|
38
|
+
agentName?: string;
|
|
39
|
+
message?: string;
|
|
40
|
+
data?: any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SchedulerEventListener = (event: SchedulerEvent) => void;
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Scheduler
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
export class Scheduler {
|
|
50
|
+
private squadId: string;
|
|
51
|
+
private pool: AgentPool;
|
|
52
|
+
private monitor: Monitor;
|
|
53
|
+
private router: Router;
|
|
54
|
+
private listeners: SchedulerEventListener[] = [];
|
|
55
|
+
private skillPaths: string[] = [];
|
|
56
|
+
private running = false;
|
|
57
|
+
|
|
58
|
+
/** Get the project cwd for this squad (from squad.json) */
|
|
59
|
+
getProjectCwd(): string | undefined {
|
|
60
|
+
return store.loadSquad(this.squadId)?.cwd;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
constructor(squadId: string, skillPaths: string[]) {
|
|
64
|
+
this.squadId = squadId;
|
|
65
|
+
this.skillPaths = skillPaths;
|
|
66
|
+
this.pool = new AgentPool();
|
|
67
|
+
this.monitor = new Monitor(this.pool, squadId);
|
|
68
|
+
this.router = new Router(this.pool, squadId);
|
|
69
|
+
|
|
70
|
+
// Wire up agent events
|
|
71
|
+
this.pool.onEvent((event) => this.handleAgentEvent(event));
|
|
72
|
+
|
|
73
|
+
// Wire up monitor events
|
|
74
|
+
this.monitor.onAction((action) => {
|
|
75
|
+
if (action.type === "steer") {
|
|
76
|
+
this.pool.steer(action.taskId, action.message);
|
|
77
|
+
} else if (action.type === "abort") {
|
|
78
|
+
this.handleTaskFailed(action.taskId, action.reason);
|
|
79
|
+
} else if (action.type === "escalate") {
|
|
80
|
+
this.emit({
|
|
81
|
+
type: "escalation",
|
|
82
|
+
squadId: this.squadId,
|
|
83
|
+
taskId: action.taskId,
|
|
84
|
+
message: action.reason,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Wire up router events
|
|
90
|
+
this.router.onEscalation((taskId, agentName, message) => {
|
|
91
|
+
this.emit({
|
|
92
|
+
type: "escalation",
|
|
93
|
+
squadId: this.squadId,
|
|
94
|
+
taskId,
|
|
95
|
+
agentName,
|
|
96
|
+
message,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Subscribe to scheduler events */
|
|
102
|
+
onEvent(listener: SchedulerEventListener): () => void {
|
|
103
|
+
this.listeners.push(listener);
|
|
104
|
+
return () => {
|
|
105
|
+
const idx = this.listeners.indexOf(listener);
|
|
106
|
+
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private emit(event: SchedulerEvent): void {
|
|
111
|
+
for (const listener of this.listeners) {
|
|
112
|
+
try {
|
|
113
|
+
listener(event);
|
|
114
|
+
} catch {
|
|
115
|
+
/* ignore */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get references for external use */
|
|
121
|
+
getPool(): AgentPool {
|
|
122
|
+
return this.pool;
|
|
123
|
+
}
|
|
124
|
+
getRouter(): Router {
|
|
125
|
+
return this.router;
|
|
126
|
+
}
|
|
127
|
+
getMonitor(): Monitor {
|
|
128
|
+
return this.monitor;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// =========================================================================
|
|
132
|
+
// Lifecycle
|
|
133
|
+
// =========================================================================
|
|
134
|
+
|
|
135
|
+
/** Start the scheduler — begins scheduling ready tasks */
|
|
136
|
+
async start(): Promise<void> {
|
|
137
|
+
this.running = true;
|
|
138
|
+
this.monitor.start();
|
|
139
|
+
await this.scheduleReadyTasks();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Stop the scheduler — kills all agents, saves state */
|
|
143
|
+
async stop(): Promise<void> {
|
|
144
|
+
this.running = false;
|
|
145
|
+
this.monitor.stop();
|
|
146
|
+
|
|
147
|
+
// Suspend in-progress tasks
|
|
148
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
149
|
+
for (const task of tasks) {
|
|
150
|
+
if (task.status === "in_progress") {
|
|
151
|
+
store.updateTaskStatus(this.squadId, task.id, "suspended");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await this.pool.killAll();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Resume from suspended state */
|
|
159
|
+
async resume(): Promise<void> {
|
|
160
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
161
|
+
for (const task of tasks) {
|
|
162
|
+
if (task.status === "suspended") {
|
|
163
|
+
store.updateTaskStatus(this.squadId, task.id, "pending");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const squad = store.loadSquad(this.squadId);
|
|
168
|
+
if (squad && squad.status === "paused") {
|
|
169
|
+
squad.status = "running";
|
|
170
|
+
store.saveSquad(squad);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await this.start();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// =========================================================================
|
|
177
|
+
// Task Scheduling
|
|
178
|
+
// =========================================================================
|
|
179
|
+
|
|
180
|
+
/** Find and spawn ready tasks up to concurrency limit */
|
|
181
|
+
private async scheduleReadyTasks(): Promise<void> {
|
|
182
|
+
if (!this.running) {
|
|
183
|
+
console.error("[squad-scheduler] scheduleReadyTasks: not running, skipping");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const squad = store.loadSquad(this.squadId);
|
|
188
|
+
if (!squad || squad.status !== "running") {
|
|
189
|
+
console.error(`[squad-scheduler] scheduleReadyTasks: squad status=${squad?.status}, skipping`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
194
|
+
const runningCount = this.pool.getRunningAgents().length;
|
|
195
|
+
const available = squad.config.maxConcurrency - runningCount;
|
|
196
|
+
|
|
197
|
+
console.error(`[squad-scheduler] scheduleReadyTasks: ${tasks.length} tasks, ${runningCount} running, ${available} slots`);
|
|
198
|
+
|
|
199
|
+
if (available <= 0) {
|
|
200
|
+
console.error("[squad-scheduler] scheduleReadyTasks: no available slots");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const ready = this.getReadyTasks(tasks);
|
|
205
|
+
console.error(`[squad-scheduler] scheduleReadyTasks: ${ready.length} ready tasks: ${ready.map(t => t.id).join(", ")}`);
|
|
206
|
+
const toSpawn = ready.slice(0, available);
|
|
207
|
+
|
|
208
|
+
for (const task of toSpawn) {
|
|
209
|
+
try {
|
|
210
|
+
await this.spawnAgentForTask(task, squad);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error(`[squad-scheduler] Failed to spawn ${task.id}: ${(error as Error).message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check if squad is complete
|
|
217
|
+
this.checkSquadCompletion(tasks, squad);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Get tasks that are ready to execute (pending + all deps done) */
|
|
221
|
+
private getReadyTasks(tasks: Task[]): Task[] {
|
|
222
|
+
return tasks.filter((task) => {
|
|
223
|
+
if (task.status !== "pending") return false;
|
|
224
|
+
return task.depends.every((depId) => {
|
|
225
|
+
const dep = tasks.find((t) => t.id === depId);
|
|
226
|
+
return dep?.status === "done";
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Spawn an agent for a task */
|
|
232
|
+
private async spawnAgentForTask(task: Task, squad: Squad): Promise<void> {
|
|
233
|
+
const agentDef = store.loadAgentDef(task.agent, squad.cwd);
|
|
234
|
+
if (!agentDef) {
|
|
235
|
+
this.handleTaskFailed(task.id, `Agent definition not found: ${task.agent}`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Apply squad-level model override
|
|
240
|
+
const squadAgentEntry = squad.agents[task.agent];
|
|
241
|
+
if (squadAgentEntry?.model) {
|
|
242
|
+
agentDef.model = squadAgentEntry.model;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Build modified files map from all running agents
|
|
246
|
+
const modifiedFiles: Record<string, string[]> = {};
|
|
247
|
+
for (const name of this.pool.getRunningAgents()) {
|
|
248
|
+
const runningTaskId = this.pool.getTaskIdForAgent(name);
|
|
249
|
+
if (runningTaskId) {
|
|
250
|
+
const activity = this.pool.getActivity(runningTaskId);
|
|
251
|
+
if (activity) {
|
|
252
|
+
modifiedFiles[name] = Array.from(activity.modifiedFiles);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Update task status
|
|
258
|
+
store.updateTaskStatus(this.squadId, task.id, "in_progress", {
|
|
259
|
+
started: store.now(),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
store.appendMessage(this.squadId, task.id, {
|
|
263
|
+
ts: store.now(),
|
|
264
|
+
from: "system",
|
|
265
|
+
type: "status",
|
|
266
|
+
text: `Agent ${task.agent} starting work`,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
this.emit({
|
|
270
|
+
type: "task_started",
|
|
271
|
+
squadId: this.squadId,
|
|
272
|
+
taskId: task.id,
|
|
273
|
+
agentName: task.agent,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await this.pool.spawn({
|
|
278
|
+
taskId: task.id,
|
|
279
|
+
agentDef,
|
|
280
|
+
protocolOptions: {
|
|
281
|
+
squadId: this.squadId,
|
|
282
|
+
squad,
|
|
283
|
+
task,
|
|
284
|
+
agentDef,
|
|
285
|
+
modifiedFiles,
|
|
286
|
+
queuedMessages: this.pool.consumeQueue(task.agent),
|
|
287
|
+
},
|
|
288
|
+
cwd: squad.cwd,
|
|
289
|
+
skillPaths: this.skillPaths,
|
|
290
|
+
});
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.handleTaskFailed(task.id, (error as Error).message);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.updateContext();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// =========================================================================
|
|
299
|
+
// Event Handlers
|
|
300
|
+
// =========================================================================
|
|
301
|
+
|
|
302
|
+
private handleAgentEvent(event: AgentEvent): void {
|
|
303
|
+
switch (event.type) {
|
|
304
|
+
case "message_end": {
|
|
305
|
+
const msg = event.data;
|
|
306
|
+
if (msg?.role === "assistant") {
|
|
307
|
+
// Extract text from assistant message
|
|
308
|
+
const text = this.extractAssistantText(msg);
|
|
309
|
+
if (text) {
|
|
310
|
+
// Route @mentions
|
|
311
|
+
this.router.processMessage(event.taskId, event.agentName, text);
|
|
312
|
+
|
|
313
|
+
// Log message
|
|
314
|
+
store.appendMessage(this.squadId, event.taskId, {
|
|
315
|
+
ts: store.now(),
|
|
316
|
+
from: event.agentName,
|
|
317
|
+
type: "text",
|
|
318
|
+
text: text.slice(0, 2000),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Track usage
|
|
323
|
+
if (msg.usage) {
|
|
324
|
+
store.updateTaskUsage(this.squadId, event.taskId, {
|
|
325
|
+
inputTokens: msg.usage.input || 0,
|
|
326
|
+
outputTokens: msg.usage.output || 0,
|
|
327
|
+
cost: msg.usage.cost?.total || 0,
|
|
328
|
+
turns: 1,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
case "tool_execution_start": {
|
|
336
|
+
const data = event.data;
|
|
337
|
+
store.appendMessage(this.squadId, event.taskId, {
|
|
338
|
+
ts: store.now(),
|
|
339
|
+
from: event.agentName,
|
|
340
|
+
type: "tool",
|
|
341
|
+
text: data.toolName || "unknown",
|
|
342
|
+
name: data.toolName,
|
|
343
|
+
args: data.args,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
this.emit({
|
|
347
|
+
type: "activity",
|
|
348
|
+
squadId: this.squadId,
|
|
349
|
+
taskId: event.taskId,
|
|
350
|
+
agentName: event.agentName,
|
|
351
|
+
message: `→ ${data.toolName}`,
|
|
352
|
+
data,
|
|
353
|
+
});
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case "tool_execution_end": {
|
|
358
|
+
// Track file modifications
|
|
359
|
+
const data = event.data;
|
|
360
|
+
if (data.toolName === "write" || data.toolName === "edit") {
|
|
361
|
+
const filePath = data.args?.path || data.args?.file_path;
|
|
362
|
+
if (filePath) {
|
|
363
|
+
this.updateModifiedFiles(event.agentName, filePath);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case "agent_end": {
|
|
370
|
+
const exitCode = event.data?.exitCode ?? 1;
|
|
371
|
+
const activity = this.pool.getActivity(event.taskId);
|
|
372
|
+
// Treat as success if agent did meaningful work (had turns),
|
|
373
|
+
// even if exit code is non-zero (common with EPIPE on parent exit)
|
|
374
|
+
const hadMeaningfulWork = activity && activity.turnCount > 0;
|
|
375
|
+
if (exitCode === 0 || hadMeaningfulWork) {
|
|
376
|
+
this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
|
|
377
|
+
} else {
|
|
378
|
+
const stderr = event.data?.stderr || "";
|
|
379
|
+
this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode}. ${stderr.slice(0, 500)}`);
|
|
380
|
+
this.updateContext();
|
|
381
|
+
}
|
|
382
|
+
// Skip the updateContext() below — handled in the branches above
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
case "error": {
|
|
387
|
+
const errorMsg = event.data?.message || "Unknown error";
|
|
388
|
+
store.appendMessage(this.squadId, event.taskId, {
|
|
389
|
+
ts: store.now(),
|
|
390
|
+
from: "system",
|
|
391
|
+
type: "error",
|
|
392
|
+
text: errorMsg,
|
|
393
|
+
});
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
this.updateContext();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async handleTaskCompleted(taskId: string): Promise<void> {
|
|
402
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
403
|
+
if (!task) return;
|
|
404
|
+
|
|
405
|
+
// Guard against double-completion
|
|
406
|
+
if (task.status === "done") return;
|
|
407
|
+
|
|
408
|
+
// Extract output from last messages
|
|
409
|
+
const messages = store.loadMessages(this.squadId, taskId);
|
|
410
|
+
const lastAgentMessages = messages
|
|
411
|
+
.filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"))
|
|
412
|
+
.slice(-3);
|
|
413
|
+
const output = lastAgentMessages.map((m) => m.text).join("\n");
|
|
414
|
+
|
|
415
|
+
store.updateTaskStatus(this.squadId, taskId, "done", {
|
|
416
|
+
output: output || "Task completed",
|
|
417
|
+
completed: store.now(),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
store.appendMessage(this.squadId, taskId, {
|
|
421
|
+
ts: store.now(),
|
|
422
|
+
from: "system",
|
|
423
|
+
type: "done",
|
|
424
|
+
text: "Task completed",
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
this.emit({
|
|
428
|
+
type: "task_completed",
|
|
429
|
+
squadId: this.squadId,
|
|
430
|
+
taskId,
|
|
431
|
+
agentName: task.agent,
|
|
432
|
+
message: output,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// Auto-unblock dependents
|
|
436
|
+
console.error(`[squad-scheduler] handleTaskCompleted: ${taskId} done, auto-unblocking dependents`);
|
|
437
|
+
this.autoUnblock(taskId);
|
|
438
|
+
|
|
439
|
+
// Schedule next ready tasks (may spawn new agents)
|
|
440
|
+
console.error(`[squad-scheduler] handleTaskCompleted: scheduling next ready tasks`);
|
|
441
|
+
await this.scheduleReadyTasks();
|
|
442
|
+
|
|
443
|
+
// Re-check squad completion with fresh data AFTER scheduling
|
|
444
|
+
const freshTasks = store.loadAllTasks(this.squadId);
|
|
445
|
+
const freshSquad = store.loadSquad(this.squadId);
|
|
446
|
+
console.error(`[squad-scheduler] handleTaskCompleted: final check — tasks: ${freshTasks.map(t => `${t.id}:${t.status}`).join(", ")}`);
|
|
447
|
+
if (freshSquad) {
|
|
448
|
+
this.checkSquadCompletion(freshTasks, freshSquad);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private handleTaskFailed(taskId: string, error: string): void {
|
|
453
|
+
store.updateTaskStatus(this.squadId, taskId, "failed", {
|
|
454
|
+
error,
|
|
455
|
+
completed: store.now(),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
store.appendMessage(this.squadId, taskId, {
|
|
459
|
+
ts: store.now(),
|
|
460
|
+
from: "system",
|
|
461
|
+
type: "error",
|
|
462
|
+
text: error,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
this.emit({
|
|
466
|
+
type: "task_failed",
|
|
467
|
+
squadId: this.squadId,
|
|
468
|
+
taskId,
|
|
469
|
+
message: error,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
this.pool.kill(taskId);
|
|
473
|
+
this.updateContext();
|
|
474
|
+
|
|
475
|
+
// Check if squad should be marked failed
|
|
476
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
477
|
+
const squad = store.loadSquad(this.squadId);
|
|
478
|
+
this.checkSquadCompletion(tasks, squad!);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Auto-unblock tasks that depend on the completed task */
|
|
482
|
+
private autoUnblock(completedTaskId: string): void {
|
|
483
|
+
const squad = store.loadSquad(this.squadId);
|
|
484
|
+
if (!squad?.config.autoUnblock) return;
|
|
485
|
+
|
|
486
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
487
|
+
|
|
488
|
+
for (const task of tasks) {
|
|
489
|
+
if (task.status !== "blocked" && task.status !== "pending") continue;
|
|
490
|
+
if (!task.depends.includes(completedTaskId)) continue;
|
|
491
|
+
|
|
492
|
+
const allDepsDone = task.depends.every((depId) => {
|
|
493
|
+
const dep = tasks.find((t) => t.id === depId);
|
|
494
|
+
return dep?.status === "done";
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
if (allDepsDone) {
|
|
498
|
+
store.updateTaskStatus(this.squadId, task.id, "pending");
|
|
499
|
+
|
|
500
|
+
store.appendMessage(this.squadId, task.id, {
|
|
501
|
+
ts: store.now(),
|
|
502
|
+
from: "system",
|
|
503
|
+
type: "status",
|
|
504
|
+
text: `Unblocked — all dependencies resolved`,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
this.emit({
|
|
508
|
+
type: "task_unblocked",
|
|
509
|
+
squadId: this.squadId,
|
|
510
|
+
taskId: task.id,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Kill agents working on tasks that became re-blocked */
|
|
517
|
+
killBlockedAgents(): void {
|
|
518
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
519
|
+
for (const task of tasks) {
|
|
520
|
+
if (task.status === "blocked" && this.pool.isRunning(task.id)) {
|
|
521
|
+
this.pool.steer(
|
|
522
|
+
task.id,
|
|
523
|
+
"[squad] Your task has been blocked because a dependency was reopened. Stopping your work.",
|
|
524
|
+
);
|
|
525
|
+
this.pool.kill(task.id);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// =========================================================================
|
|
531
|
+
// Squad Completion
|
|
532
|
+
// =========================================================================
|
|
533
|
+
|
|
534
|
+
private checkSquadCompletion(tasks: Task[], squad: Squad): void {
|
|
535
|
+
if (tasks.length === 0) return;
|
|
536
|
+
|
|
537
|
+
const allDone = tasks.every((t) => t.status === "done");
|
|
538
|
+
const anyFailed = tasks.some((t) => t.status === "failed");
|
|
539
|
+
const anyInProgress = tasks.some(
|
|
540
|
+
(t) => t.status === "in_progress" || t.status === "pending",
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
if (allDone) {
|
|
544
|
+
squad.status = "done";
|
|
545
|
+
store.saveSquad(squad);
|
|
546
|
+
this.emit({ type: "squad_completed", squadId: this.squadId });
|
|
547
|
+
} else if (anyFailed && !anyInProgress) {
|
|
548
|
+
// All remaining tasks are blocked/failed with no way forward
|
|
549
|
+
const blockedCount = tasks.filter((t) => t.status === "blocked").length;
|
|
550
|
+
const failedCount = tasks.filter((t) => t.status === "failed").length;
|
|
551
|
+
if (blockedCount + failedCount === tasks.filter((t) => t.status !== "done").length) {
|
|
552
|
+
squad.status = "failed";
|
|
553
|
+
store.saveSquad(squad);
|
|
554
|
+
this.emit({ type: "squad_failed", squadId: this.squadId });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// =========================================================================
|
|
560
|
+
// Context Updates
|
|
561
|
+
// =========================================================================
|
|
562
|
+
|
|
563
|
+
private updateModifiedFiles(agentName: string, filePath: string): void {
|
|
564
|
+
// Context will pick this up from AgentActivity
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Rebuild and save context.json */
|
|
568
|
+
updateContext(): void {
|
|
569
|
+
const squad = store.loadSquad(this.squadId);
|
|
570
|
+
if (!squad) return;
|
|
571
|
+
|
|
572
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
573
|
+
const startTime = new Date(squad.created).getTime();
|
|
574
|
+
const elapsed = formatElapsed(Date.now() - startTime);
|
|
575
|
+
|
|
576
|
+
// Build agent states
|
|
577
|
+
const agentStates: Record<string, any> = {};
|
|
578
|
+
for (const [name] of Object.entries(squad.agents)) {
|
|
579
|
+
const agentDef = store.loadAgentDef(name, squad.cwd);
|
|
580
|
+
const runningTaskId = this.pool.getTaskIdForAgent(name);
|
|
581
|
+
agentStates[name] = {
|
|
582
|
+
role: agentDef?.role || "Unknown",
|
|
583
|
+
status: runningTaskId ? "working" : "idle",
|
|
584
|
+
task: runningTaskId || null,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Build task states
|
|
589
|
+
const taskStates: Record<string, any> = {};
|
|
590
|
+
for (const task of tasks) {
|
|
591
|
+
taskStates[task.id] = {
|
|
592
|
+
status: task.status,
|
|
593
|
+
agent: task.agent,
|
|
594
|
+
title: task.title,
|
|
595
|
+
...(task.output ? { output: task.output.slice(0, 500) } : {}),
|
|
596
|
+
...(task.status === "blocked"
|
|
597
|
+
? {
|
|
598
|
+
blockedBy: task.depends.filter((d) => {
|
|
599
|
+
const dep = tasks.find((t) => t.id === d);
|
|
600
|
+
return dep && dep.status !== "done";
|
|
601
|
+
}),
|
|
602
|
+
}
|
|
603
|
+
: {}),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Build costs
|
|
608
|
+
const costs = { total: 0, byAgent: {} as Record<string, number> };
|
|
609
|
+
for (const task of tasks) {
|
|
610
|
+
costs.total += task.usage.cost;
|
|
611
|
+
costs.byAgent[task.agent] = (costs.byAgent[task.agent] || 0) + task.usage.cost;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Build modified files from activities
|
|
615
|
+
const modifiedFiles: Record<string, string[]> = {};
|
|
616
|
+
for (const agentName of this.pool.getRunningAgents()) {
|
|
617
|
+
const taskId = this.pool.getTaskIdForAgent(agentName);
|
|
618
|
+
if (taskId) {
|
|
619
|
+
const activity = this.pool.getActivity(taskId);
|
|
620
|
+
if (activity) {
|
|
621
|
+
modifiedFiles[agentName] = Array.from(activity.modifiedFiles);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Recent activity (last 20)
|
|
627
|
+
const recentActivity: any[] = [];
|
|
628
|
+
for (const task of tasks) {
|
|
629
|
+
const messages = store.loadMessages(this.squadId, task.id);
|
|
630
|
+
for (const msg of messages.slice(-5)) {
|
|
631
|
+
recentActivity.push({
|
|
632
|
+
ts: msg.ts,
|
|
633
|
+
agent: msg.from,
|
|
634
|
+
action:
|
|
635
|
+
msg.type === "tool"
|
|
636
|
+
? `→ ${msg.name} ${msg.args?.path || msg.args?.command || ""}`.trim()
|
|
637
|
+
: msg.text.slice(0, 80),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
recentActivity.sort((a, b) => b.ts.localeCompare(a.ts));
|
|
642
|
+
|
|
643
|
+
store.saveContext(this.squadId, {
|
|
644
|
+
goal: squad.goal,
|
|
645
|
+
status: squad.status,
|
|
646
|
+
elapsed,
|
|
647
|
+
costs,
|
|
648
|
+
agents: agentStates,
|
|
649
|
+
tasks: taskStates,
|
|
650
|
+
recentActivity: recentActivity.slice(0, 20),
|
|
651
|
+
modifiedFiles,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// =========================================================================
|
|
656
|
+
// External Actions
|
|
657
|
+
// =========================================================================
|
|
658
|
+
|
|
659
|
+
/** Send a human message to a task's agent */
|
|
660
|
+
async sendHumanMessage(taskId: string, message: string): Promise<boolean> {
|
|
661
|
+
store.appendMessage(this.squadId, taskId, {
|
|
662
|
+
ts: store.now(),
|
|
663
|
+
from: "human",
|
|
664
|
+
type: "message",
|
|
665
|
+
text: message,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
if (this.pool.isRunning(taskId)) {
|
|
669
|
+
return this.pool.steer(taskId, `[squad] Human: ${message}`);
|
|
670
|
+
}
|
|
671
|
+
// Queue for when agent spawns
|
|
672
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
673
|
+
if (task) {
|
|
674
|
+
this.pool.queueMessage(task.agent, {
|
|
675
|
+
ts: store.now(),
|
|
676
|
+
from: "human",
|
|
677
|
+
type: "message",
|
|
678
|
+
text: message,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Pause a running task */
|
|
685
|
+
async pauseTask(taskId: string): Promise<void> {
|
|
686
|
+
if (this.pool.isRunning(taskId)) {
|
|
687
|
+
await this.pool.steer(taskId, "[squad] Task paused by user. Summarize your current state.");
|
|
688
|
+
// Give agent a moment to respond, then kill
|
|
689
|
+
setTimeout(() => this.pool.kill(taskId), 3000);
|
|
690
|
+
}
|
|
691
|
+
store.updateTaskStatus(this.squadId, taskId, "suspended");
|
|
692
|
+
this.updateContext();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Resume a suspended task */
|
|
696
|
+
async resumeTask(taskId: string): Promise<void> {
|
|
697
|
+
store.updateTaskStatus(this.squadId, taskId, "pending");
|
|
698
|
+
await this.scheduleReadyTasks();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Cancel a task */
|
|
702
|
+
async cancelTask(taskId: string): Promise<void> {
|
|
703
|
+
if (this.pool.isRunning(taskId)) {
|
|
704
|
+
await this.pool.kill(taskId);
|
|
705
|
+
}
|
|
706
|
+
store.updateTaskStatus(this.squadId, taskId, "failed", {
|
|
707
|
+
error: "Cancelled by user",
|
|
708
|
+
});
|
|
709
|
+
this.updateContext();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// =========================================================================
|
|
713
|
+
// Helpers
|
|
714
|
+
// =========================================================================
|
|
715
|
+
|
|
716
|
+
private extractAssistantText(msg: any): string | null {
|
|
717
|
+
if (!msg.content) return null;
|
|
718
|
+
const textParts = msg.content
|
|
719
|
+
.filter((p: any) => p.type === "text")
|
|
720
|
+
.map((p: any) => p.text);
|
|
721
|
+
return textParts.length > 0 ? textParts.join("\n") : null;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function formatElapsed(ms: number): string {
|
|
726
|
+
const seconds = Math.floor(ms / 1000);
|
|
727
|
+
const minutes = Math.floor(seconds / 60);
|
|
728
|
+
const hours = Math.floor(minutes / 60);
|
|
729
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
730
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
731
|
+
return `${seconds}s`;
|
|
732
|
+
}
|