pi-squad 0.16.5 → 0.16.6
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 +5 -1
- package/package.json +1 -1
- package/src/index.ts +43 -20
- package/src/panel/squad-widget.ts +12 -4
- package/src/panel/task-list.ts +9 -1
- package/src/report.ts +9 -1
- package/src/scheduler.ts +142 -14
- package/src/skills/squad-supervisor/SKILL.md +4 -3
- package/src/store.ts +10 -3
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -209,7 +209,11 @@ Full overlay with task list, live activity preview, and scrollable message view.
|
|
|
209
209
|
| `squad` | Start a squad with goal + optional tasks/config |
|
|
210
210
|
| `squad_status` | Check progress, costs, task states |
|
|
211
211
|
| `squad_message` | Durably message an exact task; completed tasks reopen on their original session |
|
|
212
|
-
| `squad_modify` | Add/cancel/complete/pause/resume tasks or squads
|
|
212
|
+
| `squad_modify` | Add/cancel/complete/pause/resume tasks or squads, or replace a task's dependencies with `set_dependencies`; accepts `squadId` for exact same-squad failed-review rework |
|
|
213
|
+
|
|
214
|
+
Dependency repair uses top-level `taskId` and `depends`, for example `squad_modify({ action: "set_dependencies", taskId: "publish", depends: ["build"] })`. The replacement is validated atomically (known IDs, no self-reference, duplicates, or cycles) and is allowed only while the task is not running or done.
|
|
215
|
+
|
|
216
|
+
`cancel_task` is refused while any non-cancelled task directly depends on the target. Update every listed dependent explicitly with `set_dependencies`, then retry cancellation. Cancellation never cascades to dependents and never rewrites their dependency lists automatically; cancelled tasks remain visible in squad history and can be revived only with explicit `resume_task`.
|
|
213
217
|
|
|
214
218
|
The main agent sees available agents in its system prompt and squad state when a squad is active.
|
|
215
219
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -46,6 +46,16 @@ let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = n
|
|
|
46
46
|
const widgetState: SquadWidgetState = { squadId: null, enabled: true };
|
|
47
47
|
let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
|
|
48
48
|
|
|
49
|
+
/** Format completion against active work while retaining cancelled history. */
|
|
50
|
+
function formatTaskProgress(tasks: Task[]): string {
|
|
51
|
+
const done = tasks.filter((task) => task.status === "done").length;
|
|
52
|
+
const cancelled = tasks.filter((task) => task.status === "cancelled").length;
|
|
53
|
+
const active = tasks.length - cancelled;
|
|
54
|
+
return cancelled > 0
|
|
55
|
+
? `${done}/${active} active tasks done · ${cancelled} cancelled · ${tasks.length} total`
|
|
56
|
+
: `${done}/${tasks.length} tasks done`;
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
/**
|
|
50
60
|
* Resolve a model string (or null = session default) to its context window.
|
|
51
61
|
* Reads uiCtx lazily so it always uses the live session's registry.
|
|
@@ -268,11 +278,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
268
278
|
return;
|
|
269
279
|
}
|
|
270
280
|
|
|
271
|
-
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
272
281
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
273
282
|
|
|
274
283
|
const taskLines = tasks.map((t) => {
|
|
275
|
-
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
|
|
284
|
+
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "cancelled" ? "⊘" : "·";
|
|
276
285
|
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
277
286
|
if (t.output) line += ` — ${t.output}`;
|
|
278
287
|
if (t.error) line += ` ERROR: ${t.error}`;
|
|
@@ -282,7 +291,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
282
291
|
const squadContext = [
|
|
283
292
|
`<squad_status>`,
|
|
284
293
|
`Squad: ${squad.id} — ${squad.goal}`,
|
|
285
|
-
`Status: ${squad.status} | ${
|
|
294
|
+
`Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
|
|
286
295
|
taskLines,
|
|
287
296
|
`</squad_status>`,
|
|
288
297
|
...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
|
|
@@ -443,6 +452,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
443
452
|
task.status === "in_progress" ? "⏳" :
|
|
444
453
|
task.status === "blocked" ? "◻" :
|
|
445
454
|
task.status === "failed" ? "✗" :
|
|
455
|
+
task.status === "cancelled" ? "⊘" :
|
|
446
456
|
"·";
|
|
447
457
|
let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
|
|
448
458
|
if (task.blockedBy?.length) line += ` blocked by: ${task.blockedBy.join(", ")}`;
|
|
@@ -450,9 +460,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
450
460
|
})
|
|
451
461
|
.join("\n");
|
|
452
462
|
|
|
463
|
+
const durableTasks = store.loadAllTasks(id!);
|
|
453
464
|
const summary = [
|
|
454
465
|
`Squad: ${id}`,
|
|
455
466
|
`Status: ${context.status}`,
|
|
467
|
+
`Progress: ${formatTaskProgress(durableTasks)}`,
|
|
456
468
|
`Elapsed: ${context.elapsed}`,
|
|
457
469
|
`Cost: $${context.costs.total.toFixed(4)}`,
|
|
458
470
|
...(context.status === "review" ? ["Acceptance: BLOCKED — independent main-orchestrator review required via squad_review"] : []),
|
|
@@ -592,12 +604,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
592
604
|
pi.registerTool({
|
|
593
605
|
name: "squad_modify",
|
|
594
606
|
label: "Squad Modify",
|
|
595
|
-
description: "Modify one exact squad: add_task, cancel_task, complete_task (mark done + schedule dependents), pause, resume_task, resume (including failed-review rework), cancel.
|
|
607
|
+
description: "Modify one exact squad: add_task, set_dependencies (top-level depends), cancel_task, complete_task (mark done + schedule dependents), pause, resume_task, resume (including failed-review rework), cancel. Task actions reconstruct the persisted scheduler after restart when needed.",
|
|
596
608
|
parameters: Type.Object({
|
|
597
609
|
squadId: Type.Optional(Type.String({ description: "Exact squad to modify (recommended for failed-review rework; default: focused/recoverable project squad)" })),
|
|
598
610
|
action: Type.Union(
|
|
599
611
|
[
|
|
600
612
|
Type.Literal("add_task"),
|
|
613
|
+
Type.Literal("set_dependencies"),
|
|
601
614
|
Type.Literal("cancel_task"),
|
|
602
615
|
Type.Literal("pause_task"),
|
|
603
616
|
Type.Literal("resume_task"),
|
|
@@ -609,6 +622,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
609
622
|
{ description: "Action to perform" },
|
|
610
623
|
),
|
|
611
624
|
taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
|
|
625
|
+
depends: Type.Optional(Type.Array(Type.String(), { description: "Complete replacement dependency list; required at top level for set_dependencies" })),
|
|
612
626
|
output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
|
|
613
627
|
task: Type.Optional(
|
|
614
628
|
Type.Object({
|
|
@@ -638,8 +652,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
638
652
|
return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
|
|
639
653
|
}
|
|
640
654
|
const tasks = store.loadAllTasks(squad.id);
|
|
641
|
-
const
|
|
642
|
-
return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }], details: undefined };
|
|
655
|
+
return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${formatTaskProgress(tasks)}). Agents restarting in background.` }], details: undefined };
|
|
643
656
|
}
|
|
644
657
|
|
|
645
658
|
const squadId = params.squadId || activeSquadId;
|
|
@@ -647,7 +660,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
647
660
|
return { content: [{ type: "text" as const, text: params.squadId ? `Squad '${params.squadId}' not found.` : "No active squad. Provide squadId, select the squad, or start a new one." }], details: undefined };
|
|
648
661
|
}
|
|
649
662
|
let activeScheduler = schedulers.get(squadId) || null;
|
|
650
|
-
if (!activeScheduler && (params.action === "add_task" || params.action === "resume_task")) {
|
|
663
|
+
if (!activeScheduler && (params.action === "add_task" || params.action === "set_dependencies" || params.action === "cancel_task" || params.action === "resume_task" || params.action === "complete_task" || params.action === "pause_task")) {
|
|
651
664
|
activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
|
|
652
665
|
}
|
|
653
666
|
if (!activeScheduler) {
|
|
@@ -699,9 +712,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
699
712
|
return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
|
|
700
713
|
}
|
|
701
714
|
|
|
715
|
+
case "set_dependencies": {
|
|
716
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
717
|
+
if (!params.depends) return { content: [{ type: "text" as const, text: "Provide top-level depends for set_dependencies (use [] to remove all dependencies)." }], details: undefined };
|
|
718
|
+
try {
|
|
719
|
+
await activeScheduler.setDependencies(params.taskId, params.depends);
|
|
720
|
+
} catch (err) {
|
|
721
|
+
return { content: [{ type: "text" as const, text: `set_dependencies failed: ${(err as Error).message}` }], details: undefined };
|
|
722
|
+
}
|
|
723
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' dependencies updated in squad '${squadId}'.` }], details: undefined };
|
|
724
|
+
}
|
|
725
|
+
|
|
702
726
|
case "cancel_task": {
|
|
703
727
|
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
704
|
-
|
|
728
|
+
try {
|
|
729
|
+
await activeScheduler.cancelTask(params.taskId);
|
|
730
|
+
} catch (err) {
|
|
731
|
+
return { content: [{ type: "text" as const, text: `cancel_task failed: ${(err as Error).message}` }], details: undefined };
|
|
732
|
+
}
|
|
705
733
|
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }], details: undefined };
|
|
706
734
|
}
|
|
707
735
|
|
|
@@ -799,7 +827,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
799
827
|
// occur after the mailbox-first write but before the squad/task is reopened.
|
|
800
828
|
const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
|
|
801
829
|
.filter((squad) => store.loadAllTasks(squad.id)
|
|
802
|
-
.some((task) => store.loadPendingTaskMessages(squad.id, task.id).length > 0))
|
|
830
|
+
.some((task) => task.status !== "cancelled" && store.loadPendingTaskMessages(squad.id, task.id).length > 0))
|
|
803
831
|
.sort((a, b) => b.created.localeCompare(a.created));
|
|
804
832
|
for (const squad of pendingMailSquads) {
|
|
805
833
|
let scheduler = schedulers.get(squad.id);
|
|
@@ -828,7 +856,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
828
856
|
if (done > 0) {
|
|
829
857
|
pi.sendMessage({
|
|
830
858
|
customType: "squad-paused",
|
|
831
|
-
content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${
|
|
859
|
+
content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
|
|
832
860
|
`Use squad_modify with action "resume" to continue, or start a new squad.`,
|
|
833
861
|
display: true,
|
|
834
862
|
});
|
|
@@ -988,8 +1016,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
988
1016
|
return;
|
|
989
1017
|
}
|
|
990
1018
|
const tasks = store.loadAllTasks(squad.id);
|
|
991
|
-
|
|
992
|
-
ctx.ui.notify(`Resumed: ${squad.id} (${done}/${tasks.length} done)`, "info");
|
|
1019
|
+
ctx.ui.notify(`Resumed: ${squad.id} (${formatTaskProgress(tasks)})`, "info");
|
|
993
1020
|
return;
|
|
994
1021
|
}
|
|
995
1022
|
|
|
@@ -1164,10 +1191,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1164
1191
|
"🗑 Delete ALL squads",
|
|
1165
1192
|
...squads.map((s) => {
|
|
1166
1193
|
const tasks = store.loadAllTasks(s.id);
|
|
1167
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1168
1194
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1169
1195
|
const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
|
|
1170
|
-
return `${icon} ${s.id} [${s.status}] ${
|
|
1196
|
+
return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`;
|
|
1171
1197
|
}),
|
|
1172
1198
|
];
|
|
1173
1199
|
|
|
@@ -1474,11 +1500,10 @@ async function pickSquad(
|
|
|
1474
1500
|
|
|
1475
1501
|
const options = squads.map((s) => {
|
|
1476
1502
|
const tasks = store.loadAllTasks(s.id);
|
|
1477
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1478
1503
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1479
1504
|
const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
|
|
1480
1505
|
const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
|
|
1481
|
-
return `${icon} ${s.id} [${s.status}] ${
|
|
1506
|
+
return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
|
|
1482
1507
|
});
|
|
1483
1508
|
|
|
1484
1509
|
const choice = await ctx.ui.select("Select a squad", options);
|
|
@@ -1511,9 +1536,8 @@ function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-codi
|
|
|
1511
1536
|
// Compact notification — widget already shows full task details.
|
|
1512
1537
|
// Avoid large multi-line notifications that can break TUI layout.
|
|
1513
1538
|
const tasks = store.loadAllTasks(squadId);
|
|
1514
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1515
1539
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1516
|
-
ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${
|
|
1540
|
+
ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`, "info");
|
|
1517
1541
|
}
|
|
1518
1542
|
|
|
1519
1543
|
// ============================================================================
|
|
@@ -1552,11 +1576,10 @@ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: st
|
|
|
1552
1576
|
case "squad_failed": {
|
|
1553
1577
|
const tasks = store.loadAllTasks(squadId);
|
|
1554
1578
|
const failed = tasks.filter((task) => task.status === "failed");
|
|
1555
|
-
const done = tasks.filter((task) => task.status === "done");
|
|
1556
1579
|
pi.sendMessage({
|
|
1557
1580
|
customType: "squad-failed",
|
|
1558
1581
|
content: `[squad] Squad "${squadId}" has stalled. ` +
|
|
1559
|
-
`${
|
|
1582
|
+
`${formatTaskProgress(tasks)}, ${failed.length} failed.\n` +
|
|
1560
1583
|
`Failed: ${buildFailureSummary(tasks)}\n` +
|
|
1561
1584
|
"Use squad_status for details or squad_modify to adjust.",
|
|
1562
1585
|
display: true,
|
|
@@ -23,6 +23,7 @@ function statusIcon(status: TaskStatus, th: Theme): string {
|
|
|
23
23
|
case "blocked": return th.fg("muted", "◻");
|
|
24
24
|
case "failed": return th.fg("error", "✗");
|
|
25
25
|
case "suspended": return th.fg("muted", "⏸");
|
|
26
|
+
case "cancelled": return th.fg("muted", "⊘");
|
|
26
27
|
default: return th.fg("dim", "·");
|
|
27
28
|
}
|
|
28
29
|
}
|
|
@@ -75,6 +76,11 @@ export function setupSquadWidget(
|
|
|
75
76
|
const lines: string[] = [];
|
|
76
77
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
77
78
|
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
79
|
+
const cancelledCount = tasks.filter((t) => t.status === "cancelled").length;
|
|
80
|
+
const activeCount = tasks.length - cancelledCount;
|
|
81
|
+
const progressText = cancelledCount > 0
|
|
82
|
+
? `${doneCount}/${activeCount} active tasks done · ${cancelledCount} cancelled · ${tasks.length} total`
|
|
83
|
+
: `${doneCount}/${tasks.length}`;
|
|
78
84
|
const elapsed = Date.now() - new Date(squad.created).getTime();
|
|
79
85
|
const taskMessages = new Map<string, TaskMessage[]>();
|
|
80
86
|
const recentOrchestratorByTask = new Map<string, TaskMessage>();
|
|
@@ -103,7 +109,7 @@ export function setupSquadWidget(
|
|
|
103
109
|
: "";
|
|
104
110
|
lines.push(
|
|
105
111
|
`${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
106
|
-
`${th.fg("muted",
|
|
112
|
+
`${th.fg("muted", progressText)} ` +
|
|
107
113
|
`${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
|
|
108
114
|
`${th.fg("dim", formatElapsed(elapsed))} ` +
|
|
109
115
|
`${th.fg("dim", "^q detail · /squad msg")}`
|
|
@@ -144,6 +150,8 @@ export function setupSquadWidget(
|
|
|
144
150
|
line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
|
|
145
151
|
}
|
|
146
152
|
}
|
|
153
|
+
} else if (task.status === "cancelled") {
|
|
154
|
+
line += ` ${th.fg("muted", "cancelled")}`;
|
|
147
155
|
} else if (task.status === "blocked") {
|
|
148
156
|
const blockers = task.depends.filter((d) => {
|
|
149
157
|
const dep = tasks.find((t) => t.id === d);
|
|
@@ -164,12 +172,12 @@ export function setupSquadWidget(
|
|
|
164
172
|
const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
|
|
165
173
|
|
|
166
174
|
const statusText = squad.status === "done"
|
|
167
|
-
? th.fg("success", `✓ squad ${
|
|
175
|
+
? th.fg("success", `✓ squad ${progressText}`)
|
|
168
176
|
: squad.status === "failed"
|
|
169
|
-
? th.fg("error", `✗ squad ${
|
|
177
|
+
? th.fg("error", `✗ squad ${progressText}`)
|
|
170
178
|
: squad.status === "review"
|
|
171
179
|
? th.fg("warning", `◆ squad review required`)
|
|
172
|
-
: th.fg("accent", `⏳ squad ${
|
|
180
|
+
: th.fg("accent", `⏳ squad ${progressText} $${totalCost.toFixed(2)}`);
|
|
173
181
|
|
|
174
182
|
return { lines, cacheKey, statusText };
|
|
175
183
|
}
|
package/src/panel/task-list.ts
CHANGED
|
@@ -24,6 +24,8 @@ function statusIcon(status: TaskStatus, th: Theme): string {
|
|
|
24
24
|
return th.fg("error", "✗");
|
|
25
25
|
case "suspended":
|
|
26
26
|
return th.fg("muted", "⏸");
|
|
27
|
+
case "cancelled":
|
|
28
|
+
return th.fg("muted", "⊘");
|
|
27
29
|
case "pending":
|
|
28
30
|
default:
|
|
29
31
|
return th.fg("dim", "·");
|
|
@@ -42,6 +44,8 @@ function statusLabel(status: TaskStatus): string {
|
|
|
42
44
|
return "FAILED";
|
|
43
45
|
case "suspended":
|
|
44
46
|
return "paused";
|
|
47
|
+
case "cancelled":
|
|
48
|
+
return "cancelled";
|
|
45
49
|
case "pending":
|
|
46
50
|
return "pending";
|
|
47
51
|
}
|
|
@@ -241,11 +245,15 @@ export class TaskListView {
|
|
|
241
245
|
private renderSummary(tasks: Task[], width: number): string {
|
|
242
246
|
const th = this.theme;
|
|
243
247
|
const done = tasks.filter((t) => t.status === "done").length;
|
|
248
|
+
const cancelled = tasks.filter((t) => t.status === "cancelled").length;
|
|
244
249
|
const total = tasks.length;
|
|
250
|
+
const activeTotal = total - cancelled;
|
|
245
251
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
246
252
|
|
|
247
253
|
const parts: string[] = [];
|
|
248
|
-
parts.push(th.fg("accent",
|
|
254
|
+
parts.push(th.fg("accent", cancelled > 0
|
|
255
|
+
? `${done}/${activeTotal} active tasks done · ${cancelled} cancelled · ${total} total`
|
|
256
|
+
: `${done}/${total}`));
|
|
249
257
|
if (totalCost > 0) parts.push(th.fg("dim", `$${totalCost.toFixed(4)}`));
|
|
250
258
|
|
|
251
259
|
// Find squad creation time for elapsed
|
package/src/report.ts
CHANGED
|
@@ -5,10 +5,18 @@ import type { Task } from "./types.js";
|
|
|
5
5
|
* This is durable report data, not a UI preview: never shorten task output.
|
|
6
6
|
*/
|
|
7
7
|
export function buildCompletionSummary(tasks: Task[]): string {
|
|
8
|
-
|
|
8
|
+
const completed = tasks
|
|
9
9
|
.filter((task) => task.status === "done")
|
|
10
10
|
.map((task) => `- ${task.id} (${task.agent}): ${task.output || "done"}`)
|
|
11
11
|
.join("\n");
|
|
12
|
+
const cancelled = tasks
|
|
13
|
+
.filter((task) => task.status === "cancelled")
|
|
14
|
+
.map((task) => `- ${task.id} (${task.agent}): cancelled`)
|
|
15
|
+
.join("\n");
|
|
16
|
+
|
|
17
|
+
return [completed, cancelled ? `CANCELLED TASKS (neutral; not successful output)\n${cancelled}` : ""]
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.join("\n\n");
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
/** Build the full failure handoff without shortening diagnostics. */
|
package/src/scheduler.ts
CHANGED
|
@@ -258,6 +258,7 @@ export class Scheduler {
|
|
|
258
258
|
// mutation/RPC delivery, reopen exactly that task on reconstruction.
|
|
259
259
|
let recoveredMailbox = false;
|
|
260
260
|
for (const task of tasks) {
|
|
261
|
+
if (task.status === "cancelled") continue;
|
|
261
262
|
if (this.pool.isRunning(task.id)) continue;
|
|
262
263
|
if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
|
|
263
264
|
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
@@ -308,6 +309,11 @@ export class Scheduler {
|
|
|
308
309
|
}
|
|
309
310
|
|
|
310
311
|
await this.scheduleReadyTasks();
|
|
312
|
+
|
|
313
|
+
const freshSquad = store.loadSquad(this.squadId);
|
|
314
|
+
if (freshSquad && (freshSquad.status === "running" || freshSquad.status === "failed")) {
|
|
315
|
+
this.checkSquadCompletion(store.loadAllTasks(this.squadId), freshSquad);
|
|
316
|
+
}
|
|
311
317
|
}
|
|
312
318
|
|
|
313
319
|
// =========================================================================
|
|
@@ -658,6 +664,7 @@ export class Scheduler {
|
|
|
658
664
|
}
|
|
659
665
|
|
|
660
666
|
private handleUnexpectedAgentExit(event: AgentEvent): void {
|
|
667
|
+
if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
|
|
661
668
|
const exitCode = event.data?.exitCode ?? 1;
|
|
662
669
|
const turnCount = event.data?.turnCount ?? 0;
|
|
663
670
|
const stderr = event.data?.stderr || "";
|
|
@@ -787,6 +794,7 @@ export class Scheduler {
|
|
|
787
794
|
}
|
|
788
795
|
|
|
789
796
|
case "agent_settled": {
|
|
797
|
+
if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
|
|
790
798
|
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
791
799
|
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
792
800
|
// occurs before the task can become done.
|
|
@@ -838,8 +846,8 @@ export class Scheduler {
|
|
|
838
846
|
const task = store.loadTask(this.squadId, taskId);
|
|
839
847
|
if (!task) return;
|
|
840
848
|
|
|
841
|
-
// Guard against double-completion
|
|
842
|
-
if (task.status === "done") return;
|
|
849
|
+
// Guard against double-completion and late callbacks after cancellation.
|
|
850
|
+
if (task.status === "done" || task.status === "cancelled") return;
|
|
843
851
|
|
|
844
852
|
// Extract output from last messages
|
|
845
853
|
const messages = store.loadMessages(this.squadId, taskId);
|
|
@@ -907,6 +915,7 @@ export class Scheduler {
|
|
|
907
915
|
}
|
|
908
916
|
|
|
909
917
|
private handleTaskFailed(taskId: string, error: string): void {
|
|
918
|
+
if (store.loadTask(this.squadId, taskId)?.status === "cancelled") return;
|
|
910
919
|
store.updateTaskStatus(this.squadId, taskId, "failed", {
|
|
911
920
|
error,
|
|
912
921
|
completed: store.now(),
|
|
@@ -1148,10 +1157,11 @@ export class Scheduler {
|
|
|
1148
1157
|
private checkSquadCompletion(tasks: Task[], squad: Squad): void {
|
|
1149
1158
|
if (tasks.length === 0) return;
|
|
1150
1159
|
|
|
1151
|
-
const
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1160
|
+
const relevant = tasks.filter((task) => task.status !== "cancelled");
|
|
1161
|
+
const allDone = relevant.every((task) => task.status === "done");
|
|
1162
|
+
const anyFailed = relevant.some((task) => task.status === "failed");
|
|
1163
|
+
const anyInProgress = relevant.some(
|
|
1164
|
+
(task) => task.status === "in_progress" || task.status === "pending",
|
|
1155
1165
|
);
|
|
1156
1166
|
|
|
1157
1167
|
if (allDone) {
|
|
@@ -1163,9 +1173,9 @@ export class Scheduler {
|
|
|
1163
1173
|
this.emit({ type: "squad_review_required", squadId: this.squadId });
|
|
1164
1174
|
} else if (anyFailed && !anyInProgress) {
|
|
1165
1175
|
// All remaining tasks are blocked/failed with no way forward
|
|
1166
|
-
const blockedCount =
|
|
1167
|
-
const failedCount =
|
|
1168
|
-
if (blockedCount + failedCount ===
|
|
1176
|
+
const blockedCount = relevant.filter((task) => task.status === "blocked").length;
|
|
1177
|
+
const failedCount = relevant.filter((task) => task.status === "failed").length;
|
|
1178
|
+
if (blockedCount + failedCount === relevant.filter((task) => task.status !== "done").length) {
|
|
1169
1179
|
squad.status = "failed";
|
|
1170
1180
|
store.saveSquad(squad);
|
|
1171
1181
|
this.emit({ type: "squad_failed", squadId: this.squadId });
|
|
@@ -1297,6 +1307,7 @@ export class Scheduler {
|
|
|
1297
1307
|
if (seen.has(descendant.id)) continue;
|
|
1298
1308
|
seen.add(descendant.id);
|
|
1299
1309
|
queue.push(...(byDependency.get(descendant.id) ?? []));
|
|
1310
|
+
if (descendant.status === "cancelled") continue;
|
|
1300
1311
|
store.updateTaskStatus(this.squadId, descendant.id, "blocked", {
|
|
1301
1312
|
completed: null,
|
|
1302
1313
|
error: null,
|
|
@@ -1353,6 +1364,8 @@ export class Scheduler {
|
|
|
1353
1364
|
expectsReply,
|
|
1354
1365
|
});
|
|
1355
1366
|
|
|
1367
|
+
if (task.status === "cancelled") return false;
|
|
1368
|
+
|
|
1356
1369
|
const request = expectsReply
|
|
1357
1370
|
? `[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.`
|
|
1358
1371
|
: `[squad] Main orchestrator message:\n${message}`;
|
|
@@ -1387,6 +1400,9 @@ export class Scheduler {
|
|
|
1387
1400
|
|
|
1388
1401
|
/** Pause a running task */
|
|
1389
1402
|
async pauseTask(taskId: string): Promise<void> {
|
|
1403
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
1404
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1405
|
+
if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; use resume_task to revive it.`);
|
|
1390
1406
|
if (this.pool.isRunning(taskId)) {
|
|
1391
1407
|
await this.pool.steer(taskId, "[squad] Task paused by user. Summarize your current state.");
|
|
1392
1408
|
// Give agent a moment to respond, then kill
|
|
@@ -1405,6 +1421,86 @@ export class Scheduler {
|
|
|
1405
1421
|
this.updateContext();
|
|
1406
1422
|
}
|
|
1407
1423
|
|
|
1424
|
+
/** Atomically replace one task's dependency list after validating the complete historical DAG. */
|
|
1425
|
+
async setDependencies(taskId: string, depends: string[]): Promise<void> {
|
|
1426
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
1427
|
+
const target = tasks.find((task) => task.id === taskId);
|
|
1428
|
+
if (!target) throw new Error(`Task not found: ${taskId}`);
|
|
1429
|
+
if (target.status === "in_progress") {
|
|
1430
|
+
throw new Error(`Cannot edit dependencies for in_progress task '${taskId}'; pause or cancel it first.`);
|
|
1431
|
+
}
|
|
1432
|
+
if (target.status === "done") {
|
|
1433
|
+
throw new Error(`Cannot edit dependencies for done task '${taskId}'; resume it first so descendant invalidation remains authoritative.`);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const duplicate = depends.find((dependency, index) => depends.indexOf(dependency) !== index);
|
|
1437
|
+
if (duplicate) throw new Error(`Duplicate dependency '${duplicate}' for task '${taskId}'.`);
|
|
1438
|
+
if (depends.includes(taskId)) throw new Error(`Task '${taskId}' cannot depend on itself.`);
|
|
1439
|
+
const known = new Set(tasks.map((task) => task.id));
|
|
1440
|
+
const unknown = depends.filter((dependency) => !known.has(dependency));
|
|
1441
|
+
if (unknown.length > 0) throw new Error(`Unknown dependency task(s): ${unknown.join(", ")}.`);
|
|
1442
|
+
|
|
1443
|
+
const wasRunnable = target.status === "pending" && target.depends.every(
|
|
1444
|
+
(dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
|
|
1445
|
+
);
|
|
1446
|
+
const graph = new Map(tasks.map((task) => [task.id, [...task.depends]]));
|
|
1447
|
+
graph.set(taskId, [...depends]);
|
|
1448
|
+
for (const [id, dependencies] of graph) {
|
|
1449
|
+
const seen = new Set<string>();
|
|
1450
|
+
for (const dependency of dependencies) {
|
|
1451
|
+
if (!known.has(dependency)) throw new Error(`Task '${id}' depends on unknown task '${dependency}'.`);
|
|
1452
|
+
if (dependency === id) throw new Error(`Task '${id}' cannot depend on itself.`);
|
|
1453
|
+
if (seen.has(dependency)) throw new Error(`Duplicate dependency '${dependency}' for task '${id}'.`);
|
|
1454
|
+
seen.add(dependency);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const color = new Map<string, 0 | 1 | 2>();
|
|
1458
|
+
const stack: string[] = [];
|
|
1459
|
+
const visit = (id: string): void => {
|
|
1460
|
+
const state = color.get(id) ?? 0;
|
|
1461
|
+
if (state === 2) return;
|
|
1462
|
+
if (state === 1) {
|
|
1463
|
+
const start = stack.indexOf(id);
|
|
1464
|
+
const cycle = [...stack.slice(start), id];
|
|
1465
|
+
throw new Error(`Dependency cycle detected: ${cycle.join(" -> ")}`);
|
|
1466
|
+
}
|
|
1467
|
+
color.set(id, 1);
|
|
1468
|
+
stack.push(id);
|
|
1469
|
+
for (const dependency of graph.get(id) ?? []) visit(dependency);
|
|
1470
|
+
stack.pop();
|
|
1471
|
+
color.set(id, 2);
|
|
1472
|
+
};
|
|
1473
|
+
for (const id of graph.keys()) visit(id);
|
|
1474
|
+
|
|
1475
|
+
target.depends = [...depends];
|
|
1476
|
+
if (target.status === "pending" || target.status === "blocked") {
|
|
1477
|
+
target.status = depends.every(
|
|
1478
|
+
(dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
|
|
1479
|
+
) ? "pending" : "blocked";
|
|
1480
|
+
}
|
|
1481
|
+
store.saveTask(this.squadId, target);
|
|
1482
|
+
store.appendMessage(this.squadId, taskId, {
|
|
1483
|
+
ts: store.now(),
|
|
1484
|
+
from: "system",
|
|
1485
|
+
type: "status",
|
|
1486
|
+
text: `Dependencies updated: ${depends.length > 0 ? depends.join(", ") : "none"}`,
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
this.killBlockedAgents();
|
|
1490
|
+
const squad = store.loadSquad(this.squadId);
|
|
1491
|
+
const runnable = target.status === "pending" && target.depends.every(
|
|
1492
|
+
(dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
|
|
1493
|
+
);
|
|
1494
|
+
const createdRunnableWork = runnable && !wasRunnable;
|
|
1495
|
+
if (createdRunnableWork && squad && squad.status !== "paused" && squad.status !== "running") {
|
|
1496
|
+
this.reopenSquadForWork();
|
|
1497
|
+
await this.start();
|
|
1498
|
+
} else if (squad?.status === "running") {
|
|
1499
|
+
await this.start();
|
|
1500
|
+
}
|
|
1501
|
+
this.updateContext();
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1408
1504
|
/** Resume one exact task. Reopening completed work invalidates descendants and
|
|
1409
1505
|
* archives a completed active review before fresh scheduling begins. */
|
|
1410
1506
|
async resumeTask(taskId: string): Promise<void> {
|
|
@@ -1434,6 +1530,7 @@ export class Scheduler {
|
|
|
1434
1530
|
const task = store.loadTask(this.squadId, taskId);
|
|
1435
1531
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1436
1532
|
if (task.status === "done") return;
|
|
1533
|
+
if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; resume it before marking it done.`);
|
|
1437
1534
|
|
|
1438
1535
|
if (this.pool.isRunning(taskId)) {
|
|
1439
1536
|
await this.pool.kill(taskId);
|
|
@@ -1467,14 +1564,45 @@ export class Scheduler {
|
|
|
1467
1564
|
this.updateContext();
|
|
1468
1565
|
}
|
|
1469
1566
|
|
|
1470
|
-
/** Cancel
|
|
1567
|
+
/** Cancel one task after ensuring no live historical task still depends on it. */
|
|
1471
1568
|
async cancelTask(taskId: string): Promise<void> {
|
|
1472
|
-
|
|
1473
|
-
|
|
1569
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
1570
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1571
|
+
if (task.status === "cancelled") return;
|
|
1572
|
+
|
|
1573
|
+
const dependents = store.loadAllTasks(this.squadId)
|
|
1574
|
+
.filter((candidate) => candidate.status !== "cancelled" && candidate.depends.includes(taskId))
|
|
1575
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
1576
|
+
if (dependents.length > 0) {
|
|
1577
|
+
throw new Error([
|
|
1578
|
+
`Cannot cancel task '${taskId}': it is still required by:`,
|
|
1579
|
+
...dependents.map((dependent) => `- ${dependent.id} [${dependent.status}]`),
|
|
1580
|
+
"",
|
|
1581
|
+
'Update each dependent first with squad_modify action "set_dependencies",',
|
|
1582
|
+
"then retry cancel_task. Cancellation does not rewrite or cascade dependencies.",
|
|
1583
|
+
].join("\n"));
|
|
1474
1584
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1585
|
+
|
|
1586
|
+
if (this.pool.isRunning(taskId)) await this.pool.kill(taskId);
|
|
1587
|
+
store.updateTaskStatus(this.squadId, taskId, "cancelled", {
|
|
1588
|
+
error: null,
|
|
1589
|
+
completed: store.now(),
|
|
1477
1590
|
});
|
|
1591
|
+
store.appendMessage(this.squadId, taskId, {
|
|
1592
|
+
ts: store.now(),
|
|
1593
|
+
from: "system",
|
|
1594
|
+
type: "status",
|
|
1595
|
+
text: "Task cancelled by orchestrator",
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
const squad = store.loadSquad(this.squadId);
|
|
1599
|
+
if (squad) {
|
|
1600
|
+
if (squad.status === "done" || (squad.status === "review" && squad.review?.status !== "pending")) {
|
|
1601
|
+
beginOrchestratorRework(squad);
|
|
1602
|
+
store.saveSquad(squad);
|
|
1603
|
+
}
|
|
1604
|
+
this.checkSquadCompletion(store.loadAllTasks(this.squadId), squad);
|
|
1605
|
+
}
|
|
1478
1606
|
this.updateContext();
|
|
1479
1607
|
}
|
|
1480
1608
|
|
|
@@ -142,14 +142,15 @@ Send small, scoped corrections instead of restarting the task:
|
|
|
142
142
|
- Name exactly what to change and what to keep: "Change only the header component — keep the layout and routes as they are."
|
|
143
143
|
- Add missing information the moment you learn it — don't wait for the agent to get stuck
|
|
144
144
|
- If the user manually edited or reverted files the agent touched, tell the agent immediately so it doesn't overwrite the human's changes
|
|
145
|
-
- If an agent starts doing work that's no longer needed, narrow its scope via `squad_message
|
|
145
|
+
- If an agent starts doing work that's no longer needed, narrow its scope via `squad_message`. Before using `squad_modify` `cancel_task`, repair every direct dependent explicitly as described below.
|
|
146
146
|
|
|
147
147
|
## Modifying a Running Squad
|
|
148
148
|
|
|
149
149
|
Use `squad_modify` when:
|
|
150
150
|
- **`add_task`**: User requests something not in the original plan
|
|
151
|
-
- **`
|
|
152
|
-
- **`
|
|
151
|
+
- **`set_dependencies`**: Replace a task's dependency list with top-level `taskId` and `depends`, e.g. `{ action: "set_dependencies", taskId: "publish", depends: ["build"] }`. This is allowed only for tasks that are not running or done; the complete replacement is validated atomically for unknown IDs, self-dependencies, duplicates, and cycles.
|
|
152
|
+
- **`cancel_task`**: A task is no longer needed. Cancellation is refused while any non-cancelled task directly depends on it. First call `set_dependencies` for every dependent named in the refusal, then retry `cancel_task`. Cancellation never cascades and never removes or rewrites dependencies automatically.
|
|
153
|
+
- **`pause_task`** / **`resume_task`**: Temporarily halt or explicitly revive an agent; `resume_task` is also the only action that revives a cancelled task.
|
|
153
154
|
- **`pause`** / **`resume`**: Stop/restart the entire squad
|
|
154
155
|
- **`cancel`**: Abort everything (user changed their mind)
|
|
155
156
|
|
package/src/store.ts
CHANGED
|
@@ -356,8 +356,15 @@ export function findLatestSquad(projectCwd?: string): Squad | null {
|
|
|
356
356
|
// Tasks
|
|
357
357
|
// ============================================================================
|
|
358
358
|
|
|
359
|
+
function normalizeLegacyCancelledTask(task: Task | null): Task | null {
|
|
360
|
+
if (task?.status === "failed" && task.error === "Cancelled by user") {
|
|
361
|
+
return { ...task, status: "cancelled", error: null };
|
|
362
|
+
}
|
|
363
|
+
return task;
|
|
364
|
+
}
|
|
365
|
+
|
|
359
366
|
export function loadTask(squadId: string, taskId: string, parentPath?: string): Task | null {
|
|
360
|
-
return readJson<Task>(getTaskFilePath(squadId, taskId, parentPath));
|
|
367
|
+
return normalizeLegacyCancelledTask(readJson<Task>(getTaskFilePath(squadId, taskId, parentPath)));
|
|
361
368
|
}
|
|
362
369
|
|
|
363
370
|
export function saveTask(squadId: string, task: Task, parentPath?: string): void {
|
|
@@ -377,7 +384,7 @@ export function loadAllTasks(squadId: string): Task[] {
|
|
|
377
384
|
if (!entry.isDirectory()) continue;
|
|
378
385
|
if (entry.name === "knowledge") continue;
|
|
379
386
|
const taskFile = path.join(squadDir, entry.name, "task.json");
|
|
380
|
-
const task = readJson<Task>(taskFile);
|
|
387
|
+
const task = normalizeLegacyCancelledTask(readJson<Task>(taskFile));
|
|
381
388
|
if (task && !seen.has(task.id)) {
|
|
382
389
|
seen.add(task.id);
|
|
383
390
|
tasks.push(task);
|
|
@@ -401,7 +408,7 @@ function collectSubtasks(squadDir: string, parentPath: string, tasks: Task[], se
|
|
|
401
408
|
for (const entry of entries) {
|
|
402
409
|
if (!entry.isDirectory()) continue;
|
|
403
410
|
const taskFile = path.join(parentDir, entry.name, "task.json");
|
|
404
|
-
const task = readJson<Task>(taskFile);
|
|
411
|
+
const task = normalizeLegacyCancelledTask(readJson<Task>(taskFile));
|
|
405
412
|
if (task && !seen.has(task.id)) {
|
|
406
413
|
seen.add(task.id);
|
|
407
414
|
tasks.push(task);
|
package/src/types.ts
CHANGED
|
@@ -122,7 +122,7 @@ export interface Squad {
|
|
|
122
122
|
// Tasks
|
|
123
123
|
// ============================================================================
|
|
124
124
|
|
|
125
|
-
export type TaskStatus = "pending" | "blocked" | "in_progress" | "done" | "failed" | "suspended";
|
|
125
|
+
export type TaskStatus = "pending" | "blocked" | "in_progress" | "done" | "failed" | "suspended" | "cancelled";
|
|
126
126
|
|
|
127
127
|
export interface TaskUsage {
|
|
128
128
|
inputTokens: number;
|