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/README.md +24 -15
- package/package.json +1 -1
- package/src/agent-pool.ts +123 -44
- package/src/index.ts +258 -223
- package/src/panel/message-view.ts +58 -50
- package/src/panel/squad-widget.ts +35 -10
- package/src/panel/task-list.ts +13 -3
- package/src/review.ts +15 -0
- package/src/router.ts +37 -35
- package/src/scheduler.ts +300 -73
- package/src/store.ts +195 -1
- package/src/types.ts +27 -1
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
|
|
204
|
-
*
|
|
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
|
|
223
|
-
squad.status
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
399
|
-
|
|
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:
|
|
470
|
+
queuedMessages: pendingMailbox.map((entry) => entry.message),
|
|
412
471
|
},
|
|
413
472
|
cwd: squad.cwd,
|
|
414
473
|
skillPaths: this.skillPaths,
|
|
415
|
-
...(
|
|
416
|
-
? {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
1159
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
/**
|
|
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.
|
|
1197
|
-
|
|
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
|
/**
|