pi-squad 0.16.4 → 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 +10 -3
- package/package.json +1 -1
- package/src/index.ts +136 -61
- package/src/panel/squad-widget.ts +12 -4
- package/src/panel/task-list.ts +9 -1
- package/src/report.ts +9 -1
- package/src/review.ts +15 -0
- package/src/scheduler.ts +198 -33
- package/src/skills/squad-supervisor/SKILL.md +4 -3
- package/src/store.ts +10 -3
- package/src/types.ts +4 -2
package/README.md
CHANGED
|
@@ -52,8 +52,10 @@ Squad agents—including QA/reviewer agents—produce candidate work and evidenc
|
|
|
52
52
|
- Persisted status becomes `review`, never directly `done`.
|
|
53
53
|
- A persistent `<squad_review_required>` system reminder tells main Pi to re-read the original conversation contract, inspect the actual diff/source, rerun verification independently, and run integration/E2E where applicable.
|
|
54
54
|
- Main Pi must call `squad_review` with requirement-by-requirement contract checks, diff review, actual command/result evidence, integration/E2E evidence, and issues.
|
|
55
|
-
- Only `pass` or `pass_with_issues` changes the squad to `done`; `fail` leaves it review-blocked
|
|
56
|
-
-
|
|
55
|
+
- Only `pass` or `pass_with_issues` changes the squad to `done`; `fail` leaves it review-blocked and cannot be overwritten by another verdict.
|
|
56
|
+
- Failed review is reworked in the **same authoritative squad**: use `squad_modify` with that `squadId` and `add_task` or `resume_task` (or `resume` when interrupted work exists). These operations reconstruct the scheduler after restart. `/squad resume <squad-id>` provides the same resume path.
|
|
57
|
+
- When rework begins, the failed attempt moves to `reviewHistory`, the squad returns to `running`, and its evidence remains auditable. After every rework task settles, a fresh pending review becomes the active gate and `squad_review` is required again.
|
|
58
|
+
- Pending and failed review gates survive Pi restarts and are restored on the next session. A separate squad never links to, remediates, or accepts the failed gate.
|
|
57
59
|
|
|
58
60
|
The completion report is explicitly labeled **untrusted and not yet accepted**. Main Pi must never merely relay it or ask whether verification should be run.
|
|
59
61
|
|
|
@@ -188,6 +190,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
|
|
|
188
190
|
| Command | Description |
|
|
189
191
|
|---|---|
|
|
190
192
|
| `/squad select` | Pick a squad to view |
|
|
193
|
+
| `/squad resume [squad-id]` | Reconstruct and resume an exact paused/failed/failed-review squad |
|
|
191
194
|
| `/squad list` | List project squads |
|
|
192
195
|
| `/squad all` | List all squads |
|
|
193
196
|
| `/squad agents` | Manage agent definitions |
|
|
@@ -206,7 +209,11 @@ Full overlay with task list, live activity preview, and scrollable message view.
|
|
|
206
209
|
| `squad` | Start a squad with goal + optional tasks/config |
|
|
207
210
|
| `squad_status` | Check progress, costs, task states |
|
|
208
211
|
| `squad_message` | Durably message an exact task; completed tasks reopen on their original session |
|
|
209
|
-
| `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`.
|
|
210
217
|
|
|
211
218
|
The main agent sees available agents in its system prompt and squad state when a squad is active.
|
|
212
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.
|
|
@@ -176,6 +186,38 @@ function getActiveScheduler(): Scheduler | null {
|
|
|
176
186
|
return schedulers.get(activeSquadId) || null;
|
|
177
187
|
}
|
|
178
188
|
|
|
189
|
+
/** Reconstruct and focus one exact persisted squad without creating/linking another. */
|
|
190
|
+
function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
|
|
191
|
+
let scheduler = schedulers.get(squadId);
|
|
192
|
+
if (!scheduler) {
|
|
193
|
+
scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
|
|
194
|
+
schedulers.set(squadId, scheduler);
|
|
195
|
+
wireSchedulerEvents(pi, scheduler, squadId);
|
|
196
|
+
}
|
|
197
|
+
activeSquadId = squadId;
|
|
198
|
+
widgetState.squadId = squadId;
|
|
199
|
+
widgetState.enabled = true;
|
|
200
|
+
widgetControls?.requestUpdate();
|
|
201
|
+
return scheduler;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isResumeCandidate(squad: Squad): boolean {
|
|
205
|
+
return squad.status === "paused" || squad.status === "failed" ||
|
|
206
|
+
(squad.status === "review" && squad.review?.status === "failed") ||
|
|
207
|
+
store.loadAllTasks(squad.id).some((task) => task.status === "suspended" || task.status === "failed");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
|
|
211
|
+
if (explicitId) return store.loadSquad(explicitId);
|
|
212
|
+
if (activeSquadId) {
|
|
213
|
+
const active = store.loadSquad(activeSquadId);
|
|
214
|
+
if (active?.cwd === cwd && isResumeCandidate(active)) return active;
|
|
215
|
+
}
|
|
216
|
+
return store.listSquadsForProject(cwd)
|
|
217
|
+
.filter(isResumeCandidate)
|
|
218
|
+
.sort((a, b) => b.created.localeCompare(a.created))[0] ?? null;
|
|
219
|
+
}
|
|
220
|
+
|
|
179
221
|
|
|
180
222
|
// ============================================================================
|
|
181
223
|
// Extension Entry
|
|
@@ -236,11 +278,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
278
|
return;
|
|
237
279
|
}
|
|
238
280
|
|
|
239
|
-
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
240
281
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
241
282
|
|
|
242
283
|
const taskLines = tasks.map((t) => {
|
|
243
|
-
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" ? "⊘" : "·";
|
|
244
285
|
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
245
286
|
if (t.output) line += ` — ${t.output}`;
|
|
246
287
|
if (t.error) line += ` ERROR: ${t.error}`;
|
|
@@ -250,7 +291,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
250
291
|
const squadContext = [
|
|
251
292
|
`<squad_status>`,
|
|
252
293
|
`Squad: ${squad.id} — ${squad.goal}`,
|
|
253
|
-
`Status: ${squad.status} | ${
|
|
294
|
+
`Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
|
|
254
295
|
taskLines,
|
|
255
296
|
`</squad_status>`,
|
|
256
297
|
...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
|
|
@@ -411,6 +452,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
411
452
|
task.status === "in_progress" ? "⏳" :
|
|
412
453
|
task.status === "blocked" ? "◻" :
|
|
413
454
|
task.status === "failed" ? "✗" :
|
|
455
|
+
task.status === "cancelled" ? "⊘" :
|
|
414
456
|
"·";
|
|
415
457
|
let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
|
|
416
458
|
if (task.blockedBy?.length) line += ` blocked by: ${task.blockedBy.join(", ")}`;
|
|
@@ -418,9 +460,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
418
460
|
})
|
|
419
461
|
.join("\n");
|
|
420
462
|
|
|
463
|
+
const durableTasks = store.loadAllTasks(id!);
|
|
421
464
|
const summary = [
|
|
422
465
|
`Squad: ${id}`,
|
|
423
466
|
`Status: ${context.status}`,
|
|
467
|
+
`Progress: ${formatTaskProgress(durableTasks)}`,
|
|
424
468
|
`Elapsed: ${context.elapsed}`,
|
|
425
469
|
`Cost: $${context.costs.total.toFixed(4)}`,
|
|
426
470
|
...(context.status === "review" ? ["Acceptance: BLOCKED — independent main-orchestrator review required via squad_review"] : []),
|
|
@@ -560,11 +604,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
560
604
|
pi.registerTool({
|
|
561
605
|
name: "squad_modify",
|
|
562
606
|
label: "Squad Modify",
|
|
563
|
-
description: "Modify
|
|
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.",
|
|
564
608
|
parameters: Type.Object({
|
|
609
|
+
squadId: Type.Optional(Type.String({ description: "Exact squad to modify (recommended for failed-review rework; default: focused/recoverable project squad)" })),
|
|
565
610
|
action: Type.Union(
|
|
566
611
|
[
|
|
567
612
|
Type.Literal("add_task"),
|
|
613
|
+
Type.Literal("set_dependencies"),
|
|
568
614
|
Type.Literal("cancel_task"),
|
|
569
615
|
Type.Literal("pause_task"),
|
|
570
616
|
Type.Literal("resume_task"),
|
|
@@ -576,6 +622,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
576
622
|
{ description: "Action to perform" },
|
|
577
623
|
),
|
|
578
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" })),
|
|
579
626
|
output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
|
|
580
627
|
task: Type.Optional(
|
|
581
628
|
Type.Object({
|
|
@@ -590,45 +637,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
590
637
|
}),
|
|
591
638
|
|
|
592
639
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
593
|
-
// Resume can work without an active scheduler — it recreates one from disk
|
|
594
640
|
if (params.action === "resume") {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return { content: [{ type: "text" as const, text: "No paused or failed squad found to resume." }], details: undefined };
|
|
641
|
+
const squad = resolveResumeSquad(ctx.cwd, params.squadId);
|
|
642
|
+
if (!squad) {
|
|
643
|
+
const text = params.squadId
|
|
644
|
+
? `Squad '${params.squadId}' not found.`
|
|
645
|
+
: "No paused, failed, or failed-review squad found to resume.";
|
|
646
|
+
return { content: [{ type: "text" as const, text }], details: undefined };
|
|
602
647
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
activeSquadId = squadId;
|
|
609
|
-
|
|
610
|
-
// Activate widget
|
|
611
|
-
widgetState.squadId = squadId;
|
|
612
|
-
widgetState.enabled = true;
|
|
613
|
-
widgetControls?.requestUpdate();
|
|
614
|
-
|
|
615
|
-
wireSchedulerEvents(pi, scheduler, squadId);
|
|
648
|
+
const resumeSched = ensureScheduler(pi, squad.id, squadSkillPaths);
|
|
649
|
+
try {
|
|
650
|
+
await resumeSched.resume();
|
|
651
|
+
} catch (err) {
|
|
652
|
+
return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
|
|
616
653
|
}
|
|
617
|
-
|
|
618
|
-
const
|
|
619
|
-
resumeSched.resume().catch((err) => {
|
|
620
|
-
logError("squad", `Resume error: ${(err as Error).message}`);
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
const tasks = store.loadAllTasks(squadId);
|
|
624
|
-
const done = tasks.filter(t => t.status === "done").length;
|
|
625
|
-
return { content: [{ type: "text" as const, text: `Squad "${squadId}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }], details: undefined };
|
|
654
|
+
const tasks = store.loadAllTasks(squad.id);
|
|
655
|
+
return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${formatTaskProgress(tasks)}). Agents restarting in background.` }], details: undefined };
|
|
626
656
|
}
|
|
627
657
|
|
|
628
|
-
const
|
|
629
|
-
if (!
|
|
630
|
-
return { content: [{ type: "text" as const, text:
|
|
658
|
+
const squadId = params.squadId || activeSquadId;
|
|
659
|
+
if (!squadId || !store.loadSquad(squadId)) {
|
|
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 };
|
|
661
|
+
}
|
|
662
|
+
let activeScheduler = schedulers.get(squadId) || null;
|
|
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")) {
|
|
664
|
+
activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
|
|
631
665
|
}
|
|
666
|
+
if (!activeScheduler) {
|
|
667
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' has no active scheduler. Use resume, add_task, or resume_task to reconstruct it.` }], details: undefined };
|
|
668
|
+
}
|
|
669
|
+
activeSquadId = squadId;
|
|
632
670
|
|
|
633
671
|
switch (params.action) {
|
|
634
672
|
case "add_task": {
|
|
@@ -645,17 +683,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
645
683
|
if (badDeps.length > 0) {
|
|
646
684
|
return { content: [{ type: "text" as const, text: `Unknown dependency task(s): ${badDeps.join(", ")}. Existing tasks: ${[...existingIds].join(", ")}` }], details: undefined };
|
|
647
685
|
}
|
|
648
|
-
|
|
649
|
-
|
|
686
|
+
const targetCwd = store.loadSquad(squadId)!.cwd;
|
|
687
|
+
if (!store.loadAgentDef(params.task.agent, targetCwd)) {
|
|
688
|
+
const available = store.loadAllAgentDefs(targetCwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
|
|
650
689
|
return { content: [{ type: "text" as const, text: `Unknown agent '${params.task.agent}'. Available: ${available}` }], details: undefined };
|
|
651
690
|
}
|
|
691
|
+
const dependencies = params.task.depends || [];
|
|
652
692
|
const task: Task = {
|
|
653
693
|
id: params.task.id,
|
|
654
694
|
title: params.task.title,
|
|
655
695
|
description: params.task.description || "",
|
|
656
696
|
agent: params.task.agent,
|
|
657
|
-
status: "pending",
|
|
658
|
-
depends:
|
|
697
|
+
status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
|
|
698
|
+
depends: dependencies,
|
|
659
699
|
...(params.task.inheritContext ? { inheritContext: true } : {}),
|
|
660
700
|
created: store.now(),
|
|
661
701
|
started: null,
|
|
@@ -664,14 +704,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
664
704
|
error: null,
|
|
665
705
|
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
666
706
|
};
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
707
|
+
try {
|
|
708
|
+
await activeScheduler.addTask(task);
|
|
709
|
+
} catch (err) {
|
|
710
|
+
return { content: [{ type: "text" as const, text: `add_task failed: ${(err as Error).message}` }], details: undefined };
|
|
711
|
+
}
|
|
712
|
+
return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
|
|
713
|
+
}
|
|
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 };
|
|
670
724
|
}
|
|
671
725
|
|
|
672
726
|
case "cancel_task": {
|
|
673
727
|
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
674
|
-
|
|
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
|
+
}
|
|
675
733
|
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }], details: undefined };
|
|
676
734
|
}
|
|
677
735
|
|
|
@@ -683,10 +741,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
683
741
|
|
|
684
742
|
case "resume_task": {
|
|
685
743
|
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
})
|
|
689
|
-
|
|
744
|
+
try {
|
|
745
|
+
await activeScheduler.resumeTask(params.taskId);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
return { content: [{ type: "text" as const, text: `resume_task failed: ${(err as Error).message}` }], details: undefined };
|
|
748
|
+
}
|
|
749
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
|
|
690
750
|
}
|
|
691
751
|
|
|
692
752
|
case "complete_task": {
|
|
@@ -767,7 +827,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
767
827
|
// occur after the mailbox-first write but before the squad/task is reopened.
|
|
768
828
|
const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
|
|
769
829
|
.filter((squad) => store.loadAllTasks(squad.id)
|
|
770
|
-
.some((task) => store.loadPendingTaskMessages(squad.id, task.id).length > 0))
|
|
830
|
+
.some((task) => task.status !== "cancelled" && store.loadPendingTaskMessages(squad.id, task.id).length > 0))
|
|
771
831
|
.sort((a, b) => b.created.localeCompare(a.created));
|
|
772
832
|
for (const squad of pendingMailSquads) {
|
|
773
833
|
let scheduler = schedulers.get(squad.id);
|
|
@@ -796,7 +856,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
796
856
|
if (done > 0) {
|
|
797
857
|
pi.sendMessage({
|
|
798
858
|
customType: "squad-paused",
|
|
799
|
-
content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${
|
|
859
|
+
content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
|
|
800
860
|
`Use squad_modify with action "resume" to continue, or start a new squad.`,
|
|
801
861
|
display: true,
|
|
802
862
|
});
|
|
@@ -866,12 +926,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
866
926
|
// =========================================================================
|
|
867
927
|
|
|
868
928
|
pi.registerCommand("squad", {
|
|
869
|
-
description: "Browse, select, and manage squads. Usage: /squad [list|all|select|agents|msg|widget|panel|cancel|clear]",
|
|
929
|
+
description: "Browse, select, and manage squads. Usage: /squad [list|all|select|resume|agents|msg|widget|panel|cancel|clear]",
|
|
870
930
|
getArgumentCompletions: (prefix) => {
|
|
871
931
|
const subs = [
|
|
872
932
|
{ value: "list", label: "list", description: "List squads for current project" },
|
|
873
933
|
{ value: "all", label: "all", description: "List all squads, select to activate" },
|
|
874
934
|
{ value: "select", label: "select", description: "Pick a squad to view (interactive)" },
|
|
935
|
+
{ value: "resume", label: "resume", description: "Resume an exact paused/failed/failed-review squad" },
|
|
875
936
|
{ value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
|
|
876
937
|
{ value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
|
|
877
938
|
{ value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
|
|
@@ -941,6 +1002,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
941
1002
|
return;
|
|
942
1003
|
}
|
|
943
1004
|
|
|
1005
|
+
case "resume": {
|
|
1006
|
+
const squad = resolveResumeSquad(ctx.cwd, parts[1]);
|
|
1007
|
+
if (!squad) {
|
|
1008
|
+
ctx.ui.notify(parts[1] ? `Squad '${parts[1]}' not found` : "No paused, failed, or failed-review squad found", "warning");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const scheduler = ensureScheduler(pi, squad.id, squadSkillPaths);
|
|
1012
|
+
try {
|
|
1013
|
+
await scheduler.resume();
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
ctx.ui.notify(`Resume failed: ${(error as Error).message}`, "error");
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const tasks = store.loadAllTasks(squad.id);
|
|
1019
|
+
ctx.ui.notify(`Resumed: ${squad.id} (${formatTaskProgress(tasks)})`, "info");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
944
1023
|
case "widget": {
|
|
945
1024
|
widgetState.enabled = !widgetState.enabled;
|
|
946
1025
|
if (widgetState.enabled) {
|
|
@@ -1112,10 +1191,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1112
1191
|
"🗑 Delete ALL squads",
|
|
1113
1192
|
...squads.map((s) => {
|
|
1114
1193
|
const tasks = store.loadAllTasks(s.id);
|
|
1115
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1116
1194
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1117
1195
|
const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
|
|
1118
|
-
return `${icon} ${s.id} [${s.status}] ${
|
|
1196
|
+
return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`;
|
|
1119
1197
|
}),
|
|
1120
1198
|
];
|
|
1121
1199
|
|
|
@@ -1398,7 +1476,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1398
1476
|
activateSquadView(direct.id, ctx);
|
|
1399
1477
|
return;
|
|
1400
1478
|
}
|
|
1401
|
-
ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
|
|
1479
|
+
ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, resume, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
|
|
1402
1480
|
}
|
|
1403
1481
|
},
|
|
1404
1482
|
});
|
|
@@ -1422,11 +1500,10 @@ async function pickSquad(
|
|
|
1422
1500
|
|
|
1423
1501
|
const options = squads.map((s) => {
|
|
1424
1502
|
const tasks = store.loadAllTasks(s.id);
|
|
1425
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1426
1503
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1427
1504
|
const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
|
|
1428
1505
|
const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
|
|
1429
|
-
return `${icon} ${s.id} [${s.status}] ${
|
|
1506
|
+
return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
|
|
1430
1507
|
});
|
|
1431
1508
|
|
|
1432
1509
|
const choice = await ctx.ui.select("Select a squad", options);
|
|
@@ -1459,9 +1536,8 @@ function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-codi
|
|
|
1459
1536
|
// Compact notification — widget already shows full task details.
|
|
1460
1537
|
// Avoid large multi-line notifications that can break TUI layout.
|
|
1461
1538
|
const tasks = store.loadAllTasks(squadId);
|
|
1462
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1463
1539
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1464
|
-
ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${
|
|
1540
|
+
ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`, "info");
|
|
1465
1541
|
}
|
|
1466
1542
|
|
|
1467
1543
|
// ============================================================================
|
|
@@ -1500,11 +1576,10 @@ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: st
|
|
|
1500
1576
|
case "squad_failed": {
|
|
1501
1577
|
const tasks = store.loadAllTasks(squadId);
|
|
1502
1578
|
const failed = tasks.filter((task) => task.status === "failed");
|
|
1503
|
-
const done = tasks.filter((task) => task.status === "done");
|
|
1504
1579
|
pi.sendMessage({
|
|
1505
1580
|
customType: "squad-failed",
|
|
1506
1581
|
content: `[squad] Squad "${squadId}" has stalled. ` +
|
|
1507
|
-
`${
|
|
1582
|
+
`${formatTaskProgress(tasks)}, ${failed.length} failed.\n` +
|
|
1508
1583
|
`Failed: ${buildFailureSummary(tasks)}\n` +
|
|
1509
1584
|
"Use squad_status for details or squad_modify to adjust.",
|
|
1510
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/review.ts
CHANGED
|
@@ -11,6 +11,18 @@ export interface OrchestratorReviewInput {
|
|
|
11
11
|
issues: string[];
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Start same-squad rework without discarding completed review evidence.
|
|
16
|
+
* The next all-tasks-done transition creates a new active pending review.
|
|
17
|
+
*/
|
|
18
|
+
export function beginOrchestratorRework(squad: Squad): void {
|
|
19
|
+
if (squad.review && squad.review.status !== "pending") {
|
|
20
|
+
squad.reviewHistory = [...(squad.reviewHistory ?? []), { ...squad.review }];
|
|
21
|
+
}
|
|
22
|
+
delete squad.review;
|
|
23
|
+
squad.status = "running";
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
/** Move a squad from agent execution into mandatory independent main-session review. */
|
|
15
27
|
export function beginOrchestratorReview(squad: Squad): void {
|
|
16
28
|
squad.status = "review";
|
|
@@ -35,6 +47,9 @@ export function recordOrchestratorReview(squad: Squad, input: OrchestratorReview
|
|
|
35
47
|
if (squad.status !== "review" || !squad.review) {
|
|
36
48
|
throw new Error(`Squad '${squad.id}' is not awaiting orchestrator review`);
|
|
37
49
|
}
|
|
50
|
+
if (squad.review.status !== "pending") {
|
|
51
|
+
throw new Error(`Squad '${squad.id}' review attempt is already ${squad.review.status}; begin same-squad rework before submitting a fresh review`);
|
|
52
|
+
}
|
|
38
53
|
|
|
39
54
|
const contractChecks = cleanList(input.contractChecks);
|
|
40
55
|
const verificationEvidence = cleanList(input.verificationEvidence);
|
package/src/scheduler.ts
CHANGED
|
@@ -19,7 +19,7 @@ import * as store from "./store.js";
|
|
|
19
19
|
import { debug, logError } from "./logger.js";
|
|
20
20
|
import { buildAgentSystemPrompt } from "./protocol.js";
|
|
21
21
|
import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
|
|
22
|
-
import { beginOrchestratorReview } from "./review.js";
|
|
22
|
+
import { beginOrchestratorReview, beginOrchestratorRework } from "./review.js";
|
|
23
23
|
|
|
24
24
|
// ============================================================================
|
|
25
25
|
// Types
|
|
@@ -170,6 +170,10 @@ export class Scheduler {
|
|
|
170
170
|
|
|
171
171
|
/** Start the scheduler — begins scheduling ready tasks */
|
|
172
172
|
async start(): Promise<void> {
|
|
173
|
+
if (this.running) {
|
|
174
|
+
await this.reconcile();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
173
177
|
this.running = true;
|
|
174
178
|
this.monitor.start();
|
|
175
179
|
await this.reconcile();
|
|
@@ -200,13 +204,15 @@ export class Scheduler {
|
|
|
200
204
|
await this.pool.killAll();
|
|
201
205
|
}
|
|
202
206
|
|
|
203
|
-
/** Resume
|
|
204
|
-
*
|
|
207
|
+
/** Resume suspended/failed work. A failed review is archived only when
|
|
208
|
+
* resumable work actually exists; a bare resume cannot bypass the gate. */
|
|
205
209
|
async resume(): Promise<void> {
|
|
206
210
|
const tasks = store.loadAllTasks(this.squadId);
|
|
211
|
+
let resumedWork = false;
|
|
207
212
|
for (const task of tasks) {
|
|
208
213
|
if (task.status === "suspended") {
|
|
209
214
|
store.updateTaskStatus(this.squadId, task.id, "pending");
|
|
215
|
+
resumedWork = true;
|
|
210
216
|
} else if (task.status === "failed") {
|
|
211
217
|
store.updateTaskStatus(this.squadId, task.id, "pending", { error: null });
|
|
212
218
|
store.appendMessage(this.squadId, task.id, {
|
|
@@ -215,13 +221,19 @@ export class Scheduler {
|
|
|
215
221
|
type: "status",
|
|
216
222
|
text: "Reset failed → pending on squad resume",
|
|
217
223
|
});
|
|
224
|
+
resumedWork = true;
|
|
218
225
|
}
|
|
219
226
|
}
|
|
220
227
|
|
|
221
228
|
const squad = store.loadSquad(this.squadId);
|
|
222
|
-
if (squad
|
|
223
|
-
squad.status
|
|
224
|
-
|
|
229
|
+
if (squad) {
|
|
230
|
+
if (resumedWork && squad.status === "review" && squad.review?.status === "failed") {
|
|
231
|
+
beginOrchestratorRework(squad);
|
|
232
|
+
store.saveSquad(squad);
|
|
233
|
+
} else if (squad.status === "paused" || squad.status === "failed") {
|
|
234
|
+
squad.status = "running";
|
|
235
|
+
store.saveSquad(squad);
|
|
236
|
+
}
|
|
225
237
|
}
|
|
226
238
|
|
|
227
239
|
await this.start();
|
|
@@ -246,6 +258,7 @@ export class Scheduler {
|
|
|
246
258
|
// mutation/RPC delivery, reopen exactly that task on reconstruction.
|
|
247
259
|
let recoveredMailbox = false;
|
|
248
260
|
for (const task of tasks) {
|
|
261
|
+
if (task.status === "cancelled") continue;
|
|
249
262
|
if (this.pool.isRunning(task.id)) continue;
|
|
250
263
|
if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
|
|
251
264
|
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
@@ -262,8 +275,7 @@ export class Scheduler {
|
|
|
262
275
|
recoveredMailbox = true;
|
|
263
276
|
}
|
|
264
277
|
if (recoveredMailbox && squad.status !== "running") {
|
|
265
|
-
squad
|
|
266
|
-
delete squad.review;
|
|
278
|
+
beginOrchestratorRework(squad);
|
|
267
279
|
store.saveSquad(squad);
|
|
268
280
|
}
|
|
269
281
|
|
|
@@ -297,6 +309,11 @@ export class Scheduler {
|
|
|
297
309
|
}
|
|
298
310
|
|
|
299
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
|
+
}
|
|
300
317
|
}
|
|
301
318
|
|
|
302
319
|
// =========================================================================
|
|
@@ -647,6 +664,7 @@ export class Scheduler {
|
|
|
647
664
|
}
|
|
648
665
|
|
|
649
666
|
private handleUnexpectedAgentExit(event: AgentEvent): void {
|
|
667
|
+
if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
|
|
650
668
|
const exitCode = event.data?.exitCode ?? 1;
|
|
651
669
|
const turnCount = event.data?.turnCount ?? 0;
|
|
652
670
|
const stderr = event.data?.stderr || "";
|
|
@@ -776,6 +794,7 @@ export class Scheduler {
|
|
|
776
794
|
}
|
|
777
795
|
|
|
778
796
|
case "agent_settled": {
|
|
797
|
+
if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
|
|
779
798
|
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
780
799
|
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
781
800
|
// occurs before the task can become done.
|
|
@@ -827,8 +846,8 @@ export class Scheduler {
|
|
|
827
846
|
const task = store.loadTask(this.squadId, taskId);
|
|
828
847
|
if (!task) return;
|
|
829
848
|
|
|
830
|
-
// Guard against double-completion
|
|
831
|
-
if (task.status === "done") return;
|
|
849
|
+
// Guard against double-completion and late callbacks after cancellation.
|
|
850
|
+
if (task.status === "done" || task.status === "cancelled") return;
|
|
832
851
|
|
|
833
852
|
// Extract output from last messages
|
|
834
853
|
const messages = store.loadMessages(this.squadId, taskId);
|
|
@@ -896,6 +915,7 @@ export class Scheduler {
|
|
|
896
915
|
}
|
|
897
916
|
|
|
898
917
|
private handleTaskFailed(taskId: string, error: string): void {
|
|
918
|
+
if (store.loadTask(this.squadId, taskId)?.status === "cancelled") return;
|
|
899
919
|
store.updateTaskStatus(this.squadId, taskId, "failed", {
|
|
900
920
|
error,
|
|
901
921
|
completed: store.now(),
|
|
@@ -1137,10 +1157,11 @@ export class Scheduler {
|
|
|
1137
1157
|
private checkSquadCompletion(tasks: Task[], squad: Squad): void {
|
|
1138
1158
|
if (tasks.length === 0) return;
|
|
1139
1159
|
|
|
1140
|
-
const
|
|
1141
|
-
const
|
|
1142
|
-
const
|
|
1143
|
-
|
|
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",
|
|
1144
1165
|
);
|
|
1145
1166
|
|
|
1146
1167
|
if (allDone) {
|
|
@@ -1152,9 +1173,9 @@ export class Scheduler {
|
|
|
1152
1173
|
this.emit({ type: "squad_review_required", squadId: this.squadId });
|
|
1153
1174
|
} else if (anyFailed && !anyInProgress) {
|
|
1154
1175
|
// All remaining tasks are blocked/failed with no way forward
|
|
1155
|
-
const blockedCount =
|
|
1156
|
-
const failedCount =
|
|
1157
|
-
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) {
|
|
1158
1179
|
squad.status = "failed";
|
|
1159
1180
|
store.saveSquad(squad);
|
|
1160
1181
|
this.emit({ type: "squad_failed", squadId: this.squadId });
|
|
@@ -1286,6 +1307,7 @@ export class Scheduler {
|
|
|
1286
1307
|
if (seen.has(descendant.id)) continue;
|
|
1287
1308
|
seen.add(descendant.id);
|
|
1288
1309
|
queue.push(...(byDependency.get(descendant.id) ?? []));
|
|
1310
|
+
if (descendant.status === "cancelled") continue;
|
|
1289
1311
|
store.updateTaskStatus(this.squadId, descendant.id, "blocked", {
|
|
1290
1312
|
completed: null,
|
|
1291
1313
|
error: null,
|
|
@@ -1301,6 +1323,17 @@ export class Scheduler {
|
|
|
1301
1323
|
await Promise.all(kills);
|
|
1302
1324
|
}
|
|
1303
1325
|
|
|
1326
|
+
private reopenSquadForWork(): void {
|
|
1327
|
+
const squad = store.loadSquad(this.squadId);
|
|
1328
|
+
if (!squad) throw new Error(`Squad not found: ${this.squadId}`);
|
|
1329
|
+
if (squad.status === "review" || squad.status === "done" || squad.review) {
|
|
1330
|
+
beginOrchestratorRework(squad);
|
|
1331
|
+
} else if (squad.status !== "running") {
|
|
1332
|
+
squad.status = "running";
|
|
1333
|
+
}
|
|
1334
|
+
store.saveSquad(squad);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1304
1337
|
private async reopenTaskForMessage(task: Task): Promise<void> {
|
|
1305
1338
|
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
1306
1339
|
const tasks = store.loadAllTasks(this.squadId);
|
|
@@ -1313,14 +1346,7 @@ export class Scheduler {
|
|
|
1313
1346
|
completed: null,
|
|
1314
1347
|
});
|
|
1315
1348
|
|
|
1316
|
-
|
|
1317
|
-
if (squad && squad.status !== "running") {
|
|
1318
|
-
squad.status = "running";
|
|
1319
|
-
// Any prior acceptance/review applies to the pre-resume result. The
|
|
1320
|
-
// normal all-done path will require a fresh independent review.
|
|
1321
|
-
delete squad.review;
|
|
1322
|
-
store.saveSquad(squad);
|
|
1323
|
-
}
|
|
1349
|
+
this.reopenSquadForWork();
|
|
1324
1350
|
}
|
|
1325
1351
|
|
|
1326
1352
|
/** Send a main-orchestrator request to one exact task and await its next reply. */
|
|
@@ -1338,6 +1364,8 @@ export class Scheduler {
|
|
|
1338
1364
|
expectsReply,
|
|
1339
1365
|
});
|
|
1340
1366
|
|
|
1367
|
+
if (task.status === "cancelled") return false;
|
|
1368
|
+
|
|
1341
1369
|
const request = expectsReply
|
|
1342
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.`
|
|
1343
1371
|
: `[squad] Main orchestrator message:\n${message}`;
|
|
@@ -1372,6 +1400,9 @@ export class Scheduler {
|
|
|
1372
1400
|
|
|
1373
1401
|
/** Pause a running task */
|
|
1374
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.`);
|
|
1375
1406
|
if (this.pool.isRunning(taskId)) {
|
|
1376
1407
|
await this.pool.steer(taskId, "[squad] Task paused by user. Summarize your current state.");
|
|
1377
1408
|
// Give agent a moment to respond, then kill
|
|
@@ -1381,10 +1412,112 @@ export class Scheduler {
|
|
|
1381
1412
|
this.updateContext();
|
|
1382
1413
|
}
|
|
1383
1414
|
|
|
1384
|
-
/**
|
|
1415
|
+
/** Add work to this exact squad and schedule it, reconstructing safely after restart. */
|
|
1416
|
+
async addTask(task: Task): Promise<void> {
|
|
1417
|
+
if (store.loadTask(this.squadId, task.id)) throw new Error(`Task already exists: ${task.id}`);
|
|
1418
|
+
store.createTask(this.squadId, task);
|
|
1419
|
+
this.reopenSquadForWork();
|
|
1420
|
+
await this.start();
|
|
1421
|
+
this.updateContext();
|
|
1422
|
+
}
|
|
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
|
+
|
|
1504
|
+
/** Resume one exact task. Reopening completed work invalidates descendants and
|
|
1505
|
+
* archives a completed active review before fresh scheduling begins. */
|
|
1385
1506
|
async resumeTask(taskId: string): Promise<void> {
|
|
1386
|
-
store.
|
|
1387
|
-
|
|
1507
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
1508
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1509
|
+
if (task.status === "done") await this.invalidateDescendants(taskId);
|
|
1510
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
1511
|
+
const dependenciesDone = task.depends.every(
|
|
1512
|
+
(depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
|
|
1513
|
+
);
|
|
1514
|
+
store.updateTaskStatus(this.squadId, taskId, dependenciesDone ? "pending" : "blocked", {
|
|
1515
|
+
error: null,
|
|
1516
|
+
completed: null,
|
|
1517
|
+
});
|
|
1518
|
+
this.reopenSquadForWork();
|
|
1519
|
+
await this.start();
|
|
1520
|
+
this.updateContext();
|
|
1388
1521
|
}
|
|
1389
1522
|
|
|
1390
1523
|
/**
|
|
@@ -1397,6 +1530,7 @@ export class Scheduler {
|
|
|
1397
1530
|
const task = store.loadTask(this.squadId, taskId);
|
|
1398
1531
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1399
1532
|
if (task.status === "done") return;
|
|
1533
|
+
if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; resume it before marking it done.`);
|
|
1400
1534
|
|
|
1401
1535
|
if (this.pool.isRunning(taskId)) {
|
|
1402
1536
|
await this.pool.kill(taskId);
|
|
@@ -1430,14 +1564,45 @@ export class Scheduler {
|
|
|
1430
1564
|
this.updateContext();
|
|
1431
1565
|
}
|
|
1432
1566
|
|
|
1433
|
-
/** Cancel
|
|
1567
|
+
/** Cancel one task after ensuring no live historical task still depends on it. */
|
|
1434
1568
|
async cancelTask(taskId: string): Promise<void> {
|
|
1435
|
-
|
|
1436
|
-
|
|
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"));
|
|
1437
1584
|
}
|
|
1438
|
-
|
|
1439
|
-
|
|
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(),
|
|
1440
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
|
+
}
|
|
1441
1606
|
this.updateContext();
|
|
1442
1607
|
}
|
|
1443
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
|
@@ -112,15 +112,17 @@ export interface Squad {
|
|
|
112
112
|
/** Agent name → overrides. Keys must exist in .pi/squad/agents/ */
|
|
113
113
|
agents: Record<string, SquadAgentEntry>;
|
|
114
114
|
config: SquadConfig;
|
|
115
|
-
/** Mandatory independent main-session review; absent
|
|
115
|
+
/** Mandatory independent main-session review; absent while rework is running. */
|
|
116
116
|
review?: SquadReview;
|
|
117
|
+
/** Completed prior review attempts retained as same-squad audit evidence. */
|
|
118
|
+
reviewHistory?: SquadReview[];
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
// ============================================================================
|
|
120
122
|
// Tasks
|
|
121
123
|
// ============================================================================
|
|
122
124
|
|
|
123
|
-
export type TaskStatus = "pending" | "blocked" | "in_progress" | "done" | "failed" | "suspended";
|
|
125
|
+
export type TaskStatus = "pending" | "blocked" | "in_progress" | "done" | "failed" | "suspended" | "cancelled";
|
|
124
126
|
|
|
125
127
|
export interface TaskUsage {
|
|
126
128
|
inputTokens: number;
|