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 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`, or settles without meaningful work, is resumed once on the same task session and then failed if the retry is exhausted.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.19.5",
3
+ "version": "0.20.0",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "scripts": {
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
- private spawnRetries = new Set<string>();
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 the main
682
- * pi session (context inheritance). Guards against blowing the child model's
683
- * context window: forks only when the estimated session tokens fit within
684
- * 50% of the agent model's context window.
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", `inheritContext skipped for ${task.id}: ${reason}`);
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: `Context inheritance skipped: ${reason}. Agent starts with standard squad context only.`,
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
- if (!this.spawnRetries.has(retryKey)) {
782
- this.spawnRetries.add(retryKey);
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}. Retrying in 2s... stderr: ${stderr}`);
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}. Resuming the same task session...`,
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
- }, 2000);
832
+ }, delayMs);
833
+ (timer as { unref?: () => void }).unref?.();
797
834
  } else {
798
- this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} before final agent_settled (retry exhausted). ${stderr}`);
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. Fix every issue, rerun verification/E2E, then submit a fresh squad_review.`;
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;