@fradser/pi-agent-teams 0.8.0 → 0.8.3

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,9 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
13
  import { modelLabel, runPiWorker } from "@fradser/pi-kit";
14
14
  import { MODEL_INHERIT_ALIAS, discoverAgents, persistAgentDefinition, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "./agents.ts";
15
15
  import {
16
+ activeAssignmentConflict,
16
17
  applyClaimIntent,
18
+ assignTeammate,
17
19
  claimableTasks,
18
20
  clearStateDirty,
19
21
  clearWorkerRunEvents,
@@ -32,11 +34,13 @@ import {
32
34
  loadBoard,
33
35
  markPeerDelivered,
34
36
  markStateDirty,
37
+ normalizeResources,
35
38
  registerTeammate,
36
39
  releaseTask,
37
40
  releaseTasksOf,
38
41
  receiveWorkerMessage,
39
42
  setPeerInboxOffset,
43
+ setPeerDeliveryState,
40
44
  updateTeammate,
41
45
  updateTeammateProgress,
42
46
  } from "./state.ts";
@@ -63,14 +67,17 @@ import { isWorkerEvent } from "./types.ts";
63
67
  import {
64
68
  deliverPrompt,
65
69
  isCleanExit,
70
+ resolveWorkerTools,
66
71
  sendWorkerSteer,
67
72
  spawnResident,
68
73
  terminateAllTeammates,
69
74
  terminateTeammate,
75
+ unknownWorkerTools,
76
+ WORKER_TOOL_UNIVERSE,
70
77
  type WorkerProcessResult,
71
78
  } from "./spawner.ts";
72
- import { captureWorktreeDiff, cleanupWorktree, createWorktree } from "./worktree.ts";
73
- import { messageTitle, type InboxMessage, type Teammate } from "./types.ts";
79
+ import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree.ts";
80
+ import { messageTitle, type InboxMessage, type Teammate, type WorkerAssignment, type WorkerUsage } from "./types.ts";
74
81
  import type { FollowUpReport } from "./follow-up-queue.ts";
75
82
 
76
83
  export const MAX_SESSION_WORKERS = 8;
@@ -93,6 +100,13 @@ const WAKE_NOTICE_TASK_LIMIT = 10;
93
100
  * The watchdog may only notify; termination decisions belong to the leader model. */
94
101
  const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
95
102
  export const STALL_NOTICE_MS = readDurationEnv("PI_TEAMMATE_STALL_NOTICE_MS", DEFAULT_STALL_NOTICE_MS);
103
+ /** Silence with zero lifetime model output and no tool running is the provider-
104
+ * hang signature: an in-flight request stuck on the model backend will not
105
+ * recover by waiting or steering, so flag it well before the general window so
106
+ * the leader can respawn early. Defaults to five minutes, independent of the
107
+ * notice-pace floor; 0 disables the tier. */
108
+ const DEFAULT_SILENT_STALL_MS = 5 * 60 * 1000;
109
+ export const SILENT_STALL_MS = readDurationEnv("PI_TEAMMATE_SILENT_STALL_MS", DEFAULT_SILENT_STALL_MS);
96
110
  /** Fully-consumed inboxes larger than this are truncated. */
97
111
  const INBOX_COMPACT_BYTES = 256 * 1024;
98
112
 
@@ -126,6 +140,44 @@ export function formatSilenceDuration(milliseconds: number): string {
126
140
  return `${minutes}m`;
127
141
  }
128
142
 
143
+ /** True when the stream has delivered recognized model activity (text,
144
+ * thinking, tool-call, or tool execution events) at least once — the stall
145
+ * classifier. Usage totals stay diagnostics only: providers may omit them
146
+ * after real output, and an empty message_end artifact must not count. */
147
+ export function hasModelOutput(teammate: Pick<Teammate, "modelOutputSeen">): boolean {
148
+ return teammate.modelOutputSeen === true;
149
+ }
150
+
151
+ /** Silence threshold for one teammate. A worker that has never received model
152
+ * output and runs no tool is almost certainly blocked on the provider rather
153
+ * than doing slow work; that signature uses the shorter silent-stall window. */
154
+ export function stallThresholdMs(teammate: Pick<Teammate, "modelOutputSeen" | "activeTool">): number {
155
+ if (STALL_NOTICE_MS <= 0 || SILENT_STALL_MS <= 0) return STALL_NOTICE_MS;
156
+ if (!hasModelOutput(teammate) && !teammate.activeTool) return SILENT_STALL_MS;
157
+ return STALL_NOTICE_MS;
158
+ }
159
+
160
+ function usageLine(usage: WorkerUsage | undefined): string {
161
+ if (!usage) return "";
162
+ return ` Lifetime usage: ${usage.totalTokens} tokens, $${usage.cost.toFixed(4)}.`;
163
+ }
164
+
165
+ /** Stall notice body with the diagnostics a leader needs to decide: silence
166
+ * duration, spawn age, lifetime usage, and — for the zero-output provider-hang
167
+ * signature — the remedy that actually works (shutdown + respawn). */
168
+ export function stallNoticeBody(
169
+ teammate: Pick<Teammate, "name" | "createdAt" | "activeTool" | "modelOutputSeen" | "usage">,
170
+ silenceMs: number,
171
+ now = Date.now(),
172
+ ): string {
173
+ const head = `@${teammate.name} has been silent for ${formatSilenceDuration(silenceMs)} (spawn age ${formatSilenceDuration(Math.max(0, now - teammate.createdAt))}).`;
174
+ if (!hasModelOutput(teammate) && !teammate.activeTool) {
175
+ return `${head} No model output received yet.${usageLine(teammate.usage)} An in-flight request stuck on the provider will not recover by steering; recovery usually means shutting this teammate down and respawning a successor (optionally pinning another model). Decide: keep waiting, steer again, or shut it down.`;
176
+ }
177
+ const toolNote = teammate.activeTool ? ` Tool still running: ${teammate.activeTool}.` : "";
178
+ return `${head}${toolNote}${usageLine(teammate.usage)} 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 /agent-teams detail transcript).`;
179
+ }
180
+
129
181
  let livePollTimer: ReturnType<typeof setInterval> | undefined;
130
182
  let generation = 0;
131
183
  let runtimeStateFile = "";
@@ -134,12 +186,13 @@ let leaderCwd = "";
134
186
  /** Live view of the leader session's current model, resolved at spawn time. */
135
187
  let leaderModelRef: () => string | undefined = () => undefined;
136
188
  let sendUpdate: (report: FollowUpReport) => void = () => {};
189
+ let archiveQueuedReports: (spawnId: string) => FollowUpReport[] = () => [];
137
190
  let notifyChange: () => void = () => {};
138
191
 
139
192
  const pendingShutdowns = new Set<string>();
140
193
  /** Task ids under verification, bound to one exact submission via token so a
141
194
  * release/re-claim or newer submission invalidates any older in-flight gate. */
142
- const verifyingTasks = new Map<string, { worker: string; spawnId: string; token: string }>();
195
+ const verifyingTasks = new Map<string, { worker: string; spawnId: string; submissionId: string; token: string }>();
143
196
  /** Idle nudges already fired per teammate incarnation (one per transition). */
144
197
  const idleNudgesSent = new Set<string>();
145
198
  /** One finish entry per spawn incarnation; repeated terminal reports stay ordinary report rows. */
@@ -157,6 +210,8 @@ const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
157
210
 
158
211
  export interface MachineHooks {
159
212
  sendUpdate: (report: FollowUpReport) => void;
213
+ /** Suppress reports whose delivery remains harness-owned when shutdown starts. */
214
+ archiveQueuedReports?: (spawnId: string) => FollowUpReport[];
160
215
  notifyChange: () => void;
161
216
  }
162
217
 
@@ -171,6 +226,7 @@ export function initTeamMachine(
171
226
  runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
172
227
  boardFile = boardFilePath(sessionFile, leaderCwd);
173
228
  sendUpdate = hooks.sendUpdate;
229
+ archiveQueuedReports = hooks.archiveQueuedReports ?? (() => []);
174
230
  notifyChange = hooks.notifyChange;
175
231
  // Resume: reload a persisted board; claims die with their holders.
176
232
  const persisted = readBoardFile(boardFile);
@@ -187,6 +243,7 @@ export function shutdownTeamMachine(): void {
187
243
  leaderModelRef = () => undefined;
188
244
  setVerifyGateRunner(undefined);
189
245
  sendUpdate = () => {};
246
+ archiveQueuedReports = () => [];
190
247
  notifyChange = () => {};
191
248
  pendingShutdowns.clear();
192
249
  verifyingTasks.clear();
@@ -194,6 +251,8 @@ export function shutdownTeamMachine(): void {
194
251
  selfFinalizeAttempts.clear();
195
252
  pendingDeliveries.clear();
196
253
  verifyFailures.clear();
254
+ inconclusiveParks.clear();
255
+ verifyFailureParks.clear();
197
256
  announcedFinishKeys.clear();
198
257
  terminalReportKeys.clear();
199
258
  }
@@ -230,6 +289,10 @@ export function currentLeaderModelRef(): string | undefined {
230
289
 
231
290
  /** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
232
291
  export async function teardownTeammates(): Promise<string[]> {
292
+ // Session shutdown is an intentional lifecycle transition. Mark every
293
+ // resident before signalling so close callbacks do not misclassify normal
294
+ // session teardown as an unexpected crash and enqueue a follow-up turn.
295
+ for (const teammate of livingTeammates()) pendingShutdowns.add(teammate.name);
233
296
  const results = await terminateAllTeammates();
234
297
  const diagnostics: string[] = [];
235
298
  for (const result of results) {
@@ -260,7 +323,14 @@ function flushSnapshots(): void {
260
323
  const stateFile = requireStateFile();
261
324
  try {
262
325
  writeStateFile(stateFile, getState());
263
- writeRoster(rosterPath(stateFile), livingTeammates().map((t) => ({ name: t.name, agent: t.agent, status: t.status })));
326
+ writeRoster(rosterPath(stateFile), livingTeammates().map((t) => ({
327
+ name: t.name,
328
+ agent: t.agent,
329
+ status: t.status,
330
+ tools: t.tools,
331
+ currentTaskId: t.currentTaskId,
332
+ assignment: t.assignment,
333
+ })));
264
334
  if (boardFile) writeBoardFile(boardFile, getState().tasks);
265
335
  clearStateDirty();
266
336
  } catch {
@@ -306,6 +376,8 @@ export function spawnTeammate(input: {
306
376
  name: string;
307
377
  agent: string;
308
378
  prompt?: string;
379
+ resources?: string[];
380
+ handoffFrom?: string;
309
381
  definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
310
382
  tools?: string[];
311
383
  persist?: boolean;
@@ -315,7 +387,19 @@ export function spawnTeammate(input: {
315
387
  const stateFile = requireStateFile();
316
388
  const invalid = validateSpawnInput(input);
317
389
  if (invalid) return { ok: false, error: invalid };
390
+ if (input.handoffFrom) {
391
+ const predecessor = getTeammate(input.handoffFrom);
392
+ if (!predecessor) return { ok: false, error: `Cannot hand off from @${input.handoffFrom}: teammate not found.` };
393
+ if (predecessor.status !== "stopped") {
394
+ return { ok: false, error: `Cannot hand off from living @${input.handoffFrom}. Shut it down or wait for it to stop before spawning a successor.` };
395
+ }
396
+ }
318
397
  const resolved = resolveAgent(input.agent, leaderCwd);
398
+ // Reject tool ids the bare child could never grant before any side effect:
399
+ // a silent --tools drop here is how reviewers end up blind mid-audit.
400
+ const requestedTools = input.definition && inlineDefinitionApplies(resolved) ? input.definition.tools : resolved?.tools;
401
+ const unknownTools = unknownWorkerTools(requestedTools);
402
+ if (unknownTools.length > 0) return { ok: false, error: unknownWorkerToolsError(unknownTools) };
319
403
  let agent: AgentDefinition | undefined = resolved;
320
404
  if (input.definition && inlineDefinitionApplies(resolved)) {
321
405
  try {
@@ -349,6 +433,15 @@ export function spawnTeammate(input: {
349
433
  }
350
434
 
351
435
  const spawnId = randomUUID();
436
+ const effectiveKickoff = buildSuccessorHandoff(input.prompt, input.handoffFrom);
437
+ const directResources = effectiveKickoff?.trim()
438
+ ? resolveDirectResources(input.resources, input.handoffFrom)
439
+ : [];
440
+ const conflict = activeAssignmentConflict(directResources);
441
+ if (conflict) {
442
+ discardWorktreeQuietly(input.name);
443
+ return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
444
+ }
352
445
  const registered = registerTeammate(newTeammate(input, spawnId, isolation, workerCwd));
353
446
  if (!registered.ok) {
354
447
  discardWorktreeQuietly(input.name);
@@ -356,11 +449,27 @@ export function spawnTeammate(input: {
356
449
  }
357
450
 
358
451
  const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
359
- updateTeammate(input.name, { model: spawnModel.model });
452
+ // Record the grant before the first wake: a role derived without tools shows
453
+ // its narrow capability-only allowlist right on the spawn surface.
454
+ const assignment = directAssignment(effectiveKickoff, directResources, spawnId);
455
+ updateTeammate(input.name, {
456
+ model: spawnModel.model,
457
+ tools: resolveWorkerTools(agent.tools),
458
+ assignment,
459
+ });
460
+ // Flush before the kickoff is written: a fast child must not read a stale
461
+ // worker-readable roster missing its own entry or tool grant.
462
+ publishStateSnapshot();
360
463
 
361
464
  const started = spawnResident({
362
465
  workerName: input.name,
363
- description: buildKickoffPrompt(input.name, input.agent, agent.prompt, input.prompt, isolation),
466
+ description: buildKickoffPrompt(
467
+ input.name,
468
+ input.agent,
469
+ agent.prompt,
470
+ effectiveKickoff,
471
+ isolation,
472
+ ),
364
473
  model: spawnModel.model,
365
474
  tools: agent.tools,
366
475
  cwd: workerCwd,
@@ -373,8 +482,10 @@ export function spawnTeammate(input: {
373
482
  return { ok: false, error: started.error };
374
483
  }
375
484
  updateTeammate(input.name, { pid: started.pid });
376
- // A prompt-less spawn has no stream activity; it is idle from birth.
377
- if (!input.prompt?.trim()) updateTeammate(input.name, { status: "idle" });
485
+ // Status stays "starting" until real stream events arrive: every spawned
486
+ // teammate runs a kickoff turn, so marking prompt-less spawns idle here used
487
+ // to mislabel an actively-running turn as idle and misroute deliveries
488
+ // (queued instead of steered, then lost inside the running turn).
378
489
  publishStateSnapshot();
379
490
  ensureLivePoll();
380
491
  notifyChange();
@@ -388,6 +499,16 @@ export function inlineDefinitionApplies(resolved: AgentDefinition | undefined):
388
499
  return !resolved || resolved.scope === "session";
389
500
  }
390
501
 
502
+ /** Spawn failure for execution-tool ids outside the teammate universe. */
503
+ export function unknownWorkerToolsError(unknown: readonly string[]): string {
504
+ return [
505
+ `Unknown tool id${unknown.length === 1 ? "" : "s"} for a teammate: ${unknown.join(", ")}.`,
506
+ "A teammate child runs a bare pi process (--no-extensions), so only pi built-in tools plus the teammate capability set can be granted.",
507
+ `Valid ids: ${WORKER_TOOL_UNIVERSE.join(", ")}.`,
508
+ "MCP or project-extension tools cannot reach a teammate; perform that work in the leader session instead.",
509
+ ].join(" ");
510
+ }
511
+
391
512
  /** Spawn failure for an unresolvable agent name. Definitions resolve live at
392
513
  * spawn time, so the available-agents list in the cached session guidance can
393
514
  * go stale (e.g. another session removed the file); name every checked scope
@@ -398,7 +519,7 @@ export function unknownAgentError(name: string, cwd: string): string {
398
519
  `Agent "${name}" not found in any scope.`,
399
520
  `Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
400
521
  'The available-agents list in your guidance may be stale: definition files can change mid-session (for example removed by a parallel session).',
401
- 'Recover by spawning with an inline definition derived from references/agent-roles.md, choose an existing role, or create the role first.',
522
+ 'Recover by retrying teammate_spawn with name and an existing agent role id, or with name, a new agent role id, and an inline definition derived from references/agent-roles.md that includes description and prompt.',
402
523
  `Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
403
524
  ].join(' ');
404
525
  }
@@ -418,6 +539,7 @@ function validateSpawnInput(input: { name: string }): string | undefined {
418
539
 
419
540
  function newTeammate(
420
541
  input: { name: string; agent: string },
542
+
421
543
  spawnId: string,
422
544
  isolation: Teammate["isolation"],
423
545
  workerCwd: string,
@@ -437,6 +559,46 @@ function newTeammate(
437
559
  };
438
560
  }
439
561
 
562
+ export function directAssignment(
563
+ kickoff: string | undefined,
564
+ resources: string[],
565
+ spawnId: string,
566
+ ): WorkerAssignment | undefined {
567
+ if (!kickoff?.trim()) return undefined;
568
+ return {
569
+ id: `direct:${spawnId}`,
570
+ kind: "direct",
571
+ resources,
572
+ };
573
+ }
574
+
575
+ export function resolveDirectResources(resources: string[] | undefined, handoffFrom: string | undefined): string[] {
576
+ const explicit = normalizeResources(resources);
577
+ if (explicit.length > 0) return explicit;
578
+ const prior = handoffFrom ? getTeammate(handoffFrom) : undefined;
579
+ return normalizeResources(prior?.assignment?.resources ?? prior?.lastAssignment?.resources);
580
+ }
581
+
582
+ export function buildSuccessorHandoff(kickoff: string | undefined, handoffFrom: string | undefined): string | undefined {
583
+ if (!handoffFrom?.trim()) return kickoff;
584
+ const prior = getTeammate(handoffFrom);
585
+ const reports = getState().leaderMailbox
586
+ .filter((message) => message.from === handoffFrom)
587
+ .slice(-3)
588
+ .map((message) => `- ${message.subject}: ${truncated(message.body, 1000)}`)
589
+ .join("\n");
590
+ const assignment = prior?.assignment ?? prior?.lastAssignment;
591
+ const taskId = prior?.currentTaskId ?? prior?.lastTaskId;
592
+ const handoff = [
593
+ `=== SUCCESSOR HANDOFF FROM @${handoffFrom} ===`,
594
+ assignment ? `Prior assignment: ${assignment.kind} ${assignment.id}` : "Prior assignment: unavailable",
595
+ taskId ? `Prior board claim: ${taskId}` : "Prior board claim: none",
596
+ reports ? `Recent leader reports:\n${reports}` : "Recent leader reports: none",
597
+ "Verify current files yourself; do not claim the predecessor's board task unless the leader explicitly assigns it.",
598
+ ].join("\n");
599
+ return [kickoff?.trim(), handoff].filter(Boolean).join("\n\n");
600
+ }
601
+
440
602
  function teammateEnv(
441
603
  stateFile: string,
442
604
  input: { name: string; agent: string },
@@ -457,7 +619,7 @@ function teammateEnv(
457
619
  };
458
620
  }
459
621
 
460
- function buildKickoffPrompt(
622
+ export function buildKickoffPrompt(
461
623
  name: string,
462
624
  agentName: string,
463
625
  rolePrompt: string,
@@ -472,7 +634,7 @@ function buildKickoffPrompt(
472
634
  ].filter(Boolean).join(" ");
473
635
  const roleSection = `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt}`;
474
636
  const taskSection = kickoff?.trim()
475
- ? `=== KICKOFF TASK ===\n${kickoff.trim()}`
637
+ ? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly. Do not call task_list or check the task board unless this task explicitly instructs you to do so.`
476
638
  : "=== KICKOFF TASK ===\n(none yet — check the task board with task_list and claim suitable work with task_claim)";
477
639
  return `${header}\n\n${roleSection}\n\n${taskSection}`;
478
640
  }
@@ -481,7 +643,8 @@ function discardWorktreeQuietly(name: string): void {
481
643
  const handle = liveWorktrees.get(name);
482
644
  if (!handle || "error" in handle) return;
483
645
  liveWorktrees.delete(name);
484
- cleanupWorktree(handle);
646
+ // A failed spawn never produced work; the empty branch goes too.
647
+ discardWorktree(handle);
485
648
  }
486
649
 
487
650
  function boardDirectory(): string {
@@ -494,6 +657,8 @@ function applyProgress(name: string, spawnId: string, progress: {
494
657
  liveThinking?: string;
495
658
  turns: number;
496
659
  finalResponse?: boolean;
660
+ modelOutputSeen?: boolean;
661
+ usage?: WorkerUsage;
497
662
  }): void {
498
663
  const teammate = getTeammate(name);
499
664
  // A stale callback from an older incarnation must not touch the current one.
@@ -504,6 +669,8 @@ function applyProgress(name: string, spawnId: string, progress: {
504
669
  liveThinking: progress.liveThinking,
505
670
  turns: progress.turns,
506
671
  sequenceEnded: progress.finalResponse === true ? true : undefined,
672
+ modelOutputSeen: progress.modelOutputSeen,
673
+ usage: progress.usage,
507
674
  });
508
675
  updateTeammate(name, { lastOutputAt: Date.now(), stallNoticeSentAt: undefined });
509
676
  if (progress.finalResponse && teammate.status !== "idle") {
@@ -513,17 +680,32 @@ function applyProgress(name: string, spawnId: string, progress: {
513
680
  ensureLivePoll();
514
681
  }
515
682
 
683
+ export function formatAgentHealthReport(
684
+ state: "stalled",
685
+ teammate: Pick<Teammate, "name" | "agent">,
686
+ body: string,
687
+ silenceMs: number,
688
+ ): FollowUpReport {
689
+ return {
690
+ teammate: teammate.name,
691
+ agent: teammate.agent,
692
+ body,
693
+ finished: false,
694
+ health: { state, silenceMs },
695
+ };
696
+ }
697
+
516
698
  function checkStalledTeammates(now = Date.now()): void {
517
699
  if (STALL_NOTICE_MS <= 0) return;
518
700
  for (const teammate of livingTeammates()) {
519
701
  if (teammate.status !== "working" && teammate.status !== "starting") continue;
520
702
  const silence = stallSilenceMs(teammate, now);
521
- if (silence === undefined) continue;
522
- if (silence < STALL_NOTICE_MS || teammate.stallNoticeSentAt !== undefined) continue;
523
- 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 /agent-teams detail transcript).`;
703
+ if (silence === undefined || teammate.stallNoticeSentAt !== undefined) continue;
704
+ if (silence < stallThresholdMs(teammate)) continue;
705
+ const body = stallNoticeBody(teammate, silence, now);
524
706
  updateTeammate(teammate.name, { stallNoticeSentAt: now });
525
707
  deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
526
- sendUpdate({ teammate: teammate.name, agent: teammate.agent, body, finished: false });
708
+ sendUpdate(formatAgentHealthReport("stalled", teammate, body, silence));
527
709
  }
528
710
  }
529
711
 
@@ -532,6 +714,13 @@ interface VerifyFailureRecord {
532
714
  escalated: boolean;
533
715
  }
534
716
 
717
+ /** One verdict-only retry per exact verify submission. */
718
+ const inconclusiveVerifications = new Map<string, number>();
719
+ /** Two inconclusive reviews park a holding until the leader explicitly steers it. */
720
+ const inconclusiveParks = new Map<string, { worker: string; spawnId: string }>();
721
+ /** Two explicit verify failures also require leader direction before retry. */
722
+ const verifyFailureParks = new Map<string, { worker: string; spawnId: string }>();
723
+
535
724
  /** Record one finish entry per spawn incarnation; later terminal reports from
536
725
  * the same resident stay ordinary report rows. */
537
726
  export function markTeammateFinished(
@@ -600,7 +789,13 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
600
789
  idleNudgesSent.add(key);
601
790
  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 /agent-teams console.`;
602
791
  deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
603
- sendUpdate({ teammate: name, body: reminder, finished: false });
792
+ sendUpdate({
793
+ teammate: name,
794
+ body: reminder,
795
+ origin: "harness",
796
+ harnessEvent: { type: "unfinalized-report", subject: `@${name} idle without terminal report` },
797
+ finished: false,
798
+ });
604
799
  }
605
800
 
606
801
  function failSpawn(name: string, error: string): void {
@@ -626,6 +821,7 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
626
821
  const teammate = getTeammate(name);
627
822
  if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
628
823
  pendingShutdowns.add(name);
824
+ archiveReportsForConsole(archiveQueuedReports(teammate.spawnId));
629
825
  const terminated = await terminateTeammate(name);
630
826
  if (!terminated) {
631
827
  // The child was already gone; synthesize the close bookkeeping.
@@ -633,15 +829,20 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
633
829
  const released = releaseTasksOf(name, "Teammate was shut down.");
634
830
  for (const task of released) {
635
831
  verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
832
+ clearInconclusiveForHolding(task.id, teammate.spawnId);
636
833
  verifyingTasks.delete(task.id);
637
834
  rearmTaskNotice(task.id);
638
835
  }
639
836
  updateTeammate(name, { status: "stopped", activeTool: undefined });
640
837
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
641
838
  selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
839
+ // No close event will fire for an already-gone child, so this branch is
840
+ // the only chance to put the shutdown summary on the delivery channel.
841
+ const summary = summarizeShutdown(name, released.length, 0, undefined);
842
+ deliverToLeader({ from: name, subject: "Teammate shut down", body: summary });
642
843
  publishStateSnapshot();
643
844
  notifyChange();
644
- return { ok: true, body: summarizeShutdown(name, released.length, 0, undefined) };
845
+ return { ok: true, body: summary };
645
846
  }
646
847
  // Close finalization completes state transitions and reporting.
647
848
  return {
@@ -666,6 +867,7 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
666
867
  const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
667
868
  for (const task of released) {
668
869
  verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
870
+ clearInconclusiveForHolding(task.id, teammate.spawnId);
669
871
  verifyingTasks.delete(task.id);
670
872
  rearmTaskNotice(task.id);
671
873
  }
@@ -682,19 +884,27 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
682
884
  clearWorkerRunEvents(name, teammate.spawnId);
683
885
  removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
684
886
 
685
- deliverToLeader(requested
686
- ? { from: name, subject: "Teammate shut down", body: summarizeShutdown(name, released.length, result.exitCode, result.usage) }
687
- : { from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
688
-
689
- if (!requested) {
690
- const report = {
887
+ if (requested) {
888
+ // The close handler drains a final outbox after shutdown was requested.
889
+ // Archive that spawn again so any report written during termination cannot
890
+ // become a delayed leader follow-up.
891
+ archiveReportsForConsole(archiveQueuedReports(spawnId));
892
+ const summary = summarizeShutdown(name, released.length, result.exitCode, result.usage);
893
+ deliverToLeader({ from: name, subject: "Teammate shut down", body: summary });
894
+ // A requested shutdown is already represented by the tool lifecycle row;
895
+ // keep its summary in the console mailbox without starting a leader turn.
896
+ } else {
897
+ deliverToLeader({ from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
898
+ const closeReport = {
691
899
  teammate: name,
692
900
  spawnId: teammate.spawnId,
693
901
  body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
694
- finished: true,
902
+ origin: "harness" as const,
903
+ harnessEvent: { type: "unexpected-stop", subject: `@${name} stopped unexpectedly` },
904
+ finished: false,
695
905
  };
696
- recordTerminalReport(report);
697
- sendUpdate(report);
906
+ recordTerminalReport(closeReport);
907
+ sendUpdate(closeReport);
698
908
  }
699
909
  publishStateSnapshot();
700
910
  ensureLivePoll();
@@ -715,6 +925,17 @@ function crashDiagnostic(name: string, result: WorkerProcessResult, released: Ar
715
925
  ].filter(Boolean).join("\n");
716
926
  }
717
927
 
928
+ /** Retain reports the follow-up queue suppressed so the teammate detail view
929
+ * can show what was archived without turning it into another leader prompt. */
930
+ function archiveReportsForConsole(reports: FollowUpReport[]): void {
931
+ for (const report of reports) {
932
+ if (!report.eventId) continue;
933
+ const message = getState().leaderMailbox.find((candidate) => candidate.id === report.eventId);
934
+ if (message) message.archived = true;
935
+ }
936
+ if (reports.length > 0) markStateDirty();
937
+ }
938
+
718
939
  function summarizeShutdown(
719
940
  name: string,
720
941
  releasedCount: number,
@@ -735,17 +956,62 @@ async function finalizeWorktree(name: string): Promise<void> {
735
956
  }
736
957
  liveWorktrees.delete(name);
737
958
  const captured = captureWorktreeDiff(handle);
738
- const patchNote = captured.ok && captured.diff.patch.trim()
739
- ? `\n\n=== Worktree changes ===\n${captured.diff.diffStat}\n\n${captured.diff.patch}`
740
- : captured.ok
741
- ? "\n(no worktree changes)"
742
- : `\n(worktree diff capture failed: ${captured.error})`;
743
- cleanupWorktree(handle);
959
+ // Cleanup commits any remaining work onto the kept branch before removing
960
+ // the directory: staging alone would die with the worktree.
961
+ const cleaned = cleanupWorktree(handle);
962
+ if (!captured.ok) {
963
+ // The branch survives cleanup, so nothing is lost; wake the leader with
964
+ // the recovery path because this requires a decision.
965
+ const body = `Capturing @${name}'s worktree diff failed (${captured.error}). The branch ${handle.branch} was kept; inspect it manually.`;
966
+ deliverToLeader({ from: name, subject: "Worktree diff capture failed", body });
967
+ sendUpdate({
968
+ teammate: name,
969
+ origin: "harness",
970
+ harnessEvent: { type: "worktree-capture-failed", subject: `@${name} worktree diff capture failed` },
971
+ body,
972
+ finished: false,
973
+ });
974
+ return;
975
+ }
976
+ const changed = captured.diff.patch.trim().length > 0;
744
977
  deliverToLeader({
745
978
  from: name,
746
979
  subject: "Worktree diff captured",
747
- body: `Teammate @${name}'s worktree diff:${patchNote}`,
980
+ body: changed
981
+ ? `Teammate @${name}'s worktree diff:\n\n=== Worktree changes ===\n${captured.diff.diffStat}\n\n${captured.diff.patch}`
982
+ : `Teammate @${name}'s worktree diff:\n(no worktree changes)`,
748
983
  });
984
+ // Changed work must reach the leader even though the worktree directory is
985
+ // gone: dispatch a bounded preview plus the branch retrieval command. A
986
+ // clean worktree carries no information and stays log-only.
987
+ if (changed) {
988
+ sendUpdate({
989
+ teammate: name,
990
+ spawnId: getTeammate(name)?.spawnId,
991
+ origin: "harness",
992
+ harnessEvent: { type: "worktree-changes", subject: `@${name} worktree changes captured` },
993
+ body: [
994
+ `Worktree changes captured for @${name} (${captured.diff.diffStat.trim() || "diff"}).`,
995
+ "",
996
+ truncated(captured.diff.patch),
997
+ "",
998
+ `Full diff: git diff ${handle.baseCommit}..${handle.branch}`,
999
+ ].join("\n"),
1000
+ finished: false,
1001
+ });
1002
+ }
1003
+ if (!cleaned.ok) {
1004
+ const subject = cleaned.error?.includes("worktree left in place") ? "Worktree cleanup aborted" : "Worktree cleanup issue";
1005
+ const body = `Cleaning up @${name}'s worktree reported problems (${cleaned.error ?? "unknown cleanup failure"}).`;
1006
+ deliverToLeader({ from: name, subject, body });
1007
+ sendUpdate({
1008
+ teammate: name,
1009
+ origin: "harness",
1010
+ harnessEvent: { type: "worktree-cleanup-failed", subject: `@${name} worktree cleanup issue` },
1011
+ body,
1012
+ finished: false,
1013
+ });
1014
+ }
749
1015
  }
750
1016
 
751
1017
  // ── Report outbox draining ────────────────────────────────────────
@@ -783,8 +1049,33 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
783
1049
  if (!isWorkerEvent(record)) return false;
784
1050
  if (record.worker !== teammate.name || record.spawnId !== teammate.spawnId) return false;
785
1051
  const ids = getState().workerEventIds;
786
- if (ids[`${teammate.spawnId}:${record.id}`]) return false;
787
- ids[`${teammate.spawnId}:${record.id}`] = teammate.spawnId;
1052
+ const eventKey = `${teammate.spawnId}:${record.id}`;
1053
+ if (ids[eventKey]) return false;
1054
+ ids[eventKey] = teammate.spawnId;
1055
+ markStateDirty();
1056
+
1057
+ if (teammate.reportSequenceEnded) return false;
1058
+ const archived = pendingShutdowns.has(teammate.name);
1059
+ const terminal = record.status === "completed" || record.status === "failed";
1060
+ if (terminal) {
1061
+ const assignment = teammate.assignment;
1062
+ if (assignment?.kind === "direct") {
1063
+ assignTeammate(teammate.name, { ...assignment, closed: true }, undefined);
1064
+ }
1065
+ updateTeammate(teammate.name, {
1066
+ reportSequenceEnded: true,
1067
+ status: "idle",
1068
+ activeTool: undefined,
1069
+ sequenceEnded: true,
1070
+ });
1071
+ if (assignment?.kind === "board" && teammate.currentTaskId) {
1072
+ deliverFeedback(
1073
+ teammate.name,
1074
+ "Board task still open",
1075
+ `Your terminal leader report does not complete board task "${teammate.currentTaskId}". Submit it with task_submit, or submit failed to release it.`,
1076
+ );
1077
+ }
1078
+ }
788
1079
  receiveWorkerMessage({
789
1080
  id: record.id,
790
1081
  type: "message",
@@ -792,19 +1083,25 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
792
1083
  spawnId: teammate.spawnId,
793
1084
  body: record.body,
794
1085
  status: record.status,
795
- });
796
- if (record.status === "completed" || record.status === "failed") {
797
- // The assignment ends; the resident itself stays alive until sequence end.
798
- const report = {
799
- teammate: teammate.name,
800
- agent: teammate.agent,
801
- spawnId: teammate.spawnId,
802
- body: record.body,
803
- finished: true,
804
- };
805
- recordTerminalReport(report);
806
- sendUpdate(report);
807
- }
1086
+ timestamp: record.timestamp,
1087
+ }, { archived });
1088
+ // Every accepted teammate-authored report reaches the leader's context as
1089
+ // its own turn; terminal statuses additionally end the report sequence.
1090
+ const finished = terminal;
1091
+ const report = {
1092
+ teammate: teammate.name,
1093
+ agent: teammate.agent,
1094
+ spawnId: teammate.spawnId,
1095
+ body: record.body,
1096
+ origin: "teammate" as const,
1097
+ eventId: record.id,
1098
+ status: record.status,
1099
+ timestamp: record.timestamp ?? Date.now(),
1100
+ finished,
1101
+ ...(archived ? { archived: true } : {}),
1102
+ };
1103
+ if (finished) recordTerminalReport(report);
1104
+ sendUpdate(report);
808
1105
  return true;
809
1106
  }
810
1107
 
@@ -845,10 +1142,14 @@ function parseInboxMessage(record: unknown): InboxMessage | undefined {
845
1142
  }
846
1143
 
847
1144
  function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
848
- const delivered = teammate.status === "working"
1145
+ const routed = teammate.status === "working"
849
1146
  ? sendWorkerSteer(teammate.name, formatDelivery([message]))
850
1147
  : false;
851
- if (delivered) return;
1148
+ if (routed) {
1149
+ setPeerDeliveryState(message.id, "routed");
1150
+ return;
1151
+ }
1152
+ setPeerDeliveryState(message.id, "queued");
852
1153
  const queued = pendingDeliveries.get(teammate.name) ?? [];
853
1154
  queued.push(message);
854
1155
  pendingDeliveries.set(teammate.name, queued);
@@ -856,9 +1157,76 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
856
1157
  }
857
1158
 
858
1159
  /** Route the leader's addressed send_message through the same delivery path. */
859
- export function sendLeaderMessage(to: string, message: string): { ok: true; queued: boolean; stalledMs?: number } | { ok: false; error: string } {
1160
+ export type MessageRoutingOutcome = "steered" | "queued";
1161
+
1162
+ export type SendLeaderMessageResult =
1163
+ | { ok: true; outcome: MessageRoutingOutcome; priorTerminalReport?: string }
1164
+ | { ok: true; outcome: "not-sent"; terminalReport: string }
1165
+ | { ok: false; error: string };
1166
+
1167
+ /** Body of the teammate's recorded terminal report, when its latest leader
1168
+ * mailbox entry carries terminal status. Lets the leader read the report
1169
+ * directly instead of steering the teammate into a duplicate resend. */
1170
+ function recordedTerminalReportBody(name: string): string | undefined {
1171
+ const mailbox = getState().leaderMailbox;
1172
+ for (let i = mailbox.length - 1; i >= 0; i--) {
1173
+ const message = mailbox[i];
1174
+ if (message.from !== name) continue;
1175
+ return message.status === "completed" || message.status === "failed" ? message.body : undefined;
1176
+ }
1177
+ return undefined;
1178
+ }
1179
+
1180
+ export function sendLeaderMessage(
1181
+ to: string,
1182
+ message: string,
1183
+ options?: { reopen?: boolean; resources?: string[] },
1184
+ ): SendLeaderMessageResult {
860
1185
  const teammate = getTeammate(to);
861
1186
  if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${to}".` };
1187
+ if (teammate.assignment?.kind === "direct" && teammate.assignment.closed && !options?.reopen) {
1188
+ const prior = recordedTerminalReportBody(to);
1189
+ if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
1190
+ return { ok: false, error: `@${to} has a closed direct assignment. Use reopen=true for an explicit next assignment.` };
1191
+ }
1192
+ // A board holder keeps its task after a terminal report. The leader must be
1193
+ // able to steer it for verify feedback or an inconclusive decision without
1194
+ // replacing its assignment.
1195
+ const activeBoardHolder = teammate.assignment?.kind === "board" && !teammate.assignment.closed;
1196
+ if (teammate.reportSequenceEnded && !options?.reopen && !activeBoardHolder) {
1197
+ const prior = recordedTerminalReportBody(to);
1198
+ if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
1199
+ return { ok: false, error: `@${to} already sent a terminal report. Use teammate_spawn for a new assignment or send_message with reopen=true for an explicit follow-up assignment.` };
1200
+ }
1201
+ const priorTerminalReport = teammate.reportSequenceEnded ? recordedTerminalReportBody(to) : undefined;
1202
+ const boardParkKey = teammate.currentTaskId ? `${teammate.currentTaskId}:${teammate.spawnId}` : undefined;
1203
+ if (options?.reopen && teammate.assignment && !teammate.assignment.closed) {
1204
+ const action = teammate.assignment.kind === "board"
1205
+ ? `task_submit or be released/superseded`
1206
+ : "send a terminal report or be explicitly released";
1207
+ return { ok: false, error: `@${to} still owns active ${teammate.assignment.kind} assignment "${teammate.assignment.id}". It must ${action} before a direct assignment can open.` };
1208
+ }
1209
+ const opensDirectAssignment = options?.reopen || teammate.assignment === undefined;
1210
+ if (opensDirectAssignment) {
1211
+ const resources = normalizeResources(options?.resources);
1212
+ const conflict = activeAssignmentConflict(resources, to);
1213
+ if (conflict) {
1214
+ return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
1215
+ }
1216
+ assignTeammate(to, {
1217
+ id: `direct:${randomUUID()}`,
1218
+ kind: "direct",
1219
+ resources,
1220
+ }, undefined);
1221
+ updateTeammate(to, { reportSequenceEnded: false });
1222
+ }
1223
+ // A successfully accepted leader message is the explicit direction that
1224
+ // unblocks a twice-inconclusive board holding; rejected reopen does nothing.
1225
+ if (boardParkKey) {
1226
+ inconclusiveParks.delete(boardParkKey);
1227
+ verifyFailureParks.delete(boardParkKey);
1228
+ }
1229
+ if (activeBoardHolder) updateTeammate(to, { reportSequenceEnded: false });
862
1230
  const envelope: InboxMessage = {
863
1231
  id: randomUUID(),
864
1232
  from: "leader",
@@ -866,19 +1234,13 @@ export function sendLeaderMessage(to: string, message: string): { ok: true; queu
866
1234
  body: message,
867
1235
  timestamp: Date.now(),
868
1236
  };
869
- const delivered = teammate.status === "working" && sendWorkerSteer(teammate.name, formatDelivery([envelope]));
870
- if (delivered) {
871
- // A control-stream write succeeds even when the child never reads it;
872
- // surface prolonged silence so the leader knows delivery is uncertain.
873
- const stalled = isStallThresholdReached(teammate, Date.now(), STALL_NOTICE_MS);
874
- const silence = stallSilenceMs(teammate);
875
- return { ok: true, queued: false, ...(stalled && silence !== undefined ? { stalledMs: silence } : {}) };
876
- }
1237
+ const steered = teammate.status === "working" && sendWorkerSteer(teammate.name, formatDelivery([envelope]));
1238
+ if (steered) return { ok: true, outcome: "steered", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
877
1239
  const queued = pendingDeliveries.get(teammate.name) ?? [];
878
1240
  queued.push(envelope);
879
1241
  pendingDeliveries.set(teammate.name, queued);
880
1242
  ensureLivePoll();
881
- return { ok: true, queued: true };
1243
+ return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
882
1244
  }
883
1245
 
884
1246
  function continueMaybeCompact(inbox: string, inboxName: string, offset: number): void {
@@ -936,25 +1298,66 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
936
1298
  const outcome = applyClaimIntent(intent);
937
1299
  if (outcome.applied) {
938
1300
  verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
1301
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
939
1302
  // A new holding must not inherit an in-flight gate from a previous one.
940
1303
  verifyingTasks.delete(intent.taskId);
941
1304
  updateTeammate(intent.worker, { currentTaskId: intent.taskId });
1305
+ deliverFeedback(
1306
+ intent.worker,
1307
+ "Claim accepted",
1308
+ `Your claim for task "${intent.taskId}" was accepted. You now own the board assignment and may start work; complete it with task_submit.`,
1309
+ );
942
1310
  return;
943
1311
  }
944
1312
  deliverFeedback(intent.worker, "Claim rejected", outcome.reason ?? "The task is no longer available.");
945
1313
  }
946
1314
 
947
1315
  function applySubmissionMarker(intent: import("./types").TaskIntent): void {
1316
+ if (intent.status !== "completed" && intent.status !== "failed") {
1317
+ deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" has an invalid submission status.`);
1318
+ return;
1319
+ }
948
1320
  const sender = findLivingTeammate(intent);
949
1321
  if (!sender) return;
950
1322
  const task = getState().tasks[intent.taskId];
951
- if (!task || task.status !== "claimed" || task.claimedBy !== intent.worker) {
1323
+ if (!task || (task.status !== "claimed" && task.status !== "superseded") || task.claimedBy !== intent.worker) {
952
1324
  deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
953
1325
  return;
954
1326
  }
1327
+ if (task.status === "superseded") {
1328
+ if (intent.status !== "failed") {
1329
+ deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}". Stop work and submit failed to acknowledge cancellation.`);
1330
+ return;
1331
+ }
1332
+ releaseTask(intent.taskId, intent.result?.trim() || "Superseded task cancellation acknowledged.");
1333
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
1334
+ verifyingTasks.delete(intent.taskId);
1335
+ return;
1336
+ }
1337
+ const parkKey = `${intent.taskId}:${intent.spawnId}`;
1338
+ if (intent.status === "completed" && (inconclusiveParks.has(parkKey) || verifyFailureParks.has(parkKey))) {
1339
+ const reason = inconclusiveParks.has(parkKey)
1340
+ ? "two inconclusive reviews"
1341
+ : "two explicit verification failures";
1342
+ deliverFeedback(
1343
+ intent.worker,
1344
+ "Submission rejected while verification is parked",
1345
+ `Task "${intent.taskId}" has ${reason}. Wait for an explicit leader steer before submitting another completed outcome.`,
1346
+ );
1347
+ return;
1348
+ }
1349
+ if (intent.status === "completed" && verifyingTasks.has(intent.taskId)) {
1350
+ deliverFeedback(
1351
+ intent.worker,
1352
+ "Submission rejected while verification is running",
1353
+ `Task "${intent.taskId}" already has a completion review or verdict clarification in flight. Wait for it to pass, fail, or become inconclusive before submitting another completed outcome.`,
1354
+ );
1355
+ return;
1356
+ }
955
1357
  if (intent.status === "failed") {
956
1358
  releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
957
1359
  verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
1360
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
958
1361
  verifyingTasks.delete(intent.taskId);
959
1362
  rearmTaskNotice(intent.taskId);
960
1363
  freeTeammateFromTask(intent.worker, intent.taskId);
@@ -985,21 +1388,22 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
985
1388
  // The gate is bound to this exact submission via a unique token: a
986
1389
  // release/re-claim or newer submission between submit and verify resolution
987
1390
  // must not let the stale result complete the new holding.
1391
+ const submissionId = `${intent.taskId}:${intent.spawnId}:${randomUUID()}`;
988
1392
  const token = randomUUID();
989
- verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, token });
1393
+ verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, submissionId, token });
990
1394
  const input: VerifyReviewInput = {
991
1395
  verify,
992
1396
  taskSubject: task.subject,
993
1397
  workerResult: intent.result ?? "",
994
1398
  cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
995
1399
  };
996
- // A runner that throws synchronously or rejects is a failed gate, never a
997
- // silent stuck claim: the holder gets feedback and escalation still applies.
1400
+ // A reviewer crash is a concrete failed gate. A missing verdict is handled
1401
+ // separately as inconclusive so prose-format drift cannot count as a defect.
998
1402
  Promise.resolve()
999
1403
  .then(() => verifyGateRunner(input))
1000
- .then((outcome) => resolveGateOutcome(intent, task.subject, token, outcome))
1001
- .catch((error) => resolveGateOutcome(intent, task.subject, token, {
1002
- ok: false,
1404
+ .then((outcome) => resolveGateOutcome(intent, task.subject, submissionId, token, outcome))
1405
+ .catch((error) => resolveGateOutcome(intent, task.subject, submissionId, token, {
1406
+ kind: "fail",
1003
1407
  detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
1004
1408
  }));
1005
1409
  }
@@ -1008,19 +1412,26 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
1008
1412
  function resolveGateOutcome(
1009
1413
  intent: import("./types").TaskIntent,
1010
1414
  subject: string,
1415
+ submissionId: string,
1011
1416
  token: string,
1012
1417
  outcome: VerifyReviewOutcome,
1013
1418
  ): void {
1014
- if (verifyingTasks.get(intent.taskId)?.token !== token) return;
1419
+ const active = verifyingTasks.get(intent.taskId);
1420
+ if (active?.token !== token || active.submissionId !== submissionId) return;
1015
1421
  verifyingTasks.delete(intent.taskId);
1016
1422
  const current = getState().tasks[intent.taskId];
1017
1423
  const stillHolds = current?.status === "claimed"
1018
1424
  && current.claimedBy === intent.worker
1019
1425
  && getTeammate(intent.worker)?.spawnId === intent.spawnId;
1020
1426
  if (!stillHolds) return;
1021
- if (outcome.ok) {
1427
+ if (outcome.kind === "pass") {
1428
+ inconclusiveVerifications.delete(submissionId);
1022
1429
  finishCompletion(intent, subject);
1430
+ } else if (outcome.kind === "inconclusive") {
1431
+ const detail = outcome.detail ?? "(reviewer omitted a machine-readable verdict)";
1432
+ requestVerifyVerdict(intent, subject, submissionId, detail);
1023
1433
  } else {
1434
+ inconclusiveVerifications.delete(submissionId);
1024
1435
  const key = `${intent.taskId}:${intent.spawnId}`;
1025
1436
  const reaction = reactToVerifyFailure(verifyFailures.get(key));
1026
1437
  verifyFailures.set(key, reaction);
@@ -1028,8 +1439,18 @@ function resolveGateOutcome(
1028
1439
  if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
1029
1440
  // An unfixable gate parks the task with its holder instead of
1030
1441
  // looping: no further resubmit invitations, one leader escalation.
1442
+ verifyFailureParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
1031
1443
  if (reaction.escalateToLeader) {
1032
1444
  notifyTaskOutcome(subject, `verify failed ${reaction.count} times for ${intent.taskId}: manual attention needed`, detail);
1445
+ // The holder is parked and will not narrate further; the parked task
1446
+ // reaches the leader through the delivery channel itself.
1447
+ sendUpdate({
1448
+ teammate: "task-board",
1449
+ origin: "harness",
1450
+ harnessEvent: { type: "verify-escalation", subject: `Verify gate failed · ${subject}` },
1451
+ body: `Verify gate for "${subject}" (${intent.taskId}) failed ${reaction.count} consecutive times.\n\n${detail}\n\nThe task stays claimed by @${intent.worker}; decide how to proceed.`,
1452
+ finished: false,
1453
+ });
1033
1454
  }
1034
1455
  deliverFeedback(
1035
1456
  intent.worker,
@@ -1049,12 +1470,66 @@ function resolveGateOutcome(
1049
1470
  notifyChange();
1050
1471
  }
1051
1472
 
1473
+ function requestVerifyVerdict(
1474
+ intent: import("./types").TaskIntent,
1475
+ subject: string,
1476
+ submissionId: string,
1477
+ detail: string,
1478
+ ): void {
1479
+ const attempts = inconclusiveVerifications.get(submissionId) ?? 0;
1480
+ if (attempts === 0) {
1481
+ inconclusiveVerifications.set(submissionId, 1);
1482
+ const task = getState().tasks[intent.taskId];
1483
+ if (!task) return;
1484
+ const token = randomUUID();
1485
+ verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, submissionId, token });
1486
+ const input: VerifyReviewInput = {
1487
+ verify: `${task.verify ?? resolveAgent(getTeammate(intent.worker)?.agent ?? "", leaderCwd)?.verify ?? ""}\n\nThe previous review was inconclusive:\n${detail}\n\nReply with exactly one line: ${VERIFY_VERDICT_PASS} or ${VERIFY_VERDICT_FAIL} - <reasons>.`,
1488
+ taskSubject: task.subject,
1489
+ workerResult: intent.result ?? "",
1490
+ cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
1491
+ };
1492
+ Promise.resolve()
1493
+ .then(() => verifyGateRunner(input))
1494
+ .then((outcome) => resolveGateOutcome(intent, subject, submissionId, token, outcome))
1495
+ .catch((error) => resolveGateOutcome(intent, subject, submissionId, token, {
1496
+ kind: "fail",
1497
+ detail: truncated(`(verdict clarification crashed) ${error instanceof Error ? error.message : String(error)}`),
1498
+ }));
1499
+ return;
1500
+ }
1501
+ inconclusiveParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
1502
+ notifyTaskOutcome(subject, `verify inconclusive for ${intent.taskId}: manual verdict needed`, detail);
1503
+ deliverFeedback(
1504
+ intent.worker,
1505
+ `Verification inconclusive for ${intent.taskId}`,
1506
+ `The reviewer twice omitted a machine-readable verdict. Your task remains claimed without a verify failure. Wait for leader direction; after a directed fix or clarified outcome, you may submit a new completed result for a fresh review.\n\n${detail}`,
1507
+ );
1508
+ sendUpdate({
1509
+ teammate: "task-board",
1510
+ origin: "harness",
1511
+ harnessEvent: { type: "verify-inconclusive", subject: `Verify verdict missing · ${subject}` },
1512
+ body: `Verification for "${subject}" (${intent.taskId}) was inconclusive twice. It remains claimed by @${intent.worker} without counting as a verify failure.\n\n${detail}`,
1513
+ finished: false,
1514
+ });
1515
+ }
1516
+
1517
+ function clearInconclusiveForHolding(taskId: string, spawnId: string): void {
1518
+ const prefix = `${taskId}:${spawnId}:`;
1519
+ for (const key of inconclusiveVerifications.keys()) {
1520
+ if (key.startsWith(prefix)) inconclusiveVerifications.delete(key);
1521
+ }
1522
+ inconclusiveParks.delete(`${taskId}:${spawnId}`);
1523
+ verifyFailureParks.delete(`${taskId}:${spawnId}`);
1524
+ }
1525
+
1052
1526
  function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
1053
1527
  const task = getState().tasks[intent.taskId];
1054
1528
  if (!task) return;
1055
1529
  const completed = completeTask(intent.taskId, intent.result);
1056
1530
  if (!completed) return;
1057
1531
  verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
1532
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
1058
1533
  freeTeammateFromTask(intent.worker, intent.taskId);
1059
1534
  notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
1060
1535
  }
@@ -1077,7 +1552,10 @@ export interface VerifyReviewInput {
1077
1552
  cwd: string;
1078
1553
  }
1079
1554
 
1080
- export type VerifyReviewOutcome = { ok: boolean; detail?: string };
1555
+ export type VerifyReviewOutcome =
1556
+ | { kind: "pass" }
1557
+ | { kind: "fail"; detail?: string }
1558
+ | { kind: "inconclusive"; detail?: string };
1081
1559
 
1082
1560
  /** Compose the reviewer prompt: fresh context, independent checks, explicit verdict line. */
1083
1561
  export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
@@ -1103,13 +1581,16 @@ export function parseVerifyVerdict(text: string): VerifyReviewOutcome {
1103
1581
  const lines = text.trim().split("\n");
1104
1582
  for (let index = lines.length - 1; index >= 0; index--) {
1105
1583
  const candidate = lines[index].trim();
1106
- if (/^verdict:\s*pass$/i.test(candidate)) return { ok: true };
1584
+ if (/^verdict:\s*pass$/i.test(candidate)) return { kind: "pass" };
1107
1585
  const failMatch = /^verdict:\s*fail\b[\s:-]*(.*)$/i.exec(candidate);
1108
1586
  if (failMatch) {
1109
- return { ok: false, detail: truncated(failMatch[1].trim()) || "(no reasons given)" };
1587
+ return { kind: "fail", detail: truncated(failMatch[1].trim()) || "(no reasons given)" };
1110
1588
  }
1111
1589
  }
1112
- return { ok: false, detail: `(no ${VERIFY_VERDICT_PASS}/FAIL verdict line in the review)\n${truncated(text.trim())}` };
1590
+ return {
1591
+ kind: "inconclusive",
1592
+ detail: `(no ${VERIFY_VERDICT_PASS}/FAIL verdict line in the review)\n${truncated(text.trim())}`,
1593
+ };
1113
1594
  }
1114
1595
 
1115
1596
  function truncated(text: string, cap = 4000): string {
@@ -1129,7 +1610,7 @@ export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyR
1129
1610
  // A reviewer that did not exit cleanly produced no trustworthy verdict,
1130
1611
  // even if partial output happens to contain a PASS line.
1131
1612
  if (outcome.exitCode !== 0) {
1132
- return { ok: false, detail: truncated(`reviewer exited with code ${outcome.exitCode}: ${(outcome.stderr || outcome.text || "no output").trim()}`) };
1613
+ return { kind: "fail", detail: truncated(`reviewer exited with code ${outcome.exitCode}: ${(outcome.stderr || outcome.text || "no output").trim()}`) };
1133
1614
  }
1134
1615
  return parseVerifyVerdict(outcome.text);
1135
1616
  }
@@ -1201,15 +1682,30 @@ function rearmTaskNotice(taskId: string): void {
1201
1682
  * plus a board notice covering only claimable work that teammate has never
1202
1683
  * been shown. Teammates with nothing new are never woken.
1203
1684
  */
1204
- export function wakeIdleTeammates(): void {
1685
+ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
1686
+ const notified: string[] = [];
1205
1687
  for (const teammate of idleTeammates()) {
1206
1688
  const deliveries = pendingDeliveries.get(teammate.name) ?? [];
1207
- const fresh = freshClaimableTasks(teammate.noticedTaskIds, claimableTasks());
1208
- const dueNotice = fresh.length > 0 && noticeDue(teammate);
1689
+ // A terminal direct assignment stays closed until explicit reopen; a board
1690
+ // holder must submit rather than drifting into more board work.
1691
+ const boardEligible = teammate.assignment === undefined;
1692
+ const fresh = boardEligible
1693
+ ? freshClaimableTasks(teammate.noticedTaskIds, claimableTasks().filter((task) => !activeAssignmentConflict(task.resources, teammate.name)))
1694
+ : [];
1695
+ const immediateNotice = immediateTaskId !== undefined && fresh.some((task) => task.id === immediateTaskId);
1696
+ const dueNotice = fresh.length > 0 && (immediateNotice || noticeDue(teammate));
1209
1697
  if (deliveries.length === 0 && !dueNotice) continue;
1210
- const noticed = fresh.slice(0, WAKE_NOTICE_TASK_LIMIT);
1698
+ const prioritized = immediateTaskId === undefined
1699
+ ? fresh
1700
+ : [
1701
+ ...fresh.filter((task) => task.id === immediateTaskId),
1702
+ ...fresh.filter((task) => task.id !== immediateTaskId),
1703
+ ];
1704
+ const noticed = prioritized.slice(0, WAKE_NOTICE_TASK_LIMIT);
1211
1705
  const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
1212
1706
  if (!deliverPrompt(teammate.name, prompt)) continue;
1707
+ for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
1708
+ notified.push(teammate.name);
1213
1709
  pendingDeliveries.delete(teammate.name);
1214
1710
  if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
1215
1711
  // A delivered prompt is fresh activity: restart the silence clock so a
@@ -1217,11 +1713,17 @@ export function wakeIdleTeammates(): void {
1217
1713
  updateTeammate(teammate.name, {
1218
1714
  status: "working",
1219
1715
  sequenceEnded: false,
1716
+ // A generic peer/harness/board wake-up never opens a terminal direct
1717
+ // assignment. Only leader send_message(reopen=true) owns that transition.
1718
+ ...(teammate.assignment?.kind === "direct" && teammate.assignment.closed
1719
+ ? {}
1720
+ : { reportSequenceEnded: false }),
1220
1721
  lastOutputAt: Date.now(),
1221
1722
  stallNoticeSentAt: undefined,
1222
1723
  ...(dueNotice ? { lastNoticeAt: Date.now() } : {}),
1223
1724
  });
1224
1725
  }
1726
+ return notified;
1225
1727
  }
1226
1728
 
1227
1729
  function noticeDue(teammate: Teammate): boolean {
@@ -1251,17 +1753,91 @@ function formatDelivery(messages: InboxMessage[]): string {
1251
1753
 
1252
1754
  // ── Board helpers shared with tools.ts ────────────────────────────
1253
1755
 
1756
+ export interface BoardTaskCreationResult {
1757
+ ok: true;
1758
+ id: string;
1759
+ notifiedTeammates: string[];
1760
+ livingTeammates: number;
1761
+ claimable: boolean;
1762
+ resourceBlocked: boolean;
1763
+ supersededTaskIds: string[];
1764
+ }
1765
+
1766
+ export function formatBoardTaskCreation(subject: string, created: BoardTaskCreationResult): string {
1767
+ const status = created.claimable ? "pending/claimable" : "pending/blocked";
1768
+ const routing = created.notifiedTeammates.length > 0
1769
+ ? `notified=${created.notifiedTeammates.map((name) => `@${name}`).join(",")}`
1770
+ : created.livingTeammates === 0
1771
+ ? "notified=none (no living teammates)"
1772
+ : "notified=none (no eligible idle teammate)";
1773
+ const superseded = (created.supersededTaskIds ?? []).length > 0
1774
+ ? `SUPERSEDED · ${created.supersededTaskIds.join(", ")}`
1775
+ : undefined;
1776
+ const next = !created.claimable
1777
+ ? created.resourceBlocked
1778
+ ? "NEXT · waits for superseded holder or resource owner to release"
1779
+ : "NEXT · waits for dependencies"
1780
+ : created.livingTeammates === 0
1781
+ ? "NEXT · leader: teammate_spawn"
1782
+ : "NEXT · worker: task_claim";
1783
+ return [
1784
+ `BOARD · current session`,
1785
+ `CREATED · ${created.id} · ${status} · ${subject}`,
1786
+ `ROUTING · ${routing}`,
1787
+ superseded,
1788
+ next,
1789
+ ].join("\n");
1790
+ }
1791
+
1792
+ /** Create a task and synchronously offer it to currently-idle teammates.
1793
+ *
1794
+ * The normal poll loop still handles later dependency unlocks and queued mail,
1795
+ * but task creation is a user-visible boundary: an idle teammate should not
1796
+ * have to wait for a future timer tick before it can see newly-created work.
1797
+ */
1254
1798
  export function createBoardTask(input: {
1255
1799
  subject: string;
1256
1800
  description?: string;
1257
1801
  dependsOn?: string[];
1258
1802
  verify?: string;
1259
- }): { ok: true; id: string } | { ok: false; error: string } {
1803
+ resources?: string[];
1804
+ supersedes?: string[];
1805
+ }): BoardTaskCreationResult | { ok: false; error: string } {
1260
1806
  const created = createTask(input);
1261
1807
  if (!created.ok) return created;
1808
+ for (const task of created.superseded) {
1809
+ // A superseded holder intentionally retains its assignment/resource until
1810
+ // it submits failed or stops; tell it to cease writes immediately.
1811
+ if (task.claimedBy) {
1812
+ deliverFeedback(
1813
+ task.claimedBy,
1814
+ `Task superseded: ${task.id}`,
1815
+ `Task "${task.id}" was superseded by "${created.task.id}". Stop work immediately and task_submit with status="failed" to acknowledge cancellation and release its resources.`,
1816
+ );
1817
+ }
1818
+ verifyingTasks.delete(task.id);
1819
+ for (const key of [...verifyFailures.keys()]) {
1820
+ if (key.startsWith(`${task.id}:`)) verifyFailures.delete(key);
1821
+ }
1822
+ for (const key of [...inconclusiveVerifications.keys()]) {
1823
+ if (key.startsWith(`${task.id}:`)) inconclusiveVerifications.delete(key);
1824
+ }
1825
+ rearmTaskNotice(task.id);
1826
+ }
1827
+ publishStateSnapshot();
1828
+ const resourceBlocked = activeAssignmentConflict(created.task.resources) !== undefined;
1829
+ const notifiedTeammates = wakeIdleTeammates(created.task.id);
1262
1830
  publishStateSnapshot();
1263
1831
  notifyChange();
1264
- return { ok: true, id: created.task.id };
1832
+ return {
1833
+ ok: true,
1834
+ id: created.task.id,
1835
+ notifiedTeammates,
1836
+ livingTeammates: livingTeammates().length,
1837
+ claimable: claimableTasks().some((task) => task.id === created.task.id) && !resourceBlocked,
1838
+ resourceBlocked,
1839
+ supersededTaskIds: created.superseded.map((task) => task.id),
1840
+ };
1265
1841
  }
1266
1842
 
1267
1843
  export function boardOverview(): Array<import("./types").BoardTask> {