pi-squad 0.16.2 → 0.16.4

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";
@@ -34,6 +34,7 @@ export type SchedulerEventType =
34
34
  | "task_rework"
35
35
  | "squad_review_required"
36
36
  | "squad_failed"
37
+ | "orchestrator_reply"
37
38
  | "escalation"
38
39
  | "activity";
39
40
 
@@ -240,6 +241,32 @@ export class Scheduler {
240
241
 
241
242
  const tasks = store.loadAllTasks(this.squadId);
242
243
 
244
+ // 0. Mailbox recovery is level-triggered from disk. If the previous
245
+ // process stopped after queueTaskMessage's atomic write but before status
246
+ // mutation/RPC delivery, reopen exactly that task on reconstruction.
247
+ let recoveredMailbox = false;
248
+ for (const task of tasks) {
249
+ if (this.pool.isRunning(task.id)) continue;
250
+ if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
251
+ if (task.status === "done") await this.invalidateDescendants(task.id);
252
+ const dependenciesDone = task.depends.every(
253
+ (depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
254
+ );
255
+ const nextStatus: TaskStatus = dependenciesDone ? "pending" : "blocked";
256
+ if (task.status !== nextStatus || task.completed !== null || task.error !== null) {
257
+ task.status = nextStatus;
258
+ task.completed = null;
259
+ task.error = null;
260
+ store.updateTaskStatus(this.squadId, task.id, nextStatus, { completed: null, error: null });
261
+ }
262
+ recoveredMailbox = true;
263
+ }
264
+ if (recoveredMailbox && squad.status !== "running") {
265
+ squad.status = "running";
266
+ delete squad.review;
267
+ store.saveSquad(squad);
268
+ }
269
+
243
270
  // 1. Blocked → pending when all deps are done (respects autoUnblock config)
244
271
  if (squad.config.autoUnblock) {
245
272
  for (const task of tasks) {
@@ -322,7 +349,12 @@ export class Scheduler {
322
349
  /** Get tasks that are ready to execute (pending + all deps done) */
323
350
  private getReadyTasks(tasks: Task[]): Task[] {
324
351
  return tasks.filter((task) => {
325
- if (task.status !== "pending") return false;
352
+ // A reconstructed scheduler has an empty process pool. Persisted
353
+ // in_progress means the previous process stopped mid-task, so resume its
354
+ // bound session instead of stranding it or creating a fresh context.
355
+ const needsProcess = task.status === "pending" ||
356
+ (task.status === "in_progress" && !this.pool.isRunning(task.id));
357
+ if (!needsProcess) return false;
326
358
  return task.depends.every((depId) => {
327
359
  const dep = tasks.find((t) => t.id === depId);
328
360
  return dep?.status === "done";
@@ -375,9 +407,24 @@ export class Scheduler {
375
407
  }
376
408
  }
377
409
 
378
- // Update task status
410
+ const resumeSession = store.loadTaskSession(this.squadId, task.id) ?? undefined;
411
+ const pendingMailbox = store.loadPendingTaskMessages(this.squadId, task.id);
412
+ // Legacy tasks predate task-owned Pi sessions. Their first durable spawn
413
+ // must receive the complete persisted history/output as a migration seed;
414
+ // otherwise reopening silently discards all prior context.
415
+ const legacyHistory = !resumeSession && (task.started !== null || task.completed !== null || task.output !== null)
416
+ ? store.loadMessages(this.squadId, task.id)
417
+ : [];
418
+ const legacySeed = legacyHistory.length > 0 || (!resumeSession && task.output !== null)
419
+ ? { messages: legacyHistory, output: task.output }
420
+ : undefined;
421
+
422
+ // Keep the original start time across resumes. A resumed/live process owns
423
+ // in_progress until Pi emits final agent_settled.
379
424
  store.updateTaskStatus(this.squadId, task.id, "in_progress", {
380
- started: store.now(),
425
+ started: task.started ?? store.now(),
426
+ completed: null,
427
+ error: null,
381
428
  });
382
429
 
383
430
  store.appendMessage(this.squadId, task.id, {
@@ -394,11 +441,13 @@ export class Scheduler {
394
441
  agentName: task.agent,
395
442
  });
396
443
 
397
- // Decide whether to fork the main session for context inheritance
398
- const forkSessionFile = this.resolveForkSession(task, squad, agentDef);
444
+ // Context inheritance creates a session only on the task's first run.
445
+ // Every later run must reopen the immutable task binding.
446
+ const forkSessionFile = resumeSession ? undefined : this.resolveForkSession(task, squad, agentDef);
447
+ const taskSessionDir = store.getTaskSessionDir(this.squadId, task.id);
399
448
 
400
449
  try {
401
- await this.pool.spawn({
450
+ const agent = await this.pool.spawn({
402
451
  taskId: task.id,
403
452
  agentDef,
404
453
  protocolOptions: {
@@ -407,19 +456,28 @@ export class Scheduler {
407
456
  task,
408
457
  agentDef,
409
458
  modifiedFiles,
410
- queuedMessages: this.pool.consumeQueue(task.agent),
459
+ queuedMessages: pendingMailbox.map((entry) => entry.message),
411
460
  },
412
461
  cwd: squad.cwd,
413
462
  skillPaths: this.skillPaths,
414
- ...(forkSessionFile
415
- ? {
416
- forkSession: {
417
- file: forkSessionFile,
418
- sessionDir: path.join(store.getSquadDir(this.squadId), "sessions"),
419
- },
420
- }
421
- : {}),
463
+ ...(resumeSession
464
+ ? { resumeSession }
465
+ : forkSessionFile
466
+ ? { forkSession: { file: forkSessionFile, sessionDir: taskSessionDir } }
467
+ : { sessionDir: taskSessionDir }),
422
468
  });
469
+ store.bindTaskSession(this.squadId, task.id, agent.session);
470
+
471
+ const accepted = await this.pool.prompt(
472
+ task.id,
473
+ this.buildTaskPrompt(task, Boolean(resumeSession), pendingMailbox, legacySeed),
474
+ );
475
+ if (!accepted) throw new Error(`Agent ${task.agent} did not accept the task prompt`);
476
+ store.acknowledgeTaskMessages(
477
+ this.squadId,
478
+ task.id,
479
+ pendingMailbox.map((entry) => entry.id),
480
+ );
423
481
  } catch (error) {
424
482
  this.handleTaskFailed(task.id, (error as Error).message);
425
483
  }
@@ -544,6 +602,77 @@ export class Scheduler {
544
602
  return sessionFile;
545
603
  }
546
604
 
605
+ private buildTaskPrompt(
606
+ task: Task,
607
+ resumed: boolean,
608
+ entries: TaskMailboxEntry[],
609
+ legacySeed?: { messages: TaskMessage[]; output: string | null },
610
+ ): string {
611
+ const lines = resumed
612
+ ? [
613
+ `Resume your existing task: ${task.title}`,
614
+ "Continue from the durable Pi session context. Do not restart the task from scratch.",
615
+ ]
616
+ : [
617
+ `Your task: ${task.title}`,
618
+ "",
619
+ task.description || "",
620
+ ];
621
+
622
+ if (legacySeed) {
623
+ const pendingIds = new Set(entries.map((entry) => entry.id));
624
+ lines.push("", "Legacy task migration: the following persisted history is the complete prior context. Preserve it and continue from it.");
625
+ for (const message of legacySeed.messages) {
626
+ if (message.id && pendingIds.has(message.id)) continue;
627
+ lines.push("", `[${message.ts}] ${message.from} (${message.type}):`, message.text);
628
+ }
629
+ if (legacySeed.output !== null) {
630
+ lines.push("", "Prior durable task output:", legacySeed.output);
631
+ }
632
+ }
633
+
634
+ if (entries.length > 0) {
635
+ lines.push("", "New durable messages for this task:");
636
+ for (const entry of entries) {
637
+ lines.push("", `[${entry.message.ts}] ${entry.message.from}:`, entry.message.text);
638
+ if (entry.message.expectsReply) {
639
+ lines.push("[Direct response required by main orchestrator]");
640
+ }
641
+ }
642
+ lines.push("", "Read and act on every complete message above.");
643
+ } else if (resumed) {
644
+ lines.push("", "The previous process ended before final settlement. Continue the unfinished work and report the final result.");
645
+ }
646
+ return lines.join("\n");
647
+ }
648
+
649
+ private handleUnexpectedAgentExit(event: AgentEvent): void {
650
+ const exitCode = event.data?.exitCode ?? 1;
651
+ const turnCount = event.data?.turnCount ?? 0;
652
+ const stderr = event.data?.stderr || "";
653
+ const retryKey = `spawn-retry:${event.taskId}`;
654
+ if (!this.spawnRetries.has(retryKey)) {
655
+ this.spawnRetries.add(retryKey);
656
+ const reason = turnCount === 0
657
+ ? "exited with 0 turns (likely rate limit or API error)"
658
+ : `exited before final agent_settled after ${turnCount} turns`;
659
+ logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
660
+ store.updateTaskStatus(this.squadId, event.taskId, "pending");
661
+ store.appendMessage(this.squadId, event.taskId, {
662
+ ts: store.now(),
663
+ from: "system",
664
+ type: "status",
665
+ text: `Agent ${reason}. Resuming the same task session...`,
666
+ });
667
+ setTimeout(() => {
668
+ if (this.running) this.scheduleReadyTasks();
669
+ }, 2000);
670
+ } else {
671
+ this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} before final agent_settled (retry exhausted). ${stderr}`);
672
+ }
673
+ this.updateContext();
674
+ }
675
+
547
676
  // =========================================================================
548
677
  // Event Handlers
549
678
  // =========================================================================
@@ -568,6 +697,26 @@ export class Scheduler {
568
697
  // presentation layers may viewport them, but source data must never truncate.
569
698
  text,
570
699
  });
700
+
701
+ // A squad_message is a request/response channel, not fire-and-forget.
702
+ // Persist an acknowledgement marker before emitting so restart/focus
703
+ // changes cannot forward the same response twice.
704
+ if (this.hasPendingOrchestratorRequest(event.taskId)) {
705
+ store.appendMessage(this.squadId, event.taskId, {
706
+ ts: store.now(),
707
+ from: event.agentName,
708
+ type: "reply",
709
+ to: "orchestrator",
710
+ text: "Response forwarded to main orchestrator",
711
+ });
712
+ this.emit({
713
+ type: "orchestrator_reply",
714
+ squadId: this.squadId,
715
+ taskId: event.taskId,
716
+ agentName: event.agentName,
717
+ message: text,
718
+ });
719
+ }
571
720
  }
572
721
 
573
722
  // Track usage
@@ -617,50 +766,45 @@ export class Scheduler {
617
766
  break;
618
767
  }
619
768
 
620
- case "agent_end": {
621
- const exitCode = event.data?.exitCode ?? 1;
769
+ case "agent_end": {
770
+ // Pi's low-level agent_end is not completion. AgentPool normally keeps
771
+ // it internal; if observed here, preserve in_progress. Only an actual
772
+ // child-process exit carries unexpectedExit and enters retry handling.
773
+ if (!event.data?.unexpectedExit) break;
774
+ this.handleUnexpectedAgentExit(event);
775
+ return;
776
+ }
777
+
778
+ case "agent_settled": {
779
+ // A mailbox entry not acknowledged by Pi outranks this run's candidate
780
+ // completion. Reopen the same session so accepted-at-least-once delivery
781
+ // occurs before the task can become done.
782
+ if (store.loadPendingTaskMessages(this.squadId, event.taskId).length > 0) {
783
+ store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null });
784
+ store.appendMessage(this.squadId, event.taskId, {
785
+ ts: store.now(),
786
+ from: "system",
787
+ type: "status",
788
+ text: "Agent settled with pending durable messages; resuming the same task session",
789
+ });
790
+ if (this.running) void this.reconcile();
791
+ this.updateContext();
792
+ return;
793
+ }
794
+
622
795
  const turnCount = event.data?.turnCount ?? 0;
623
796
  const toolCallCount = event.data?.toolCallCount ?? 0;
624
-
625
- // Tool use is strong evidence of work, but planning/review tasks can
626
- // legitimately deliver a substantive report without calling a tool.
627
- // Accept durable assistant output; the mandatory main-orchestrator
628
- // review gate still decides whether the work satisfies the contract.
629
797
  const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
630
798
  .some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
631
799
  const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
632
800
  if (hadMeaningfulWork) {
633
801
  this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
634
802
  } else {
635
- // Agent exited without a completed turn, tool work, or substantive
636
- // assistant artifact. Common causes: rate limit/API/resource failure.
637
- // Retry once before failing.
638
- const retryKey = `spawn-retry:${event.taskId}`;
639
- if (!this.spawnRetries.has(retryKey)) {
640
- this.spawnRetries.add(retryKey);
641
- const stderr = event.data?.stderr || "";
642
- const reason = turnCount === 0
643
- ? `exited with 0 turns (likely rate limit or API error)`
644
- : `exited with ${turnCount} turns but no tool calls or substantive output`;
645
- logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
646
- store.updateTaskStatus(this.squadId, event.taskId, "pending");
647
- store.appendMessage(this.squadId, event.taskId, {
648
- ts: store.now(),
649
- from: "system",
650
- type: "status",
651
- text: `Agent ${reason}. Retrying...`,
652
- });
653
- // Delay retry to let resources settle
654
- setTimeout(() => {
655
- if (this.running) this.scheduleReadyTasks();
656
- }, 2000);
657
- } else {
658
- const stderr = event.data?.stderr || "";
659
- this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} (retry exhausted). ${stderr}`);
660
- }
661
- this.updateContext();
803
+ this.handleUnexpectedAgentExit({
804
+ ...event,
805
+ data: { ...event.data, unexpectedExit: true },
806
+ });
662
807
  }
663
- // Skip the updateContext() below — handled in the branches above
664
808
  return;
665
809
  }
666
810
 
@@ -1118,31 +1262,114 @@ export class Scheduler {
1118
1262
  // External Actions
1119
1263
  // =========================================================================
1120
1264
 
1121
- /** Send a human message to a task's agent */
1122
- async sendHumanMessage(taskId: string, message: string): Promise<boolean> {
1123
- store.appendMessage(this.squadId, taskId, {
1265
+ /**
1266
+ * A reopened dependency invalidates every transitive descendant, including
1267
+ * descendants that had already completed. They retain history/output for
1268
+ * audit and durable-session continuation, but must rerun in dependency order.
1269
+ */
1270
+ private async invalidateDescendants(reopenedTaskId: string): Promise<void> {
1271
+ const tasks = store.loadAllTasks(this.squadId);
1272
+ const byDependency = new Map<string, Task[]>();
1273
+ for (const task of tasks) {
1274
+ for (const dependency of task.depends) {
1275
+ const dependents = byDependency.get(dependency) ?? [];
1276
+ dependents.push(task);
1277
+ byDependency.set(dependency, dependents);
1278
+ }
1279
+ }
1280
+
1281
+ const queue = [...(byDependency.get(reopenedTaskId) ?? [])];
1282
+ const seen = new Set<string>();
1283
+ const kills: Promise<void>[] = [];
1284
+ while (queue.length > 0) {
1285
+ const descendant = queue.shift()!;
1286
+ if (seen.has(descendant.id)) continue;
1287
+ seen.add(descendant.id);
1288
+ queue.push(...(byDependency.get(descendant.id) ?? []));
1289
+ store.updateTaskStatus(this.squadId, descendant.id, "blocked", {
1290
+ completed: null,
1291
+ error: null,
1292
+ });
1293
+ store.appendMessage(this.squadId, descendant.id, {
1294
+ ts: store.now(),
1295
+ from: "system",
1296
+ type: "status",
1297
+ text: `Blocked — dependency ancestry reopened at ${reopenedTaskId}; prior result requires revalidation`,
1298
+ });
1299
+ if (this.pool.isRunning(descendant.id)) kills.push(this.pool.kill(descendant.id));
1300
+ }
1301
+ await Promise.all(kills);
1302
+ }
1303
+
1304
+ private async reopenTaskForMessage(task: Task): Promise<void> {
1305
+ if (task.status === "done") await this.invalidateDescendants(task.id);
1306
+ const tasks = store.loadAllTasks(this.squadId);
1307
+ const dependenciesDone = task.depends.every(
1308
+ (depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
1309
+ );
1310
+ const nextStatus: TaskStatus = dependenciesDone ? "pending" : "blocked";
1311
+ store.updateTaskStatus(this.squadId, task.id, nextStatus, {
1312
+ error: null,
1313
+ completed: null,
1314
+ });
1315
+
1316
+ const squad = store.loadSquad(this.squadId);
1317
+ if (squad && squad.status !== "running") {
1318
+ squad.status = "running";
1319
+ // Any prior acceptance/review applies to the pre-resume result. The
1320
+ // normal all-done path will require a fresh independent review.
1321
+ delete squad.review;
1322
+ store.saveSquad(squad);
1323
+ }
1324
+ }
1325
+
1326
+ /** Send a main-orchestrator request to one exact task and await its next reply. */
1327
+ async sendHumanMessage(taskId: string, message: string, expectsReply = true): Promise<boolean> {
1328
+ const task = store.loadTask(this.squadId, taskId);
1329
+ if (!task) return false;
1330
+
1331
+ // Mailbox-first: a process/scheduler crash can occur at any later point
1332
+ // without losing or redirecting this message to another task of the role.
1333
+ const queued = store.queueTaskMessage(this.squadId, taskId, {
1124
1334
  ts: store.now(),
1125
- from: "human",
1335
+ from: "orchestrator",
1126
1336
  type: "message",
1127
1337
  text: message,
1338
+ expectsReply,
1128
1339
  });
1129
1340
 
1341
+ const request = expectsReply
1342
+ ? `[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.`
1343
+ : `[squad] Main orchestrator message:\n${message}`;
1130
1344
  if (this.pool.isRunning(taskId)) {
1131
- return this.pool.steer(taskId, `[squad] Human: ${message}`);
1345
+ if (await this.pool.steer(taskId, request)) {
1346
+ store.acknowledgeTaskMessages(this.squadId, taskId, [queued.id]);
1347
+ return true;
1348
+ }
1349
+ // The process may still be completing its current run. Preserve
1350
+ // in_progress while it is live; agent_settled will observe the pending
1351
+ // mailbox and reopen the same session instead of marking done.
1352
+ if (this.pool.isRunning(taskId)) return false;
1132
1353
  }
1133
- // Queue for when agent spawns
1134
- const task = store.loadTask(this.squadId, taskId);
1135
- if (task) {
1136
- this.pool.queueMessage(task.agent, {
1137
- ts: store.now(),
1138
- from: "human",
1139
- type: "message",
1140
- text: message,
1141
- });
1354
+
1355
+ await this.reopenTaskForMessage(task);
1356
+ if (this.running) {
1357
+ await this.reconcile();
1358
+ return store.loadPendingTaskMessages(this.squadId, taskId)
1359
+ .every((entry) => entry.id !== queued.id);
1142
1360
  }
1143
1361
  return false;
1144
1362
  }
1145
1363
 
1364
+ private hasPendingOrchestratorRequest(taskId: string): boolean {
1365
+ let pending = false;
1366
+ for (const message of store.loadMessages(this.squadId, taskId)) {
1367
+ if (message.from === "orchestrator" && message.expectsReply) pending = true;
1368
+ if (message.type === "reply" && message.to === "orchestrator") pending = false;
1369
+ }
1370
+ return pending;
1371
+ }
1372
+
1146
1373
  /** Pause a running task */
1147
1374
  async pauseTask(taskId: string): Promise<void> {
1148
1375
  if (this.pool.isRunning(taskId)) {
@@ -130,7 +130,8 @@ Common escalation patterns:
130
130
  Use `squad_message` with:
131
131
  - `taskId` — target a specific task
132
132
  - `agent` — target whichever task an agent is working on
133
- - `message` — your instruction or answer
133
+ - `message` — your instruction or question
134
+ - `expectReply` — defaults to `true`; the agent's next substantive response is durably pushed back into the main Pi session and wakes you. Set `false` only for fire-and-forget steering.
134
135
 
135
136
  Keep messages **specific and actionable**:
136
137
  - Good: "Use RS256 for JWT signing. The secret is in env var JWT_SECRET."