pi-squad 0.16.3 → 0.16.5

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/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, TaskMessage, TaskStatus } from "./types.js";
14
+ import type { AgentDef, Squad, SquadConfig, 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";
@@ -19,7 +19,7 @@ import * as store from "./store.js";
19
19
  import { debug, logError } from "./logger.js";
20
20
  import { buildAgentSystemPrompt } from "./protocol.js";
21
21
  import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
22
- import { beginOrchestratorReview } from "./review.js";
22
+ import { beginOrchestratorReview, beginOrchestratorRework } from "./review.js";
23
23
 
24
24
  // ============================================================================
25
25
  // Types
@@ -170,6 +170,10 @@ export class Scheduler {
170
170
 
171
171
  /** Start the scheduler — begins scheduling ready tasks */
172
172
  async start(): Promise<void> {
173
+ if (this.running) {
174
+ await this.reconcile();
175
+ return;
176
+ }
173
177
  this.running = true;
174
178
  this.monitor.start();
175
179
  await this.reconcile();
@@ -200,13 +204,15 @@ export class Scheduler {
200
204
  await this.pool.killAll();
201
205
  }
202
206
 
203
- /** Resume from suspended OR failed state. Failure is never terminal:
204
- * failed tasks are reset to pending and a failed squad becomes running. */
207
+ /** Resume suspended/failed work. A failed review is archived only when
208
+ * resumable work actually exists; a bare resume cannot bypass the gate. */
205
209
  async resume(): Promise<void> {
206
210
  const tasks = store.loadAllTasks(this.squadId);
211
+ let resumedWork = false;
207
212
  for (const task of tasks) {
208
213
  if (task.status === "suspended") {
209
214
  store.updateTaskStatus(this.squadId, task.id, "pending");
215
+ resumedWork = true;
210
216
  } else if (task.status === "failed") {
211
217
  store.updateTaskStatus(this.squadId, task.id, "pending", { error: null });
212
218
  store.appendMessage(this.squadId, task.id, {
@@ -215,13 +221,19 @@ export class Scheduler {
215
221
  type: "status",
216
222
  text: "Reset failed → pending on squad resume",
217
223
  });
224
+ resumedWork = true;
218
225
  }
219
226
  }
220
227
 
221
228
  const squad = store.loadSquad(this.squadId);
222
- if (squad && (squad.status === "paused" || squad.status === "failed")) {
223
- squad.status = "running";
224
- store.saveSquad(squad);
229
+ if (squad) {
230
+ if (resumedWork && squad.status === "review" && squad.review?.status === "failed") {
231
+ beginOrchestratorRework(squad);
232
+ store.saveSquad(squad);
233
+ } else if (squad.status === "paused" || squad.status === "failed") {
234
+ squad.status = "running";
235
+ store.saveSquad(squad);
236
+ }
225
237
  }
226
238
 
227
239
  await this.start();
@@ -241,6 +253,31 @@ export class Scheduler {
241
253
 
242
254
  const tasks = store.loadAllTasks(this.squadId);
243
255
 
256
+ // 0. Mailbox recovery is level-triggered from disk. If the previous
257
+ // process stopped after queueTaskMessage's atomic write but before status
258
+ // mutation/RPC delivery, reopen exactly that task on reconstruction.
259
+ let recoveredMailbox = false;
260
+ for (const task of tasks) {
261
+ if (this.pool.isRunning(task.id)) continue;
262
+ if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
263
+ if (task.status === "done") await this.invalidateDescendants(task.id);
264
+ const dependenciesDone = task.depends.every(
265
+ (depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
266
+ );
267
+ const nextStatus: TaskStatus = dependenciesDone ? "pending" : "blocked";
268
+ if (task.status !== nextStatus || task.completed !== null || task.error !== null) {
269
+ task.status = nextStatus;
270
+ task.completed = null;
271
+ task.error = null;
272
+ store.updateTaskStatus(this.squadId, task.id, nextStatus, { completed: null, error: null });
273
+ }
274
+ recoveredMailbox = true;
275
+ }
276
+ if (recoveredMailbox && squad.status !== "running") {
277
+ beginOrchestratorRework(squad);
278
+ store.saveSquad(squad);
279
+ }
280
+
244
281
  // 1. Blocked → pending when all deps are done (respects autoUnblock config)
245
282
  if (squad.config.autoUnblock) {
246
283
  for (const task of tasks) {
@@ -323,7 +360,12 @@ export class Scheduler {
323
360
  /** Get tasks that are ready to execute (pending + all deps done) */
324
361
  private getReadyTasks(tasks: Task[]): Task[] {
325
362
  return tasks.filter((task) => {
326
- if (task.status !== "pending") return false;
363
+ // A reconstructed scheduler has an empty process pool. Persisted
364
+ // in_progress means the previous process stopped mid-task, so resume its
365
+ // bound session instead of stranding it or creating a fresh context.
366
+ const needsProcess = task.status === "pending" ||
367
+ (task.status === "in_progress" && !this.pool.isRunning(task.id));
368
+ if (!needsProcess) return false;
327
369
  return task.depends.every((depId) => {
328
370
  const dep = tasks.find((t) => t.id === depId);
329
371
  return dep?.status === "done";
@@ -376,9 +418,24 @@ export class Scheduler {
376
418
  }
377
419
  }
378
420
 
379
- // Update task status
421
+ const resumeSession = store.loadTaskSession(this.squadId, task.id) ?? undefined;
422
+ const pendingMailbox = store.loadPendingTaskMessages(this.squadId, task.id);
423
+ // Legacy tasks predate task-owned Pi sessions. Their first durable spawn
424
+ // must receive the complete persisted history/output as a migration seed;
425
+ // otherwise reopening silently discards all prior context.
426
+ const legacyHistory = !resumeSession && (task.started !== null || task.completed !== null || task.output !== null)
427
+ ? store.loadMessages(this.squadId, task.id)
428
+ : [];
429
+ const legacySeed = legacyHistory.length > 0 || (!resumeSession && task.output !== null)
430
+ ? { messages: legacyHistory, output: task.output }
431
+ : undefined;
432
+
433
+ // Keep the original start time across resumes. A resumed/live process owns
434
+ // in_progress until Pi emits final agent_settled.
380
435
  store.updateTaskStatus(this.squadId, task.id, "in_progress", {
381
- started: store.now(),
436
+ started: task.started ?? store.now(),
437
+ completed: null,
438
+ error: null,
382
439
  });
383
440
 
384
441
  store.appendMessage(this.squadId, task.id, {
@@ -395,11 +452,13 @@ export class Scheduler {
395
452
  agentName: task.agent,
396
453
  });
397
454
 
398
- // Decide whether to fork the main session for context inheritance
399
- const forkSessionFile = this.resolveForkSession(task, squad, agentDef);
455
+ // Context inheritance creates a session only on the task's first run.
456
+ // Every later run must reopen the immutable task binding.
457
+ const forkSessionFile = resumeSession ? undefined : this.resolveForkSession(task, squad, agentDef);
458
+ const taskSessionDir = store.getTaskSessionDir(this.squadId, task.id);
400
459
 
401
460
  try {
402
- await this.pool.spawn({
461
+ const agent = await this.pool.spawn({
403
462
  taskId: task.id,
404
463
  agentDef,
405
464
  protocolOptions: {
@@ -408,19 +467,28 @@ export class Scheduler {
408
467
  task,
409
468
  agentDef,
410
469
  modifiedFiles,
411
- queuedMessages: this.pool.consumeQueue(task.agent),
470
+ queuedMessages: pendingMailbox.map((entry) => entry.message),
412
471
  },
413
472
  cwd: squad.cwd,
414
473
  skillPaths: this.skillPaths,
415
- ...(forkSessionFile
416
- ? {
417
- forkSession: {
418
- file: forkSessionFile,
419
- sessionDir: path.join(store.getSquadDir(this.squadId), "sessions"),
420
- },
421
- }
422
- : {}),
474
+ ...(resumeSession
475
+ ? { resumeSession }
476
+ : forkSessionFile
477
+ ? { forkSession: { file: forkSessionFile, sessionDir: taskSessionDir } }
478
+ : { sessionDir: taskSessionDir }),
423
479
  });
480
+ store.bindTaskSession(this.squadId, task.id, agent.session);
481
+
482
+ const accepted = await this.pool.prompt(
483
+ task.id,
484
+ this.buildTaskPrompt(task, Boolean(resumeSession), pendingMailbox, legacySeed),
485
+ );
486
+ if (!accepted) throw new Error(`Agent ${task.agent} did not accept the task prompt`);
487
+ store.acknowledgeTaskMessages(
488
+ this.squadId,
489
+ task.id,
490
+ pendingMailbox.map((entry) => entry.id),
491
+ );
424
492
  } catch (error) {
425
493
  this.handleTaskFailed(task.id, (error as Error).message);
426
494
  }
@@ -545,6 +613,77 @@ export class Scheduler {
545
613
  return sessionFile;
546
614
  }
547
615
 
616
+ private buildTaskPrompt(
617
+ task: Task,
618
+ resumed: boolean,
619
+ entries: TaskMailboxEntry[],
620
+ legacySeed?: { messages: TaskMessage[]; output: string | null },
621
+ ): string {
622
+ const lines = resumed
623
+ ? [
624
+ `Resume your existing task: ${task.title}`,
625
+ "Continue from the durable Pi session context. Do not restart the task from scratch.",
626
+ ]
627
+ : [
628
+ `Your task: ${task.title}`,
629
+ "",
630
+ task.description || "",
631
+ ];
632
+
633
+ if (legacySeed) {
634
+ const pendingIds = new Set(entries.map((entry) => entry.id));
635
+ lines.push("", "Legacy task migration: the following persisted history is the complete prior context. Preserve it and continue from it.");
636
+ for (const message of legacySeed.messages) {
637
+ if (message.id && pendingIds.has(message.id)) continue;
638
+ lines.push("", `[${message.ts}] ${message.from} (${message.type}):`, message.text);
639
+ }
640
+ if (legacySeed.output !== null) {
641
+ lines.push("", "Prior durable task output:", legacySeed.output);
642
+ }
643
+ }
644
+
645
+ if (entries.length > 0) {
646
+ lines.push("", "New durable messages for this task:");
647
+ for (const entry of entries) {
648
+ lines.push("", `[${entry.message.ts}] ${entry.message.from}:`, entry.message.text);
649
+ if (entry.message.expectsReply) {
650
+ lines.push("[Direct response required by main orchestrator]");
651
+ }
652
+ }
653
+ lines.push("", "Read and act on every complete message above.");
654
+ } else if (resumed) {
655
+ lines.push("", "The previous process ended before final settlement. Continue the unfinished work and report the final result.");
656
+ }
657
+ return lines.join("\n");
658
+ }
659
+
660
+ private handleUnexpectedAgentExit(event: AgentEvent): void {
661
+ const exitCode = event.data?.exitCode ?? 1;
662
+ const turnCount = event.data?.turnCount ?? 0;
663
+ const stderr = event.data?.stderr || "";
664
+ const retryKey = `spawn-retry:${event.taskId}`;
665
+ if (!this.spawnRetries.has(retryKey)) {
666
+ this.spawnRetries.add(retryKey);
667
+ const reason = turnCount === 0
668
+ ? "exited with 0 turns (likely rate limit or API error)"
669
+ : `exited before final agent_settled after ${turnCount} turns`;
670
+ logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
671
+ store.updateTaskStatus(this.squadId, event.taskId, "pending");
672
+ store.appendMessage(this.squadId, event.taskId, {
673
+ ts: store.now(),
674
+ from: "system",
675
+ type: "status",
676
+ text: `Agent ${reason}. Resuming the same task session...`,
677
+ });
678
+ setTimeout(() => {
679
+ if (this.running) this.scheduleReadyTasks();
680
+ }, 2000);
681
+ } else {
682
+ this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} before final agent_settled (retry exhausted). ${stderr}`);
683
+ }
684
+ this.updateContext();
685
+ }
686
+
548
687
  // =========================================================================
549
688
  // Event Handlers
550
689
  // =========================================================================
@@ -638,50 +777,45 @@ export class Scheduler {
638
777
  break;
639
778
  }
640
779
 
641
- case "agent_end": {
642
- const exitCode = event.data?.exitCode ?? 1;
780
+ case "agent_end": {
781
+ // Pi's low-level agent_end is not completion. AgentPool normally keeps
782
+ // it internal; if observed here, preserve in_progress. Only an actual
783
+ // child-process exit carries unexpectedExit and enters retry handling.
784
+ if (!event.data?.unexpectedExit) break;
785
+ this.handleUnexpectedAgentExit(event);
786
+ return;
787
+ }
788
+
789
+ case "agent_settled": {
790
+ // A mailbox entry not acknowledged by Pi outranks this run's candidate
791
+ // completion. Reopen the same session so accepted-at-least-once delivery
792
+ // occurs before the task can become done.
793
+ if (store.loadPendingTaskMessages(this.squadId, event.taskId).length > 0) {
794
+ store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null });
795
+ store.appendMessage(this.squadId, event.taskId, {
796
+ ts: store.now(),
797
+ from: "system",
798
+ type: "status",
799
+ text: "Agent settled with pending durable messages; resuming the same task session",
800
+ });
801
+ if (this.running) void this.reconcile();
802
+ this.updateContext();
803
+ return;
804
+ }
805
+
643
806
  const turnCount = event.data?.turnCount ?? 0;
644
807
  const toolCallCount = event.data?.toolCallCount ?? 0;
645
-
646
- // Tool use is strong evidence of work, but planning/review tasks can
647
- // legitimately deliver a substantive report without calling a tool.
648
- // Accept durable assistant output; the mandatory main-orchestrator
649
- // review gate still decides whether the work satisfies the contract.
650
808
  const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
651
809
  .some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
652
810
  const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
653
811
  if (hadMeaningfulWork) {
654
812
  this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
655
813
  } else {
656
- // Agent exited without a completed turn, tool work, or substantive
657
- // assistant artifact. Common causes: rate limit/API/resource failure.
658
- // Retry once before failing.
659
- const retryKey = `spawn-retry:${event.taskId}`;
660
- if (!this.spawnRetries.has(retryKey)) {
661
- this.spawnRetries.add(retryKey);
662
- const stderr = event.data?.stderr || "";
663
- const reason = turnCount === 0
664
- ? `exited with 0 turns (likely rate limit or API error)`
665
- : `exited with ${turnCount} turns but no tool calls or substantive output`;
666
- logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
667
- store.updateTaskStatus(this.squadId, event.taskId, "pending");
668
- store.appendMessage(this.squadId, event.taskId, {
669
- ts: store.now(),
670
- from: "system",
671
- type: "status",
672
- text: `Agent ${reason}. Retrying...`,
673
- });
674
- // Delay retry to let resources settle
675
- setTimeout(() => {
676
- if (this.running) this.scheduleReadyTasks();
677
- }, 2000);
678
- } else {
679
- const stderr = event.data?.stderr || "";
680
- this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} (retry exhausted). ${stderr}`);
681
- }
682
- this.updateContext();
814
+ this.handleUnexpectedAgentExit({
815
+ ...event,
816
+ data: { ...event.data, unexpectedExit: true },
817
+ });
683
818
  }
684
- // Skip the updateContext() below — handled in the branches above
685
819
  return;
686
820
  }
687
821
 
@@ -1139,12 +1273,79 @@ export class Scheduler {
1139
1273
  // External Actions
1140
1274
  // =========================================================================
1141
1275
 
1142
- /** Send a main-orchestrator request to a task's agent and await its next reply. */
1276
+ /**
1277
+ * A reopened dependency invalidates every transitive descendant, including
1278
+ * descendants that had already completed. They retain history/output for
1279
+ * audit and durable-session continuation, but must rerun in dependency order.
1280
+ */
1281
+ private async invalidateDescendants(reopenedTaskId: string): Promise<void> {
1282
+ const tasks = store.loadAllTasks(this.squadId);
1283
+ const byDependency = new Map<string, Task[]>();
1284
+ for (const task of tasks) {
1285
+ for (const dependency of task.depends) {
1286
+ const dependents = byDependency.get(dependency) ?? [];
1287
+ dependents.push(task);
1288
+ byDependency.set(dependency, dependents);
1289
+ }
1290
+ }
1291
+
1292
+ const queue = [...(byDependency.get(reopenedTaskId) ?? [])];
1293
+ const seen = new Set<string>();
1294
+ const kills: Promise<void>[] = [];
1295
+ while (queue.length > 0) {
1296
+ const descendant = queue.shift()!;
1297
+ if (seen.has(descendant.id)) continue;
1298
+ seen.add(descendant.id);
1299
+ queue.push(...(byDependency.get(descendant.id) ?? []));
1300
+ store.updateTaskStatus(this.squadId, descendant.id, "blocked", {
1301
+ completed: null,
1302
+ error: null,
1303
+ });
1304
+ store.appendMessage(this.squadId, descendant.id, {
1305
+ ts: store.now(),
1306
+ from: "system",
1307
+ type: "status",
1308
+ text: `Blocked — dependency ancestry reopened at ${reopenedTaskId}; prior result requires revalidation`,
1309
+ });
1310
+ if (this.pool.isRunning(descendant.id)) kills.push(this.pool.kill(descendant.id));
1311
+ }
1312
+ await Promise.all(kills);
1313
+ }
1314
+
1315
+ private reopenSquadForWork(): void {
1316
+ const squad = store.loadSquad(this.squadId);
1317
+ if (!squad) throw new Error(`Squad not found: ${this.squadId}`);
1318
+ if (squad.status === "review" || squad.status === "done" || squad.review) {
1319
+ beginOrchestratorRework(squad);
1320
+ } else if (squad.status !== "running") {
1321
+ squad.status = "running";
1322
+ }
1323
+ store.saveSquad(squad);
1324
+ }
1325
+
1326
+ private async reopenTaskForMessage(task: Task): Promise<void> {
1327
+ if (task.status === "done") await this.invalidateDescendants(task.id);
1328
+ const tasks = store.loadAllTasks(this.squadId);
1329
+ const dependenciesDone = task.depends.every(
1330
+ (depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
1331
+ );
1332
+ const nextStatus: TaskStatus = dependenciesDone ? "pending" : "blocked";
1333
+ store.updateTaskStatus(this.squadId, task.id, nextStatus, {
1334
+ error: null,
1335
+ completed: null,
1336
+ });
1337
+
1338
+ this.reopenSquadForWork();
1339
+ }
1340
+
1341
+ /** Send a main-orchestrator request to one exact task and await its next reply. */
1143
1342
  async sendHumanMessage(taskId: string, message: string, expectsReply = true): Promise<boolean> {
1144
1343
  const task = store.loadTask(this.squadId, taskId);
1145
1344
  if (!task) return false;
1146
1345
 
1147
- store.appendMessage(this.squadId, taskId, {
1346
+ // Mailbox-first: a process/scheduler crash can occur at any later point
1347
+ // without losing or redirecting this message to another task of the role.
1348
+ const queued = store.queueTaskMessage(this.squadId, taskId, {
1148
1349
  ts: store.now(),
1149
1350
  from: "orchestrator",
1150
1351
  type: "message",
@@ -1155,19 +1356,23 @@ export class Scheduler {
1155
1356
  const request = expectsReply
1156
1357
  ? `[squad] Main orchestrator requests a direct response:\n${message}\n\nReply directly in your next assistant message. That complete message will be forwarded automatically to the main session.`
1157
1358
  : `[squad] Main orchestrator message:\n${message}`;
1158
- if (this.pool.isRunning(taskId) && await this.pool.steer(taskId, request)) {
1159
- return true;
1359
+ if (this.pool.isRunning(taskId)) {
1360
+ if (await this.pool.steer(taskId, request)) {
1361
+ store.acknowledgeTaskMessages(this.squadId, taskId, [queued.id]);
1362
+ return true;
1363
+ }
1364
+ // The process may still be completing its current run. Preserve
1365
+ // in_progress while it is live; agent_settled will observe the pending
1366
+ // mailbox and reopen the same session instead of marking done.
1367
+ if (this.pool.isRunning(taskId)) return false;
1160
1368
  }
1161
1369
 
1162
- // Queue for when the assigned agent starts/restarts. The durable request
1163
- // marker lets a reconstructed scheduler still forward the eventual reply.
1164
- this.pool.queueMessage(task.agent, {
1165
- ts: store.now(),
1166
- from: "orchestrator",
1167
- type: "message",
1168
- text: expectsReply ? `${message}\n\n[Direct response required by main orchestrator]` : message,
1169
- expectsReply,
1170
- });
1370
+ await this.reopenTaskForMessage(task);
1371
+ if (this.running) {
1372
+ await this.reconcile();
1373
+ return store.loadPendingTaskMessages(this.squadId, taskId)
1374
+ .every((entry) => entry.id !== queued.id);
1375
+ }
1171
1376
  return false;
1172
1377
  }
1173
1378
 
@@ -1191,10 +1396,32 @@ export class Scheduler {
1191
1396
  this.updateContext();
1192
1397
  }
1193
1398
 
1194
- /** Resume a suspended/failed task. Reconciles so a failed squad heals too. */
1399
+ /** Add work to this exact squad and schedule it, reconstructing safely after restart. */
1400
+ async addTask(task: Task): Promise<void> {
1401
+ if (store.loadTask(this.squadId, task.id)) throw new Error(`Task already exists: ${task.id}`);
1402
+ store.createTask(this.squadId, task);
1403
+ this.reopenSquadForWork();
1404
+ await this.start();
1405
+ this.updateContext();
1406
+ }
1407
+
1408
+ /** Resume one exact task. Reopening completed work invalidates descendants and
1409
+ * archives a completed active review before fresh scheduling begins. */
1195
1410
  async resumeTask(taskId: string): Promise<void> {
1196
- store.updateTaskStatus(this.squadId, taskId, "pending", { error: null });
1197
- await this.reconcile();
1411
+ const task = store.loadTask(this.squadId, taskId);
1412
+ if (!task) throw new Error(`Task not found: ${taskId}`);
1413
+ if (task.status === "done") await this.invalidateDescendants(taskId);
1414
+ const tasks = store.loadAllTasks(this.squadId);
1415
+ const dependenciesDone = task.depends.every(
1416
+ (depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
1417
+ );
1418
+ store.updateTaskStatus(this.squadId, taskId, dependenciesDone ? "pending" : "blocked", {
1419
+ error: null,
1420
+ completed: null,
1421
+ });
1422
+ this.reopenSquadForWork();
1423
+ await this.start();
1424
+ this.updateContext();
1198
1425
  }
1199
1426
 
1200
1427
  /**