@fradser/pi-agent-teams 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,946 @@
1
+ /**
2
+ * Team machine — resident teammate lifecycle, peer mail routing, task-intent
3
+ * processing, verify gating, and the harness poll loop that wakes idle
4
+ * teammates. The leader model never polls; this loop is the coordination
5
+ * engine described by the BDD contract.
6
+ */
7
+
8
+ import { exec } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { resolveAgent } from "./agents.ts";
14
+ import {
15
+ applyClaimIntent,
16
+ claimableTasks,
17
+ clearStateDirty,
18
+ clearWorkerRunEvents,
19
+ completeTask,
20
+ createTask,
21
+ deliverToLeader,
22
+ getPeerInboxOffset,
23
+ getState,
24
+ getTeammate,
25
+ idleTeammates,
26
+ isPeerDelivered,
27
+ isValidTeammateName,
28
+ listTasks,
29
+ livingTeammates,
30
+ loadBoard,
31
+ markPeerDelivered,
32
+ markStateDirty,
33
+ registerTeammate,
34
+ releaseTask,
35
+ releaseTasksOf,
36
+ receiveWorkerMessage,
37
+ setPeerInboxOffset,
38
+ updateTeammate,
39
+ updateTeammateProgress,
40
+ } from "./state.ts";
41
+ import {
42
+ appendInboxMessage,
43
+ boardFilePath,
44
+ claimsDir,
45
+ createTaskIntent,
46
+ inboxPath,
47
+ readBoardFile,
48
+ readJsonlBatch,
49
+ removeSessionStateDir,
50
+ removeWorkerOutbox,
51
+ rosterPath,
52
+ stateFilePath,
53
+ submissionsDir,
54
+ takeTaskIntent,
55
+ writeBoardFile,
56
+ writeRoster,
57
+ writeStateFile,
58
+ workerOutboxPath,
59
+ } from "./statefile.ts";
60
+ import { isWorkerEvent } from "./types.ts";
61
+ import {
62
+ deliverPrompt,
63
+ isCleanExit,
64
+ sendWorkerSteer,
65
+ spawnResident,
66
+ terminateAllTeammates,
67
+ terminateTeammate,
68
+ type WorkerProcessResult,
69
+ } from "./spawner.ts";
70
+ import { captureWorktreeDiff, cleanupWorktree, createWorktree } from "./worktree.ts";
71
+ import { messageTitle, type InboxMessage, type Teammate } from "./types.ts";
72
+ import type { FollowUpReport } from "./follow-up-queue.ts";
73
+
74
+ export const MAX_SESSION_WORKERS = 8;
75
+ /** Harness coordination cadence: outbox drain every tick, notices paced. */
76
+ const LIVE_POLL_MS = 500;
77
+ export const NOTICE_PACE_MS = 2000;
78
+ /** Silence is not a work deadline: active stream output keeps a teammate alive.
79
+ * The watchdog may only notify; termination decisions belong to the leader model. */
80
+ const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
81
+ export const STALL_NOTICE_MS = readDurationEnv("PI_TEAMMATE_STALL_NOTICE_MS", DEFAULT_STALL_NOTICE_MS);
82
+ /** Fully-consumed inboxes larger than this are truncated. */
83
+ const INBOX_COMPACT_BYTES = 256 * 1024;
84
+
85
+ function readDurationEnv(name: string, fallback: number): number {
86
+ const raw = process.env[name];
87
+ if (raw === undefined || raw.trim() === "") return fallback;
88
+ const value = Number(raw);
89
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
90
+ }
91
+
92
+ export function stallSilenceMs(teammate: Pick<Teammate, "lastOutputAt">, now = Date.now()): number | undefined {
93
+ if (teammate.lastOutputAt === undefined) return undefined;
94
+ return Math.max(0, now - teammate.lastOutputAt);
95
+ }
96
+
97
+ export function isStallThresholdReached(
98
+ teammate: Pick<Teammate, "lastOutputAt">,
99
+ now: number,
100
+ thresholdMs: number,
101
+ ): boolean {
102
+ const silence = stallSilenceMs(teammate, now);
103
+ return thresholdMs > 0 && silence !== undefined && silence >= thresholdMs;
104
+ }
105
+
106
+ export function formatSilenceDuration(milliseconds: number): string {
107
+ const totalMinutes = Math.floor(Math.max(0, milliseconds) / 60_000);
108
+ if (totalMinutes < 1) return "less than 1m";
109
+ const hours = Math.floor(totalMinutes / 60);
110
+ const minutes = totalMinutes % 60;
111
+ if (hours > 0) return `${hours}h${minutes > 0 ? ` ${minutes}m` : ""}`;
112
+ return `${minutes}m`;
113
+ }
114
+
115
+ let livePollTimer: ReturnType<typeof setInterval> | undefined;
116
+ let generation = 0;
117
+ let runtimeStateFile = "";
118
+ let boardFile = "";
119
+ let leaderCwd = "";
120
+ let sendUpdate: (report: FollowUpReport) => void = () => {};
121
+ let notifyChange: () => void = () => {};
122
+
123
+ const pendingShutdowns = new Set<string>();
124
+ /** Task ids under verification, bound to the claiming teammate incarnation. */
125
+ const verifyingTasks = new Map<string, { worker: string; spawnId: string }>();
126
+ /** Idle nudges already fired per teammate incarnation (one per transition). */
127
+ const idleNudgesSent = new Set<string>();
128
+ const pendingDeliveries = new Map<string, InboxMessage[]>();
129
+ const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
130
+
131
+ // ── Lifecycle ─────────────────────────────────────────────────────
132
+
133
+ export interface MachineHooks {
134
+ sendUpdate: (report: FollowUpReport) => void;
135
+ notifyChange: () => void;
136
+ }
137
+
138
+ export function initTeamMachine(
139
+ ctx: Pick<ExtensionContext, "sessionManager" | "cwd">,
140
+ hooks: MachineHooks,
141
+ ): void {
142
+ generation++;
143
+ leaderCwd = ctx.cwd || process.cwd();
144
+ const sessionFile = ctx.sessionManager?.getSessionFile();
145
+ runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
146
+ boardFile = boardFilePath(sessionFile, leaderCwd);
147
+ sendUpdate = hooks.sendUpdate;
148
+ notifyChange = hooks.notifyChange;
149
+ // Resume: reload a persisted board; claims die with their holders.
150
+ const persisted = readBoardFile(boardFile);
151
+ if (persisted) loadBoard(persisted.tasks);
152
+ }
153
+
154
+ export function shutdownTeamMachine(): void {
155
+ generation++;
156
+ if (livePollTimer) clearInterval(livePollTimer);
157
+ livePollTimer = undefined;
158
+ runtimeStateFile = "";
159
+ boardFile = "";
160
+ leaderCwd = "";
161
+ sendUpdate = () => {};
162
+ notifyChange = () => {};
163
+ pendingShutdowns.clear();
164
+ verifyingTasks.clear();
165
+ idleNudgesSent.clear();
166
+ pendingDeliveries.clear();
167
+ }
168
+
169
+ /** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
170
+ export async function teardownTeammates(): Promise<string[]> {
171
+ const results = await terminateAllTeammates();
172
+ const diagnostics: string[] = [];
173
+ for (const result of results) {
174
+ if (!result.confirmedClosed) diagnostics.push(`Teammate ${result.name} could not be confirmed closed.`);
175
+ updateTeammate(result.name, { status: "stopped" });
176
+ }
177
+ return diagnostics;
178
+ }
179
+
180
+ /** Remove the current session's runtime dir; the task board persists. */
181
+ export function removeRuntimeDir(ctx: Pick<ExtensionContext, "sessionManager" | "cwd">): void {
182
+ removeSessionStateDir(ctx.sessionManager?.getSessionFile(), ctx.cwd || process.cwd());
183
+ }
184
+
185
+ function requireStateFile(): string {
186
+ if (!runtimeStateFile) throw new Error("Agent Teams session state is unavailable.");
187
+ return runtimeStateFile;
188
+ }
189
+
190
+ /** Directory of the current session's runtime files (for UI mail inspection). */
191
+ export function runtimeDirPath(): string {
192
+ const file = runtimeStateFile;
193
+ if (!file) return "";
194
+ return file.slice(0, Math.max(file.lastIndexOf("/"), 0));
195
+ }
196
+
197
+ function flushSnapshots(): void {
198
+ const stateFile = requireStateFile();
199
+ try {
200
+ writeStateFile(stateFile, getState());
201
+ writeRoster(rosterPath(stateFile), livingTeammates().map((t) => ({ name: t.name, agent: t.agent, status: t.status })));
202
+ if (boardFile) writeBoardFile(boardFile, getState().tasks);
203
+ clearStateDirty();
204
+ } catch {
205
+ // Keep the dirty bit set so the next poll retries the snapshot.
206
+ }
207
+ }
208
+
209
+ export function publishStateSnapshot(): void {
210
+ drainTeammateOutboxes();
211
+ flushSnapshots();
212
+ }
213
+
214
+ export function ensureLivePoll(): void {
215
+ const busy = livingTeammates().length > 0 || verifyingTasks.size > 0 || pendingDeliveries.size > 0;
216
+ if (busy && !livePollTimer && runtimeStateFile) {
217
+ livePollTimer = setInterval(() => {
218
+ try {
219
+ tick();
220
+ } catch {
221
+ // Never let a poll error break the extension.
222
+ }
223
+ }, LIVE_POLL_MS);
224
+ } else if (!busy && livePollTimer) {
225
+ clearInterval(livePollTimer);
226
+ livePollTimer = undefined;
227
+ }
228
+ }
229
+
230
+ function tick(): void {
231
+ drainTeammateOutboxes();
232
+ processTaskIntents();
233
+ routePeerInboxes();
234
+ wakeIdleTeammates();
235
+ checkStalledTeammates();
236
+ flushSnapshots();
237
+ ensureLivePoll();
238
+ notifyChange();
239
+ }
240
+
241
+ // ── Spawning ──────────────────────────────────────────────────────
242
+
243
+ export function spawnTeammate(input: {
244
+ name: string;
245
+ agent: string;
246
+ prompt?: string;
247
+ }): { ok: true; teammate: Teammate } | { ok: false; error: string } {
248
+ const stateFile = requireStateFile();
249
+ const invalid = validateSpawnInput(input);
250
+ if (invalid) return { ok: false, error: invalid };
251
+ const agent = resolveAgent(input.agent, leaderCwd);
252
+ if (!agent) return { ok: false, error: `Agent "${input.agent}" not found in any scope.` };
253
+
254
+ let isolation: Teammate["isolation"] = "none";
255
+ let workerCwd = leaderCwd;
256
+ if (agent.worktree) {
257
+ const worktree = createWorktree(workerCwd, `${input.name}-${Date.now()}`);
258
+ if ("error" in worktree) return { ok: false, error: `Cannot isolate teammate: ${worktree.error}` };
259
+ liveWorktrees.set(input.name, worktree);
260
+ isolation = "worktree";
261
+ workerCwd = worktree.cwd;
262
+ }
263
+
264
+ const spawnId = randomUUID();
265
+ const registered = registerTeammate(newTeammate(input, spawnId, isolation, workerCwd));
266
+ if (!registered.ok) {
267
+ discardWorktreeQuietly(input.name);
268
+ return { ok: false, error: registered.error };
269
+ }
270
+
271
+ const started = spawnResident({
272
+ workerName: input.name,
273
+ description: buildKickoffPrompt(input.name, input.agent, agent.prompt, input.prompt, isolation),
274
+ model: agent.model,
275
+ tools: agent.tools,
276
+ cwd: workerCwd,
277
+ env: teammateEnv(stateFile, input, spawnId, agent.verify),
278
+ onUpdate: (progress) => applyProgress(input.name, spawnId, progress),
279
+ onExit: (result) => void handleTeammateClose(input.name, spawnId, result),
280
+ });
281
+ if ("error" in started) {
282
+ failSpawn(input.name, started.error);
283
+ return { ok: false, error: started.error };
284
+ }
285
+ updateTeammate(input.name, { pid: started.pid });
286
+ // A prompt-less spawn has no stream activity; it is idle from birth.
287
+ if (!input.prompt?.trim()) updateTeammate(input.name, { status: "idle" });
288
+ publishStateSnapshot();
289
+ ensureLivePoll();
290
+ notifyChange();
291
+ return { ok: true, teammate: getTeammate(input.name)! };
292
+ }
293
+
294
+ function validateSpawnInput(input: { name: string }): string | undefined {
295
+ if (!isValidTeammateName(input.name)) {
296
+ return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
297
+ }
298
+ if (livingTeammates().some((t) => t.name === input.name)) {
299
+ return `A living teammate named "${input.name}" already exists.`;
300
+ }
301
+ if (livingTeammates().length >= MAX_SESSION_WORKERS) {
302
+ return `Session cap reached: at most ${MAX_SESSION_WORKERS} teammates may be alive at once.`;
303
+ }
304
+ return undefined;
305
+ }
306
+
307
+ function newTeammate(
308
+ input: { name: string; agent: string },
309
+ spawnId: string,
310
+ isolation: Teammate["isolation"],
311
+ workerCwd: string,
312
+ ): Teammate {
313
+ return {
314
+ name: input.name,
315
+ agent: input.agent,
316
+ spawnId,
317
+ pid: 0,
318
+ status: "starting",
319
+ cwd: workerCwd,
320
+ isolation,
321
+ turns: 0,
322
+ createdAt: Date.now(),
323
+ updatedAt: Date.now(),
324
+ lastOutputAt: Date.now(),
325
+ };
326
+ }
327
+
328
+ function teammateEnv(
329
+ stateFile: string,
330
+ input: { name: string; agent: string },
331
+ spawnId: string,
332
+ verify: string | undefined,
333
+ ): Record<string, string | undefined> {
334
+ return {
335
+ PI_TEAMMATE_WORKER_NAME: input.name,
336
+ PI_TEAMMATE_SPAWN_ID: spawnId,
337
+ PI_TEAMMATE_OUTBOX_FILE: workerOutboxPath(stateFile, input.name, spawnId),
338
+ PI_TEAMMATE_INBOX_FILE: inboxPath(stateFile, input.name),
339
+ PI_TEAMMATE_ROSTER_FILE: rosterPath(stateFile),
340
+ PI_TEAMMATE_BOARD_FILE: boardFile,
341
+ PI_TEAMMATE_CLAIMS_DIR: claimsDir(boardDirectory()),
342
+ PI_TEAMMATE_SUBMISSIONS_DIR: submissionsDir(boardDirectory()),
343
+ PI_TEAMMATE_ROLE_AGENT: input.agent,
344
+ PI_TEAMMATE_VERIFY_DEFAULT: verify ?? "",
345
+ };
346
+ }
347
+
348
+ function buildKickoffPrompt(
349
+ name: string,
350
+ agentName: string,
351
+ rolePrompt: string,
352
+ kickoff: string | undefined,
353
+ isolation: Teammate["isolation"],
354
+ ): string {
355
+ const header = [
356
+ `You are a FULLY AUTONOMOUS resident teammate named "${name}" (agent: ${agentName}) in a pi multi-agent team.`,
357
+ "You stay alive between tasks. The harness wakes you with new prompts when",
358
+ "messages arrive for you or when the task board has unclaimed work.",
359
+ isolation === "worktree" ? "You are working inside your own dedicated git worktree." : "",
360
+ ].filter(Boolean).join(" ");
361
+ const roleSection = `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt}`;
362
+ const taskSection = kickoff?.trim()
363
+ ? `=== KICKOFF TASK ===\n${kickoff.trim()}`
364
+ : "=== KICKOFF TASK ===\n(none yet — check the task board with task_list and claim suitable work with task_claim)";
365
+ return `${header}\n\n${roleSection}\n\n${taskSection}`;
366
+ }
367
+
368
+ function discardWorktreeQuietly(name: string): void {
369
+ const handle = liveWorktrees.get(name);
370
+ if (!handle || "error" in handle) return;
371
+ liveWorktrees.delete(name);
372
+ cleanupWorktree(handle);
373
+ }
374
+
375
+ function boardDirectory(): string {
376
+ return path.dirname(boardFile);
377
+ }
378
+
379
+ function applyProgress(name: string, spawnId: string, progress: {
380
+ text: string;
381
+ activeTool?: string;
382
+ liveThinking?: string;
383
+ turns: number;
384
+ finalResponse?: boolean;
385
+ }): void {
386
+ const teammate = getTeammate(name);
387
+ // A stale callback from an older incarnation must not touch the current one.
388
+ if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
389
+ updateTeammateProgress(name, teammate.spawnId, {
390
+ liveText: progress.text,
391
+ activeTool: progress.activeTool,
392
+ liveThinking: progress.liveThinking,
393
+ turns: progress.turns,
394
+ sequenceEnded: progress.finalResponse === true ? true : undefined,
395
+ });
396
+ updateTeammate(name, { lastOutputAt: Date.now(), stallNoticeSentAt: undefined });
397
+ if (progress.finalResponse && teammate.status !== "idle") {
398
+ updateTeammate(name, { status: "idle", activeTool: undefined });
399
+ nudgeIfUnfinalized(name, spawnId);
400
+ }
401
+ ensureLivePoll();
402
+ }
403
+
404
+ function checkStalledTeammates(now = Date.now()): void {
405
+ if (STALL_NOTICE_MS <= 0) return;
406
+ for (const teammate of livingTeammates()) {
407
+ if (teammate.status !== "working" && teammate.status !== "starting") continue;
408
+ const silence = stallSilenceMs(teammate, now);
409
+ if (silence === undefined) continue;
410
+ if (silence < STALL_NOTICE_MS || teammate.stallNoticeSentAt !== undefined) continue;
411
+ const body = `@${teammate.name} has produced no RPC output for ${formatSilenceDuration(silence)}. The child may be blocked in a provider or tool call; steer delivery is uncertain. Decide: keep waiting, steer again, or shut it down (and respawn a successor with context from the original kickoff, its mailbox reports, board claims, and the /teammate detail transcript).`;
412
+ updateTeammate(teammate.name, { stallNoticeSentAt: now });
413
+ deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
414
+ sendUpdate({ teammate: teammate.name, agent: teammate.agent, body, finished: false });
415
+ }
416
+ }
417
+
418
+ /** True when the teammate's last leader-bound report lacks a terminal status. */
419
+ export function hasUnfinalizedReport(name: string): boolean {
420
+ const mailbox = getState().leaderMailbox;
421
+ for (let i = mailbox.length - 1; i >= 0; i--) {
422
+ if (mailbox[i].from !== name) continue;
423
+ return mailbox[i].status !== "completed" && mailbox[i].status !== "failed";
424
+ }
425
+ return false;
426
+ }
427
+
428
+ /** One light reminder per idle transition when work looks unfinished. */
429
+ function nudgeIfUnfinalized(name: string, spawnId: string): void {
430
+ const key = `${name}:${spawnId}`;
431
+ if (idleNudgesSent.has(key) || !hasUnfinalizedReport(name)) return;
432
+ idleNudgesSent.add(key);
433
+ const reminder = `@${name} is now idle but its last report was not marked status="completed" or "failed". Its conclusions may be stuck in the mailbox — ask it to finalize or inspect the /teammate console.`;
434
+ deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
435
+ sendUpdate({ teammate: name, body: reminder, finished: false });
436
+ }
437
+
438
+ function failSpawn(name: string, error: string): void {
439
+ const teammate = getTeammate(name);
440
+ discardWorktreeQuietly(name);
441
+ updateTeammate(name, { status: "stopped", error });
442
+ if (teammate) {
443
+ clearWorkerRunEvents(name, teammate.spawnId);
444
+ removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
445
+ }
446
+ deliverToLeader({
447
+ from: name,
448
+ subject: "Teammate failed to start",
449
+ body: `Teammate @${name} could not start.\nError: ${error}`,
450
+ });
451
+ publishStateSnapshot();
452
+ notifyChange();
453
+ }
454
+
455
+ // ── Shutdown and close ────────────────────────────────────────────
456
+
457
+ export async function shutdownTeammate(name: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
458
+ const teammate = getTeammate(name);
459
+ if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
460
+ pendingShutdowns.add(name);
461
+ const terminated = await terminateTeammate(name);
462
+ if (!terminated) {
463
+ // The child was already gone; synthesize the close bookkeeping.
464
+ pendingShutdowns.delete(name);
465
+ const released = releaseTasksOf(name, "Teammate was shut down.");
466
+ updateTeammate(name, { status: "stopped", activeTool: undefined });
467
+ publishStateSnapshot();
468
+ notifyChange();
469
+ return { ok: true, body: summarizeShutdown(name, released.length, 0, undefined) };
470
+ }
471
+ // Close finalization completes state transitions and reporting.
472
+ return {
473
+ ok: true,
474
+ body: `Shutdown requested for @${name}.`,
475
+ };
476
+ }
477
+
478
+ async function handleTeammateClose(name: string, spawnId: string, result: WorkerProcessResult): Promise<void> {
479
+ if (!runtimeStateFile) return;
480
+ const teammate = getTeammate(name);
481
+ if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
482
+ const requested = pendingShutdowns.has(name);
483
+ const crashed = !requested && !isCleanExit(result);
484
+
485
+ await finalizeWorktree(name);
486
+
487
+ // Drain final reports before tearing down replay metadata so nothing
488
+ // written before close is lost.
489
+ drainTeammateOutboxes();
490
+
491
+ const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
492
+ pendingShutdowns.delete(name);
493
+ // A stopped teammate must not keep the poll loop or its queue alive.
494
+ pendingDeliveries.delete(name);
495
+ idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
496
+ updateTeammate(name, {
497
+ status: "stopped",
498
+ activeTool: undefined,
499
+ error: requested ? undefined : closeErrorText(result, crashed),
500
+ });
501
+ clearWorkerRunEvents(name, teammate.spawnId);
502
+ removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
503
+
504
+ deliverToLeader(requested
505
+ ? { from: name, subject: "Teammate shut down", body: summarizeShutdown(name, released.length, result.exitCode, result.usage) }
506
+ : { from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
507
+
508
+ if (!requested) {
509
+ sendUpdate({
510
+ teammate: name,
511
+ body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
512
+ finished: true,
513
+ });
514
+ }
515
+ publishStateSnapshot();
516
+ ensureLivePoll();
517
+ notifyChange();
518
+ }
519
+
520
+ function closeErrorText(result: WorkerProcessResult, crashed: boolean): string | undefined {
521
+ if (!crashed) return undefined;
522
+ return `Closed unexpectedly (code ${result.exitCode ?? "unknown"}${result.signal ? `, signal ${result.signal}` : ""}).`;
523
+ }
524
+
525
+ function crashDiagnostic(name: string, result: WorkerProcessResult, released: Array<{ id: string }>): string {
526
+ return [
527
+ `Teammate @${name} closed without a shutdown request.`,
528
+ result.stderr?.trim() ? `stderr: ${result.stderr.trim()}` : undefined,
529
+ released.length > 0 ? `Released claimed task(s): ${released.map((t) => t.id).join(", ")}.` : undefined,
530
+ result.stdout?.trim() ? `Last output: ${result.stdout.trim()}` : undefined,
531
+ ].filter(Boolean).join("\n");
532
+ }
533
+
534
+ function summarizeShutdown(
535
+ name: string,
536
+ releasedCount: number,
537
+ exitCode: number | null | undefined,
538
+ usage?: import("./types").WorkerUsage,
539
+ ): string {
540
+ const lines = [`Teammate @${name} shut down (exit code ${exitCode ?? "unknown"}).`];
541
+ if (releasedCount > 0) lines.push(`Released claimed task(s): ${releasedCount}.`);
542
+ if (usage) lines.push(`Lifetime usage: ${usage.totalTokens} tokens, $${usage.cost.toFixed(4)}.`);
543
+ return lines.join("\n");
544
+ }
545
+
546
+ async function finalizeWorktree(name: string): Promise<void> {
547
+ const handle = liveWorktrees.get(name);
548
+ if (!handle || "error" in handle) {
549
+ liveWorktrees.delete(name);
550
+ return;
551
+ }
552
+ liveWorktrees.delete(name);
553
+ const captured = captureWorktreeDiff(handle);
554
+ const patchNote = captured.ok && captured.diff.patch.trim()
555
+ ? `\n\n=== Worktree changes ===\n${captured.diff.diffStat}\n\n${captured.diff.patch}`
556
+ : captured.ok
557
+ ? "\n(no worktree changes)"
558
+ : `\n(worktree diff capture failed: ${captured.error})`;
559
+ cleanupWorktree(handle);
560
+ deliverToLeader({
561
+ from: name,
562
+ subject: "Worktree diff captured",
563
+ body: `Teammate @${name}'s worktree diff:${patchNote}`,
564
+ });
565
+ }
566
+
567
+ // ── Report outbox draining ────────────────────────────────────────
568
+
569
+ /** Drain validated report events from every living teammate's outbox. */
570
+ export function drainTeammateOutboxes(): void {
571
+ const stateFile = requireStateFile();
572
+ let changed = false;
573
+ for (const teammate of livingTeammates()) {
574
+ const key = `${teammate.name}:${teammate.spawnId}`;
575
+ const offsets = getState().workerEventOffsets;
576
+ const previousOffset = offsets[key] ?? 0;
577
+ const { records, nextOffset, diagnostics } = readJsonlBatch(
578
+ workerOutboxPath(stateFile, teammate.name, teammate.spawnId),
579
+ previousOffset,
580
+ );
581
+ if (nextOffset !== previousOffset) {
582
+ offsets[key] = nextOffset;
583
+ changed = true;
584
+ }
585
+ for (const diagnostic of diagnostics) deliverDiagnostic(teammate.name, diagnostic);
586
+ for (const record of records) {
587
+ if (applyOutboxRecord(teammate, record)) changed = true;
588
+ }
589
+ }
590
+ // Persist on transitions, not per poll tick.
591
+ if (changed) markStateDirty();
592
+ }
593
+
594
+ function deliverDiagnostic(from: string, detail: string): void {
595
+ deliverToLeader({ from, subject: "Teammate channel diagnostic", body: detail });
596
+ }
597
+
598
+ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
599
+ if (!isWorkerEvent(record)) return false;
600
+ if (record.worker !== teammate.name || record.spawnId !== teammate.spawnId) return false;
601
+ const ids = getState().workerEventIds;
602
+ if (ids[`${teammate.spawnId}:${record.id}`]) return false;
603
+ ids[`${teammate.spawnId}:${record.id}`] = teammate.spawnId;
604
+ receiveWorkerMessage({
605
+ id: record.id,
606
+ type: "message",
607
+ worker: teammate.name,
608
+ spawnId: teammate.spawnId,
609
+ body: record.body,
610
+ status: record.status,
611
+ });
612
+ if (record.status === "completed" || record.status === "failed") {
613
+ // The assignment ends; the resident itself stays alive until sequence end.
614
+ sendUpdate({
615
+ teammate: teammate.name,
616
+ agent: teammate.agent,
617
+ body: record.body,
618
+ finished: true,
619
+ });
620
+ }
621
+ return true;
622
+ }
623
+
624
+ // ── Peer inbox routing ────────────────────────────────────────────
625
+
626
+ /**
627
+ * Route new inbox messages. Idle teammates get the content queued for their
628
+ * next wake-up; working teammates receive it immediately through their
629
+ * control stream (falling back to queueing when the stream is unavailable).
630
+ */
631
+ export function routePeerInboxes(): void {
632
+ const stateFile = requireStateFile();
633
+ for (const teammate of livingTeammates()) {
634
+ const inboxName = teammate.name;
635
+ const inbox = inboxPath(stateFile, inboxName);
636
+ const offset = getPeerInboxOffset(inboxName);
637
+ const { records, nextOffset } = readJsonlBatch(inbox, offset);
638
+ if (nextOffset === offset) {
639
+ continueMaybeCompact(inbox, inboxName, offset);
640
+ continue;
641
+ }
642
+ setPeerInboxOffset(inboxName, nextOffset);
643
+ for (const record of records) {
644
+ const message = parseInboxMessage(record);
645
+ if (!message || isPeerDelivered(inboxName, message.id)) continue;
646
+ markPeerDelivered(inboxName, message.id);
647
+ dispatchInboxMessage(teammate, message);
648
+ }
649
+ }
650
+ }
651
+
652
+ function parseInboxMessage(record: unknown): InboxMessage | undefined {
653
+ if (!record || typeof record !== "object") return undefined;
654
+ const candidate = record as Partial<InboxMessage>;
655
+ if (typeof candidate.id !== "string" || typeof candidate.from !== "string") return undefined;
656
+ if (typeof candidate.subject !== "string" || typeof candidate.body !== "string") return undefined;
657
+ return { ...candidate, timestamp: candidate.timestamp ?? Date.now() } as InboxMessage;
658
+ }
659
+
660
+ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
661
+ const delivered = teammate.status === "working"
662
+ ? sendWorkerSteer(teammate.name, formatDelivery([message]))
663
+ : false;
664
+ if (delivered) return;
665
+ const queued = pendingDeliveries.get(teammate.name) ?? [];
666
+ queued.push(message);
667
+ pendingDeliveries.set(teammate.name, queued);
668
+ ensureLivePoll();
669
+ }
670
+
671
+ /** Route the leader's addressed send_message through the same delivery path. */
672
+ export function sendLeaderMessage(to: string, message: string): { ok: true; queued: boolean; stalledMs?: number } | { ok: false; error: string } {
673
+ const teammate = getTeammate(to);
674
+ if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${to}".` };
675
+ const envelope: InboxMessage = {
676
+ id: randomUUID(),
677
+ from: "leader",
678
+ subject: messageTitle(message),
679
+ body: message,
680
+ timestamp: Date.now(),
681
+ };
682
+ const delivered = teammate.status === "working" && sendWorkerSteer(teammate.name, formatDelivery([envelope]));
683
+ if (delivered) {
684
+ // A control-stream write succeeds even when the child never reads it;
685
+ // surface prolonged silence so the leader knows delivery is uncertain.
686
+ const stalled = isStallThresholdReached(teammate, Date.now(), STALL_NOTICE_MS);
687
+ const silence = stallSilenceMs(teammate);
688
+ return { ok: true, queued: false, ...(stalled && silence !== undefined ? { stalledMs: silence } : {}) };
689
+ }
690
+ const queued = pendingDeliveries.get(teammate.name) ?? [];
691
+ queued.push(envelope);
692
+ pendingDeliveries.set(teammate.name, queued);
693
+ ensureLivePoll();
694
+ return { ok: true, queued: true };
695
+ }
696
+
697
+ function continueMaybeCompact(inbox: string, inboxName: string, offset: number): void {
698
+ let size = 0;
699
+ try {
700
+ size = fs.statSync(inbox).size;
701
+ } catch {
702
+ return;
703
+ }
704
+ // Truncate only fully-consumed inboxes; message ids make replay safe.
705
+ if (size > INBOX_COMPACT_BYTES && offset >= size) {
706
+ fs.truncateSync(inbox, 0);
707
+ setPeerInboxOffset(inboxName, 0);
708
+ }
709
+ }
710
+
711
+ /** Deliver harness feedback (e.g. verify failures) into a teammate inbox. */
712
+ export function deliverFeedback(to: string, subject: string, body: string): void {
713
+ const stateFile = requireStateFile();
714
+ appendInboxMessage(inboxPath(stateFile, to), { id: randomUUID(), from: "harness", subject, body });
715
+ ensureLivePoll();
716
+ }
717
+
718
+ // ── Task intents and verify gating ────────────────────────────────
719
+
720
+ /** Apply claim markers, then submission markers (with verify gating). */
721
+ export function processTaskIntents(): void {
722
+ const claims = claimsDir(boardDirectory());
723
+ const submissions = submissionsDir(boardDirectory());
724
+ let guard = 0;
725
+ while (guard++ < 64) {
726
+ const { intent, diagnostic } = takeTaskIntent(claims);
727
+ if (diagnostic) {
728
+ deliverToLeader({ from: "task-board", subject: "Task intent diagnostic", body: diagnostic });
729
+ continue;
730
+ }
731
+ if (!intent) break;
732
+ applyClaimMarker(intent);
733
+ }
734
+ guard = 0;
735
+ while (guard++ < 64) {
736
+ const { intent, diagnostic } = takeTaskIntent(submissions);
737
+ if (diagnostic) {
738
+ deliverToLeader({ from: "task-board", subject: "Task intent diagnostic", body: diagnostic });
739
+ continue;
740
+ }
741
+ if (!intent) break;
742
+ applySubmissionMarker(intent);
743
+ }
744
+ }
745
+
746
+ function applyClaimMarker(intent: import("./types").TaskIntent): void {
747
+ const sender = findLivingTeammate(intent);
748
+ if (!sender) return;
749
+ const outcome = applyClaimIntent(intent);
750
+ if (outcome.applied) {
751
+ updateTeammate(intent.worker, { currentTaskId: intent.taskId });
752
+ return;
753
+ }
754
+ deliverFeedback(intent.worker, "Claim rejected", outcome.reason ?? "The task is no longer available.");
755
+ }
756
+
757
+ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
758
+ const sender = findLivingTeammate(intent);
759
+ if (!sender) return;
760
+ const task = getState().tasks[intent.taskId];
761
+ if (!task || task.status !== "claimed" || task.claimedBy !== intent.worker) {
762
+ deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
763
+ return;
764
+ }
765
+ if (intent.status === "failed") {
766
+ releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
767
+ freeTeammateFromTask(intent.worker, intent.taskId);
768
+ notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
769
+ return;
770
+ }
771
+ beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
772
+ }
773
+
774
+ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
775
+ const task = getState().tasks[intent.taskId];
776
+ if (!task) return;
777
+ if (!verify?.trim()) {
778
+ finishCompletion(intent, task.subject);
779
+ return;
780
+ }
781
+ // The gate is bound to this claim: a crash/release/re-claim between submit
782
+ // and verify resolution must not let an old result complete a new holder.
783
+ verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId });
784
+ runVerifyCommand(verify)
785
+ .then((outcome) => {
786
+ verifyingTasks.delete(intent.taskId);
787
+ const current = getState().tasks[intent.taskId];
788
+ const stillHolds = current?.status === "claimed"
789
+ && current.claimedBy === intent.worker
790
+ && getTeammate(intent.worker)?.spawnId === intent.spawnId;
791
+ if (!stillHolds) return;
792
+ if (outcome.ok) finishCompletion(intent, task.subject);
793
+ else {
794
+ deliverFeedback(
795
+ intent.worker,
796
+ `Verify failed for ${intent.taskId}`,
797
+ [`The completion gate for "${task.subject}" failed.`, outcome.detail ?? "(no verify output)", "Fix the issues and resubmit with task_submit."].filter(Boolean).join("\n"),
798
+ );
799
+ notifyTaskOutcome(task.subject, `verify gate failed for ${intent.taskId}`, outcome.detail ?? "(no verify output)");
800
+ }
801
+ ensureLivePoll();
802
+ notifyChange();
803
+ })
804
+ .catch(() => verifyingTasks.delete(intent.taskId));
805
+ }
806
+
807
+ function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
808
+ const task = getState().tasks[intent.taskId];
809
+ if (!task) return;
810
+ const completed = completeTask(intent.taskId, intent.result);
811
+ if (!completed) return;
812
+ freeTeammateFromTask(intent.worker, intent.taskId);
813
+ notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
814
+ }
815
+
816
+ function runVerifyCommand(command: string): Promise<{ ok: boolean; detail?: string }> {
817
+ return new Promise((resolve) => {
818
+ exec(command, { cwd: leaderCwd, maxBuffer: 1024 * 1024 }, (error, _stdout, stderr) => {
819
+ const code = (error as { code?: number | string } | null)?.code;
820
+ if (error) {
821
+ resolve({ ok: false, detail: `verify exited with code ${code ?? "unknown"}: ${(stderr || error.message).slice(0, 4000)}` });
822
+ return;
823
+ }
824
+ resolve({ ok: true });
825
+ });
826
+ });
827
+ }
828
+
829
+ function freeTeammateFromTask(workerName: string, taskId: string): void {
830
+ const teammate = getTeammate(workerName);
831
+ if (teammate?.currentTaskId === taskId) updateTeammate(workerName, { currentTaskId: undefined });
832
+ }
833
+
834
+ function notifyTaskOutcome(subject: string, headline: string, body: string): void {
835
+ deliverToLeader({
836
+ from: "task-board",
837
+ subject: `Task ${headline}: ${subject}`,
838
+ body: body || "(no result summary submitted)",
839
+ });
840
+ }
841
+
842
+ function findLivingTeammate(intent: import("./types").TaskIntent): Teammate | undefined {
843
+ const teammate = getTeammate(intent.worker);
844
+ return teammate && teammate.status !== "stopped" && teammate.spawnId === intent.spawnId ? teammate : undefined;
845
+ }
846
+
847
+ // ── Wake-ups ──────────────────────────────────────────────────────
848
+
849
+ /**
850
+ * Compose one wake-up prompt per idle teammate from queued inbox deliveries
851
+ * plus a paced claimable-task notice. Teammates with nothing to do are never
852
+ * woken.
853
+ */
854
+ export function wakeIdleTeammates(): void {
855
+ const claimable = claimableTasks();
856
+ for (const teammate of idleTeammates()) {
857
+ const deliveries = pendingDeliveries.get(teammate.name) ?? [];
858
+ const dueNotice = claimable.length > 0 && noticeDue(teammate);
859
+ if (deliveries.length === 0 && !dueNotice) continue;
860
+ const prompt = buildWakePrompt(deliveries, claimable, dueNotice);
861
+ if (!deliverPrompt(teammate.name, prompt)) continue;
862
+ pendingDeliveries.delete(teammate.name);
863
+ // A delivered prompt is fresh activity: restart the silence clock so a
864
+ // long-idle teammate is never insta-flagged as stalled on wake.
865
+ updateTeammate(teammate.name, {
866
+ status: "working",
867
+ sequenceEnded: false,
868
+ lastOutputAt: Date.now(),
869
+ stallNoticeSentAt: undefined,
870
+ ...(dueNotice ? { lastNoticeAt: Date.now() } : {}),
871
+ });
872
+ }
873
+ }
874
+
875
+ function noticeDue(teammate: Teammate): boolean {
876
+ return teammate.lastNoticeAt === undefined || Date.now() - teammate.lastNoticeAt >= NOTICE_PACE_MS;
877
+ }
878
+
879
+ export function buildWakePrompt(
880
+ deliveries: InboxMessage[],
881
+ claimable: Array<{ id: string; subject: string }>,
882
+ includeNotice: boolean,
883
+ ): string {
884
+ const sections: string[] = [];
885
+ if (deliveries.length > 0) {
886
+ sections.push(`=== INBOX (${deliveries.length} new) ===\n${formatDelivery(deliveries)}`);
887
+ }
888
+ if (includeNotice && claimable.length > 0) {
889
+ const listed = claimable.slice(0, 10).map((task) => `${task.id} (${task.subject})`).join(", ");
890
+ sections.push(`=== BOARD NOTICE ===\nUnclaimed tasks: ${listed}\nUse task_list for details and task_claim to take one if appropriate for your role.`);
891
+ }
892
+ if (sections.length === 0) return "";
893
+ return `Wake up. New activity for you:\n\n${sections.join("\n\n")}\n\nHandle the items above, then go idle again.`;
894
+ }
895
+
896
+ function formatDelivery(messages: InboxMessage[]): string {
897
+ return messages.map((message) => `From ${message.from} · ${message.subject}\n${message.body}`).join("\n\n---\n\n");
898
+ }
899
+
900
+ // ── Board helpers shared with tools.ts ────────────────────────────
901
+
902
+ export function createBoardTask(input: {
903
+ subject: string;
904
+ description?: string;
905
+ dependsOn?: string[];
906
+ verify?: string;
907
+ }): { ok: true; id: string } | { ok: false; error: string } {
908
+ const created = createTask(input);
909
+ if (!created.ok) return created;
910
+ publishStateSnapshot();
911
+ notifyChange();
912
+ return { ok: true, id: created.task.id };
913
+ }
914
+
915
+ export function boardOverview(): Array<import("./types").BoardTask> {
916
+ return listTasks();
917
+ }
918
+
919
+ /** Worker-side claim attempt through an exclusive-create marker file. */
920
+ export function attemptClaim(workerName: string, spawnId: string, taskId: string): boolean {
921
+ return createTaskIntent(claimsDir(boardDirectory()), taskId, {
922
+ taskId,
923
+ worker: workerName,
924
+ spawnId,
925
+ status: "completed",
926
+ timestamp: Date.now(),
927
+ });
928
+ }
929
+
930
+ /** Worker-side submission through an exclusive-create marker file. */
931
+ export function attemptSubmission(
932
+ workerName: string,
933
+ spawnId: string,
934
+ taskId: string,
935
+ status: "completed" | "failed",
936
+ result?: string,
937
+ ): boolean {
938
+ return createTaskIntent(submissionsDir(boardDirectory()), taskId, {
939
+ taskId,
940
+ worker: workerName,
941
+ spawnId,
942
+ status,
943
+ result,
944
+ timestamp: Date.now(),
945
+ });
946
+ }