pi-squad 0.14.0 → 0.15.1
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 +12 -5
- package/package.json +4 -1
- package/src/advisor.ts +9 -18
- package/src/agent-pool.ts +3 -2
- package/src/index.ts +25 -18
- package/src/monitor.ts +35 -14
- package/src/planner.ts +2 -2
- package/src/protocol.ts +6 -9
- package/src/report.ts +20 -0
- package/src/router.ts +1 -1
- package/src/scheduler.ts +141 -24
- package/src/store.ts +3 -1
- package/src/supervisor.ts +3 -4
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -41,9 +41,13 @@ The planner agent reads your codebase and creates a task breakdown automatically
|
|
|
41
41
|
2. A **live widget** appears above the editor showing task progress
|
|
42
42
|
3. **Specialist agents** spawn as separate pi processes, working in parallel where dependencies allow
|
|
43
43
|
4. QA agents can trigger **automatic rework loops** when they find bugs
|
|
44
|
-
5. On completion, pi receives a summary with each task's output
|
|
44
|
+
5. On completion, pi receives a summary with each task's **complete, untruncated output**
|
|
45
45
|
6. Multiple squads can run concurrently across different projects
|
|
46
46
|
|
|
47
|
+
### No-Truncation Contract
|
|
48
|
+
|
|
49
|
+
Task messages, task outputs, dependency/rework handoffs, QA feedback, advisor handoffs, completion reports, failure diagnostics, and planner errors are persisted and forwarded in full. There is no character or task-count limit on report data. TUI widgets may show width/height-limited **views** to fit the terminal, but the underlying data and agent/main-session handoffs remain complete.
|
|
50
|
+
|
|
47
51
|
## Features
|
|
48
52
|
|
|
49
53
|
### Dependency-Aware Scheduling
|
|
@@ -183,7 +187,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
|
|
|
183
187
|
| `squad` | Start a squad with goal + optional tasks/config |
|
|
184
188
|
| `squad_status` | Check progress, costs, task states |
|
|
185
189
|
| `squad_message` | Send message to a running agent |
|
|
186
|
-
| `squad_modify` | Add/cancel/pause/resume tasks or squads |
|
|
190
|
+
| `squad_modify` | Add/cancel/complete/pause/resume tasks or squads |
|
|
187
191
|
|
|
188
192
|
The main agent sees available agents in its system prompt and squad state when a squad is active.
|
|
189
193
|
|
|
@@ -280,18 +284,21 @@ Agents must complete at least 1 LLM turn AND make at least 1 tool call to be mar
|
|
|
280
284
|
### Session Resilience
|
|
281
285
|
|
|
282
286
|
- In-progress tasks are **suspended** on session crash, **resumed** on next startup
|
|
287
|
+
- Failure is never terminal: `resume` recovers failed squads (failed tasks reset to pending), `complete_task` marks recovered work done and schedules dependents, and a 60s reconcile loop re-derives scheduling from persisted state so out-of-band store edits can't strand ready tasks
|
|
283
288
|
- Squads are fully reconstructable from JSON files on disk
|
|
284
289
|
- Spawn failures are retried once with a 2-second delay
|
|
285
290
|
- All errors logged to `~/.pi/squad/debug.log` (always for errors, `PI_SQUAD_DEBUG=1` for verbose)
|
|
286
291
|
|
|
287
292
|
### Health Monitoring
|
|
288
293
|
|
|
294
|
+
The monitor never kills or blocks work on its own — its strongest action is notifying the main Pi session so you (or the main agent) can decide.
|
|
295
|
+
|
|
289
296
|
| Check | Threshold | Action |
|
|
290
297
|
|---|---|---|
|
|
291
298
|
| Idle warning | 3 minutes no output | Steer agent with nudge |
|
|
292
|
-
| Stuck detection | 5 minutes no output |
|
|
299
|
+
| Stuck detection | 5 minutes no output | Steer, then escalate to main session |
|
|
293
300
|
| Loop detection | Same tool call 5x | Steer with warning |
|
|
294
|
-
|
|
|
301
|
+
| Long-running check-in | Every 30 minutes total (`PI_SQUAD_CEILING_MS`) | Notify main session — work continues |
|
|
295
302
|
|
|
296
303
|
## Data Layout
|
|
297
304
|
|
|
@@ -320,7 +327,7 @@ src/
|
|
|
320
327
|
├── agent-pool.ts — pi RPC process management, activity tracking
|
|
321
328
|
├── protocol.ts — system prompt builder (chain context, sibling awareness, knowledge)
|
|
322
329
|
├── router.ts — @mention parsing, cross-agent messaging
|
|
323
|
-
├── monitor.ts — health checks (idle, stuck, loop,
|
|
330
|
+
├── monitor.ts — health checks (idle, stuck, loop, long-run notify)
|
|
324
331
|
├── planner.ts — one-shot goal decomposition via LLM
|
|
325
332
|
├── logger.ts — file-based logging (never writes to stderr)
|
|
326
333
|
├── panel/ — TUI overlay panel and widget
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-squad",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
"publishConfig": {
|
|
49
49
|
"registry": "https://registry.npmjs.org/",
|
|
50
50
|
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^26.1.1"
|
|
51
54
|
}
|
|
52
55
|
}
|
package/src/advisor.ts
CHANGED
|
@@ -78,18 +78,10 @@ Output format:
|
|
|
78
78
|
|
|
79
79
|
Keep it short. The agent reads your advice and immediately acts on it.`;
|
|
80
80
|
|
|
81
|
-
const MAX_MSG_CHARS = 400;
|
|
82
|
-
const MAX_MESSAGES = 12;
|
|
83
|
-
const MAX_TOOL_CALLS = 10;
|
|
84
|
-
|
|
85
|
-
function clamp(text: string, max: number): string {
|
|
86
|
-
const t = text.trim();
|
|
87
|
-
return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
81
|
/**
|
|
91
82
|
* Build the user-message digest sent to the advisor model.
|
|
92
|
-
*
|
|
83
|
+
* Preserve complete descriptions, messages, and tool history: advisor handoffs
|
|
84
|
+
* follow the same no-truncation contract as task and completion reports.
|
|
93
85
|
*/
|
|
94
86
|
export function buildAdvisorConsultText(input: AdvisorConsultInput): string {
|
|
95
87
|
const lines: string[] = [];
|
|
@@ -102,22 +94,21 @@ export function buildAdvisorConsultText(input: AdvisorConsultInput): string {
|
|
|
102
94
|
lines.push(`Progress: ${input.turnCount} turns, ~${Math.round(input.elapsedMinutes)} min elapsed`);
|
|
103
95
|
lines.push("");
|
|
104
96
|
lines.push(`## Task Description`);
|
|
105
|
-
lines.push(
|
|
97
|
+
lines.push((input.taskDescription || "(no description)").trim());
|
|
106
98
|
|
|
107
99
|
if (input.recentToolCalls.length > 0) {
|
|
108
100
|
lines.push("");
|
|
109
101
|
lines.push(`## Recent Tool Activity (newest last)`);
|
|
110
|
-
for (const call of input.recentToolCalls
|
|
111
|
-
lines.push(`- ${
|
|
102
|
+
for (const call of input.recentToolCalls) {
|
|
103
|
+
lines.push(`- ${call.trim()}`);
|
|
112
104
|
}
|
|
113
105
|
}
|
|
114
106
|
|
|
115
|
-
|
|
116
|
-
if (messages.length > 0) {
|
|
107
|
+
if (input.recentMessages.length > 0) {
|
|
117
108
|
lines.push("");
|
|
118
109
|
lines.push(`## Recent Messages (newest last)`);
|
|
119
|
-
for (const msg of
|
|
120
|
-
lines.push(`[${msg.from}/${msg.type}] ${
|
|
110
|
+
for (const msg of input.recentMessages) {
|
|
111
|
+
lines.push(`[${msg.from}/${msg.type}] ${msg.text.trim()}`);
|
|
121
112
|
}
|
|
122
113
|
}
|
|
123
114
|
|
|
@@ -141,5 +132,5 @@ export function formatAdvisorSteerMessage(advice: string, reason: string): strin
|
|
|
141
132
|
|
|
142
133
|
/** True when the advisor's verdict says a human decision is required. */
|
|
143
134
|
export function adviceNeedsHuman(advice: string): boolean {
|
|
144
|
-
return /needs human input/i.test(advice
|
|
135
|
+
return /needs human input/i.test(advice);
|
|
145
136
|
}
|
package/src/agent-pool.ts
CHANGED
|
@@ -217,7 +217,7 @@ export class AgentPool {
|
|
|
217
217
|
proc.on("exit", (code, signal) => {
|
|
218
218
|
// Log diagnostic info for debugging spawn failures
|
|
219
219
|
if (code !== 0 && code !== null) {
|
|
220
|
-
logError("squad-pool", `${agentDef.name} exited: code=${code} signal=${signal} pid=${proc.pid} stdoutLines=${stdoutLines} stderr=${stderr
|
|
220
|
+
logError("squad-pool", `${agentDef.name} exited: code=${code} signal=${signal} pid=${proc.pid} stdoutLines=${stdoutLines} stderr=${stderr || "(empty)"}`);
|
|
221
221
|
}
|
|
222
222
|
// Capture activity stats BEFORE deleting the agent
|
|
223
223
|
const finalActivity = agentProc.activity;
|
|
@@ -232,7 +232,8 @@ export class AgentPool {
|
|
|
232
232
|
agentName: agentDef.name,
|
|
233
233
|
data: {
|
|
234
234
|
exitCode: code,
|
|
235
|
-
|
|
235
|
+
// Preserve complete diagnostics for task failure reports and recovery.
|
|
236
|
+
stderr,
|
|
236
237
|
turnCount: finalActivity.turnCount,
|
|
237
238
|
toolCallCount: finalActivity.recentToolCalls.length,
|
|
238
239
|
filesModified: finalActivity.modifiedFiles.size,
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
|
|
|
25
25
|
import { setupSquadWidget, type SquadWidgetState } from "./panel/squad-widget.js";
|
|
26
26
|
import * as store from "./store.js";
|
|
27
27
|
import { debug, logError } from "./logger.js";
|
|
28
|
+
import { buildCompletionSummary, buildFailureSummary } from "./report.js";
|
|
28
29
|
|
|
29
30
|
// ============================================================================
|
|
30
31
|
// State
|
|
@@ -229,8 +230,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
229
230
|
const taskLines = tasks.map((t) => {
|
|
230
231
|
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
|
|
231
232
|
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
232
|
-
if (t.output) line += ` — ${t.output
|
|
233
|
-
if (t.error) line += ` ERROR: ${t.error
|
|
233
|
+
if (t.output) line += ` — ${t.output}`;
|
|
234
|
+
if (t.error) line += ` ERROR: ${t.error}`;
|
|
234
235
|
return line;
|
|
235
236
|
}).join("\n");
|
|
236
237
|
|
|
@@ -461,7 +462,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
461
462
|
pi.registerTool({
|
|
462
463
|
name: "squad_modify",
|
|
463
464
|
label: "Squad Modify",
|
|
464
|
-
description: "Modify the running squad: add_task, cancel_task, pause, resume, cancel (entire squad).",
|
|
465
|
+
description: "Modify the running squad: add_task, cancel_task, complete_task (mark done + schedule dependents), pause, resume (also recovers failed squads), cancel (entire squad).",
|
|
465
466
|
parameters: Type.Object({
|
|
466
467
|
action: Type.Union(
|
|
467
468
|
[
|
|
@@ -469,6 +470,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
469
470
|
Type.Literal("cancel_task"),
|
|
470
471
|
Type.Literal("pause_task"),
|
|
471
472
|
Type.Literal("resume_task"),
|
|
473
|
+
Type.Literal("complete_task"),
|
|
472
474
|
Type.Literal("pause"),
|
|
473
475
|
Type.Literal("resume"),
|
|
474
476
|
Type.Literal("cancel"),
|
|
@@ -476,6 +478,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
476
478
|
{ description: "Action to perform" },
|
|
477
479
|
),
|
|
478
480
|
taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
|
|
481
|
+
output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
|
|
479
482
|
task: Type.Optional(
|
|
480
483
|
Type.Object({
|
|
481
484
|
id: Type.String(),
|
|
@@ -493,11 +496,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
493
496
|
if (params.action === "resume") {
|
|
494
497
|
// Find a squad to resume: use activeSquadId or find the latest paused one
|
|
495
498
|
const squadId = activeSquadId || store.findActiveSquads()
|
|
496
|
-
.filter((s) => s.cwd === ctx.cwd && s.status === "paused")
|
|
499
|
+
.filter((s) => s.cwd === ctx.cwd && (s.status === "paused" || s.status === "failed"))
|
|
497
500
|
.sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
|
|
498
501
|
|
|
499
502
|
if (!squadId) {
|
|
500
|
-
return { content: [{ type: "text" as const, text: "No paused squad found to resume." }], details: undefined };
|
|
503
|
+
return { content: [{ type: "text" as const, text: "No paused or failed squad found to resume." }], details: undefined };
|
|
501
504
|
}
|
|
502
505
|
|
|
503
506
|
// Create a fresh scheduler if needed
|
|
@@ -517,10 +520,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
517
520
|
switch (event.type) {
|
|
518
521
|
case "squad_completed": {
|
|
519
522
|
const tasks = store.loadAllTasks(squadId);
|
|
520
|
-
const summary = tasks
|
|
521
|
-
.filter((t) => t.status === "done")
|
|
522
|
-
.map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
|
|
523
|
-
.join("\n");
|
|
523
|
+
const summary = buildCompletionSummary(tasks);
|
|
524
524
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
525
525
|
const s = schedulers.get(squadId); if (s) s.updateContext();
|
|
526
526
|
// followUp + triggerTurn: wake an idle main agent to review;
|
|
@@ -540,7 +540,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
540
540
|
const done = tasks.filter((t) => t.status === "done");
|
|
541
541
|
pi.sendMessage({
|
|
542
542
|
customType: "squad-failed",
|
|
543
|
-
content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${
|
|
543
|
+
content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${buildFailureSummary(tasks)}`,
|
|
544
544
|
display: true,
|
|
545
545
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
546
546
|
forceWidgetUpdate();
|
|
@@ -632,6 +632,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
632
632
|
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }], details: undefined };
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
+
case "complete_task": {
|
|
636
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
637
|
+
try {
|
|
638
|
+
await activeScheduler.completeTask(params.taskId, params.output);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
return { content: [{ type: "text" as const, text: `complete_task failed: ${(err as Error).message}` }], details: undefined };
|
|
641
|
+
}
|
|
642
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' marked done — dependents unblocked and scheduled.` }], details: undefined };
|
|
643
|
+
}
|
|
644
|
+
|
|
635
645
|
case "pause": {
|
|
636
646
|
const squad = store.loadSquad(activeSquadId);
|
|
637
647
|
if (squad) {
|
|
@@ -920,7 +930,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
920
930
|
const msgSched = getActiveScheduler();
|
|
921
931
|
if (msgSched) {
|
|
922
932
|
await msgSched.sendHumanMessage(targetTaskId, msgText);
|
|
923
|
-
ctx.ui.notify(`Sent to ${targetAgent}: "${msgText
|
|
933
|
+
ctx.ui.notify(`Sent to ${targetAgent}: "${msgText}"`, "info");
|
|
924
934
|
} else {
|
|
925
935
|
store.appendMessage(activeSquadId, targetTaskId, {
|
|
926
936
|
ts: store.now(),
|
|
@@ -1198,7 +1208,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1198
1208
|
`Tags: ${agent.tags.join(", ")}`,
|
|
1199
1209
|
``,
|
|
1200
1210
|
`Prompt:`,
|
|
1201
|
-
|
|
1211
|
+
agent.prompt,
|
|
1202
1212
|
``,
|
|
1203
1213
|
`File: ${store.getGlobalAgentsDir()}/${agent.name}.json`,
|
|
1204
1214
|
].join("\n");
|
|
@@ -1387,7 +1397,7 @@ function openPanel(
|
|
|
1387
1397
|
const panelSched = schedulers.get(squadId);
|
|
1388
1398
|
if (input && panelSched) {
|
|
1389
1399
|
await panelSched.sendHumanMessage(taskId, input);
|
|
1390
|
-
ctx.ui.notify(`Sent to ${agentName}: "${input
|
|
1400
|
+
ctx.ui.notify(`Sent to ${agentName}: "${input}"`, "info");
|
|
1391
1401
|
} else if (input) {
|
|
1392
1402
|
store.appendMessage(squadId, taskId, {
|
|
1393
1403
|
ts: store.now(),
|
|
@@ -1555,10 +1565,7 @@ async function startSquad(
|
|
|
1555
1565
|
switch (event.type) {
|
|
1556
1566
|
case "squad_completed": {
|
|
1557
1567
|
const tasks = store.loadAllTasks(squadId);
|
|
1558
|
-
const summary = tasks
|
|
1559
|
-
.filter((t) => t.status === "done")
|
|
1560
|
-
.map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
|
|
1561
|
-
.join("\n");
|
|
1568
|
+
const summary = buildCompletionSummary(tasks);
|
|
1562
1569
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1563
1570
|
|
|
1564
1571
|
// Final context update before clearing scheduler
|
|
@@ -1593,7 +1600,7 @@ async function startSquad(
|
|
|
1593
1600
|
customType: "squad-failed",
|
|
1594
1601
|
content: `[squad] Squad "${squadId}" has stalled. ` +
|
|
1595
1602
|
`${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
|
|
1596
|
-
`Failed: ${
|
|
1603
|
+
`Failed: ${buildFailureSummary(tasks)}\n` +
|
|
1597
1604
|
`Use squad_status for details or squad_modify to adjust.`,
|
|
1598
1605
|
display: true,
|
|
1599
1606
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
package/src/monitor.ts
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
* - Idle timeout (no output for N minutes)
|
|
6
6
|
* - Stuck detection (no output for longer)
|
|
7
7
|
* - Loop detection (same tool call repeated)
|
|
8
|
-
* -
|
|
8
|
+
* - Long-running check-in (notify the main session; never aborts)
|
|
9
9
|
* - File conflict warnings
|
|
10
|
+
*
|
|
11
|
+
* The monitor NEVER kills or blocks work on its own. Its strongest action is
|
|
12
|
+
* `notify`: tell the main Pi session so a human/main agent can check in.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import type { AgentPool } from "./agent-pool.js";
|
|
@@ -25,7 +28,7 @@ function envMs(name: string, fallback: number): number {
|
|
|
25
28
|
|
|
26
29
|
const IDLE_WARNING_MS = envMs("PI_SQUAD_IDLE_MS", 3 * 60 * 1000); // no output → warn
|
|
27
30
|
const STUCK_TIMEOUT_MS = envMs("PI_SQUAD_STUCK_MS", 5 * 60 * 1000); // no output → intervene
|
|
28
|
-
const
|
|
31
|
+
const LONG_RUN_NOTIFY_MS = envMs("PI_SQUAD_CEILING_MS", 30 * 60 * 1000); // total → notify main session (no abort)
|
|
29
32
|
const LOOP_THRESHOLD = 5; // same tool call 5x → looping
|
|
30
33
|
const POLL_INTERVAL_MS = envMs("PI_SQUAD_POLL_MS", 30 * 1000); // check interval
|
|
31
34
|
|
|
@@ -33,7 +36,7 @@ const POLL_INTERVAL_MS = envMs("PI_SQUAD_POLL_MS", 30 * 1000); // check interval
|
|
|
33
36
|
// Types
|
|
34
37
|
// ============================================================================
|
|
35
38
|
|
|
36
|
-
export type MonitorActionType = "steer" | "
|
|
39
|
+
export type MonitorActionType = "steer" | "escalate" | "notify";
|
|
37
40
|
|
|
38
41
|
export interface MonitorAction {
|
|
39
42
|
type: MonitorActionType;
|
|
@@ -60,6 +63,8 @@ export class Monitor {
|
|
|
60
63
|
private stuckSteered = new Set<string>();
|
|
61
64
|
/** Track which tasks have been escalated (one escalation per stuck episode) */
|
|
62
65
|
private escalated = new Set<string>();
|
|
66
|
+
/** Last long-run threshold multiple notified per task (notify once per multiple) */
|
|
67
|
+
private longRunNotified = new Map<string, number>();
|
|
63
68
|
|
|
64
69
|
constructor(pool: AgentPool, squadId: string) {
|
|
65
70
|
this.pool = pool;
|
|
@@ -100,6 +105,7 @@ export class Monitor {
|
|
|
100
105
|
this.warned.clear();
|
|
101
106
|
this.stuckSteered.clear();
|
|
102
107
|
this.escalated.clear();
|
|
108
|
+
this.longRunNotified.clear();
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
/** Check all running agents */
|
|
@@ -112,7 +118,7 @@ export class Monitor {
|
|
|
112
118
|
if (!activity) continue;
|
|
113
119
|
|
|
114
120
|
const health = this.checkHealth(activity);
|
|
115
|
-
this.handleHealth(taskId, agentName, health);
|
|
121
|
+
this.handleHealth(taskId, agentName, health, activity);
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -133,7 +139,7 @@ export class Monitor {
|
|
|
133
139
|
if (unique.size === 1) return "looping";
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
if (totalMs >=
|
|
142
|
+
if (totalMs >= LONG_RUN_NOTIFY_MS) return "long_running";
|
|
137
143
|
if (idleMs >= STUCK_TIMEOUT_MS) return "stuck";
|
|
138
144
|
if (idleMs >= IDLE_WARNING_MS) return "idle_warning";
|
|
139
145
|
|
|
@@ -141,7 +147,12 @@ export class Monitor {
|
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
/** Take action based on health status */
|
|
144
|
-
private handleHealth(
|
|
150
|
+
private handleHealth(
|
|
151
|
+
taskId: string,
|
|
152
|
+
agentName: string,
|
|
153
|
+
status: HealthStatus,
|
|
154
|
+
activity?: { startedAt: number },
|
|
155
|
+
): void {
|
|
145
156
|
if (status !== "healthy") debug("squad-monitor", `${taskId} (${agentName}): ${status}`);
|
|
146
157
|
switch (status) {
|
|
147
158
|
case "healthy":
|
|
@@ -203,15 +214,25 @@ export class Monitor {
|
|
|
203
214
|
});
|
|
204
215
|
break;
|
|
205
216
|
|
|
206
|
-
case "
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
217
|
+
case "long_running": {
|
|
218
|
+
// Never abort. Notify the main Pi session once per threshold multiple
|
|
219
|
+
// (30m, 60m, 90m, …) so it can check in if needed — work continues.
|
|
220
|
+
const totalMs = Date.now() - (activity?.startedAt ?? Date.now());
|
|
221
|
+
const multiple = Math.floor(totalMs / LONG_RUN_NOTIFY_MS);
|
|
222
|
+
const lastNotified = this.longRunNotified.get(taskId) ?? 0;
|
|
223
|
+
if (multiple > lastNotified) {
|
|
224
|
+
this.longRunNotified.set(taskId, multiple);
|
|
225
|
+
const minutes = Math.round(totalMs / 60_000);
|
|
226
|
+
this.emit({
|
|
227
|
+
type: "notify",
|
|
228
|
+
taskId,
|
|
229
|
+
agentName,
|
|
230
|
+
reason: `Agent ${agentName} has been working on ${taskId} for ${minutes}m. Still running — check on it if needed (steer, pause, or let it continue).`,
|
|
231
|
+
message: "",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
214
234
|
break;
|
|
235
|
+
}
|
|
215
236
|
}
|
|
216
237
|
}
|
|
217
238
|
}
|
package/src/planner.ts
CHANGED
|
@@ -152,7 +152,7 @@ async function runPiJson(options: PiJsonOptions): Promise<string> {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
if (code !== 0 && messages.length === 0) {
|
|
155
|
-
reject(new Error(`Planner failed (code ${code}): ${stderr
|
|
155
|
+
reject(new Error(`Planner failed (code ${code}): ${stderr}`));
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
158
158
|
|
|
@@ -217,7 +217,7 @@ function parsePlannerOutput(text: string, validAgents?: Set<string>): PlannerOut
|
|
|
217
217
|
return parsed as PlannerOutput;
|
|
218
218
|
} catch (error) {
|
|
219
219
|
if (error instanceof SyntaxError) {
|
|
220
|
-
throw new Error(`Planner output is not valid JSON: ${text
|
|
220
|
+
throw new Error(`Planner output is not valid JSON: ${text}`);
|
|
221
221
|
}
|
|
222
222
|
throw error;
|
|
223
223
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -102,7 +102,6 @@ function buildChainContext(task: Task, allTasks: Task[], squadId: string): strin
|
|
|
102
102
|
const messages = loadMessages(squadId, dep.id);
|
|
103
103
|
const lastText = messages
|
|
104
104
|
.filter((m) => m.from === dep.agent && (m.type === "text" || m.type === "done"))
|
|
105
|
-
.slice(-3)
|
|
106
105
|
.map((m) => m.text)
|
|
107
106
|
.join("\n");
|
|
108
107
|
if (lastText) {
|
|
@@ -155,12 +154,9 @@ function buildSiblingAwareness(
|
|
|
155
154
|
for (const [agent, files] of fileEntries) {
|
|
156
155
|
if (files.length > 0) {
|
|
157
156
|
lines.push(`**${agent}:**`);
|
|
158
|
-
for (const f of files
|
|
157
|
+
for (const f of files) {
|
|
159
158
|
lines.push(` - ${f}`);
|
|
160
159
|
}
|
|
161
|
-
if (files.length > 10) {
|
|
162
|
-
lines.push(` - ...and ${files.length - 10} more`);
|
|
163
|
-
}
|
|
164
160
|
}
|
|
165
161
|
}
|
|
166
162
|
lines.push(
|
|
@@ -187,7 +183,7 @@ function buildKnowledgeSection(squadId: string): string {
|
|
|
187
183
|
|
|
188
184
|
if (decisions.length > 0) {
|
|
189
185
|
lines.push("## Decisions");
|
|
190
|
-
for (const d of decisions
|
|
186
|
+
for (const d of decisions) {
|
|
191
187
|
lines.push(`- ${d.text} (${d.from})`);
|
|
192
188
|
}
|
|
193
189
|
lines.push("");
|
|
@@ -195,7 +191,7 @@ function buildKnowledgeSection(squadId: string): string {
|
|
|
195
191
|
|
|
196
192
|
if (conventions.length > 0) {
|
|
197
193
|
lines.push("## Project Conventions");
|
|
198
|
-
for (const c of conventions
|
|
194
|
+
for (const c of conventions) {
|
|
199
195
|
lines.push(`- ${c.text} (${c.from})`);
|
|
200
196
|
}
|
|
201
197
|
lines.push("");
|
|
@@ -203,7 +199,7 @@ function buildKnowledgeSection(squadId: string): string {
|
|
|
203
199
|
|
|
204
200
|
if (findings.length > 0) {
|
|
205
201
|
lines.push("## Findings");
|
|
206
|
-
for (const f of findings
|
|
202
|
+
for (const f of findings) {
|
|
207
203
|
lines.push(`- ${f.text} (${f.from})`);
|
|
208
204
|
}
|
|
209
205
|
lines.push("");
|
|
@@ -256,7 +252,8 @@ function buildReworkContext(task: Task, squadId: string): string {
|
|
|
256
252
|
|
|
257
253
|
if (originalTask?.output) {
|
|
258
254
|
lines.push("## What Was Built (Previous Attempt)");
|
|
259
|
-
|
|
255
|
+
// Rework agents need the complete prior handoff, not an arbitrary prefix.
|
|
256
|
+
lines.push(originalTask.output);
|
|
260
257
|
lines.push("");
|
|
261
258
|
}
|
|
262
259
|
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Task } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the full task handoff included in squad completion notifications.
|
|
5
|
+
* This is durable report data, not a UI preview: never shorten task output.
|
|
6
|
+
*/
|
|
7
|
+
export function buildCompletionSummary(tasks: Task[]): string {
|
|
8
|
+
return tasks
|
|
9
|
+
.filter((task) => task.status === "done")
|
|
10
|
+
.map((task) => `- ${task.id} (${task.agent}): ${task.output || "done"}`)
|
|
11
|
+
.join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Build the full failure handoff without shortening diagnostics. */
|
|
15
|
+
export function buildFailureSummary(tasks: Task[]): string {
|
|
16
|
+
return tasks
|
|
17
|
+
.filter((task) => task.status === "failed")
|
|
18
|
+
.map((task) => `${task.id}: ${task.error || "unknown error"}`)
|
|
19
|
+
.join("; ");
|
|
20
|
+
}
|
package/src/router.ts
CHANGED
package/src/scheduler.ts
CHANGED
|
@@ -72,6 +72,8 @@ export class Scheduler {
|
|
|
72
72
|
private running = false;
|
|
73
73
|
/** Track spawn retries to allow one retry per task */
|
|
74
74
|
private spawnRetries = new Set<string>();
|
|
75
|
+
/** Periodic level-triggered reconcile (heals missed events / out-of-band store edits) */
|
|
76
|
+
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
|
75
77
|
|
|
76
78
|
/** Get the project cwd for this squad (from squad.json) */
|
|
77
79
|
getProjectCwd(): string | undefined {
|
|
@@ -93,8 +95,16 @@ export class Scheduler {
|
|
|
93
95
|
this.monitor.onAction((action) => {
|
|
94
96
|
if (action.type === "steer") {
|
|
95
97
|
this.pool.steer(action.taskId, action.message);
|
|
96
|
-
} else if (action.type === "
|
|
97
|
-
|
|
98
|
+
} else if (action.type === "notify") {
|
|
99
|
+
// Informational only — tell the main Pi session directly (no advisor
|
|
100
|
+
// detour, no kill). The agent keeps working.
|
|
101
|
+
this.emit({
|
|
102
|
+
type: "escalation",
|
|
103
|
+
squadId: this.squadId,
|
|
104
|
+
taskId: action.taskId,
|
|
105
|
+
agentName: action.agentName,
|
|
106
|
+
message: action.reason,
|
|
107
|
+
});
|
|
98
108
|
} else if (action.type === "escalate") {
|
|
99
109
|
// Advisor-first: try a strong-model rescue before interrupting the human
|
|
100
110
|
void this.tryAdvisorRescue(action.taskId, action.agentName, action.reason).then((rescued) => {
|
|
@@ -160,13 +170,22 @@ export class Scheduler {
|
|
|
160
170
|
async start(): Promise<void> {
|
|
161
171
|
this.running = true;
|
|
162
172
|
this.monitor.start();
|
|
163
|
-
await this.
|
|
173
|
+
await this.reconcile();
|
|
174
|
+
// Level-triggered safety net: periodically re-derive scheduling decisions
|
|
175
|
+
// from persisted state so missed in-memory events (crashes, out-of-band
|
|
176
|
+
// store edits, external recovery) cannot strand ready tasks.
|
|
177
|
+
this.reconcileTimer = setInterval(() => void this.reconcile(), 60_000);
|
|
178
|
+
(this.reconcileTimer as { unref?: () => void }).unref?.();
|
|
164
179
|
}
|
|
165
180
|
|
|
166
181
|
/** Stop the scheduler — kills all agents, saves state */
|
|
167
182
|
async stop(): Promise<void> {
|
|
168
183
|
this.running = false;
|
|
169
184
|
this.monitor.stop();
|
|
185
|
+
if (this.reconcileTimer) {
|
|
186
|
+
clearInterval(this.reconcileTimer);
|
|
187
|
+
this.reconcileTimer = null;
|
|
188
|
+
}
|
|
170
189
|
|
|
171
190
|
// Suspend in-progress tasks
|
|
172
191
|
const tasks = store.loadAllTasks(this.squadId);
|
|
@@ -179,17 +198,26 @@ export class Scheduler {
|
|
|
179
198
|
await this.pool.killAll();
|
|
180
199
|
}
|
|
181
200
|
|
|
182
|
-
/** Resume from suspended state
|
|
201
|
+
/** Resume from suspended OR failed state. Failure is never terminal:
|
|
202
|
+
* failed tasks are reset to pending and a failed squad becomes running. */
|
|
183
203
|
async resume(): Promise<void> {
|
|
184
204
|
const tasks = store.loadAllTasks(this.squadId);
|
|
185
205
|
for (const task of tasks) {
|
|
186
206
|
if (task.status === "suspended") {
|
|
187
207
|
store.updateTaskStatus(this.squadId, task.id, "pending");
|
|
208
|
+
} else if (task.status === "failed") {
|
|
209
|
+
store.updateTaskStatus(this.squadId, task.id, "pending", { error: null });
|
|
210
|
+
store.appendMessage(this.squadId, task.id, {
|
|
211
|
+
ts: store.now(),
|
|
212
|
+
from: "system",
|
|
213
|
+
type: "status",
|
|
214
|
+
text: "Reset failed → pending on squad resume",
|
|
215
|
+
});
|
|
188
216
|
}
|
|
189
217
|
}
|
|
190
218
|
|
|
191
219
|
const squad = store.loadSquad(this.squadId);
|
|
192
|
-
if (squad && squad.status === "paused") {
|
|
220
|
+
if (squad && (squad.status === "paused" || squad.status === "failed")) {
|
|
193
221
|
squad.status = "running";
|
|
194
222
|
store.saveSquad(squad);
|
|
195
223
|
}
|
|
@@ -197,6 +225,52 @@ export class Scheduler {
|
|
|
197
225
|
await this.start();
|
|
198
226
|
}
|
|
199
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Level-triggered reconciliation — derive scheduling from persisted state:
|
|
230
|
+
* 1. Unblock blocked tasks whose deps are all done (missed autoUnblock events)
|
|
231
|
+
* 2. Self-heal a "failed" squad when runnable work exists (out-of-band recovery)
|
|
232
|
+
* 3. Schedule ready tasks
|
|
233
|
+
* Safe to call any time; no-ops when nothing changed.
|
|
234
|
+
*/
|
|
235
|
+
async reconcile(): Promise<void> {
|
|
236
|
+
if (!this.running) return;
|
|
237
|
+
const squad = store.loadSquad(this.squadId);
|
|
238
|
+
if (!squad) return;
|
|
239
|
+
|
|
240
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
241
|
+
|
|
242
|
+
// 1. Blocked → pending when all deps are done (respects autoUnblock config)
|
|
243
|
+
if (squad.config.autoUnblock) {
|
|
244
|
+
for (const task of tasks) {
|
|
245
|
+
if (task.status !== "blocked") continue;
|
|
246
|
+
const allDepsDone = task.depends.every((depId) => {
|
|
247
|
+
const dep = tasks.find((t) => t.id === depId);
|
|
248
|
+
return dep?.status === "done";
|
|
249
|
+
});
|
|
250
|
+
if (allDepsDone) {
|
|
251
|
+
task.status = "pending";
|
|
252
|
+
store.updateTaskStatus(this.squadId, task.id, "pending");
|
|
253
|
+
this.emit({ type: "task_unblocked", squadId: this.squadId, taskId: task.id });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 2. A "failed" squad with runnable work self-heals to running. This makes
|
|
259
|
+
// the terminal status derivable from task state instead of a one-way latch.
|
|
260
|
+
const hasRunnable = tasks.some((t) => {
|
|
261
|
+
if (t.status === "in_progress") return true;
|
|
262
|
+
if (t.status !== "pending") return false;
|
|
263
|
+
return t.depends.every((depId) => tasks.find((x) => x.id === depId)?.status === "done");
|
|
264
|
+
});
|
|
265
|
+
if (squad.status === "failed" && hasRunnable) {
|
|
266
|
+
squad.status = "running";
|
|
267
|
+
store.saveSquad(squad);
|
|
268
|
+
debug("squad-scheduler", "reconcile: failed squad has runnable work — healing to running");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await this.scheduleReadyTasks();
|
|
272
|
+
}
|
|
273
|
+
|
|
200
274
|
// =========================================================================
|
|
201
275
|
// Task Scheduling
|
|
202
276
|
// =========================================================================
|
|
@@ -391,7 +465,7 @@ export class Scheduler {
|
|
|
391
465
|
agentName: task.agent,
|
|
392
466
|
agentRole: agentDef?.role || task.agent,
|
|
393
467
|
reason,
|
|
394
|
-
recentMessages: store.loadMessages(this.squadId, taskId).
|
|
468
|
+
recentMessages: store.loadMessages(this.squadId, taskId).map((m) => ({ from: m.from, type: m.type, text: m.text })),
|
|
395
469
|
recentToolCalls: activity ? [...activity.recentToolCalls] : [],
|
|
396
470
|
turnCount: activity?.turnCount || 0,
|
|
397
471
|
elapsedMinutes: activity ? (Date.now() - activity.startedAt) / 60000 : 0,
|
|
@@ -414,7 +488,7 @@ export class Scheduler {
|
|
|
414
488
|
squadId: this.squadId,
|
|
415
489
|
taskId,
|
|
416
490
|
agentName,
|
|
417
|
-
message: `${reason}\n\nAdvisor assessment:\n${advice
|
|
491
|
+
message: `${reason}\n\nAdvisor assessment:\n${advice}`,
|
|
418
492
|
});
|
|
419
493
|
return true; // escalation already emitted with richer context
|
|
420
494
|
}
|
|
@@ -489,7 +563,9 @@ export class Scheduler {
|
|
|
489
563
|
ts: store.now(),
|
|
490
564
|
from: event.agentName,
|
|
491
565
|
type: "text",
|
|
492
|
-
|
|
566
|
+
// Persist the complete handoff. Reports can be arbitrarily long;
|
|
567
|
+
// presentation layers may viewport them, but source data must never truncate.
|
|
568
|
+
text,
|
|
493
569
|
});
|
|
494
570
|
}
|
|
495
571
|
|
|
@@ -562,7 +638,7 @@ export class Scheduler {
|
|
|
562
638
|
const reason = turnCount === 0
|
|
563
639
|
? `exited with 0 turns (likely rate limit or API error)`
|
|
564
640
|
: `exited with ${turnCount} turns but 0 tool calls (no work done)`;
|
|
565
|
-
logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr
|
|
641
|
+
logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
|
|
566
642
|
store.updateTaskStatus(this.squadId, event.taskId, "pending");
|
|
567
643
|
store.appendMessage(this.squadId, event.taskId, {
|
|
568
644
|
ts: store.now(),
|
|
@@ -576,7 +652,7 @@ export class Scheduler {
|
|
|
576
652
|
}, 2000);
|
|
577
653
|
} else {
|
|
578
654
|
const stderr = event.data?.stderr || "";
|
|
579
|
-
this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} (retry exhausted). ${stderr
|
|
655
|
+
this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} (retry exhausted). ${stderr}`);
|
|
580
656
|
}
|
|
581
657
|
this.updateContext();
|
|
582
658
|
}
|
|
@@ -608,10 +684,9 @@ export class Scheduler {
|
|
|
608
684
|
|
|
609
685
|
// Extract output from last messages
|
|
610
686
|
const messages = store.loadMessages(this.squadId, taskId);
|
|
611
|
-
const
|
|
612
|
-
.filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"))
|
|
613
|
-
|
|
614
|
-
const output = lastAgentMessages.map((m) => m.text).join("\n");
|
|
687
|
+
const agentMessages = messages
|
|
688
|
+
.filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"));
|
|
689
|
+
const output = agentMessages.map((m) => m.text).join("\n");
|
|
615
690
|
|
|
616
691
|
store.updateTaskStatus(this.squadId, taskId, "done", {
|
|
617
692
|
output: output || "Task completed",
|
|
@@ -795,7 +870,7 @@ export class Scheduler {
|
|
|
795
870
|
squadId: this.squadId,
|
|
796
871
|
taskId: task.id,
|
|
797
872
|
agentName: task.agent,
|
|
798
|
-
message: `QA failed ${originalId} ${retryCount} times. Retry limit reached.\nLatest feedback:\n${feedback
|
|
873
|
+
message: `QA failed ${originalId} ${retryCount} times. Retry limit reached.\nLatest feedback:\n${feedback}`,
|
|
799
874
|
});
|
|
800
875
|
continue;
|
|
801
876
|
}
|
|
@@ -900,12 +975,11 @@ export class Scheduler {
|
|
|
900
975
|
|
|
901
976
|
// Try to extract lines containing "FAIL", "Error", "✗"
|
|
902
977
|
const failLines = output.split("\n")
|
|
903
|
-
.filter((line) => /fail|error|✗|✘|broken|bug/i.test(line))
|
|
904
|
-
.slice(0, 20);
|
|
978
|
+
.filter((line) => /fail|error|✗|✘|broken|bug/i.test(line));
|
|
905
979
|
if (failLines.length > 0) return failLines.join("\n");
|
|
906
980
|
|
|
907
|
-
//
|
|
908
|
-
return output
|
|
981
|
+
// Preserve the complete QA handoff when no structured failure section exists.
|
|
982
|
+
return output;
|
|
909
983
|
}
|
|
910
984
|
|
|
911
985
|
// =========================================================================
|
|
@@ -973,7 +1047,7 @@ export class Scheduler {
|
|
|
973
1047
|
status: task.status,
|
|
974
1048
|
agent: task.agent,
|
|
975
1049
|
title: task.title,
|
|
976
|
-
...(task.output ? { output: task.output
|
|
1050
|
+
...(task.output ? { output: task.output } : {}),
|
|
977
1051
|
...(task.status === "blocked"
|
|
978
1052
|
? {
|
|
979
1053
|
blockedBy: task.depends.filter((d) => {
|
|
@@ -1015,7 +1089,7 @@ export class Scheduler {
|
|
|
1015
1089
|
action:
|
|
1016
1090
|
msg.type === "tool"
|
|
1017
1091
|
? `→ ${msg.name} ${msg.args?.path || msg.args?.command || ""}`.trim()
|
|
1018
|
-
: msg.text
|
|
1092
|
+
: msg.text,
|
|
1019
1093
|
});
|
|
1020
1094
|
}
|
|
1021
1095
|
}
|
|
@@ -1073,10 +1147,53 @@ export class Scheduler {
|
|
|
1073
1147
|
this.updateContext();
|
|
1074
1148
|
}
|
|
1075
1149
|
|
|
1076
|
-
/** Resume a suspended task */
|
|
1150
|
+
/** Resume a suspended/failed task. Reconciles so a failed squad heals too. */
|
|
1077
1151
|
async resumeTask(taskId: string): Promise<void> {
|
|
1078
|
-
store.updateTaskStatus(this.squadId, taskId, "pending");
|
|
1079
|
-
await this.
|
|
1152
|
+
store.updateTaskStatus(this.squadId, taskId, "pending", { error: null });
|
|
1153
|
+
await this.reconcile();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Mark a task done through the normal completion flow (admin/recovery path).
|
|
1158
|
+
* Unlike editing the store directly, this fires auto-unblock and scheduling,
|
|
1159
|
+
* so dependents transition pending → running. Skips the QA rework check —
|
|
1160
|
+
* a human/main agent marking a task done is an explicit override.
|
|
1161
|
+
*/
|
|
1162
|
+
async completeTask(taskId: string, output?: string): Promise<void> {
|
|
1163
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
1164
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1165
|
+
if (task.status === "done") return;
|
|
1166
|
+
|
|
1167
|
+
if (this.pool.isRunning(taskId)) {
|
|
1168
|
+
await this.pool.kill(taskId);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
store.updateTaskStatus(this.squadId, taskId, "done", {
|
|
1172
|
+
output: output ?? task.output ?? "Marked done (recovered/admin)",
|
|
1173
|
+
error: null,
|
|
1174
|
+
completed: store.now(),
|
|
1175
|
+
});
|
|
1176
|
+
store.appendMessage(this.squadId, taskId, {
|
|
1177
|
+
ts: store.now(),
|
|
1178
|
+
from: "system",
|
|
1179
|
+
type: "done",
|
|
1180
|
+
text: "Task marked done (recovered/admin)",
|
|
1181
|
+
});
|
|
1182
|
+
this.emit({
|
|
1183
|
+
type: "task_completed",
|
|
1184
|
+
squadId: this.squadId,
|
|
1185
|
+
taskId,
|
|
1186
|
+
agentName: task.agent,
|
|
1187
|
+
message: output ?? "",
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
this.autoUnblock(taskId);
|
|
1191
|
+
await this.reconcile();
|
|
1192
|
+
|
|
1193
|
+
const freshTasks = store.loadAllTasks(this.squadId);
|
|
1194
|
+
const freshSquad = store.loadSquad(this.squadId);
|
|
1195
|
+
if (freshSquad) this.checkSquadCompletion(freshTasks, freshSquad);
|
|
1196
|
+
this.updateContext();
|
|
1080
1197
|
}
|
|
1081
1198
|
|
|
1082
1199
|
/** Cancel a task */
|
package/src/store.ts
CHANGED
|
@@ -269,9 +269,11 @@ export function listSquads(): string[] {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
export function findActiveSquads(): Squad[] {
|
|
272
|
+
// Includes "failed": failure is recoverable (resume resets failed tasks), so
|
|
273
|
+
// failed squads must stay discoverable. All callers filter by status.
|
|
272
274
|
return listSquads()
|
|
273
275
|
.map((id) => loadSquad(id))
|
|
274
|
-
.filter((s): s is Squad => s !== null && (s.status === "running" || s.status === "paused"));
|
|
276
|
+
.filter((s): s is Squad => s !== null && (s.status === "running" || s.status === "paused" || s.status === "failed"));
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
/** List squads filtered by project cwd. If no cwd, returns all. */
|
package/src/supervisor.ts
CHANGED
|
@@ -70,10 +70,9 @@ export async function analyzeStuckAgent(
|
|
|
70
70
|
): Promise<{ action: "retry" | "reassign" | "escalate"; reason: string; suggestion?: string }> {
|
|
71
71
|
const task = store.loadTask(squadId, taskId);
|
|
72
72
|
const messages = store.loadMessages(squadId, taskId);
|
|
73
|
-
const recentMessages = messages.slice(-10);
|
|
74
73
|
|
|
75
|
-
// Simple heuristic for now
|
|
76
|
-
const errorMessages =
|
|
74
|
+
// Simple heuristic for now — inspect the complete task history.
|
|
75
|
+
const errorMessages = messages.filter((m) => m.type === "error");
|
|
77
76
|
if (errorMessages.length >= 3) {
|
|
78
77
|
return {
|
|
79
78
|
action: "escalate",
|
|
@@ -128,7 +127,7 @@ export async function analyzeBlockRequest(
|
|
|
128
127
|
return {
|
|
129
128
|
action: "create_subtask",
|
|
130
129
|
subtask: {
|
|
131
|
-
title: `Support: ${blockReason
|
|
130
|
+
title: `Support: ${blockReason}`,
|
|
132
131
|
agent: "fullstack",
|
|
133
132
|
description: `Created from block request by ${agentName} on task ${taskId}: ${blockReason}`,
|
|
134
133
|
},
|
package/src/types.ts
CHANGED
|
@@ -223,7 +223,7 @@ export interface AgentActivity {
|
|
|
223
223
|
modifiedFiles: Set<string>;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
export type HealthStatus = "healthy" | "idle_warning" | "stuck" | "looping" | "
|
|
226
|
+
export type HealthStatus = "healthy" | "idle_warning" | "stuck" | "looping" | "long_running";
|
|
227
227
|
|
|
228
228
|
// ============================================================================
|
|
229
229
|
// Supervisor
|