pi-squad 0.19.5 → 0.20.0
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/CHANGELOG.md +11 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/scheduler.ts +62 -16
- package/src/skills/squad-supervisor/SKILL.md +3 -1
- package/src/tools-registration.ts +17 -1
- package/src/types.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.0] - 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `squad_modify add_task` accepts `forkFromTask: "<task id>"`: the new task's session is forked from the source task's durable session, so follow-up and review-rework agents continue with the source's complete context instead of redoing everything. Validated at add time (source must exist and have run once; mutually exclusive with `inheritContext`) and guarded by the same 50%-of-context-window check as `inheritContext`.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Provider/API outages no longer leave tasks permanently unretriable. Unexpected agent exits now retry on the same durable session with backoff (2s/10s/30s/60s/120s, `PI_SQUAD_SPAWN_RETRIES` overrides the count) instead of a single 2s retry whose in-memory flag was never cleared — previously one early blip consumed the only retry forever, and a resumed task could instantly re-fail terminally. Successful completion and explicit `resume`/`resume_task` grant a fresh budget, and the terminal failure message now teaches the exact recovery call.
|
|
19
|
+
- A failed `squad_review` verdict now responds with explicit same-squad rework instructions (add `-fix` tasks, ideally `forkFromTask` the reviewed implementation) and forbids cancelling or abandoning the squad, closing the observed "review failed → squad stopped, no rework requested" drift.
|
|
20
|
+
|
|
10
21
|
## [0.19.5] - 2026-07-24
|
|
11
22
|
|
|
12
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -284,6 +284,10 @@ squad({
|
|
|
284
284
|
- Once created, that task-to-session binding is immutable; later resumes pass the original file through `--session`
|
|
285
285
|
- Prefer restating the 3-5 key decisions in the task description — reach for `inheritContext` only when that's impractical
|
|
286
286
|
|
|
287
|
+
### Task-to-Task Context (`forkFromTask`)
|
|
288
|
+
|
|
289
|
+
Follow-up and review-rework tasks can fork an **existing task's** durable session instead of starting fresh: `squad_modify { action: "add_task", task: { id: "impl-fix-1", agent: "backend", forkFromTask: "impl", ... } }`. The new agent continues with the source task's complete conversation context — nothing is re-explored or redone. The source must have run at least once (it needs a durable session); the same 50%-of-context-window guard as `inheritContext` applies, and `forkFromTask` is mutually exclusive with `inheritContext`. This is the recommended shape for rework after a failed independent review.
|
|
290
|
+
|
|
287
291
|
### Custom Agents
|
|
288
292
|
|
|
289
293
|
Create `~/.pi/squad/agents/my-agent.json` (global) or `{project}/.pi/squad/agents/my-agent.json` (project override):
|
|
@@ -334,7 +338,7 @@ Configure with `/squad advisor` (on/off, model, max calls per task, reasoning ef
|
|
|
334
338
|
|
|
335
339
|
### Meaningful Work Check
|
|
336
340
|
|
|
337
|
-
Agents must complete at least one LLM turn and produce either a tool call or a substantive assistant artifact before they can be marked `done`. This permits legitimate report-only planning/review work while rejecting empty exits. A child that exits before final `agent_settled
|
|
341
|
+
Agents must complete at least one LLM turn and produce either a tool call or a substantive assistant artifact before they can be marked `done`. This permits legitimate report-only planning/review work while rejecting empty exits. A child that exits before final `agent_settled` (typically a provider/API outage) is resumed on the same task session with backoff — 2s, 10s, 30s, 60s, 120s (override the attempt count with `PI_SQUAD_SPAWN_RETRIES`) — and only fails after the budget is exhausted. Successful completion or an explicit `resume_task`/`resume` grants a fresh budget, so an outage-failed task is always retriggerable once the provider recovers: `squad_modify { action: "resume_task", taskId: "..." }` reopens the same durable session and no work is redone.
|
|
338
342
|
|
|
339
343
|
### Session Resilience
|
|
340
344
|
|
package/package.json
CHANGED
package/src/scheduler.ts
CHANGED
|
@@ -22,6 +22,15 @@ import { buildAgentSystemPrompt } from "./protocol.js";
|
|
|
22
22
|
import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
|
|
23
23
|
import { beginOrchestratorReview, beginOrchestratorRework } from "./review.js";
|
|
24
24
|
|
|
25
|
+
/** Backoff schedule for unexpected agent exits (provider/API outages). The
|
|
26
|
+
* last delay repeats when PI_SQUAD_SPAWN_RETRIES exceeds the schedule. */
|
|
27
|
+
const SPAWN_RETRY_BACKOFF_MS = [2_000, 10_000, 30_000, 60_000, 120_000];
|
|
28
|
+
|
|
29
|
+
function maxSpawnRetries(): number {
|
|
30
|
+
const value = Number(process.env.PI_SQUAD_SPAWN_RETRIES);
|
|
31
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : SPAWN_RETRY_BACKOFF_MS.length;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
// ============================================================================
|
|
26
35
|
// Types
|
|
27
36
|
// ============================================================================
|
|
@@ -130,7 +139,9 @@ export class Scheduler {
|
|
|
130
139
|
private spawnContext?: SchedulerSpawnContext;
|
|
131
140
|
private running = false;
|
|
132
141
|
/** Track spawn retries to allow one retry per task */
|
|
133
|
-
|
|
142
|
+
/** Spawn retries per task. Success and explicit resume grant a fresh budget
|
|
143
|
+
* so a provider/API outage never leaves a task permanently unretriable. */
|
|
144
|
+
private spawnRetryCounts = new Map<string, number>();
|
|
134
145
|
/** Periodic level-triggered reconcile (heals missed events / out-of-band store edits) */
|
|
135
146
|
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
|
136
147
|
/** Suppress duplicate edge emission within one scheduler; disk remains the outbox. */
|
|
@@ -289,6 +300,9 @@ export class Scheduler {
|
|
|
289
300
|
/** Resume suspended/failed work. A failed review is archived only when
|
|
290
301
|
* resumable work actually exists; a bare resume cannot bypass the gate. */
|
|
291
302
|
async resume(): Promise<void> {
|
|
303
|
+
// Every resume is an explicit operator decision: grant fresh spawn-retry
|
|
304
|
+
// budgets so provider-outage failures are always retriggerable.
|
|
305
|
+
this.spawnRetryCounts.clear();
|
|
292
306
|
const tasks = store.loadAllTasks(this.squadId);
|
|
293
307
|
let resumedWork = false;
|
|
294
308
|
for (const task of tasks) {
|
|
@@ -678,25 +692,44 @@ export class Scheduler {
|
|
|
678
692
|
}
|
|
679
693
|
|
|
680
694
|
/**
|
|
681
|
-
* Decide whether this task's agent should be spawned as a fork of
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
695
|
+
* Decide whether this task's agent should be spawned as a fork of an
|
|
696
|
+
* existing session: another task's durable session (forkFromTaskId) or the
|
|
697
|
+
* main pi session (inheritContext). Guards against blowing the child
|
|
698
|
+
* model's context window: forks only when the estimated session tokens fit
|
|
699
|
+
* within 50% of the agent model's context window.
|
|
685
700
|
*/
|
|
686
701
|
private resolveForkSession(task: Task, squad: Squad, agentDef: AgentDef): string | undefined {
|
|
687
|
-
if (!task.inheritContext) return undefined;
|
|
702
|
+
if (!task.inheritContext && !task.forkFromTaskId) return undefined;
|
|
688
703
|
|
|
689
704
|
const skip = (reason: string): undefined => {
|
|
690
|
-
logError("squad-scheduler", `
|
|
705
|
+
logError("squad-scheduler", `session fork skipped for ${task.id}: ${reason}`);
|
|
691
706
|
store.appendMessage(this.squadId, task.id, {
|
|
692
707
|
ts: store.now(),
|
|
693
708
|
from: "system",
|
|
694
709
|
type: "status",
|
|
695
|
-
text: `
|
|
710
|
+
text: `Session fork skipped: ${reason}. Agent starts with standard squad context only.`,
|
|
696
711
|
});
|
|
697
712
|
return undefined;
|
|
698
713
|
};
|
|
699
714
|
|
|
715
|
+
// Fork another task's durable session: the follow-up/rework agent
|
|
716
|
+
// continues with the source task's complete context.
|
|
717
|
+
if (task.forkFromTaskId) {
|
|
718
|
+
const source = store.loadTaskSession(this.squadId, task.forkFromTaskId);
|
|
719
|
+
if (!source || !fs.existsSync(source.file)) {
|
|
720
|
+
return skip(`fork source task '${task.forkFromTaskId}' has no durable session file`);
|
|
721
|
+
}
|
|
722
|
+
const estTokens = Math.ceil(fs.statSync(source.file).size / 4);
|
|
723
|
+
const window = this.spawnContext?.resolveContextWindow?.(agentDef.model ?? null);
|
|
724
|
+
// An unknown window does not veto an explicit operator/orchestrator
|
|
725
|
+
// request: task sessions are bounded, unlike whole main sessions.
|
|
726
|
+
if (window && estTokens > window * 0.5) {
|
|
727
|
+
return skip(`fork source session (~${Math.round(estTokens / 1000)}k tokens) exceeds 50% of ${agentDef.model || "default model"}'s ${Math.round(window / 1000)}k window — restate key context in the task description instead`);
|
|
728
|
+
}
|
|
729
|
+
debug("squad-scheduler", `forkFromTask: forking ${source.file} (task ${task.forkFromTaskId}) for ${task.id} (~${Math.round(estTokens / 1000)}k tokens)`);
|
|
730
|
+
return source.file;
|
|
731
|
+
}
|
|
732
|
+
|
|
700
733
|
const sessionFile = squad.sessionFile;
|
|
701
734
|
if (!sessionFile) return skip("main session has no session file (ephemeral --no-session run)");
|
|
702
735
|
if (!fs.existsSync(sessionFile)) return skip(`session file not found: ${sessionFile}`);
|
|
@@ -778,24 +811,32 @@ export class Scheduler {
|
|
|
778
811
|
const turnCount = event.data?.turnCount ?? 0;
|
|
779
812
|
const stderr = event.data?.stderr || "";
|
|
780
813
|
const retryKey = `spawn-retry:${event.taskId}`;
|
|
781
|
-
|
|
782
|
-
|
|
814
|
+
const attempt = this.spawnRetryCounts.get(retryKey) ?? 0;
|
|
815
|
+
const maxRetries = maxSpawnRetries();
|
|
816
|
+
if (attempt < maxRetries) {
|
|
817
|
+
this.spawnRetryCounts.set(retryKey, attempt + 1);
|
|
818
|
+
const delayMs = SPAWN_RETRY_BACKOFF_MS[Math.min(attempt, SPAWN_RETRY_BACKOFF_MS.length - 1)];
|
|
783
819
|
const reason = turnCount === 0
|
|
784
|
-
? "exited with 0 turns (likely rate limit or API error)"
|
|
820
|
+
? "exited with 0 turns (likely rate limit or provider API error)"
|
|
785
821
|
: `exited before final agent_settled after ${turnCount} turns`;
|
|
786
|
-
logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}.
|
|
822
|
+
logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retry ${attempt + 1}/${maxRetries} in ${Math.round(delayMs / 1000)}s... stderr: ${stderr}`);
|
|
787
823
|
store.updateTaskStatus(this.squadId, event.taskId, "pending");
|
|
788
824
|
store.appendMessage(this.squadId, event.taskId, {
|
|
789
825
|
ts: store.now(),
|
|
790
826
|
from: "system",
|
|
791
827
|
type: "status",
|
|
792
|
-
text: `Agent ${reason}.
|
|
828
|
+
text: `Agent ${reason}. Retry ${attempt + 1}/${maxRetries} resumes the same task session in ${Math.round(delayMs / 1000)}s...`,
|
|
793
829
|
});
|
|
794
|
-
setTimeout(() => {
|
|
830
|
+
const timer = setTimeout(() => {
|
|
795
831
|
if (this.running) this.scheduleReadyTasks();
|
|
796
|
-
},
|
|
832
|
+
}, delayMs);
|
|
833
|
+
(timer as { unref?: () => void }).unref?.();
|
|
797
834
|
} else {
|
|
798
|
-
this.handleTaskFailed(
|
|
835
|
+
this.handleTaskFailed(
|
|
836
|
+
event.taskId,
|
|
837
|
+
`Agent exited with code ${exitCode} before final agent_settled (${maxRetries} backoff retries exhausted — likely provider/API outage). ${stderr}`.trimEnd() +
|
|
838
|
+
`\nWhen the provider recovers, squad_modify { action: "resume_task", taskId: "${event.taskId}" } reopens the same durable session with a fresh retry budget — no work is redone.`,
|
|
839
|
+
);
|
|
799
840
|
}
|
|
800
841
|
this.updateContext();
|
|
801
842
|
}
|
|
@@ -994,6 +1035,9 @@ export class Scheduler {
|
|
|
994
1035
|
.filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"));
|
|
995
1036
|
const output = agentMessages.map((m) => m.text).join("\n");
|
|
996
1037
|
|
|
1038
|
+
// A successful completion also restores the full spawn-retry budget for
|
|
1039
|
+
// any later reopen of this task (rework, follow-up messages).
|
|
1040
|
+
this.spawnRetryCounts.delete(`spawn-retry:${taskId}`);
|
|
997
1041
|
// Clear any interim failure annotation (spawn retry, RPC race): a task
|
|
998
1042
|
// that ultimately completed must not display a stale error forever.
|
|
999
1043
|
store.updateTaskStatus(this.squadId, taskId, "done", {
|
|
@@ -1679,6 +1723,8 @@ export class Scheduler {
|
|
|
1679
1723
|
async resumeTask(taskId: string): Promise<"resumed" | "already_running"> {
|
|
1680
1724
|
const task = store.loadTask(this.squadId, taskId);
|
|
1681
1725
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1726
|
+
// Explicit resume grants a fresh spawn-retry budget (provider recovery).
|
|
1727
|
+
this.spawnRetryCounts.delete(`spawn-retry:${taskId}`);
|
|
1682
1728
|
const live = this.pool.isRunning(taskId);
|
|
1683
1729
|
if (task.status === "in_progress" && live) return "already_running";
|
|
1684
1730
|
if (live) throw new Error(`Task '${taskId}' has a live child but durable status '${task.status}'; no duplicate resume was started.`);
|
|
@@ -149,7 +149,9 @@ Send small, scoped corrections instead of restarting the task:
|
|
|
149
149
|
## Modifying a Running Squad
|
|
150
150
|
|
|
151
151
|
Use `squad_modify` when:
|
|
152
|
-
- **`add_task`**: User requests something not in the original plan
|
|
152
|
+
- **`add_task`**: User requests something not in the original plan. For follow-up or review-rework work, set `forkFromTask: "<source task id>"` so the new agent forks the source task's durable session and continues with its full context instead of redoing everything (mutually exclusive with `inheritContext`; the source must have run at least once).
|
|
153
|
+
- **Provider outage recovery**: a task that failed with "retries exhausted — likely provider/API outage" is not stuck. When the provider recovers, `{ action: "resume_task", taskId: "..." }` reopens the exact durable session with a fresh retry budget — never recreate the task from scratch for this.
|
|
154
|
+
- **Failed independent review**: never cancel the squad or mark it failed. Add same-squad rework tasks (ideally with `forkFromTask` pointing at the reviewed implementation task), let them settle, re-verify, and submit a fresh `squad_review`.
|
|
153
155
|
- **`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.
|
|
154
156
|
- **`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.
|
|
155
157
|
- **`pause_task`** / **`resume_task`**: Temporarily halt or explicitly revive an agent; `resume_task` is also the only action that revives a cancelled task.
|
|
@@ -362,7 +362,9 @@ pi.registerTool({
|
|
|
362
362
|
if (accepted && runtime.activeSquadId === id) focusSquad(null);
|
|
363
363
|
const text = accepted
|
|
364
364
|
? `Independent orchestrator review recorded for '${id}' (${params.verdict}). The squad is now accepted as done.`
|
|
365
|
-
: `Independent review FAILED for '${id}'. The squad remains review-required
|
|
365
|
+
: `Independent review FAILED for '${id}'. The squad remains review-required — do NOT cancel it, do NOT mark it failed, and do NOT stop here.\n` +
|
|
366
|
+
`Route same-squad rework NOW: squad_modify { action: "add_task", squadId: "${id}", task: { id: "<slice>-fix-1", agent: "<original implementer>", forkFromTask: "<original task id>", title: "...", description: "<the exact failed issues>" } }.\n` +
|
|
367
|
+
`forkFromTask reopens the implementer's full session context so nothing is redone. When rework settles, independently re-verify and submit a fresh squad_review.`;
|
|
366
368
|
return { content: [{ type: "text" as const, text }], details: undefined };
|
|
367
369
|
},
|
|
368
370
|
});
|
|
@@ -457,6 +459,7 @@ pi.registerTool({
|
|
|
457
459
|
agent: Type.String(),
|
|
458
460
|
depends: Type.Optional(Type.Array(Type.String())),
|
|
459
461
|
inheritContext: Type.Optional(Type.Boolean({ description: "Fork the current pi session so the agent inherits this conversation's context (see squad tool docs for caveats)" })),
|
|
462
|
+
forkFromTask: Type.Optional(Type.String({ description: "Existing task ID in this squad whose durable session seeds the new task as a fork — the agent continues with that task's full context instead of redoing everything. Ideal for follow-up and review-rework tasks. Mutually exclusive with inheritContext." })),
|
|
460
463
|
}, { description: "Task definition for add_task" }),
|
|
461
464
|
),
|
|
462
465
|
}),
|
|
@@ -534,6 +537,18 @@ pi.registerTool({
|
|
|
534
537
|
const available = store.loadAllAgentDefs(targetCwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
|
|
535
538
|
return { content: [{ type: "text" as const, text: `Unknown agent '${params.task.agent}'. Available: ${available}` }], details: undefined };
|
|
536
539
|
}
|
|
540
|
+
const forkFromTask = params.task.forkFromTask?.trim();
|
|
541
|
+
if (forkFromTask) {
|
|
542
|
+
if (params.task.inheritContext) {
|
|
543
|
+
return { content: [{ type: "text" as const, text: "Choose either forkFromTask or inheritContext, not both." }], details: undefined };
|
|
544
|
+
}
|
|
545
|
+
if (!existingIds.has(forkFromTask)) {
|
|
546
|
+
return { content: [{ type: "text" as const, text: `forkFromTask '${forkFromTask}' not found in squad '${squadId}'. Existing tasks: ${[...existingIds].join(", ")}` }], details: undefined };
|
|
547
|
+
}
|
|
548
|
+
if (!store.loadTaskSession(squadId, forkFromTask)) {
|
|
549
|
+
return { content: [{ type: "text" as const, text: `forkFromTask '${forkFromTask}' has no durable session yet (it never spawned). Add the task without forkFromTask, or fork a task that has run.` }], details: undefined };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
537
552
|
const dependencies = params.task.depends || [];
|
|
538
553
|
const task: Task = {
|
|
539
554
|
id: params.task.id,
|
|
@@ -543,6 +558,7 @@ pi.registerTool({
|
|
|
543
558
|
status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
|
|
544
559
|
depends: dependencies,
|
|
545
560
|
...(params.task.inheritContext ? { inheritContext: true } : {}),
|
|
561
|
+
...(forkFromTask ? { forkFromTaskId: forkFromTask } : {}),
|
|
546
562
|
...(targetSquad.spec ? { fileSpecDelta: true } : {}),
|
|
547
563
|
created: store.now(),
|
|
548
564
|
started: null,
|
package/src/types.ts
CHANGED
|
@@ -169,6 +169,10 @@ export interface Task {
|
|
|
169
169
|
/** Fork the main pi session so this agent inherits the full conversation context.
|
|
170
170
|
* Skipped automatically if the estimated context exceeds 50% of the agent model's window. */
|
|
171
171
|
inheritContext?: boolean;
|
|
172
|
+
/** Seed this task's new session as a fork of another task's durable session,
|
|
173
|
+
* so follow-up/rework work continues with that task's full context instead of
|
|
174
|
+
* starting fresh. Mutually exclusive with inheritContext. */
|
|
175
|
+
forkFromTaskId?: string;
|
|
172
176
|
created: string;
|
|
173
177
|
started: string | null;
|
|
174
178
|
completed: string | null;
|