pi-squad 0.16.5 → 0.16.7
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 +26 -3
- package/package.json +1 -1
- package/src/index.ts +158 -56
- package/src/panel/squad-widget.ts +27 -10
- package/src/panel/task-list.ts +19 -1
- package/src/presentation.ts +38 -0
- package/src/report.ts +9 -1
- package/src/review.ts +11 -2
- package/src/scheduler.ts +263 -16
- package/src/skills/squad-supervisor/SKILL.md +10 -4
- package/src/store.ts +10 -3
- package/src/types.ts +14 -1
package/src/panel/task-list.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
import type { Task, TaskStatus } from "../types.js";
|
|
8
8
|
import type { Scheduler } from "../scheduler.js";
|
|
9
|
+
import { formatSuspendedAttention, getReviewPresentation } from "../presentation.js";
|
|
9
10
|
import * as store from "../store.js";
|
|
10
11
|
|
|
11
12
|
// ============================================================================
|
|
@@ -24,6 +25,8 @@ function statusIcon(status: TaskStatus, th: Theme): string {
|
|
|
24
25
|
return th.fg("error", "✗");
|
|
25
26
|
case "suspended":
|
|
26
27
|
return th.fg("muted", "⏸");
|
|
28
|
+
case "cancelled":
|
|
29
|
+
return th.fg("muted", "⊘");
|
|
27
30
|
case "pending":
|
|
28
31
|
default:
|
|
29
32
|
return th.fg("dim", "·");
|
|
@@ -42,6 +45,8 @@ function statusLabel(status: TaskStatus): string {
|
|
|
42
45
|
return "FAILED";
|
|
43
46
|
case "suspended":
|
|
44
47
|
return "paused";
|
|
48
|
+
case "cancelled":
|
|
49
|
+
return "cancelled";
|
|
45
50
|
case "pending":
|
|
46
51
|
return "pending";
|
|
47
52
|
}
|
|
@@ -86,6 +91,15 @@ export class TaskListView {
|
|
|
86
91
|
bottomLines.push("");
|
|
87
92
|
bottomLines.push(truncateToWidth(th.fg("border", " " + "─".repeat(width - 2)), width, ""));
|
|
88
93
|
|
|
94
|
+
const squad = store.loadSquad(this.squadId);
|
|
95
|
+
const review = squad ? getReviewPresentation(squad) : null;
|
|
96
|
+
if (review) bottomLines.push(truncateToWidth(` ${th.fg(review.tone, review.label)}`, width, ""));
|
|
97
|
+
if (squad) {
|
|
98
|
+
for (const line of formatSuspendedAttention(squad)) {
|
|
99
|
+
bottomLines.push(truncateToWidth(` ${th.fg("warning", line)}`, width, ""));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
const runningTask = tasks.find((t) => t.status === "in_progress");
|
|
90
104
|
if (runningTask) {
|
|
91
105
|
bottomLines.push(...this.renderLiveActivity(runningTask, width, scheduler));
|
|
@@ -241,11 +255,15 @@ export class TaskListView {
|
|
|
241
255
|
private renderSummary(tasks: Task[], width: number): string {
|
|
242
256
|
const th = this.theme;
|
|
243
257
|
const done = tasks.filter((t) => t.status === "done").length;
|
|
258
|
+
const cancelled = tasks.filter((t) => t.status === "cancelled").length;
|
|
244
259
|
const total = tasks.length;
|
|
260
|
+
const activeTotal = total - cancelled;
|
|
245
261
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
246
262
|
|
|
247
263
|
const parts: string[] = [];
|
|
248
|
-
parts.push(th.fg("accent",
|
|
264
|
+
parts.push(th.fg("accent", cancelled > 0
|
|
265
|
+
? `${done}/${activeTotal} active tasks done · ${cancelled} cancelled · ${total} total`
|
|
266
|
+
: `${done}/${total}`));
|
|
249
267
|
if (totalCost > 0) parts.push(th.fg("dim", `$${totalCost.toFixed(4)}`));
|
|
250
268
|
|
|
251
269
|
// Find squad creation time for elapsed
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Squad, SuspendedStallAttention } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const REVIEW_PENDING_LABEL = "◆ REVIEW PENDING · independent review required";
|
|
4
|
+
export const REVIEW_FAILED_LABEL = "✗ REVIEW FAILED · awaiting same-squad rework";
|
|
5
|
+
export const SUSPENDED_ATTENTION_LABEL = "⚠ SUSPENDED — explicit resume required";
|
|
6
|
+
|
|
7
|
+
export interface ReviewPresentation {
|
|
8
|
+
kind: "pending" | "failed";
|
|
9
|
+
icon: "◆" | "✗";
|
|
10
|
+
label: string;
|
|
11
|
+
tone: "warning" | "error";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Keep execution progress separate from the independent acceptance gate. */
|
|
15
|
+
export function getReviewPresentation(squad: Squad): ReviewPresentation | null {
|
|
16
|
+
if (squad.status !== "review") return null;
|
|
17
|
+
if (squad.review?.status === "failed") {
|
|
18
|
+
return { kind: "failed", icon: "✗", label: REVIEW_FAILED_LABEL, tone: "error" };
|
|
19
|
+
}
|
|
20
|
+
return { kind: "pending", icon: "◆", label: REVIEW_PENDING_LABEL, tone: "warning" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getSuspendedAttention(squad: Squad): SuspendedStallAttention | null {
|
|
24
|
+
return squad.suspendedStallAttention?.kind === "suspended_stall" ? squad.suspendedStallAttention : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Full-fidelity operator output. Terminal components may clip only while rendering. */
|
|
28
|
+
export function formatSuspendedAttention(squad: Squad): string[] {
|
|
29
|
+
const attention = getSuspendedAttention(squad);
|
|
30
|
+
if (!attention) return [];
|
|
31
|
+
return [
|
|
32
|
+
`Attention: ${SUSPENDED_ATTENTION_LABEL}`,
|
|
33
|
+
`Suspended task IDs: ${attention.suspendedTaskIds.join(", ")}`,
|
|
34
|
+
`Blocked by suspended work: ${attention.blockedTaskIds.length > 0 ? attention.blockedTaskIds.join(", ") : "none"}`,
|
|
35
|
+
`No task was resumed automatically.`,
|
|
36
|
+
`Resume intentionally with squad_modify { action: "resume_task", squadId: "${squad.id}", taskId: "<exact-task-id>" } for each task you choose.`,
|
|
37
|
+
];
|
|
38
|
+
}
|
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
|
@@ -92,6 +92,13 @@ export function recordOrchestratorReview(squad: Squad, input: OrchestratorReview
|
|
|
92
92
|
|
|
93
93
|
/** Persistent system-prompt contract shown until squad_review accepts the work. */
|
|
94
94
|
export function buildOrchestratorReviewGate(squad: Squad, tasks: Task[]): string {
|
|
95
|
+
const failed = squad.review?.status === "failed";
|
|
96
|
+
const reviewLabel = failed
|
|
97
|
+
? "✗ REVIEW FAILED · awaiting same-squad rework"
|
|
98
|
+
: "◆ REVIEW PENDING · independent review required";
|
|
99
|
+
const reviewAction = failed
|
|
100
|
+
? `This candidate was rejected. Do not submit another verdict yet. Start concrete rework in this same exact squad with squad_modify and squadId: "${squad.id}"; the failed evidence remains immutable history. After rework settles, a fresh REVIEW PENDING gate will require a new independent review.`
|
|
101
|
+
: `This candidate is awaiting its first verdict. Complete the checks below, then call squad_review for squadId: "${squad.id}".`;
|
|
95
102
|
const delegatedPlan = tasks
|
|
96
103
|
.map((task) => `- ${task.id} (${task.agent}): ${task.title}\n ${task.description || "(no description)"}`)
|
|
97
104
|
.join("\n");
|
|
@@ -101,6 +108,8 @@ UNTRUSTED CANDIDATE WORK — INDEPENDENT ORCHESTRATOR REVIEW IS MANDATORY.
|
|
|
101
108
|
|
|
102
109
|
Authoritative contract: re-read the user's ORIGINAL request and all later clarifications in this main-session conversation. The squad report and delegated task descriptions are claims, not proof and not substitutes for that contract.
|
|
103
110
|
Recorded squad goal (secondary reference): ${squad.goal}
|
|
111
|
+
Acceptance: ${reviewLabel}
|
|
112
|
+
${reviewAction}
|
|
104
113
|
|
|
105
114
|
Delegated plan (non-authoritative):
|
|
106
115
|
${delegatedPlan}
|
|
@@ -111,9 +120,9 @@ Before reporting success, completion, or acceptance to the user, YOU (the main P
|
|
|
111
120
|
3. Independently run the original Verify commands and appropriate build/tests. Do not rely on pasted squad output.
|
|
112
121
|
4. Run integration/E2E in the real target or production-like environment when the request affects runtime behavior. If genuinely not applicable or impossible, record the precise reason and mark it unverified.
|
|
113
122
|
5. Fix discovered defects and repeat checks. Squad QA PASS does not override your findings.
|
|
114
|
-
6.
|
|
123
|
+
6. When the active gate is REVIEW PENDING, call squad_review with contract checks, diff review, actual command/result evidence, integration/E2E evidence, and remaining issues. When it is REVIEW FAILED, route same-squad rework first; another verdict cannot overwrite the failed evidence.
|
|
115
124
|
|
|
116
|
-
Do not ask the user whether you should verify. Do not merely summarize the squad report. Until squad_review records PASS/PASS_WITH_ISSUES, the work is not accepted.
|
|
125
|
+
Do not ask the user whether you should verify. Do not merely summarize the squad report. Until fresh rework is independently reviewed and squad_review records PASS/PASS_WITH_ISSUES, the work is not accepted.
|
|
117
126
|
</squad_review_required>`;
|
|
118
127
|
}
|
|
119
128
|
|
package/src/scheduler.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import type { AgentDef, Squad, SquadConfig, Task, TaskMailboxEntry, TaskMessage, TaskStatus } from "./types.js";
|
|
14
|
+
import type { AgentDef, Squad, SquadConfig, SuspendedStallAttention, Task, TaskMailboxEntry, TaskMessage, TaskStatus } from "./types.js";
|
|
15
15
|
import { AgentPool, type AgentEvent } from "./agent-pool.js";
|
|
16
16
|
import { Monitor } from "./monitor.js";
|
|
17
17
|
import { Router } from "./router.js";
|
|
@@ -34,6 +34,7 @@ export type SchedulerEventType =
|
|
|
34
34
|
| "task_rework"
|
|
35
35
|
| "squad_review_required"
|
|
36
36
|
| "squad_failed"
|
|
37
|
+
| "suspended_stall"
|
|
37
38
|
| "orchestrator_reply"
|
|
38
39
|
| "escalation"
|
|
39
40
|
| "activity";
|
|
@@ -49,6 +50,61 @@ export interface SchedulerEvent {
|
|
|
49
50
|
|
|
50
51
|
export type SchedulerEventListener = (event: SchedulerEvent) => void;
|
|
51
52
|
|
|
53
|
+
export interface SuspendedStallState {
|
|
54
|
+
fingerprint: string;
|
|
55
|
+
suspendedTaskIds: string[];
|
|
56
|
+
blockedTaskIds: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pure derivation of an explicit-suspension stall from one persisted DAG. */
|
|
60
|
+
export function deriveSuspendedStall(tasks: Task[]): SuspendedStallState | null {
|
|
61
|
+
const relevant = tasks.filter((task) => task.status !== "cancelled");
|
|
62
|
+
const byId = new Map(relevant.map((task) => [task.id, task]));
|
|
63
|
+
const suspendedTaskIds = relevant
|
|
64
|
+
.filter((task) => task.status === "suspended")
|
|
65
|
+
.map((task) => task.id)
|
|
66
|
+
.sort((left, right) => left.localeCompare(right));
|
|
67
|
+
if (suspendedTaskIds.length === 0) return null;
|
|
68
|
+
const suspended = new Set(suspendedTaskIds);
|
|
69
|
+
|
|
70
|
+
const reachesSuspended = (taskId: string, seen = new Set<string>()): boolean => {
|
|
71
|
+
if (suspended.has(taskId)) return true;
|
|
72
|
+
if (seen.has(taskId)) return false;
|
|
73
|
+
seen.add(taskId);
|
|
74
|
+
const task = byId.get(taskId);
|
|
75
|
+
if (!task) return false;
|
|
76
|
+
return task.depends.some((dependencyId) => reachesSuspended(dependencyId, seen));
|
|
77
|
+
};
|
|
78
|
+
const suspensionBlocked = (task: Task): boolean => task.status === "blocked" && task.depends.some((dependencyId) => {
|
|
79
|
+
const dependency = byId.get(dependencyId);
|
|
80
|
+
return dependency?.status !== "done" && reachesSuspended(dependencyId);
|
|
81
|
+
});
|
|
82
|
+
const runnableOrLive = relevant.some((task) => task.status === "in_progress" || (
|
|
83
|
+
task.status === "pending" && task.depends.every((dependencyId) => byId.get(dependencyId)?.status === "done")
|
|
84
|
+
));
|
|
85
|
+
if (runnableOrLive) return null;
|
|
86
|
+
|
|
87
|
+
const blockedTaskIds = relevant.filter(suspensionBlocked).map((task) => task.id).sort((left, right) => left.localeCompare(right));
|
|
88
|
+
const blocked = new Set(blockedTaskIds);
|
|
89
|
+
const terminal = new Set<TaskStatus>(["done", "failed", "cancelled"]);
|
|
90
|
+
if (!relevant.every((task) => suspended.has(task.id) || terminal.has(task.status) || blocked.has(task.id))) return null;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
fingerprint: JSON.stringify([suspendedTaskIds, blockedTaskIds]),
|
|
94
|
+
suspendedTaskIds,
|
|
95
|
+
blockedTaskIds,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Complete, actionable wake text; semantic task IDs are never abbreviated. */
|
|
100
|
+
export function formatSuspendedStallAttention(squadId: string, attention: Pick<SuspendedStallAttention, "suspendedTaskIds" | "blockedTaskIds">): string {
|
|
101
|
+
return `[squad] SUSPENDED WORK NEEDS ACTION in '${squadId}'.\n` +
|
|
102
|
+
`Suspended task IDs: ${attention.suspendedTaskIds.join(", ")}\n` +
|
|
103
|
+
`Blocked by suspended work: ${attention.blockedTaskIds.length > 0 ? attention.blockedTaskIds.join(", ") : "none"}\n` +
|
|
104
|
+
"No task was resumed automatically.\n" +
|
|
105
|
+
`Resume intentionally with squad_modify { action: "resume_task", squadId: "${squadId}", taskId: "<exact-task-id>" } for each task you choose.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
52
108
|
/** Host-session capabilities passed in by the extension (index.ts) */
|
|
53
109
|
export interface SchedulerSpawnContext {
|
|
54
110
|
/** Resolve a model string (or null = default model) to its context window in tokens */
|
|
@@ -76,6 +132,8 @@ export class Scheduler {
|
|
|
76
132
|
private spawnRetries = new Set<string>();
|
|
77
133
|
/** Periodic level-triggered reconcile (heals missed events / out-of-band store edits) */
|
|
78
134
|
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
|
135
|
+
/** Suppress duplicate edge emission within one scheduler; disk remains the outbox. */
|
|
136
|
+
private attentionEmittedFingerprint: string | null = null;
|
|
79
137
|
|
|
80
138
|
/** Get the project cwd for this squad (from squad.json) */
|
|
81
139
|
getProjectCwd(): string | undefined {
|
|
@@ -258,6 +316,7 @@ export class Scheduler {
|
|
|
258
316
|
// mutation/RPC delivery, reopen exactly that task on reconstruction.
|
|
259
317
|
let recoveredMailbox = false;
|
|
260
318
|
for (const task of tasks) {
|
|
319
|
+
if (task.status === "cancelled" || task.status === "suspended") continue;
|
|
261
320
|
if (this.pool.isRunning(task.id)) continue;
|
|
262
321
|
if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
|
|
263
322
|
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
@@ -308,6 +367,12 @@ export class Scheduler {
|
|
|
308
367
|
}
|
|
309
368
|
|
|
310
369
|
await this.scheduleReadyTasks();
|
|
370
|
+
this.reconcileSuspendedStallAttention();
|
|
371
|
+
|
|
372
|
+
const freshSquad = store.loadSquad(this.squadId);
|
|
373
|
+
if (freshSquad && (freshSquad.status === "running" || freshSquad.status === "failed")) {
|
|
374
|
+
this.checkSquadCompletion(store.loadAllTasks(this.squadId), freshSquad);
|
|
375
|
+
}
|
|
311
376
|
}
|
|
312
377
|
|
|
313
378
|
// =========================================================================
|
|
@@ -658,6 +723,8 @@ export class Scheduler {
|
|
|
658
723
|
}
|
|
659
724
|
|
|
660
725
|
private handleUnexpectedAgentExit(event: AgentEvent): void {
|
|
726
|
+
const status = store.loadTask(this.squadId, event.taskId)?.status;
|
|
727
|
+
if (status === "cancelled" || status === "suspended") return;
|
|
661
728
|
const exitCode = event.data?.exitCode ?? 1;
|
|
662
729
|
const turnCount = event.data?.turnCount ?? 0;
|
|
663
730
|
const stderr = event.data?.stderr || "";
|
|
@@ -787,6 +854,8 @@ export class Scheduler {
|
|
|
787
854
|
}
|
|
788
855
|
|
|
789
856
|
case "agent_settled": {
|
|
857
|
+
const status = store.loadTask(this.squadId, event.taskId)?.status;
|
|
858
|
+
if (status === "cancelled" || status === "suspended") return;
|
|
790
859
|
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
791
860
|
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
792
861
|
// occurs before the task can become done.
|
|
@@ -838,8 +907,9 @@ export class Scheduler {
|
|
|
838
907
|
const task = store.loadTask(this.squadId, taskId);
|
|
839
908
|
if (!task) return;
|
|
840
909
|
|
|
841
|
-
// Guard against double-completion
|
|
842
|
-
|
|
910
|
+
// Guard against double-completion and late callbacks after cancellation or
|
|
911
|
+
// an explicit pause. Only exact resume_task may revive suspended work.
|
|
912
|
+
if (task.status === "done" || task.status === "cancelled" || task.status === "suspended") return;
|
|
843
913
|
|
|
844
914
|
// Extract output from last messages
|
|
845
915
|
const messages = store.loadMessages(this.squadId, taskId);
|
|
@@ -897,6 +967,11 @@ export class Scheduler {
|
|
|
897
967
|
debug("squad-scheduler", `handleTaskCompleted: scheduling next ready tasks`);
|
|
898
968
|
await this.scheduleReadyTasks();
|
|
899
969
|
|
|
970
|
+
// A completion can remove the last independent runnable task and expose a
|
|
971
|
+
// stall behind an explicitly suspended task. Derive/wake immediately rather
|
|
972
|
+
// than waiting for the periodic reconciliation timer.
|
|
973
|
+
this.reconcileSuspendedStallAttention();
|
|
974
|
+
|
|
900
975
|
// Re-check squad completion with fresh data AFTER scheduling
|
|
901
976
|
const freshTasks = store.loadAllTasks(this.squadId);
|
|
902
977
|
const freshSquad = store.loadSquad(this.squadId);
|
|
@@ -907,6 +982,7 @@ export class Scheduler {
|
|
|
907
982
|
}
|
|
908
983
|
|
|
909
984
|
private handleTaskFailed(taskId: string, error: string): void {
|
|
985
|
+
if (store.loadTask(this.squadId, taskId)?.status === "cancelled") return;
|
|
910
986
|
store.updateTaskStatus(this.squadId, taskId, "failed", {
|
|
911
987
|
error,
|
|
912
988
|
completed: store.now(),
|
|
@@ -929,6 +1005,9 @@ export class Scheduler {
|
|
|
929
1005
|
this.pool.kill(taskId);
|
|
930
1006
|
this.updateContext();
|
|
931
1007
|
|
|
1008
|
+
// Failure can likewise expose a suspended-only cut in the remaining DAG.
|
|
1009
|
+
this.reconcileSuspendedStallAttention();
|
|
1010
|
+
|
|
932
1011
|
// Check if squad should be marked failed
|
|
933
1012
|
const tasks = store.loadAllTasks(this.squadId);
|
|
934
1013
|
const squad = store.loadSquad(this.squadId);
|
|
@@ -1148,10 +1227,11 @@ export class Scheduler {
|
|
|
1148
1227
|
private checkSquadCompletion(tasks: Task[], squad: Squad): void {
|
|
1149
1228
|
if (tasks.length === 0) return;
|
|
1150
1229
|
|
|
1151
|
-
const
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1230
|
+
const relevant = tasks.filter((task) => task.status !== "cancelled");
|
|
1231
|
+
const allDone = relevant.every((task) => task.status === "done");
|
|
1232
|
+
const anyFailed = relevant.some((task) => task.status === "failed");
|
|
1233
|
+
const anyInProgress = relevant.some(
|
|
1234
|
+
(task) => task.status === "in_progress" || task.status === "pending",
|
|
1155
1235
|
);
|
|
1156
1236
|
|
|
1157
1237
|
if (allDone) {
|
|
@@ -1163,9 +1243,9 @@ export class Scheduler {
|
|
|
1163
1243
|
this.emit({ type: "squad_review_required", squadId: this.squadId });
|
|
1164
1244
|
} else if (anyFailed && !anyInProgress) {
|
|
1165
1245
|
// All remaining tasks are blocked/failed with no way forward
|
|
1166
|
-
const blockedCount =
|
|
1167
|
-
const failedCount =
|
|
1168
|
-
if (blockedCount + failedCount ===
|
|
1246
|
+
const blockedCount = relevant.filter((task) => task.status === "blocked").length;
|
|
1247
|
+
const failedCount = relevant.filter((task) => task.status === "failed").length;
|
|
1248
|
+
if (blockedCount + failedCount === relevant.filter((task) => task.status !== "done").length) {
|
|
1169
1249
|
squad.status = "failed";
|
|
1170
1250
|
store.saveSquad(squad);
|
|
1171
1251
|
this.emit({ type: "squad_failed", squadId: this.squadId });
|
|
@@ -1297,6 +1377,7 @@ export class Scheduler {
|
|
|
1297
1377
|
if (seen.has(descendant.id)) continue;
|
|
1298
1378
|
seen.add(descendant.id);
|
|
1299
1379
|
queue.push(...(byDependency.get(descendant.id) ?? []));
|
|
1380
|
+
if (descendant.status === "cancelled") continue;
|
|
1300
1381
|
store.updateTaskStatus(this.squadId, descendant.id, "blocked", {
|
|
1301
1382
|
completed: null,
|
|
1302
1383
|
error: null,
|
|
@@ -1353,6 +1434,8 @@ export class Scheduler {
|
|
|
1353
1434
|
expectsReply,
|
|
1354
1435
|
});
|
|
1355
1436
|
|
|
1437
|
+
if (task.status === "cancelled") return false;
|
|
1438
|
+
|
|
1356
1439
|
const request = expectsReply
|
|
1357
1440
|
? `[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
1441
|
: `[squad] Main orchestrator message:\n${message}`;
|
|
@@ -1387,12 +1470,16 @@ export class Scheduler {
|
|
|
1387
1470
|
|
|
1388
1471
|
/** Pause a running task */
|
|
1389
1472
|
async pauseTask(taskId: string): Promise<void> {
|
|
1473
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
1474
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1475
|
+
if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; use resume_task to revive it.`);
|
|
1390
1476
|
if (this.pool.isRunning(taskId)) {
|
|
1391
1477
|
await this.pool.steer(taskId, "[squad] Task paused by user. Summarize your current state.");
|
|
1392
1478
|
// Give agent a moment to respond, then kill
|
|
1393
1479
|
setTimeout(() => this.pool.kill(taskId), 3000);
|
|
1394
1480
|
}
|
|
1395
1481
|
store.updateTaskStatus(this.squadId, taskId, "suspended");
|
|
1482
|
+
this.reconcileSuspendedStallAttention();
|
|
1396
1483
|
this.updateContext();
|
|
1397
1484
|
}
|
|
1398
1485
|
|
|
@@ -1405,11 +1492,94 @@ export class Scheduler {
|
|
|
1405
1492
|
this.updateContext();
|
|
1406
1493
|
}
|
|
1407
1494
|
|
|
1495
|
+
/** Atomically replace one task's dependency list after validating the complete historical DAG. */
|
|
1496
|
+
async setDependencies(taskId: string, depends: string[]): Promise<void> {
|
|
1497
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
1498
|
+
const target = tasks.find((task) => task.id === taskId);
|
|
1499
|
+
if (!target) throw new Error(`Task not found: ${taskId}`);
|
|
1500
|
+
if (target.status === "in_progress") {
|
|
1501
|
+
throw new Error(`Cannot edit dependencies for in_progress task '${taskId}'; pause or cancel it first.`);
|
|
1502
|
+
}
|
|
1503
|
+
if (target.status === "done") {
|
|
1504
|
+
throw new Error(`Cannot edit dependencies for done task '${taskId}'; resume it first so descendant invalidation remains authoritative.`);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const duplicate = depends.find((dependency, index) => depends.indexOf(dependency) !== index);
|
|
1508
|
+
if (duplicate) throw new Error(`Duplicate dependency '${duplicate}' for task '${taskId}'.`);
|
|
1509
|
+
if (depends.includes(taskId)) throw new Error(`Task '${taskId}' cannot depend on itself.`);
|
|
1510
|
+
const known = new Set(tasks.map((task) => task.id));
|
|
1511
|
+
const unknown = depends.filter((dependency) => !known.has(dependency));
|
|
1512
|
+
if (unknown.length > 0) throw new Error(`Unknown dependency task(s): ${unknown.join(", ")}.`);
|
|
1513
|
+
|
|
1514
|
+
const wasRunnable = target.status === "pending" && target.depends.every(
|
|
1515
|
+
(dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
|
|
1516
|
+
);
|
|
1517
|
+
const graph = new Map(tasks.map((task) => [task.id, [...task.depends]]));
|
|
1518
|
+
graph.set(taskId, [...depends]);
|
|
1519
|
+
for (const [id, dependencies] of graph) {
|
|
1520
|
+
const seen = new Set<string>();
|
|
1521
|
+
for (const dependency of dependencies) {
|
|
1522
|
+
if (!known.has(dependency)) throw new Error(`Task '${id}' depends on unknown task '${dependency}'.`);
|
|
1523
|
+
if (dependency === id) throw new Error(`Task '${id}' cannot depend on itself.`);
|
|
1524
|
+
if (seen.has(dependency)) throw new Error(`Duplicate dependency '${dependency}' for task '${id}'.`);
|
|
1525
|
+
seen.add(dependency);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const color = new Map<string, 0 | 1 | 2>();
|
|
1529
|
+
const stack: string[] = [];
|
|
1530
|
+
const visit = (id: string): void => {
|
|
1531
|
+
const state = color.get(id) ?? 0;
|
|
1532
|
+
if (state === 2) return;
|
|
1533
|
+
if (state === 1) {
|
|
1534
|
+
const start = stack.indexOf(id);
|
|
1535
|
+
const cycle = [...stack.slice(start), id];
|
|
1536
|
+
throw new Error(`Dependency cycle detected: ${cycle.join(" -> ")}`);
|
|
1537
|
+
}
|
|
1538
|
+
color.set(id, 1);
|
|
1539
|
+
stack.push(id);
|
|
1540
|
+
for (const dependency of graph.get(id) ?? []) visit(dependency);
|
|
1541
|
+
stack.pop();
|
|
1542
|
+
color.set(id, 2);
|
|
1543
|
+
};
|
|
1544
|
+
for (const id of graph.keys()) visit(id);
|
|
1545
|
+
|
|
1546
|
+
target.depends = [...depends];
|
|
1547
|
+
if (target.status === "pending" || target.status === "blocked") {
|
|
1548
|
+
target.status = depends.every(
|
|
1549
|
+
(dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
|
|
1550
|
+
) ? "pending" : "blocked";
|
|
1551
|
+
}
|
|
1552
|
+
store.saveTask(this.squadId, target);
|
|
1553
|
+
store.appendMessage(this.squadId, taskId, {
|
|
1554
|
+
ts: store.now(),
|
|
1555
|
+
from: "system",
|
|
1556
|
+
type: "status",
|
|
1557
|
+
text: `Dependencies updated: ${depends.length > 0 ? depends.join(", ") : "none"}`,
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
this.killBlockedAgents();
|
|
1561
|
+
const squad = store.loadSquad(this.squadId);
|
|
1562
|
+
const runnable = target.status === "pending" && target.depends.every(
|
|
1563
|
+
(dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
|
|
1564
|
+
);
|
|
1565
|
+
const createdRunnableWork = runnable && !wasRunnable;
|
|
1566
|
+
if (createdRunnableWork && squad && squad.status !== "paused" && squad.status !== "running") {
|
|
1567
|
+
this.reopenSquadForWork();
|
|
1568
|
+
await this.start();
|
|
1569
|
+
} else if (squad?.status === "running") {
|
|
1570
|
+
await this.start();
|
|
1571
|
+
}
|
|
1572
|
+
this.updateContext();
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1408
1575
|
/** Resume one exact task. Reopening completed work invalidates descendants and
|
|
1409
1576
|
* archives a completed active review before fresh scheduling begins. */
|
|
1410
|
-
async resumeTask(taskId: string): Promise<
|
|
1577
|
+
async resumeTask(taskId: string): Promise<"resumed" | "already_running"> {
|
|
1411
1578
|
const task = store.loadTask(this.squadId, taskId);
|
|
1412
1579
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1580
|
+
const live = this.pool.isRunning(taskId);
|
|
1581
|
+
if (task.status === "in_progress" && live) return "already_running";
|
|
1582
|
+
if (live) throw new Error(`Task '${taskId}' has a live child but durable status '${task.status}'; no duplicate resume was started.`);
|
|
1413
1583
|
if (task.status === "done") await this.invalidateDescendants(taskId);
|
|
1414
1584
|
const tasks = store.loadAllTasks(this.squadId);
|
|
1415
1585
|
const dependenciesDone = task.depends.every(
|
|
@@ -1422,6 +1592,18 @@ export class Scheduler {
|
|
|
1422
1592
|
this.reopenSquadForWork();
|
|
1423
1593
|
await this.start();
|
|
1424
1594
|
this.updateContext();
|
|
1595
|
+
return "resumed";
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** Mark an exact pending outbox fingerprint delivered after host acceptance. */
|
|
1599
|
+
acknowledgeSuspendedStall(fingerprint: string): boolean {
|
|
1600
|
+
const squad = store.loadSquad(this.squadId);
|
|
1601
|
+
const attention = squad?.suspendedStallAttention;
|
|
1602
|
+
if (!squad || !attention || attention.fingerprint !== fingerprint || attention.delivery !== "pending") return false;
|
|
1603
|
+
attention.delivery = "delivered";
|
|
1604
|
+
attention.deliveredAt = store.now();
|
|
1605
|
+
store.saveSquad(squad);
|
|
1606
|
+
return true;
|
|
1425
1607
|
}
|
|
1426
1608
|
|
|
1427
1609
|
/**
|
|
@@ -1434,6 +1616,7 @@ export class Scheduler {
|
|
|
1434
1616
|
const task = store.loadTask(this.squadId, taskId);
|
|
1435
1617
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1436
1618
|
if (task.status === "done") return;
|
|
1619
|
+
if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; resume it before marking it done.`);
|
|
1437
1620
|
|
|
1438
1621
|
if (this.pool.isRunning(taskId)) {
|
|
1439
1622
|
await this.pool.kill(taskId);
|
|
@@ -1467,14 +1650,46 @@ export class Scheduler {
|
|
|
1467
1650
|
this.updateContext();
|
|
1468
1651
|
}
|
|
1469
1652
|
|
|
1470
|
-
/** Cancel
|
|
1653
|
+
/** Cancel one task after ensuring no live historical task still depends on it. */
|
|
1471
1654
|
async cancelTask(taskId: string): Promise<void> {
|
|
1472
|
-
|
|
1473
|
-
|
|
1655
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
1656
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1657
|
+
if (task.status === "cancelled") return;
|
|
1658
|
+
|
|
1659
|
+
const dependents = store.loadAllTasks(this.squadId)
|
|
1660
|
+
.filter((candidate) => candidate.status !== "cancelled" && candidate.depends.includes(taskId))
|
|
1661
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
1662
|
+
if (dependents.length > 0) {
|
|
1663
|
+
throw new Error([
|
|
1664
|
+
`Cannot cancel task '${taskId}': it is still required by:`,
|
|
1665
|
+
...dependents.map((dependent) => `- ${dependent.id} [${dependent.status}]`),
|
|
1666
|
+
"",
|
|
1667
|
+
'Update each dependent first with squad_modify action "set_dependencies",',
|
|
1668
|
+
"then retry cancel_task. Cancellation does not rewrite or cascade dependencies.",
|
|
1669
|
+
].join("\n"));
|
|
1474
1670
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1671
|
+
|
|
1672
|
+
if (this.pool.isRunning(taskId)) await this.pool.kill(taskId);
|
|
1673
|
+
store.updateTaskStatus(this.squadId, taskId, "cancelled", {
|
|
1674
|
+
error: null,
|
|
1675
|
+
completed: store.now(),
|
|
1477
1676
|
});
|
|
1677
|
+
store.appendMessage(this.squadId, taskId, {
|
|
1678
|
+
ts: store.now(),
|
|
1679
|
+
from: "system",
|
|
1680
|
+
type: "status",
|
|
1681
|
+
text: "Task cancelled by orchestrator",
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
const squad = store.loadSquad(this.squadId);
|
|
1685
|
+
if (squad) {
|
|
1686
|
+
if (squad.status === "done" || (squad.status === "review" && squad.review?.status !== "pending")) {
|
|
1687
|
+
beginOrchestratorRework(squad);
|
|
1688
|
+
store.saveSquad(squad);
|
|
1689
|
+
}
|
|
1690
|
+
this.reconcileSuspendedStallAttention();
|
|
1691
|
+
this.checkSquadCompletion(store.loadAllTasks(this.squadId), squad);
|
|
1692
|
+
}
|
|
1478
1693
|
this.updateContext();
|
|
1479
1694
|
}
|
|
1480
1695
|
|
|
@@ -1482,6 +1697,38 @@ export class Scheduler {
|
|
|
1482
1697
|
// Helpers
|
|
1483
1698
|
// =========================================================================
|
|
1484
1699
|
|
|
1700
|
+
private reconcileSuspendedStallAttention(): void {
|
|
1701
|
+
const squad = store.loadSquad(this.squadId);
|
|
1702
|
+
if (!squad) return;
|
|
1703
|
+
const derived = deriveSuspendedStall(store.loadAllTasks(this.squadId));
|
|
1704
|
+
if (!derived) {
|
|
1705
|
+
if (squad.suspendedStallAttention) {
|
|
1706
|
+
delete squad.suspendedStallAttention;
|
|
1707
|
+
store.saveSquad(squad);
|
|
1708
|
+
}
|
|
1709
|
+
this.attentionEmittedFingerprint = null;
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
let attention = squad.suspendedStallAttention;
|
|
1714
|
+
if (!attention || attention.fingerprint !== derived.fingerprint) {
|
|
1715
|
+
attention = {
|
|
1716
|
+
kind: "suspended_stall",
|
|
1717
|
+
...derived,
|
|
1718
|
+
detectedAt: store.now(),
|
|
1719
|
+
delivery: "pending",
|
|
1720
|
+
deliveredAt: null,
|
|
1721
|
+
};
|
|
1722
|
+
squad.suspendedStallAttention = attention;
|
|
1723
|
+
store.saveSquad(squad);
|
|
1724
|
+
this.attentionEmittedFingerprint = null;
|
|
1725
|
+
}
|
|
1726
|
+
if (attention.delivery === "pending" && this.attentionEmittedFingerprint !== attention.fingerprint) {
|
|
1727
|
+
this.attentionEmittedFingerprint = attention.fingerprint;
|
|
1728
|
+
this.emit({ type: "suspended_stall", squadId: this.squadId, data: attention });
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1485
1732
|
private extractAssistantText(msg: any): string | null {
|
|
1486
1733
|
if (!msg.content) return null;
|
|
1487
1734
|
const textParts = msg.content
|
|
@@ -120,6 +120,7 @@ When you receive `[squad] Agent needs attention`:
|
|
|
120
120
|
4. **If you can't**: ask the user, then relay their answer via `squad_message`
|
|
121
121
|
|
|
122
122
|
Common escalation patterns:
|
|
123
|
+
- **`SUSPENDED WORK NEEDS ACTION`** → Read every exact suspended task ID and blocked descendant. Do not assume consent and do not use whole-squad `resume`; resume only each intended task with `squad_modify { action: "resume_task", squadId: "<exact squad>", taskId: "<exact task>" }`. Nothing resumes automatically.
|
|
123
124
|
- **"Which approach should I use?"** → Ask the user for preference, relay via `squad_message`
|
|
124
125
|
- **"I need info from another agent"** → Check if that agent is done, relay their output
|
|
125
126
|
- **"I'm blocked by a failing test"** → Check the error, suggest a fix via `squad_message`
|
|
@@ -142,21 +143,26 @@ Send small, scoped corrections instead of restarting the task:
|
|
|
142
143
|
- Name exactly what to change and what to keep: "Change only the header component — keep the layout and routes as they are."
|
|
143
144
|
- Add missing information the moment you learn it — don't wait for the agent to get stuck
|
|
144
145
|
- 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
|
|
146
|
+
- 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
147
|
|
|
147
148
|
## Modifying a Running Squad
|
|
148
149
|
|
|
149
150
|
Use `squad_modify` when:
|
|
150
151
|
- **`add_task`**: User requests something not in the original plan
|
|
151
|
-
- **`
|
|
152
|
-
- **`
|
|
152
|
+
- **`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.
|
|
153
|
+
- **`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.
|
|
154
|
+
- **`pause_task`** / **`resume_task`**: Temporarily halt or explicitly revive an agent; `resume_task` is also the only action that revives a cancelled task.
|
|
153
155
|
- **`pause`** / **`resume`**: Stop/restart the entire squad
|
|
154
|
-
- **`cancel`**: Abort everything (user changed their mind)
|
|
156
|
+
- **`cancel`**: Abort everything (user changed their mind). This destructive tool action requires the exact `squadId`; never infer it from focus or recency. The result must name the affected squad. Interactive `/squad cancel` is the only cancellation shorthand that may use the visibly focused squad.
|
|
157
|
+
|
|
158
|
+
A suspended task is an explicit pause. Scheduler restart, reconciliation, dependency edits, and attention delivery must not be treated as permission to resume it. When durable suspended-stall attention is active, use `squad_status` once to see the complete IDs and exact-squad guidance, then resume only the tasks deliberately chosen.
|
|
155
159
|
|
|
156
160
|
## After Agents Finish — Mandatory Independent Orchestrator Review
|
|
157
161
|
|
|
158
162
|
Agent execution finishing is **not completion or acceptance**. When you receive `[squad] TASK EXECUTION FINISHED` / `<squad_review_required>`, every squad output—including QA PASS—is an untrusted claim. You are the independent acceptance authority.
|
|
159
163
|
|
|
164
|
+
Read acceptance labels literally: `◆ REVIEW PENDING · independent review required` means review has not happened; `✗ REVIEW FAILED · awaiting same-squad rework` means review happened and rejected the candidate. Task progress such as `3/3` is execution progress only. A failed gate requires concrete rework in that same exact squad before a fresh review can exist; never overwrite the failed verdict or create a separate squad to evade it.
|
|
165
|
+
|
|
160
166
|
You MUST do all of this before telling the user the work succeeded:
|
|
161
167
|
1. **Re-read the original contract**: reconstruct every requirement, boundary, and later clarification from the user's main-session conversation. The squad goal/task plan is only a secondary aid and cannot narrow the original request.
|
|
162
168
|
2. **Inspect reality, not reports**: read the actual working-tree/commit diff and relevant source. Check every changed line for correctness, scope, integration points, unintended changes, error paths, security, and regressions.
|