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.
@@ -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", `${done}/${total}`));
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
- return tasks
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. Call squad_review with contract checks, diff review, actual command/result evidence, integration/E2E evidence, and remaining issues.
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
- if (task.status === "done") return;
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 allDone = tasks.every((t) => t.status === "done");
1152
- const anyFailed = tasks.some((t) => t.status === "failed");
1153
- const anyInProgress = tasks.some(
1154
- (t) => t.status === "in_progress" || t.status === "pending",
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 = tasks.filter((t) => t.status === "blocked").length;
1167
- const failedCount = tasks.filter((t) => t.status === "failed").length;
1168
- if (blockedCount + failedCount === tasks.filter((t) => t.status !== "done").length) {
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<void> {
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 a task */
1653
+ /** Cancel one task after ensuring no live historical task still depends on it. */
1471
1654
  async cancelTask(taskId: string): Promise<void> {
1472
- if (this.pool.isRunning(taskId)) {
1473
- await this.pool.kill(taskId);
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
- store.updateTaskStatus(this.squadId, taskId, "failed", {
1476
- error: "Cancelled by user",
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` or stop it with `squad_modify` `cancel_task` don't let it burn tokens on obsolete work
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
- - **`cancel_task`**: A task is no longer needed
152
- - **`pause_task`** / **`resume_task`**: Temporarily halt an agent
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.