pi-squad 0.16.6 → 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 +22 -3
- package/package.json +1 -1
- package/src/index.ts +118 -39
- package/src/panel/squad-widget.ts +16 -7
- package/src/panel/task-list.ts +10 -0
- package/src/presentation.ts +38 -0
- package/src/review.ts +11 -2
- package/src/scheduler.ts +126 -7
- package/src/skills/squad-supervisor/SKILL.md +6 -1
- package/src/types.ts +13 -0
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ Squad agents—including QA/reviewer agents—produce candidate work and evidenc
|
|
|
56
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
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
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.
|
|
59
|
+
- Every UI/status surface keeps execution progress adjacent to an explicit acceptance state: `◆ REVIEW PENDING · independent review required` means no verdict exists yet; `✗ REVIEW FAILED · awaiting same-squad rework` means the candidate was rejected. A truthful `3/3` task count never means a failed candidate was accepted.
|
|
59
60
|
|
|
60
61
|
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.
|
|
61
62
|
|
|
@@ -77,6 +78,18 @@ architect → frontend ─┘
|
|
|
77
78
|
|
|
78
79
|
Architect runs first. Backend and frontend run in parallel after architect completes. QA waits for both.
|
|
79
80
|
|
|
81
|
+
### Suspended Work Requires Explicit Action
|
|
82
|
+
|
|
83
|
+
Suspension is an explicit pause, not a retry signal. If every remaining non-cancelled task is suspended or transitively blocked by suspended work, pi-squad durably wakes the main orchestrator once for that exact stall state. The widget shows `⚠ SUSPENDED — explicit resume required`; `squad_status` and the detail panel list every exact suspended task ID and blocked descendant.
|
|
84
|
+
|
|
85
|
+
Nothing resumes automatically. Choose each task intentionally with an exact squad and task ID:
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
squad_modify({ action: "resume_task", squadId: "<exact-squad-id>", taskId: "<exact-task-id>" })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Repeated reconciliation and restart do not create notification storms. A delivered stall remains visible until explicit resumption changes the state; a different suspended/blocked set is a new actionable episode.
|
|
92
|
+
|
|
80
93
|
### QA Rework Loop
|
|
81
94
|
|
|
82
95
|
When a QA agent outputs `## Verdict: FAIL`, the scheduler automatically:
|
|
@@ -156,9 +169,13 @@ When the main agent provides tasks directly (via the `tasks` parameter), unknown
|
|
|
156
169
|
|
|
157
170
|
### Widget (above editor)
|
|
158
171
|
|
|
159
|
-
Shows live
|
|
172
|
+
Shows live execution progress plus a distinct acceptance/attention state. Truncation is viewport-only: no wrapping, deterministic height, with complete IDs and evidence retained for detail/status output.
|
|
160
173
|
|
|
161
174
|
```
|
|
175
|
+
◆ squad Build task API 3/3 · ◆ REVIEW PENDING · independent review required
|
|
176
|
+
✗ squad Build task API 3/3 · ✗ REVIEW FAILED · awaiting same-squad rework
|
|
177
|
+
⚠ SUSPENDED — explicit resume required · ^q detail
|
|
178
|
+
|
|
162
179
|
⏳ squad Build task API 2/3 $0.58 3m12s ^q detail · /squad msg
|
|
163
180
|
✓ api (backend) 2m12s Created CRUD REST API with validation
|
|
164
181
|
⏳ tests (qa) 45s → bash npm test
|
|
@@ -197,7 +214,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
|
|
|
197
214
|
| `/squad msg [task-id\|running-agent] text` | Message an exact task, or use an agent name only when it has one live task |
|
|
198
215
|
| `/squad widget` | Toggle widget |
|
|
199
216
|
| `/squad panel` | Toggle panel |
|
|
200
|
-
| `/squad cancel` | Cancel
|
|
217
|
+
| `/squad cancel` | Cancel the visibly focused squad; the notification names it and the focused widget/status clear |
|
|
201
218
|
| `/squad clear` | Dismiss widget |
|
|
202
219
|
| `/squad cleanup` | Delete squad data |
|
|
203
220
|
| `/squad enable/disable` | Enable/disable the extension |
|
|
@@ -209,7 +226,9 @@ Full overlay with task list, live activity preview, and scrollable message view.
|
|
|
209
226
|
| `squad` | Start a squad with goal + optional tasks/config |
|
|
210
227
|
| `squad_status` | Check progress, costs, task states |
|
|
211
228
|
| `squad_message` | Durably message an exact task; completed tasks reopen on their original session |
|
|
212
|
-
| `squad_modify` | Add/cancel/complete/pause/resume tasks or squads, or replace a task's dependencies with `set_dependencies`;
|
|
229
|
+
| `squad_modify` | Add/cancel/complete/pause/resume tasks or squads, or replace a task's dependencies with `set_dependencies`; exact `squadId` is required for destructive whole-squad `cancel` and recommended for all task actions |
|
|
230
|
+
|
|
231
|
+
Tool-level whole-squad cancellation never infers a target: `squad_modify({ action: "cancel", squadId: "<exact-squad-id>" })` affects only that persisted squad and names it in the result. Omitting `squadId` is rejected without changing any squad. Interactive `/squad cancel` instead uses the squad visibly focused in the current UI and names the affected squad.
|
|
213
232
|
|
|
214
233
|
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
234
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -14,9 +14,9 @@ import * as path from "node:path";
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import type { Squad, Task, SquadConfig, PlannerOutput } from "./types.js";
|
|
17
|
+
import type { Squad, Task, SquadConfig, PlannerOutput, SuspendedStallAttention } from "./types.js";
|
|
18
18
|
import { DEFAULT_SQUAD_CONFIG, THINKING_LEVELS } from "./types.js";
|
|
19
|
-
import { Scheduler, type SchedulerEvent, type SchedulerSpawnContext } from "./scheduler.js";
|
|
19
|
+
import { Scheduler, formatSuspendedStallAttention, type SchedulerEvent, type SchedulerSpawnContext } from "./scheduler.js";
|
|
20
20
|
import { runPlanner } from "./planner.js";
|
|
21
21
|
import { validatePlan, PLAN_STRUCTURE_RULES } from "./plan-rules.js";
|
|
22
22
|
import { ADVISOR_SYSTEM_PROMPT, buildAdvisorConsultText, type AdvisorConsultInput } from "./advisor.js";
|
|
@@ -27,6 +27,7 @@ import * as store from "./store.js";
|
|
|
27
27
|
import { debug, logError } from "./logger.js";
|
|
28
28
|
import { buildCompletionSummary, buildFailureSummary } from "./report.js";
|
|
29
29
|
import { buildOrchestratorReviewGate, recordOrchestratorReview } from "./review.js";
|
|
30
|
+
import { formatSuspendedAttention, getReviewPresentation } from "./presentation.js";
|
|
30
31
|
|
|
31
32
|
// ============================================================================
|
|
32
33
|
// State
|
|
@@ -186,6 +187,21 @@ function getActiveScheduler(): Scheduler | null {
|
|
|
186
187
|
return schedulers.get(activeSquadId) || null;
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
/** Restore the single focus/widget invariant after destructive exact cancellation. */
|
|
191
|
+
function repairFocusAfterCancellation(cancelledId: string): void {
|
|
192
|
+
if (activeSquadId === cancelledId || (activeSquadId !== null && !store.loadSquad(activeSquadId))) {
|
|
193
|
+
activeSquadId = null;
|
|
194
|
+
}
|
|
195
|
+
widgetState.squadId = activeSquadId;
|
|
196
|
+
widgetControls?.requestUpdate();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function activeSuspendedAttentionForProject(cwd: string): Array<{ squadId: string; attention: SuspendedStallAttention }> {
|
|
200
|
+
return store.listSquadsForProject(cwd)
|
|
201
|
+
.filter((squad) => Boolean(squad.suspendedStallAttention))
|
|
202
|
+
.map((squad) => ({ squadId: squad.id, attention: squad.suspendedStallAttention! }));
|
|
203
|
+
}
|
|
204
|
+
|
|
189
205
|
/** Reconstruct and focus one exact persisted squad without creating/linking another. */
|
|
190
206
|
function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
|
|
191
207
|
let scheduler = schedulers.get(squadId);
|
|
@@ -244,6 +260,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
244
260
|
const skillsDir = path.join(path.dirname(new URL(import.meta.url).pathname), "skills");
|
|
245
261
|
const squadSkillPaths = getSquadSkillPaths(skillsDir);
|
|
246
262
|
|
|
263
|
+
/** Cancel one persisted exact squad without ever inferring or changing focus. */
|
|
264
|
+
const cancelExactSquad = async (squadId: string): Promise<boolean> => {
|
|
265
|
+
const squad = store.loadSquad(squadId);
|
|
266
|
+
if (!squad) return false;
|
|
267
|
+
const live = schedulers.get(squadId);
|
|
268
|
+
if (live) await live.stop();
|
|
269
|
+
else await new Scheduler(squadId, squadSkillPaths, schedulerSpawnContext).stop();
|
|
270
|
+
const fresh = store.loadSquad(squadId);
|
|
271
|
+
if (fresh) {
|
|
272
|
+
fresh.status = "failed";
|
|
273
|
+
delete fresh.suspendedStallAttention;
|
|
274
|
+
store.saveSquad(fresh);
|
|
275
|
+
}
|
|
276
|
+
schedulers.delete(squadId);
|
|
277
|
+
repairFocusAfterCancellation(squadId);
|
|
278
|
+
return true;
|
|
279
|
+
};
|
|
280
|
+
|
|
247
281
|
// =========================================================================
|
|
248
282
|
// Context Injection — give main agent awareness of squad state
|
|
249
283
|
// =========================================================================
|
|
@@ -255,9 +289,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
255
289
|
const pendingReviewGates = store.findActiveSquads()
|
|
256
290
|
.filter((s) => s.cwd === ctx.cwd && s.status === "review")
|
|
257
291
|
.map((s) => ({ squad: s, gate: buildOrchestratorReviewGate(s, store.loadAllTasks(s.id)) }));
|
|
292
|
+
const suspendedAttention = activeSuspendedAttentionForProject(ctx.cwd)
|
|
293
|
+
.map(({ squadId, attention }) => formatSuspendedStallAttention(squadId, attention));
|
|
294
|
+
const durablePrompts = [...pendingReviewGates.map(({ gate }) => gate), ...suspendedAttention];
|
|
258
295
|
if (!squadEnabled) {
|
|
259
|
-
if (
|
|
260
|
-
return { systemPrompt: event.systemPrompt + "\n\n" +
|
|
296
|
+
if (durablePrompts.length === 0) return;
|
|
297
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
261
298
|
}
|
|
262
299
|
|
|
263
300
|
// When a squad is active, inject its status
|
|
@@ -265,15 +302,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
265
302
|
const squad = store.loadSquad(activeSquadId);
|
|
266
303
|
if (!squad) {
|
|
267
304
|
activeSquadId = null;
|
|
268
|
-
|
|
269
|
-
|
|
305
|
+
widgetState.squadId = null;
|
|
306
|
+
if (durablePrompts.length > 0) {
|
|
307
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
270
308
|
}
|
|
271
309
|
return;
|
|
272
310
|
}
|
|
273
311
|
const tasks = store.loadAllTasks(activeSquadId);
|
|
274
312
|
if (tasks.length === 0) {
|
|
275
|
-
if (
|
|
276
|
-
return { systemPrompt: event.systemPrompt + "\n\n" +
|
|
313
|
+
if (durablePrompts.length > 0) {
|
|
314
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
277
315
|
}
|
|
278
316
|
return;
|
|
279
317
|
}
|
|
@@ -281,21 +319,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
319
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
282
320
|
|
|
283
321
|
const taskLines = tasks.map((t) => {
|
|
284
|
-
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "cancelled" ? "⊘" : "·";
|
|
322
|
+
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "suspended" ? "⏸" : t.status === "cancelled" ? "⊘" : "·";
|
|
285
323
|
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
286
324
|
if (t.output) line += ` — ${t.output}`;
|
|
287
325
|
if (t.error) line += ` ERROR: ${t.error}`;
|
|
288
326
|
return line;
|
|
289
327
|
}).join("\n");
|
|
290
328
|
|
|
329
|
+
const reviewPresentation = getReviewPresentation(squad);
|
|
291
330
|
const squadContext = [
|
|
292
331
|
`<squad_status>`,
|
|
293
332
|
`Squad: ${squad.id} — ${squad.goal}`,
|
|
294
333
|
`Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
|
|
334
|
+
...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
|
|
295
335
|
taskLines,
|
|
296
336
|
`</squad_status>`,
|
|
297
337
|
...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
|
|
298
338
|
...pendingReviewGates.filter(({ squad: pending }) => pending.id !== squad.id).map(({ gate }) => gate),
|
|
339
|
+
...suspendedAttention,
|
|
299
340
|
`You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
|
|
300
341
|
`Do NOT poll squad_status in a loop or sleep-wait — the squad wakes you automatically on completion, failure, or escalation. Keep helping the user with other work, or end your turn and stay idle.`,
|
|
301
342
|
].join("\n");
|
|
@@ -323,7 +364,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
323
364
|
|
|
324
365
|
return {
|
|
325
366
|
systemPrompt: event.systemPrompt + "\n\n" + squadNudge +
|
|
326
|
-
(
|
|
367
|
+
(durablePrompts.length > 0 ? "\n\n" + durablePrompts.join("\n\n") : ""),
|
|
327
368
|
};
|
|
328
369
|
});
|
|
329
370
|
|
|
@@ -452,6 +493,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
452
493
|
task.status === "in_progress" ? "⏳" :
|
|
453
494
|
task.status === "blocked" ? "◻" :
|
|
454
495
|
task.status === "failed" ? "✗" :
|
|
496
|
+
task.status === "suspended" ? "⏸" :
|
|
455
497
|
task.status === "cancelled" ? "⊘" :
|
|
456
498
|
"·";
|
|
457
499
|
let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
|
|
@@ -461,13 +503,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
461
503
|
.join("\n");
|
|
462
504
|
|
|
463
505
|
const durableTasks = store.loadAllTasks(id!);
|
|
506
|
+
const squad = store.loadSquad(id!);
|
|
507
|
+
const review = squad ? getReviewPresentation(squad) : null;
|
|
464
508
|
const summary = [
|
|
465
509
|
`Squad: ${id}`,
|
|
466
510
|
`Status: ${context.status}`,
|
|
467
511
|
`Progress: ${formatTaskProgress(durableTasks)}`,
|
|
468
512
|
`Elapsed: ${context.elapsed}`,
|
|
469
513
|
`Cost: $${context.costs.total.toFixed(4)}`,
|
|
470
|
-
...(
|
|
514
|
+
...(review ? [`Acceptance: ${review.label}`] : []),
|
|
515
|
+
...(squad ? formatSuspendedAttention(squad) : []),
|
|
471
516
|
"",
|
|
472
517
|
"Tasks:",
|
|
473
518
|
taskLines,
|
|
@@ -604,9 +649,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
604
649
|
pi.registerTool({
|
|
605
650
|
name: "squad_modify",
|
|
606
651
|
label: "Squad Modify",
|
|
607
|
-
description: "Modify
|
|
652
|
+
description: "Modify a squad. The destructive cancel action requires an exact squadId and never infers focus; task actions reconstruct the persisted scheduler after restart when needed.",
|
|
608
653
|
parameters: Type.Object({
|
|
609
|
-
squadId: Type.Optional(Type.String({ description: "Exact squad to modify
|
|
654
|
+
squadId: Type.Optional(Type.String({ description: "Exact squad to modify; required for cancel (other actions may use the focused/recoverable project squad)" })),
|
|
610
655
|
action: Type.Union(
|
|
611
656
|
[
|
|
612
657
|
Type.Literal("add_task"),
|
|
@@ -637,6 +682,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
637
682
|
}),
|
|
638
683
|
|
|
639
684
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
685
|
+
if (params.action === "cancel") {
|
|
686
|
+
if (!params.squadId?.trim()) {
|
|
687
|
+
return { content: [{ type: "text" as const, text: "cancel requires exact squadId; no squad was changed." }], details: undefined };
|
|
688
|
+
}
|
|
689
|
+
const squadId = params.squadId.trim();
|
|
690
|
+
if (!store.loadSquad(squadId)) {
|
|
691
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' not found; no squad was changed.` }], details: undefined };
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
await cancelExactSquad(squadId);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
return { content: [{ type: "text" as const, text: `Cancel failed for squad '${squadId}': ${(error as Error).message}` }], details: undefined };
|
|
697
|
+
}
|
|
698
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' cancelled.` }], details: undefined };
|
|
699
|
+
}
|
|
700
|
+
|
|
640
701
|
if (params.action === "resume") {
|
|
641
702
|
const squad = resolveResumeSquad(ctx.cwd, params.squadId);
|
|
642
703
|
if (!squad) {
|
|
@@ -742,9 +803,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
742
803
|
case "resume_task": {
|
|
743
804
|
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
744
805
|
try {
|
|
745
|
-
await activeScheduler.resumeTask(params.taskId);
|
|
806
|
+
const result = await activeScheduler.resumeTask(params.taskId);
|
|
807
|
+
if (result === "already_running") {
|
|
808
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' is already running in squad '${squadId}'; no duplicate resume was started.` }], details: undefined };
|
|
809
|
+
}
|
|
746
810
|
} catch (err) {
|
|
747
|
-
return { content: [{ type: "text" as const, text: `resume_task failed: ${(err as Error).message}` }], details: undefined };
|
|
811
|
+
return { content: [{ type: "text" as const, text: `resume_task failed for task '${params.taskId}' in squad '${squadId}': ${(err as Error).message}` }], details: undefined };
|
|
748
812
|
}
|
|
749
813
|
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
|
|
750
814
|
}
|
|
@@ -769,19 +833,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
769
833
|
return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }], details: undefined };
|
|
770
834
|
}
|
|
771
835
|
|
|
772
|
-
// Note: "resume"
|
|
773
|
-
|
|
774
|
-
case "cancel": {
|
|
775
|
-
await activeScheduler.stop();
|
|
776
|
-
const squad = store.loadSquad(activeSquadId);
|
|
777
|
-
if (squad) {
|
|
778
|
-
squad.status = "failed";
|
|
779
|
-
store.saveSquad(squad);
|
|
780
|
-
}
|
|
781
|
-
schedulers.delete(activeSquadId);
|
|
782
|
-
activeSquadId = null;
|
|
783
|
-
return { content: [{ type: "text" as const, text: "Squad cancelled." }], details: undefined };
|
|
784
|
-
}
|
|
836
|
+
// Note: "resume" and exact-ID "cancel" are handled above.
|
|
785
837
|
|
|
786
838
|
default:
|
|
787
839
|
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], details: undefined };
|
|
@@ -822,6 +874,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
822
874
|
}
|
|
823
875
|
}
|
|
824
876
|
|
|
877
|
+
// Reconstruct schedulers for explicit-suspension stalls without resuming any
|
|
878
|
+
// task. Reconcile derives/persists a missing outbox record and emits only a
|
|
879
|
+
// pending fingerprint; delivered attention remains durable and silent.
|
|
880
|
+
const suspensionCandidates = store.listSquadsForProject(ctx.cwd)
|
|
881
|
+
.filter((squad) => Boolean(squad.suspendedStallAttention) || store.loadAllTasks(squad.id).some((task) => task.status === "suspended"));
|
|
882
|
+
for (const squad of suspensionCandidates) {
|
|
883
|
+
let scheduler = schedulers.get(squad.id);
|
|
884
|
+
if (!scheduler) {
|
|
885
|
+
scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext);
|
|
886
|
+
schedulers.set(squad.id, scheduler);
|
|
887
|
+
wireSchedulerEvents(pi, scheduler, squad.id);
|
|
888
|
+
}
|
|
889
|
+
await scheduler.start();
|
|
890
|
+
}
|
|
891
|
+
|
|
825
892
|
// Mailbox recovery is automatic after extension/main-process restart. Scan
|
|
826
893
|
// every project squad (including accepted done squads) because a crash can
|
|
827
894
|
// occur after the mailbox-first write but before the squad/task is reopened.
|
|
@@ -1130,17 +1197,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1130
1197
|
}
|
|
1131
1198
|
|
|
1132
1199
|
case "cancel": {
|
|
1133
|
-
const
|
|
1134
|
-
if (!
|
|
1135
|
-
ctx.ui.notify("No
|
|
1200
|
+
const cancelledId = activeSquadId;
|
|
1201
|
+
if (!cancelledId) {
|
|
1202
|
+
ctx.ui.notify("No focused squad to cancel", "info");
|
|
1136
1203
|
return;
|
|
1137
1204
|
}
|
|
1138
|
-
await
|
|
1139
|
-
|
|
1140
|
-
if (squad) { squad.status = "failed"; store.saveSquad(squad); }
|
|
1141
|
-
if (activeSquadId) schedulers.delete(activeSquadId);
|
|
1142
|
-
forceWidgetUpdate();
|
|
1143
|
-
ctx.ui.notify("Squad cancelled", "info");
|
|
1205
|
+
await cancelExactSquad(cancelledId);
|
|
1206
|
+
ctx.ui.notify(`Squad '${cancelledId}' cancelled`, "info");
|
|
1144
1207
|
return;
|
|
1145
1208
|
}
|
|
1146
1209
|
|
|
@@ -1501,9 +1564,11 @@ async function pickSquad(
|
|
|
1501
1564
|
const options = squads.map((s) => {
|
|
1502
1565
|
const tasks = store.loadAllTasks(s.id);
|
|
1503
1566
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1504
|
-
const
|
|
1567
|
+
const review = getReviewPresentation(s);
|
|
1568
|
+
const icon = review?.icon ?? (s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·");
|
|
1569
|
+
const acceptance = review ? ` ${review.label}` : ` [${s.status}]`;
|
|
1505
1570
|
const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
|
|
1506
|
-
return `${icon} ${s.id}
|
|
1571
|
+
return `${icon} ${s.id}${acceptance} · ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
|
|
1507
1572
|
});
|
|
1508
1573
|
|
|
1509
1574
|
const choice = await ctx.ui.select("Select a squad", options);
|
|
@@ -1573,6 +1638,20 @@ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: st
|
|
|
1573
1638
|
// can reopen the task immediately on its bound durable Pi session.
|
|
1574
1639
|
break;
|
|
1575
1640
|
}
|
|
1641
|
+
case "suspended_stall": {
|
|
1642
|
+
try {
|
|
1643
|
+
const attention = event.data as SuspendedStallAttention;
|
|
1644
|
+
pi.sendMessage({
|
|
1645
|
+
customType: `squad-suspended-stall:${squadId}:${attention.fingerprint}`,
|
|
1646
|
+
content: formatSuspendedStallAttention(squadId, attention),
|
|
1647
|
+
display: true,
|
|
1648
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1649
|
+
scheduler.acknowledgeSuspendedStall(attention.fingerprint);
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
logError("squad-scheduler", `suspended-stall delivery failed for ${squadId}: ${(error as Error).message}`);
|
|
1652
|
+
}
|
|
1653
|
+
break;
|
|
1654
|
+
}
|
|
1576
1655
|
case "squad_failed": {
|
|
1577
1656
|
const tasks = store.loadAllTasks(squadId);
|
|
1578
1657
|
const failed = tasks.filter((task) => task.status === "failed");
|
|
@@ -14,6 +14,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
14
14
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
15
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { TaskMessage, TaskStatus } from "../types.js";
|
|
17
|
+
import { getReviewPresentation, getSuspendedAttention, SUSPENDED_ATTENTION_LABEL } from "../presentation.js";
|
|
17
18
|
import * as store from "../store.js";
|
|
18
19
|
|
|
19
20
|
function statusIcon(status: TaskStatus, th: Theme): string {
|
|
@@ -99,21 +100,25 @@ export function setupSquadWidget(
|
|
|
99
100
|
if (recentOrchestrator) recentOrchestratorByTask.set(task.id, recentOrchestrator);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const
|
|
103
|
+
const review = getReviewPresentation(squad);
|
|
104
|
+
const attention = getSuspendedAttention(squad);
|
|
105
|
+
const sIcon = review ? th.fg(review.tone, review.icon)
|
|
106
|
+
: squad.status === "done" ? th.fg("success", "✓")
|
|
103
107
|
: squad.status === "failed" ? th.fg("error", "✗")
|
|
104
|
-
: squad.status === "review" ? th.fg("warning", "◆")
|
|
105
108
|
: th.fg("warning", "⏳");
|
|
106
109
|
|
|
107
110
|
const orchestratorSignal = recentOrchestratorByTask.size > 0
|
|
108
111
|
? ` ${th.fg("accent", `✉ ${recentOrchestratorByTask.size > 1 ? recentOrchestratorByTask.size + " " : ""}ORCH`)}`
|
|
109
112
|
: "";
|
|
113
|
+
const acceptanceText = review ? ` · ${th.fg(review.tone, review.label)}` : "";
|
|
110
114
|
lines.push(
|
|
111
115
|
`${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
112
|
-
`${th.fg("muted", progressText)} ` +
|
|
116
|
+
`${th.fg("muted", progressText)}${acceptanceText} ` +
|
|
113
117
|
`${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
|
|
114
118
|
`${th.fg("dim", formatElapsed(elapsed))} ` +
|
|
115
119
|
`${th.fg("dim", "^q detail · /squad msg")}`
|
|
116
120
|
);
|
|
121
|
+
if (attention) lines.push(` ${th.fg("warning", SUSPENDED_ATTENTION_LABEL)} ${th.fg("dim", "· ^q detail")}`);
|
|
117
122
|
|
|
118
123
|
// Cap visible tasks based on total count
|
|
119
124
|
const maxVisible = tasks.length > 6 ? 4 : tasks.length;
|
|
@@ -169,14 +174,16 @@ export function setupSquadWidget(
|
|
|
169
174
|
lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
|
|
170
175
|
}
|
|
171
176
|
|
|
172
|
-
const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
|
|
177
|
+
const cacheKey = `${state.squadId}:${squad.status}:${squad.review?.status ?? "none"}:${attention?.fingerprint ?? "no-attention"}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
|
|
173
178
|
|
|
174
|
-
const statusText =
|
|
179
|
+
const statusText = review
|
|
180
|
+
? th.fg(review.tone, `${review.label} · ${progressText}`)
|
|
181
|
+
: attention
|
|
182
|
+
? th.fg("warning", `${SUSPENDED_ATTENTION_LABEL} · ${progressText}`)
|
|
183
|
+
: squad.status === "done"
|
|
175
184
|
? th.fg("success", `✓ squad ${progressText}`)
|
|
176
185
|
: squad.status === "failed"
|
|
177
186
|
? th.fg("error", `✗ squad ${progressText}`)
|
|
178
|
-
: squad.status === "review"
|
|
179
|
-
? th.fg("warning", `◆ squad review required`)
|
|
180
187
|
: th.fg("accent", `⏳ squad ${progressText} $${totalCost.toFixed(2)}`);
|
|
181
188
|
|
|
182
189
|
return { lines, cacheKey, statusText };
|
|
@@ -216,6 +223,8 @@ export function setupSquadWidget(
|
|
|
216
223
|
ctx.ui.setWidget("squad-tasks", undefined);
|
|
217
224
|
ctx.ui.setStatus("squad", undefined);
|
|
218
225
|
widgetInstalled = false;
|
|
226
|
+
widgetComponent = null;
|
|
227
|
+
lastCacheKey = "";
|
|
219
228
|
return;
|
|
220
229
|
}
|
|
221
230
|
|
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
|
// ============================================================================
|
|
@@ -90,6 +91,15 @@ export class TaskListView {
|
|
|
90
91
|
bottomLines.push("");
|
|
91
92
|
bottomLines.push(truncateToWidth(th.fg("border", " " + "─".repeat(width - 2)), width, ""));
|
|
92
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
|
+
|
|
93
103
|
const runningTask = tasks.find((t) => t.status === "in_progress");
|
|
94
104
|
if (runningTask) {
|
|
95
105
|
bottomLines.push(...this.renderLiveActivity(runningTask, width, scheduler));
|
|
@@ -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/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,7 +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) {
|
|
261
|
-
if (task.status === "cancelled") continue;
|
|
319
|
+
if (task.status === "cancelled" || task.status === "suspended") continue;
|
|
262
320
|
if (this.pool.isRunning(task.id)) continue;
|
|
263
321
|
if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
|
|
264
322
|
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
@@ -309,6 +367,7 @@ export class Scheduler {
|
|
|
309
367
|
}
|
|
310
368
|
|
|
311
369
|
await this.scheduleReadyTasks();
|
|
370
|
+
this.reconcileSuspendedStallAttention();
|
|
312
371
|
|
|
313
372
|
const freshSquad = store.loadSquad(this.squadId);
|
|
314
373
|
if (freshSquad && (freshSquad.status === "running" || freshSquad.status === "failed")) {
|
|
@@ -664,7 +723,8 @@ export class Scheduler {
|
|
|
664
723
|
}
|
|
665
724
|
|
|
666
725
|
private handleUnexpectedAgentExit(event: AgentEvent): void {
|
|
667
|
-
|
|
726
|
+
const status = store.loadTask(this.squadId, event.taskId)?.status;
|
|
727
|
+
if (status === "cancelled" || status === "suspended") return;
|
|
668
728
|
const exitCode = event.data?.exitCode ?? 1;
|
|
669
729
|
const turnCount = event.data?.turnCount ?? 0;
|
|
670
730
|
const stderr = event.data?.stderr || "";
|
|
@@ -794,7 +854,8 @@ export class Scheduler {
|
|
|
794
854
|
}
|
|
795
855
|
|
|
796
856
|
case "agent_settled": {
|
|
797
|
-
|
|
857
|
+
const status = store.loadTask(this.squadId, event.taskId)?.status;
|
|
858
|
+
if (status === "cancelled" || status === "suspended") return;
|
|
798
859
|
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
799
860
|
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
800
861
|
// occurs before the task can become done.
|
|
@@ -846,8 +907,9 @@ export class Scheduler {
|
|
|
846
907
|
const task = store.loadTask(this.squadId, taskId);
|
|
847
908
|
if (!task) return;
|
|
848
909
|
|
|
849
|
-
// Guard against double-completion and late callbacks after cancellation
|
|
850
|
-
|
|
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;
|
|
851
913
|
|
|
852
914
|
// Extract output from last messages
|
|
853
915
|
const messages = store.loadMessages(this.squadId, taskId);
|
|
@@ -905,6 +967,11 @@ export class Scheduler {
|
|
|
905
967
|
debug("squad-scheduler", `handleTaskCompleted: scheduling next ready tasks`);
|
|
906
968
|
await this.scheduleReadyTasks();
|
|
907
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
|
+
|
|
908
975
|
// Re-check squad completion with fresh data AFTER scheduling
|
|
909
976
|
const freshTasks = store.loadAllTasks(this.squadId);
|
|
910
977
|
const freshSquad = store.loadSquad(this.squadId);
|
|
@@ -938,6 +1005,9 @@ export class Scheduler {
|
|
|
938
1005
|
this.pool.kill(taskId);
|
|
939
1006
|
this.updateContext();
|
|
940
1007
|
|
|
1008
|
+
// Failure can likewise expose a suspended-only cut in the remaining DAG.
|
|
1009
|
+
this.reconcileSuspendedStallAttention();
|
|
1010
|
+
|
|
941
1011
|
// Check if squad should be marked failed
|
|
942
1012
|
const tasks = store.loadAllTasks(this.squadId);
|
|
943
1013
|
const squad = store.loadSquad(this.squadId);
|
|
@@ -1409,6 +1479,7 @@ export class Scheduler {
|
|
|
1409
1479
|
setTimeout(() => this.pool.kill(taskId), 3000);
|
|
1410
1480
|
}
|
|
1411
1481
|
store.updateTaskStatus(this.squadId, taskId, "suspended");
|
|
1482
|
+
this.reconcileSuspendedStallAttention();
|
|
1412
1483
|
this.updateContext();
|
|
1413
1484
|
}
|
|
1414
1485
|
|
|
@@ -1503,9 +1574,12 @@ export class Scheduler {
|
|
|
1503
1574
|
|
|
1504
1575
|
/** Resume one exact task. Reopening completed work invalidates descendants and
|
|
1505
1576
|
* archives a completed active review before fresh scheduling begins. */
|
|
1506
|
-
async resumeTask(taskId: string): Promise<
|
|
1577
|
+
async resumeTask(taskId: string): Promise<"resumed" | "already_running"> {
|
|
1507
1578
|
const task = store.loadTask(this.squadId, taskId);
|
|
1508
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.`);
|
|
1509
1583
|
if (task.status === "done") await this.invalidateDescendants(taskId);
|
|
1510
1584
|
const tasks = store.loadAllTasks(this.squadId);
|
|
1511
1585
|
const dependenciesDone = task.depends.every(
|
|
@@ -1518,6 +1592,18 @@ export class Scheduler {
|
|
|
1518
1592
|
this.reopenSquadForWork();
|
|
1519
1593
|
await this.start();
|
|
1520
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;
|
|
1521
1607
|
}
|
|
1522
1608
|
|
|
1523
1609
|
/**
|
|
@@ -1601,6 +1687,7 @@ export class Scheduler {
|
|
|
1601
1687
|
beginOrchestratorRework(squad);
|
|
1602
1688
|
store.saveSquad(squad);
|
|
1603
1689
|
}
|
|
1690
|
+
this.reconcileSuspendedStallAttention();
|
|
1604
1691
|
this.checkSquadCompletion(store.loadAllTasks(this.squadId), squad);
|
|
1605
1692
|
}
|
|
1606
1693
|
this.updateContext();
|
|
@@ -1610,6 +1697,38 @@ export class Scheduler {
|
|
|
1610
1697
|
// Helpers
|
|
1611
1698
|
// =========================================================================
|
|
1612
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
|
+
|
|
1613
1732
|
private extractAssistantText(msg: any): string | null {
|
|
1614
1733
|
if (!msg.content) return null;
|
|
1615
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`
|
|
@@ -152,12 +153,16 @@ Use `squad_modify` when:
|
|
|
152
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.
|
|
153
154
|
- **`pause_task`** / **`resume_task`**: Temporarily halt or explicitly revive an agent; `resume_task` is also the only action that revives a cancelled task.
|
|
154
155
|
- **`pause`** / **`resume`**: Stop/restart the entire squad
|
|
155
|
-
- **`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.
|
|
156
159
|
|
|
157
160
|
## After Agents Finish — Mandatory Independent Orchestrator Review
|
|
158
161
|
|
|
159
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.
|
|
160
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
|
+
|
|
161
166
|
You MUST do all of this before telling the user the work succeeded:
|
|
162
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.
|
|
163
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.
|
package/src/types.ts
CHANGED
|
@@ -85,6 +85,17 @@ export interface SquadReview {
|
|
|
85
85
|
issues: string[];
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export interface SuspendedStallAttention {
|
|
89
|
+
kind: "suspended_stall";
|
|
90
|
+
/** Canonical identity of the exact suspended/blocked task sets. */
|
|
91
|
+
fingerprint: string;
|
|
92
|
+
suspendedTaskIds: string[];
|
|
93
|
+
blockedTaskIds: string[];
|
|
94
|
+
detectedAt: string;
|
|
95
|
+
delivery: "pending" | "delivered";
|
|
96
|
+
deliveredAt: string | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
export interface SquadConfig {
|
|
89
100
|
maxConcurrency: number;
|
|
90
101
|
autoUnblock: boolean;
|
|
@@ -116,6 +127,8 @@ export interface Squad {
|
|
|
116
127
|
review?: SquadReview;
|
|
117
128
|
/** Completed prior review attempts retained as same-squad audit evidence. */
|
|
118
129
|
reviewHistory?: SquadReview[];
|
|
130
|
+
/** Durable, level-triggered attention for an explicit-suspension stall. */
|
|
131
|
+
suspendedStallAttention?: SuspendedStallAttention;
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
// ============================================================================
|