pi-squad 0.16.3 → 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.
@@ -13,7 +13,7 @@
13
13
  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
- import type { TaskStatus } from "../types.js";
16
+ import type { TaskMessage, TaskStatus } from "../types.js";
17
17
  import * as store from "../store.js";
18
18
 
19
19
  function statusIcon(status: TaskStatus, th: Theme): string {
@@ -76,14 +76,33 @@ export function setupSquadWidget(
76
76
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
77
77
  const doneCount = tasks.filter((t) => t.status === "done").length;
78
78
  const elapsed = Date.now() - new Date(squad.created).getTime();
79
+ const taskMessages = new Map<string, TaskMessage[]>();
80
+ const recentOrchestratorByTask = new Map<string, TaskMessage>();
81
+ const recentMessageKeys: string[] = [];
82
+ for (const task of tasks) {
83
+ if (task.status !== "in_progress") continue;
84
+ const messages = store.loadMessages(state.squadId, task.id);
85
+ taskMessages.set(task.id, messages);
86
+ const latest = messages.at(-1);
87
+ recentMessageKeys.push(`${task.id}:${messages.length}:${latest?.id || latest?.ts || "none"}`);
88
+ const recentOrchestrator = [...messages.slice(-5)].reverse().find((message) =>
89
+ message.from === "orchestrator" &&
90
+ (message.type === "text" || message.type === "message" ||
91
+ message.type === "reply" || message.type === "mention"),
92
+ );
93
+ if (recentOrchestrator) recentOrchestratorByTask.set(task.id, recentOrchestrator);
94
+ }
79
95
 
80
96
  const sIcon = squad.status === "done" ? th.fg("success", "✓")
81
97
  : squad.status === "failed" ? th.fg("error", "✗")
82
98
  : squad.status === "review" ? th.fg("warning", "◆")
83
99
  : th.fg("warning", "⏳");
84
100
 
101
+ const orchestratorSignal = recentOrchestratorByTask.size > 0
102
+ ? ` ${th.fg("accent", `✉ ${recentOrchestratorByTask.size > 1 ? recentOrchestratorByTask.size + " " : ""}ORCH`)}`
103
+ : "";
85
104
  lines.push(
86
- `${sIcon} ${th.fg("accent", "squad")} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
105
+ `${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
87
106
  `${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
88
107
  `${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
89
108
  `${th.fg("dim", formatElapsed(elapsed))} ` +
@@ -111,13 +130,19 @@ export function setupSquadWidget(
111
130
  const runningFor = task.started ? Date.now() - new Date(task.started).getTime() : 0;
112
131
  const timeColor = runningFor > 180_000 ? "warning" : "dim";
113
132
  line += ` ${th.fg(timeColor as any, formatElapsed(runningFor))}`;
114
- const messages = store.loadMessages(state.squadId!, task.id);
115
- const lastTool = [...messages].reverse().find(m => m.type === "tool");
116
- if (lastTool) {
117
- const rawDetail = (lastTool.args?.path || lastTool.args?.command || "").toString();
118
- const detail = rawDetail.split("\n")[0]; // first line only
119
- const toolStr = `→ ${lastTool.name || lastTool.text}`;
120
- line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
133
+ const messages = taskMessages.get(task.id) || [];
134
+ const recentOrchestrator = recentOrchestratorByTask.get(task.id);
135
+ if (recentOrchestrator) {
136
+ const preview = recentOrchestrator.text.split("\n")[0].slice(0, 24);
137
+ line += ` ${th.fg("accent", "← ORCH")} ${th.fg("dim", preview)}`;
138
+ } else {
139
+ const lastTool = [...messages].reverse().find(m => m.type === "tool");
140
+ if (lastTool) {
141
+ const rawDetail = (lastTool.args?.path || lastTool.args?.command || "").toString();
142
+ const detail = rawDetail.split("\n")[0]; // first line only
143
+ const toolStr = `→ ${lastTool.name || lastTool.text}`;
144
+ line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
145
+ }
121
146
  }
122
147
  } else if (task.status === "blocked") {
123
148
  const blockers = task.depends.filter((d) => {
@@ -136,7 +161,7 @@ export function setupSquadWidget(
136
161
  lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
137
162
  }
138
163
 
139
- const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}`;
164
+ const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
140
165
 
141
166
  const statusText = squad.status === "done"
142
167
  ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
@@ -200,15 +200,25 @@ export class TaskListView {
200
200
  const recent = messages.slice(-3);
201
201
 
202
202
  for (const msg of recent) {
203
- if (msg.type === "tool") {
203
+ const isConversation = msg.type === "text" || msg.type === "message" ||
204
+ msg.type === "reply" || msg.type === "mention";
205
+ if (msg.from === "orchestrator" && isConversation) {
206
+ const preview = msg.text.split("\n")[0];
207
+ lines.push(truncateToWidth(
208
+ ` ${th.fg("accent", "ORCHESTRATOR")} ${th.fg("dim", `"${preview}"`)}`,
209
+ width,
210
+ "…",
211
+ ));
212
+ } else if (msg.type === "tool") {
204
213
  const toolStr = `→ ${msg.name || msg.text}`;
205
214
  const rawArgs = (msg.args?.path || msg.args?.command || "").toString();
206
215
  const argsStr = rawArgs.split("\n")[0]; // first line only
207
216
  const preview = argsStr ? `${toolStr} ${argsStr}` : toolStr;
208
217
  lines.push(truncateToWidth(` ${th.fg("muted", preview)}`, width, "…"));
209
- } else if (msg.type === "text" && msg.from !== "system") {
218
+ } else if (isConversation && msg.from !== "system") {
210
219
  const preview = msg.text.split("\n")[0];
211
- lines.push(truncateToWidth(` ${th.fg("dim", `"${preview}"`)}`, width, "…"));
220
+ const sender = msg.from === "human" ? "YOU" : msg.from;
221
+ lines.push(truncateToWidth(` ${th.fg("dim", `${sender}: "${preview}"`)}`, width, "…"));
212
222
  }
213
223
  }
214
224
 
package/src/router.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * router.ts — @mention parsing and cross-agent message delivery.
3
3
  *
4
4
  * Parses assistant text for @agentname patterns.
5
- * Routes messages to target agents via steer() if running,
6
- * or queues them for delivery on next spawn.
5
+ * Routes messages to exact target tasks via steer() if running,
6
+ * or a durable task-owned mailbox for delivery on next spawn.
7
7
  */
8
8
 
9
9
  import type { AgentPool } from "./agent-pool.js";
@@ -83,30 +83,41 @@ export class Router {
83
83
  text: message,
84
84
  });
85
85
 
86
- // Find if target agent is running
87
- const targetTaskId = this.pool.getTaskIdForAgent(targetAgent);
88
-
89
- if (targetTaskId && this.pool.isRunning(targetTaskId)) {
90
- // Target is running — steer them
91
- const steerMessage = `[squad] Message from @${fromAgent} (working on ${sourceTaskId}):\n${message}`;
92
- this.pool.steer(targetTaskId, steerMessage);
86
+ // Agent-name mentions are safe only when exactly one live task owns that
87
+ // role. Multiple concurrent same-role tasks must never receive guessed mail.
88
+ const liveTargets = store.loadAllTasks(this.squadId).filter(
89
+ (task) => task.agent === targetAgent && this.pool.isRunning(task.id),
90
+ );
91
+ const targetTaskId = liveTargets.length === 1 ? liveTargets[0].id : undefined;
93
92
 
94
- // Log in target task too
95
- store.appendMessage(this.squadId, targetTaskId, {
93
+ if (targetTaskId) {
94
+ const queued = store.queueTaskMessage(this.squadId, targetTaskId, {
96
95
  ts: store.now(),
97
96
  from: fromAgent,
98
97
  type: "mention",
99
98
  to: targetAgent,
100
99
  text: message,
101
100
  });
101
+ const steerMessage = `[squad] Message from @${fromAgent} (working on ${sourceTaskId}):\n${message}`;
102
+ void this.pool.steer(targetTaskId, steerMessage).then((delivered) => {
103
+ if (delivered) store.acknowledgeTaskMessages(this.squadId, targetTaskId, [queued.id]);
104
+ });
102
105
  return false;
103
106
  }
104
107
 
105
108
  const targetTasks = store.loadAllTasks(this.squadId).filter((task) => task.agent === targetAgent);
106
- const hasFutureRun = targetTasks.some((task) =>
107
- task.status === "pending" || task.status === "blocked" || task.status === "suspended" || task.status === "in_progress",
109
+ const futureTasks = targetTasks.filter((task) =>
110
+ task.status === "in_progress" || task.status === "pending" || task.status === "suspended" || task.status === "blocked",
108
111
  );
109
- if (!hasFutureRun) {
112
+ const interrupted = futureTasks.filter((task) => task.status === "in_progress");
113
+ // Never guess between two tasks assigned to the same role. A uniquely
114
+ // interrupted task is authoritative; otherwise only one future task is safe.
115
+ const futureTask = interrupted.length === 1
116
+ ? interrupted[0]
117
+ : futureTasks.length === 1
118
+ ? futureTasks[0]
119
+ : undefined;
120
+ if (futureTasks.length === 0) {
110
121
  const completed = targetTasks.filter((task) => task.status === "done" && task.output);
111
122
  if (completed.length > 0) {
112
123
  const durableReply = [
@@ -120,21 +131,20 @@ export class Router {
120
131
  to: fromAgent,
121
132
  text: durableReply,
122
133
  };
123
- store.appendMessage(this.squadId, sourceTaskId, reply);
134
+ const queued = store.queueTaskMessage(this.squadId, sourceTaskId, reply);
124
135
  if (this.pool.isRunning(sourceTaskId)) {
125
- this.pool.steer(sourceTaskId, durableReply);
126
- } else {
127
- this.pool.queueMessage(fromAgent, reply);
136
+ void this.pool.steer(sourceTaskId, durableReply).then((delivered) => {
137
+ if (delivered) store.acknowledgeTaskMessages(this.squadId, sourceTaskId, [queued.id]);
138
+ });
128
139
  }
129
140
  return true;
130
141
  }
131
142
  }
132
143
 
133
- // Target has future work and may spawn again queue for that run. If the
134
- // target is terminal with no output, do not create an undeliverable queue;
135
- // leave the blocker unresolved so it escalates to the main orchestrator.
136
- if (hasFutureRun) {
137
- this.pool.queueMessage(targetAgent, {
144
+ // Target has future work and may spawn again. Bind the message to exactly
145
+ // one durable task, never to the shared role name.
146
+ if (futureTask) {
147
+ store.queueTaskMessage(this.squadId, futureTask.id, {
138
148
  ts: store.now(),
139
149
  from: fromAgent,
140
150
  type: "mention",
@@ -149,7 +159,7 @@ export class Router {
149
159
  * Route a human message to an agent.
150
160
  */
151
161
  routeHumanMessage(taskId: string, message: string): void {
152
- store.appendMessage(this.squadId, taskId, {
162
+ const queued = store.queueTaskMessage(this.squadId, taskId, {
153
163
  ts: store.now(),
154
164
  from: "human",
155
165
  type: "message",
@@ -157,17 +167,9 @@ export class Router {
157
167
  });
158
168
 
159
169
  if (this.pool.isRunning(taskId)) {
160
- this.pool.steer(taskId, `[squad] Human: ${message}`);
161
- } else {
162
- const task = store.loadTask(this.squadId, taskId);
163
- if (task) {
164
- this.pool.queueMessage(task.agent, {
165
- ts: store.now(),
166
- from: "human",
167
- type: "message",
168
- text: message,
169
- });
170
- }
170
+ void this.pool.steer(taskId, `[squad] Human: ${message}`).then((delivered) => {
171
+ if (delivered) store.acknowledgeTaskMessages(this.squadId, taskId, [queued.id]);
172
+ });
171
173
  }
172
174
  }
173
175
 
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";
@@ -241,6 +241,32 @@ export class Scheduler {
241
241
 
242
242
  const tasks = store.loadAllTasks(this.squadId);
243
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
+
244
270
  // 1. Blocked → pending when all deps are done (respects autoUnblock config)
245
271
  if (squad.config.autoUnblock) {
246
272
  for (const task of tasks) {
@@ -323,7 +349,12 @@ export class Scheduler {
323
349
  /** Get tasks that are ready to execute (pending + all deps done) */
324
350
  private getReadyTasks(tasks: Task[]): Task[] {
325
351
  return tasks.filter((task) => {
326
- 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;
327
358
  return task.depends.every((depId) => {
328
359
  const dep = tasks.find((t) => t.id === depId);
329
360
  return dep?.status === "done";
@@ -376,9 +407,24 @@ export class Scheduler {
376
407
  }
377
408
  }
378
409
 
379
- // 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.
380
424
  store.updateTaskStatus(this.squadId, task.id, "in_progress", {
381
- started: store.now(),
425
+ started: task.started ?? store.now(),
426
+ completed: null,
427
+ error: null,
382
428
  });
383
429
 
384
430
  store.appendMessage(this.squadId, task.id, {
@@ -395,11 +441,13 @@ export class Scheduler {
395
441
  agentName: task.agent,
396
442
  });
397
443
 
398
- // Decide whether to fork the main session for context inheritance
399
- 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);
400
448
 
401
449
  try {
402
- await this.pool.spawn({
450
+ const agent = await this.pool.spawn({
403
451
  taskId: task.id,
404
452
  agentDef,
405
453
  protocolOptions: {
@@ -408,19 +456,28 @@ export class Scheduler {
408
456
  task,
409
457
  agentDef,
410
458
  modifiedFiles,
411
- queuedMessages: this.pool.consumeQueue(task.agent),
459
+ queuedMessages: pendingMailbox.map((entry) => entry.message),
412
460
  },
413
461
  cwd: squad.cwd,
414
462
  skillPaths: this.skillPaths,
415
- ...(forkSessionFile
416
- ? {
417
- forkSession: {
418
- file: forkSessionFile,
419
- sessionDir: path.join(store.getSquadDir(this.squadId), "sessions"),
420
- },
421
- }
422
- : {}),
463
+ ...(resumeSession
464
+ ? { resumeSession }
465
+ : forkSessionFile
466
+ ? { forkSession: { file: forkSessionFile, sessionDir: taskSessionDir } }
467
+ : { sessionDir: taskSessionDir }),
423
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
+ );
424
481
  } catch (error) {
425
482
  this.handleTaskFailed(task.id, (error as Error).message);
426
483
  }
@@ -545,6 +602,77 @@ export class Scheduler {
545
602
  return sessionFile;
546
603
  }
547
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
+
548
676
  // =========================================================================
549
677
  // Event Handlers
550
678
  // =========================================================================
@@ -638,50 +766,45 @@ export class Scheduler {
638
766
  break;
639
767
  }
640
768
 
641
- case "agent_end": {
642
- 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
+
643
795
  const turnCount = event.data?.turnCount ?? 0;
644
796
  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
797
  const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
651
798
  .some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
652
799
  const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
653
800
  if (hadMeaningfulWork) {
654
801
  this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
655
802
  } 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();
803
+ this.handleUnexpectedAgentExit({
804
+ ...event,
805
+ data: { ...event.data, unexpectedExit: true },
806
+ });
683
807
  }
684
- // Skip the updateContext() below — handled in the branches above
685
808
  return;
686
809
  }
687
810
 
@@ -1139,12 +1262,75 @@ export class Scheduler {
1139
1262
  // External Actions
1140
1263
  // =========================================================================
1141
1264
 
1142
- /** Send a main-orchestrator request to a task's agent and await its next reply. */
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. */
1143
1327
  async sendHumanMessage(taskId: string, message: string, expectsReply = true): Promise<boolean> {
1144
1328
  const task = store.loadTask(this.squadId, taskId);
1145
1329
  if (!task) return false;
1146
1330
 
1147
- store.appendMessage(this.squadId, taskId, {
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, {
1148
1334
  ts: store.now(),
1149
1335
  from: "orchestrator",
1150
1336
  type: "message",
@@ -1155,19 +1341,23 @@ export class Scheduler {
1155
1341
  const request = expectsReply
1156
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.`
1157
1343
  : `[squad] Main orchestrator message:\n${message}`;
1158
- if (this.pool.isRunning(taskId) && await this.pool.steer(taskId, request)) {
1159
- return true;
1344
+ if (this.pool.isRunning(taskId)) {
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;
1160
1353
  }
1161
1354
 
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
- });
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);
1360
+ }
1171
1361
  return false;
1172
1362
  }
1173
1363