@fradser/pi-agent-teams 0.7.0 → 0.8.2

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.
@@ -5,14 +5,17 @@
5
5
  * engine described by the BDD contract.
6
6
  */
7
7
 
8
- import { exec } from "node:child_process";
9
8
  import { randomUUID } from "node:crypto";
10
9
  import * as fs from "node:fs";
11
10
  import * as path from "node:path";
12
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
- import { resolveAgent } from "./agents.ts";
12
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
+ import { modelLabel, runPiWorker } from "@fradser/pi-kit";
14
+ import { MODEL_INHERIT_ALIAS, discoverAgents, persistAgentDefinition, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "./agents.ts";
14
15
  import {
16
+ activeAssignmentConflict,
15
17
  applyClaimIntent,
18
+ assignTeammate,
16
19
  claimableTasks,
17
20
  clearStateDirty,
18
21
  clearWorkerRunEvents,
@@ -22,6 +25,7 @@ import {
22
25
  getPeerInboxOffset,
23
26
  getState,
24
27
  getTeammate,
28
+ getTeamDefaultModel,
25
29
  idleTeammates,
26
30
  isPeerDelivered,
27
31
  isValidTeammateName,
@@ -30,11 +34,13 @@ import {
30
34
  loadBoard,
31
35
  markPeerDelivered,
32
36
  markStateDirty,
37
+ normalizeResources,
33
38
  registerTeammate,
34
39
  releaseTask,
35
40
  releaseTasksOf,
36
41
  receiveWorkerMessage,
37
42
  setPeerInboxOffset,
43
+ setPeerDeliveryState,
38
44
  updateTeammate,
39
45
  updateTeammateProgress,
40
46
  } from "./state.ts";
@@ -61,24 +67,46 @@ import { isWorkerEvent } from "./types.ts";
61
67
  import {
62
68
  deliverPrompt,
63
69
  isCleanExit,
70
+ resolveWorkerTools,
64
71
  sendWorkerSteer,
65
72
  spawnResident,
66
73
  terminateAllTeammates,
67
74
  terminateTeammate,
75
+ unknownWorkerTools,
76
+ WORKER_TOOL_UNIVERSE,
68
77
  type WorkerProcessResult,
69
78
  } from "./spawner.ts";
70
- import { captureWorktreeDiff, cleanupWorktree, createWorktree } from "./worktree.ts";
71
- 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";
72
81
  import type { FollowUpReport } from "./follow-up-queue.ts";
73
82
 
74
83
  export const MAX_SESSION_WORKERS = 8;
75
84
  /** Harness coordination cadence: outbox drain every tick, notices paced. */
76
85
  const LIVE_POLL_MS = 500;
77
- export const NOTICE_PACE_MS = 2000;
86
+ /** Minimum gap between claimable-task notices per teammate. One-shot noticing
87
+ * makes repeats rare; this floor keeps any residual burst from stampeding
88
+ * every idle teammate at once, and each wake costs a full worker turn. */
89
+ const DEFAULT_NOTICE_PACE_MS = 5 * 60 * 1000;
90
+ export const NOTICE_PACE_MS = readDurationEnv("PI_TEAMMATE_NOTICE_PACE_MS", DEFAULT_NOTICE_PACE_MS);
91
+ /** Consecutive verify failures before the harness stops inviting resubmission and escalates to the leader instead. */
92
+ export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
93
+ /** Claimable task ids remembered per teammate. markTasksNoticed prunes stale
94
+ * ids on every notice, so slots are occupied only by currently-claimable
95
+ * work; the cap is far above any realistic concurrent board size. */
96
+ const MAX_NOTICED_TASK_IDS = 256;
97
+ /** Claimable tasks listed in one wake-prompt board notice. */
98
+ const WAKE_NOTICE_TASK_LIMIT = 10;
78
99
  /** Silence is not a work deadline: active stream output keeps a teammate alive.
79
100
  * The watchdog may only notify; termination decisions belong to the leader model. */
80
101
  const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
81
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);
82
110
  /** Fully-consumed inboxes larger than this are truncated. */
83
111
  const INBOX_COMPACT_BYTES = 256 * 1024;
84
112
 
@@ -112,19 +140,67 @@ export function formatSilenceDuration(milliseconds: number): string {
112
140
  return `${minutes}m`;
113
141
  }
114
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
+
115
181
  let livePollTimer: ReturnType<typeof setInterval> | undefined;
116
182
  let generation = 0;
117
183
  let runtimeStateFile = "";
118
184
  let boardFile = "";
119
185
  let leaderCwd = "";
186
+ /** Live view of the leader session's current model, resolved at spawn time. */
187
+ let leaderModelRef: () => string | undefined = () => undefined;
120
188
  let sendUpdate: (report: FollowUpReport) => void = () => {};
189
+ let archiveQueuedReports: (spawnId: string) => FollowUpReport[] = () => [];
121
190
  let notifyChange: () => void = () => {};
122
191
 
123
192
  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 }>();
193
+ /** Task ids under verification, bound to one exact submission via token so a
194
+ * release/re-claim or newer submission invalidates any older in-flight gate. */
195
+ const verifyingTasks = new Map<string, { worker: string; spawnId: string; submissionId: string; token: string }>();
126
196
  /** Idle nudges already fired per teammate incarnation (one per transition). */
127
197
  const idleNudgesSent = new Set<string>();
198
+ /** One finish entry per spawn incarnation; repeated terminal reports stay ordinary report rows. */
199
+ const announcedFinishKeys = new Set<string>();
200
+ /** Incarnations whose terminal report reached the leader pipeline, queued or dispatched. */
201
+ const terminalReportKeys = new Set<string>();
202
+ /** Consecutive verify failures per taskId:spawnId holder incarnation. */
203
+ const verifyFailures = new Map<string, VerifyFailureRecord>();
128
204
  /** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
129
205
  const selfFinalizeAttempts = new Set<string>();
130
206
  const pendingDeliveries = new Map<string, InboxMessage[]>();
@@ -134,19 +210,23 @@ const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
134
210
 
135
211
  export interface MachineHooks {
136
212
  sendUpdate: (report: FollowUpReport) => void;
213
+ /** Suppress reports whose delivery remains harness-owned when shutdown starts. */
214
+ archiveQueuedReports?: (spawnId: string) => FollowUpReport[];
137
215
  notifyChange: () => void;
138
216
  }
139
217
 
140
218
  export function initTeamMachine(
141
- ctx: Pick<ExtensionContext, "sessionManager" | "cwd">,
219
+ ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model">,
142
220
  hooks: MachineHooks,
143
221
  ): void {
144
222
  generation++;
145
223
  leaderCwd = ctx.cwd || process.cwd();
224
+ leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
146
225
  const sessionFile = ctx.sessionManager?.getSessionFile();
147
226
  runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
148
227
  boardFile = boardFilePath(sessionFile, leaderCwd);
149
228
  sendUpdate = hooks.sendUpdate;
229
+ archiveQueuedReports = hooks.archiveQueuedReports ?? (() => []);
150
230
  notifyChange = hooks.notifyChange;
151
231
  // Resume: reload a persisted board; claims die with their holders.
152
232
  const persisted = readBoardFile(boardFile);
@@ -160,17 +240,59 @@ export function shutdownTeamMachine(): void {
160
240
  runtimeStateFile = "";
161
241
  boardFile = "";
162
242
  leaderCwd = "";
243
+ leaderModelRef = () => undefined;
244
+ setVerifyGateRunner(undefined);
163
245
  sendUpdate = () => {};
246
+ archiveQueuedReports = () => [];
164
247
  notifyChange = () => {};
165
248
  pendingShutdowns.clear();
166
249
  verifyingTasks.clear();
167
250
  idleNudgesSent.clear();
168
251
  selfFinalizeAttempts.clear();
169
252
  pendingDeliveries.clear();
253
+ verifyFailures.clear();
254
+ inconclusiveParks.clear();
255
+ verifyFailureParks.clear();
256
+ announcedFinishKeys.clear();
257
+ terminalReportKeys.clear();
258
+ }
259
+
260
+ // ── Spawn model resolution ────────────────────────────────────
261
+
262
+ /** How a spawn's effective model was chosen. */
263
+ export type SpawnModelSource = "pin" | "inherit" | "team-default" | "none";
264
+
265
+ /**
266
+ * Resolve the effective spawn model. Precedence: explicit role pin beats the
267
+ * `inherit` alias (the leader session's current model), which beats the team
268
+ * default set from the console; with none of these Pi picks its own default.
269
+ * The value is resolved at spawn time so mid-session leader model switches
270
+ * apply to later spawns.
271
+ */
272
+ export function resolveSpawnModel(
273
+ pinned: string | undefined,
274
+ teamDefault: string | undefined,
275
+ leaderModel: string | undefined,
276
+ ): { model?: string; source: SpawnModelSource } {
277
+ const pin = pinned?.trim();
278
+ if (pin && pin.toLowerCase() !== MODEL_INHERIT_ALIAS) return { model: pin, source: "pin" };
279
+ if (pin && leaderModel) return { model: leaderModel, source: "inherit" };
280
+ const fallback = teamDefault?.trim();
281
+ if (fallback) return { model: fallback, source: "team-default" };
282
+ return { model: undefined, source: "none" };
283
+ }
284
+
285
+ /** The leader session's current model reference, when one is selected. */
286
+ export function currentLeaderModelRef(): string | undefined {
287
+ return leaderModelRef();
170
288
  }
171
289
 
172
290
  /** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
173
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);
174
296
  const results = await terminateAllTeammates();
175
297
  const diagnostics: string[] = [];
176
298
  for (const result of results) {
@@ -201,7 +323,14 @@ function flushSnapshots(): void {
201
323
  const stateFile = requireStateFile();
202
324
  try {
203
325
  writeStateFile(stateFile, getState());
204
- 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
+ })));
205
334
  if (boardFile) writeBoardFile(boardFile, getState().tasks);
206
335
  clearStateDirty();
207
336
  } catch {
@@ -247,12 +376,51 @@ export function spawnTeammate(input: {
247
376
  name: string;
248
377
  agent: string;
249
378
  prompt?: string;
379
+ resources?: string[];
380
+ handoffFrom?: string;
381
+ definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
382
+ tools?: string[];
383
+ persist?: boolean;
384
+ persistScope?: "project" | "project-local";
385
+ };
250
386
  }): { ok: true; teammate: Teammate } | { ok: false; error: string } {
251
387
  const stateFile = requireStateFile();
252
388
  const invalid = validateSpawnInput(input);
253
389
  if (invalid) return { ok: false, error: invalid };
254
- const agent = resolveAgent(input.agent, leaderCwd);
255
- if (!agent) return { ok: false, error: `Agent "${input.agent}" not found in any scope.` };
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
+ }
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) };
403
+ let agent: AgentDefinition | undefined = resolved;
404
+ if (input.definition && inlineDefinitionApplies(resolved)) {
405
+ try {
406
+ const generated = input.definition;
407
+ const roleInput: AgentDefinitionInput = {
408
+ name: input.agent,
409
+ description: generated.description,
410
+ tools: generated.tools ?? [],
411
+ model: generated.model,
412
+ verify: generated.verify,
413
+ worktree: generated.worktree,
414
+ prompt: generated.prompt,
415
+ };
416
+ agent = generated.persist
417
+ ? persistAgentDefinition(roleInput, generated.persistScope ?? "project-local", leaderCwd)
418
+ : registerSessionAgent(roleInput);
419
+ } catch (error) {
420
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
421
+ }
422
+ }
423
+ if (!agent) return { ok: false, error: unknownAgentError(input.agent, leaderCwd) };
256
424
 
257
425
  let isolation: Teammate["isolation"] = "none";
258
426
  let workerCwd = leaderCwd;
@@ -265,16 +433,44 @@ export function spawnTeammate(input: {
265
433
  }
266
434
 
267
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
+ }
268
445
  const registered = registerTeammate(newTeammate(input, spawnId, isolation, workerCwd));
269
446
  if (!registered.ok) {
270
447
  discardWorktreeQuietly(input.name);
271
448
  return { ok: false, error: registered.error };
272
449
  }
273
450
 
451
+ const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
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();
463
+
274
464
  const started = spawnResident({
275
465
  workerName: input.name,
276
- description: buildKickoffPrompt(input.name, input.agent, agent.prompt, input.prompt, isolation),
277
- model: agent.model,
466
+ description: buildKickoffPrompt(
467
+ input.name,
468
+ input.agent,
469
+ agent.prompt,
470
+ effectiveKickoff,
471
+ isolation,
472
+ ),
473
+ model: spawnModel.model,
278
474
  tools: agent.tools,
279
475
  cwd: workerCwd,
280
476
  env: teammateEnv(stateFile, input, spawnId, agent.verify),
@@ -286,14 +482,48 @@ export function spawnTeammate(input: {
286
482
  return { ok: false, error: started.error };
287
483
  }
288
484
  updateTeammate(input.name, { pid: started.pid });
289
- // A prompt-less spawn has no stream activity; it is idle from birth.
290
- 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).
291
489
  publishStateSnapshot();
292
490
  ensureLivePoll();
293
491
  notifyChange();
294
492
  return { ok: true, teammate: getTeammate(input.name)! };
295
493
  }
296
494
 
495
+ /** True when a spawn's inline definition should create or replace the role:
496
+ * nothing resolved, or only a session-scoped generated role did. Filesystem
497
+ * scopes are user-owned and always win over inline input. */
498
+ export function inlineDefinitionApplies(resolved: AgentDefinition | undefined): boolean {
499
+ return !resolved || resolved.scope === "session";
500
+ }
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
+
512
+ /** Spawn failure for an unresolvable agent name. Definitions resolve live at
513
+ * spawn time, so the available-agents list in the cached session guidance can
514
+ * go stale (e.g. another session removed the file); name every checked scope
515
+ * and the recovery path. */
516
+ export function unknownAgentError(name: string, cwd: string): string {
517
+ const available = [...discoverAgents(cwd).keys()];
518
+ return [
519
+ `Agent "${name}" not found in any scope.`,
520
+ `Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
521
+ 'The available-agents list in your guidance may be stale: definition files can change mid-session (for example removed by a parallel session).',
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.',
523
+ `Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
524
+ ].join(' ');
525
+ }
526
+
297
527
  function validateSpawnInput(input: { name: string }): string | undefined {
298
528
  if (!isValidTeammateName(input.name)) {
299
529
  return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
@@ -309,6 +539,7 @@ function validateSpawnInput(input: { name: string }): string | undefined {
309
539
 
310
540
  function newTeammate(
311
541
  input: { name: string; agent: string },
542
+
312
543
  spawnId: string,
313
544
  isolation: Teammate["isolation"],
314
545
  workerCwd: string,
@@ -328,6 +559,46 @@ function newTeammate(
328
559
  };
329
560
  }
330
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
+
331
602
  function teammateEnv(
332
603
  stateFile: string,
333
604
  input: { name: string; agent: string },
@@ -348,7 +619,7 @@ function teammateEnv(
348
619
  };
349
620
  }
350
621
 
351
- function buildKickoffPrompt(
622
+ export function buildKickoffPrompt(
352
623
  name: string,
353
624
  agentName: string,
354
625
  rolePrompt: string,
@@ -363,7 +634,7 @@ function buildKickoffPrompt(
363
634
  ].filter(Boolean).join(" ");
364
635
  const roleSection = `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt}`;
365
636
  const taskSection = kickoff?.trim()
366
- ? `=== 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.`
367
638
  : "=== KICKOFF TASK ===\n(none yet — check the task board with task_list and claim suitable work with task_claim)";
368
639
  return `${header}\n\n${roleSection}\n\n${taskSection}`;
369
640
  }
@@ -372,7 +643,8 @@ function discardWorktreeQuietly(name: string): void {
372
643
  const handle = liveWorktrees.get(name);
373
644
  if (!handle || "error" in handle) return;
374
645
  liveWorktrees.delete(name);
375
- cleanupWorktree(handle);
646
+ // A failed spawn never produced work; the empty branch goes too.
647
+ discardWorktree(handle);
376
648
  }
377
649
 
378
650
  function boardDirectory(): string {
@@ -385,6 +657,8 @@ function applyProgress(name: string, spawnId: string, progress: {
385
657
  liveThinking?: string;
386
658
  turns: number;
387
659
  finalResponse?: boolean;
660
+ modelOutputSeen?: boolean;
661
+ usage?: WorkerUsage;
388
662
  }): void {
389
663
  const teammate = getTeammate(name);
390
664
  // A stale callback from an older incarnation must not touch the current one.
@@ -395,6 +669,8 @@ function applyProgress(name: string, spawnId: string, progress: {
395
669
  liveThinking: progress.liveThinking,
396
670
  turns: progress.turns,
397
671
  sequenceEnded: progress.finalResponse === true ? true : undefined,
672
+ modelOutputSeen: progress.modelOutputSeen,
673
+ usage: progress.usage,
398
674
  });
399
675
  updateTeammate(name, { lastOutputAt: Date.now(), stallNoticeSentAt: undefined });
400
676
  if (progress.finalResponse && teammate.status !== "idle") {
@@ -404,20 +680,83 @@ function applyProgress(name: string, spawnId: string, progress: {
404
680
  ensureLivePoll();
405
681
  }
406
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
+
407
698
  function checkStalledTeammates(now = Date.now()): void {
408
699
  if (STALL_NOTICE_MS <= 0) return;
409
700
  for (const teammate of livingTeammates()) {
410
701
  if (teammate.status !== "working" && teammate.status !== "starting") continue;
411
702
  const silence = stallSilenceMs(teammate, now);
412
- if (silence === undefined) continue;
413
- if (silence < STALL_NOTICE_MS || teammate.stallNoticeSentAt !== undefined) continue;
414
- 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);
415
706
  updateTeammate(teammate.name, { stallNoticeSentAt: now });
416
707
  deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
417
- sendUpdate({ teammate: teammate.name, agent: teammate.agent, body, finished: false });
708
+ sendUpdate(formatAgentHealthReport("stalled", teammate, body, silence));
418
709
  }
419
710
  }
420
711
 
712
+ interface VerifyFailureRecord {
713
+ count: number;
714
+ escalated: boolean;
715
+ }
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
+
724
+ /** Record one finish entry per spawn incarnation; later terminal reports from
725
+ * the same resident stay ordinary report rows. */
726
+ export function markTeammateFinished(
727
+ report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
728
+ ): boolean {
729
+ if (!report.finished) return false;
730
+ const name = report.teammate ?? report.agent ?? "teammate";
731
+ const key = `${name}:${report.spawnId ?? "session"}`;
732
+ if (announcedFinishKeys.has(key)) return false;
733
+ announcedFinishKeys.add(key);
734
+ return true;
735
+ }
736
+
737
+ /** True when this teammate's current incarnation already announced its finish entry. */
738
+ export function hasAnnouncedFinish(name: string): boolean {
739
+ const spawnId = getTeammate(name)?.spawnId;
740
+ return announcedFinishKeys.has(`${name}:${spawnId ?? "session"}`);
741
+ }
742
+
743
+ /** Remember at send time that this incarnation produced a terminal report, so
744
+ * end-of-life suppression covers reports still queued for dispatch. */
745
+ export function recordTerminalReport(
746
+ report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
747
+ ): void {
748
+ if (!report.finished) return;
749
+ const name = report.teammate ?? report.agent ?? "teammate";
750
+ terminalReportKeys.add(`${name}:${report.spawnId ?? "session"}`);
751
+ }
752
+
753
+ /** True when this teammate's current incarnation has a terminal report in the
754
+ * leader pipeline, whether or not its finish entry has been dispatched yet. */
755
+ export function hasTerminalReport(name: string): boolean {
756
+ const spawnId = getTeammate(name)?.spawnId;
757
+ return terminalReportKeys.has(`${name}:${spawnId ?? "session"}`);
758
+ }
759
+
421
760
  /** True when the teammate's last leader-bound report lacks a terminal status. */
422
761
  export function hasUnfinalizedReport(name: string): boolean {
423
762
  const mailbox = getState().leaderMailbox;
@@ -450,7 +789,13 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
450
789
  idleNudgesSent.add(key);
451
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.`;
452
791
  deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
453
- 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
+ });
454
799
  }
455
800
 
456
801
  function failSpawn(name: string, error: string): void {
@@ -476,17 +821,28 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
476
821
  const teammate = getTeammate(name);
477
822
  if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
478
823
  pendingShutdowns.add(name);
824
+ archiveReportsForConsole(archiveQueuedReports(teammate.spawnId));
479
825
  const terminated = await terminateTeammate(name);
480
826
  if (!terminated) {
481
827
  // The child was already gone; synthesize the close bookkeeping.
482
828
  pendingShutdowns.delete(name);
483
829
  const released = releaseTasksOf(name, "Teammate was shut down.");
830
+ for (const task of released) {
831
+ verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
832
+ clearInconclusiveForHolding(task.id, teammate.spawnId);
833
+ verifyingTasks.delete(task.id);
834
+ rearmTaskNotice(task.id);
835
+ }
484
836
  updateTeammate(name, { status: "stopped", activeTool: undefined });
485
837
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
486
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 });
487
843
  publishStateSnapshot();
488
844
  notifyChange();
489
- return { ok: true, body: summarizeShutdown(name, released.length, 0, undefined) };
845
+ return { ok: true, body: summary };
490
846
  }
491
847
  // Close finalization completes state transitions and reporting.
492
848
  return {
@@ -509,6 +865,12 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
509
865
  drainTeammateOutboxes();
510
866
 
511
867
  const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
868
+ for (const task of released) {
869
+ verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
870
+ clearInconclusiveForHolding(task.id, teammate.spawnId);
871
+ verifyingTasks.delete(task.id);
872
+ rearmTaskNotice(task.id);
873
+ }
512
874
  pendingShutdowns.delete(name);
513
875
  // A stopped teammate must not keep the poll loop or its queue alive.
514
876
  pendingDeliveries.delete(name);
@@ -522,16 +884,27 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
522
884
  clearWorkerRunEvents(name, teammate.spawnId);
523
885
  removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
524
886
 
525
- deliverToLeader(requested
526
- ? { from: name, subject: "Teammate shut down", body: summarizeShutdown(name, released.length, result.exitCode, result.usage) }
527
- : { from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
528
-
529
- if (!requested) {
530
- sendUpdate({
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 = {
531
899
  teammate: name,
900
+ spawnId: teammate.spawnId,
532
901
  body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
533
- finished: true,
534
- });
902
+ origin: "harness" as const,
903
+ harnessEvent: { type: "unexpected-stop", subject: `@${name} stopped unexpectedly` },
904
+ finished: false,
905
+ };
906
+ recordTerminalReport(closeReport);
907
+ sendUpdate(closeReport);
535
908
  }
536
909
  publishStateSnapshot();
537
910
  ensureLivePoll();
@@ -552,6 +925,17 @@ function crashDiagnostic(name: string, result: WorkerProcessResult, released: Ar
552
925
  ].filter(Boolean).join("\n");
553
926
  }
554
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
+
555
939
  function summarizeShutdown(
556
940
  name: string,
557
941
  releasedCount: number,
@@ -572,17 +956,62 @@ async function finalizeWorktree(name: string): Promise<void> {
572
956
  }
573
957
  liveWorktrees.delete(name);
574
958
  const captured = captureWorktreeDiff(handle);
575
- const patchNote = captured.ok && captured.diff.patch.trim()
576
- ? `\n\n=== Worktree changes ===\n${captured.diff.diffStat}\n\n${captured.diff.patch}`
577
- : captured.ok
578
- ? "\n(no worktree changes)"
579
- : `\n(worktree diff capture failed: ${captured.error})`;
580
- 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;
581
977
  deliverToLeader({
582
978
  from: name,
583
979
  subject: "Worktree diff captured",
584
- 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)`,
585
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
+ }
586
1015
  }
587
1016
 
588
1017
  // ── Report outbox draining ────────────────────────────────────────
@@ -620,8 +1049,33 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
620
1049
  if (!isWorkerEvent(record)) return false;
621
1050
  if (record.worker !== teammate.name || record.spawnId !== teammate.spawnId) return false;
622
1051
  const ids = getState().workerEventIds;
623
- if (ids[`${teammate.spawnId}:${record.id}`]) return false;
624
- 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
+ }
625
1079
  receiveWorkerMessage({
626
1080
  id: record.id,
627
1081
  type: "message",
@@ -629,17 +1083,25 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
629
1083
  spawnId: teammate.spawnId,
630
1084
  body: record.body,
631
1085
  status: record.status,
632
- });
633
- if (record.status === "completed" || record.status === "failed") {
634
- // The assignment ends; the resident itself stays alive until sequence end.
635
- sendUpdate({
636
- teammate: teammate.name,
637
- agent: teammate.agent,
638
- spawnId: teammate.spawnId,
639
- body: record.body,
640
- finished: true,
641
- });
642
- }
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);
643
1105
  return true;
644
1106
  }
645
1107
 
@@ -680,10 +1142,14 @@ function parseInboxMessage(record: unknown): InboxMessage | undefined {
680
1142
  }
681
1143
 
682
1144
  function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
683
- const delivered = teammate.status === "working"
1145
+ const routed = teammate.status === "working"
684
1146
  ? sendWorkerSteer(teammate.name, formatDelivery([message]))
685
1147
  : false;
686
- if (delivered) return;
1148
+ if (routed) {
1149
+ setPeerDeliveryState(message.id, "routed");
1150
+ return;
1151
+ }
1152
+ setPeerDeliveryState(message.id, "queued");
687
1153
  const queued = pendingDeliveries.get(teammate.name) ?? [];
688
1154
  queued.push(message);
689
1155
  pendingDeliveries.set(teammate.name, queued);
@@ -691,9 +1157,76 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
691
1157
  }
692
1158
 
693
1159
  /** Route the leader's addressed send_message through the same delivery path. */
694
- 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 {
695
1185
  const teammate = getTeammate(to);
696
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 });
697
1230
  const envelope: InboxMessage = {
698
1231
  id: randomUUID(),
699
1232
  from: "leader",
@@ -701,19 +1234,13 @@ export function sendLeaderMessage(to: string, message: string): { ok: true; queu
701
1234
  body: message,
702
1235
  timestamp: Date.now(),
703
1236
  };
704
- const delivered = teammate.status === "working" && sendWorkerSteer(teammate.name, formatDelivery([envelope]));
705
- if (delivered) {
706
- // A control-stream write succeeds even when the child never reads it;
707
- // surface prolonged silence so the leader knows delivery is uncertain.
708
- const stalled = isStallThresholdReached(teammate, Date.now(), STALL_NOTICE_MS);
709
- const silence = stallSilenceMs(teammate);
710
- return { ok: true, queued: false, ...(stalled && silence !== undefined ? { stalledMs: silence } : {}) };
711
- }
1237
+ const steered = teammate.status === "working" && sendWorkerSteer(teammate.name, formatDelivery([envelope]));
1238
+ if (steered) return { ok: true, outcome: "steered", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
712
1239
  const queued = pendingDeliveries.get(teammate.name) ?? [];
713
1240
  queued.push(envelope);
714
1241
  pendingDeliveries.set(teammate.name, queued);
715
1242
  ensureLivePoll();
716
- return { ok: true, queued: true };
1243
+ return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
717
1244
  }
718
1245
 
719
1246
  function continueMaybeCompact(inbox: string, inboxName: string, offset: number): void {
@@ -770,22 +1297,69 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
770
1297
  if (!sender) return;
771
1298
  const outcome = applyClaimIntent(intent);
772
1299
  if (outcome.applied) {
1300
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
1301
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
1302
+ // A new holding must not inherit an in-flight gate from a previous one.
1303
+ verifyingTasks.delete(intent.taskId);
773
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
+ );
774
1310
  return;
775
1311
  }
776
1312
  deliverFeedback(intent.worker, "Claim rejected", outcome.reason ?? "The task is no longer available.");
777
1313
  }
778
1314
 
779
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
+ }
780
1320
  const sender = findLivingTeammate(intent);
781
1321
  if (!sender) return;
782
1322
  const task = getState().tasks[intent.taskId];
783
- if (!task || task.status !== "claimed" || task.claimedBy !== intent.worker) {
1323
+ if (!task || (task.status !== "claimed" && task.status !== "superseded") || task.claimedBy !== intent.worker) {
784
1324
  deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
785
1325
  return;
786
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
+ }
787
1357
  if (intent.status === "failed") {
788
1358
  releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
1359
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
1360
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
1361
+ verifyingTasks.delete(intent.taskId);
1362
+ rearmTaskNotice(intent.taskId);
789
1363
  freeTeammateFromTask(intent.worker, intent.taskId);
790
1364
  notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
791
1365
  return;
@@ -793,6 +1367,17 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
793
1367
  beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
794
1368
  }
795
1369
 
1370
+ /** Fold one more verify failure into bookkeeping. The first failure invites a
1371
+ * fix-and-resubmit cycle; from the second the harness escalates to the leader
1372
+ * exactly once per holding and stays quiet after that. */
1373
+ export function reactToVerifyFailure(
1374
+ previous: VerifyFailureRecord | undefined,
1375
+ ): VerifyFailureRecord & { escalateToLeader: boolean } {
1376
+ const count = (previous?.count ?? 0) + 1;
1377
+ const escalated = previous?.escalated === true || count >= VERIFY_FAILURE_ESCALATE_AFTER;
1378
+ return { count, escalated, escalateToLeader: escalated && previous?.escalated !== true };
1379
+ }
1380
+
796
1381
  function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
797
1382
  const task = getState().tasks[intent.taskId];
798
1383
  if (!task) return;
@@ -800,30 +1385,142 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
800
1385
  finishCompletion(intent, task.subject);
801
1386
  return;
802
1387
  }
803
- // The gate is bound to this claim: a crash/release/re-claim between submit
804
- // and verify resolution must not let an old result complete a new holder.
805
- verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId });
806
- runVerifyCommand(verify)
807
- .then((outcome) => {
808
- verifyingTasks.delete(intent.taskId);
809
- const current = getState().tasks[intent.taskId];
810
- const stillHolds = current?.status === "claimed"
811
- && current.claimedBy === intent.worker
812
- && getTeammate(intent.worker)?.spawnId === intent.spawnId;
813
- if (!stillHolds) return;
814
- if (outcome.ok) finishCompletion(intent, task.subject);
815
- else {
816
- deliverFeedback(
817
- intent.worker,
818
- `Verify failed for ${intent.taskId}`,
819
- [`The completion gate for "${task.subject}" failed.`, outcome.detail ?? "(no verify output)", "Fix the issues and resubmit with task_submit."].filter(Boolean).join("\n"),
820
- );
821
- notifyTaskOutcome(task.subject, `verify gate failed for ${intent.taskId}`, outcome.detail ?? "(no verify output)");
1388
+ // The gate is bound to this exact submission via a unique token: a
1389
+ // release/re-claim or newer submission between submit and verify resolution
1390
+ // must not let the stale result complete the new holding.
1391
+ const submissionId = `${intent.taskId}:${intent.spawnId}:${randomUUID()}`;
1392
+ const token = randomUUID();
1393
+ verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, submissionId, token });
1394
+ const input: VerifyReviewInput = {
1395
+ verify,
1396
+ taskSubject: task.subject,
1397
+ workerResult: intent.result ?? "",
1398
+ cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
1399
+ };
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.
1402
+ Promise.resolve()
1403
+ .then(() => verifyGateRunner(input))
1404
+ .then((outcome) => resolveGateOutcome(intent, task.subject, submissionId, token, outcome))
1405
+ .catch((error) => resolveGateOutcome(intent, task.subject, submissionId, token, {
1406
+ kind: "fail",
1407
+ detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
1408
+ }));
1409
+ }
1410
+
1411
+ /** Apply one gate outcome to its submission, guarded by the binding token. */
1412
+ function resolveGateOutcome(
1413
+ intent: import("./types").TaskIntent,
1414
+ subject: string,
1415
+ submissionId: string,
1416
+ token: string,
1417
+ outcome: VerifyReviewOutcome,
1418
+ ): void {
1419
+ const active = verifyingTasks.get(intent.taskId);
1420
+ if (active?.token !== token || active.submissionId !== submissionId) return;
1421
+ verifyingTasks.delete(intent.taskId);
1422
+ const current = getState().tasks[intent.taskId];
1423
+ const stillHolds = current?.status === "claimed"
1424
+ && current.claimedBy === intent.worker
1425
+ && getTeammate(intent.worker)?.spawnId === intent.spawnId;
1426
+ if (!stillHolds) return;
1427
+ if (outcome.kind === "pass") {
1428
+ inconclusiveVerifications.delete(submissionId);
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);
1433
+ } else {
1434
+ inconclusiveVerifications.delete(submissionId);
1435
+ const key = `${intent.taskId}:${intent.spawnId}`;
1436
+ const reaction = reactToVerifyFailure(verifyFailures.get(key));
1437
+ verifyFailures.set(key, reaction);
1438
+ const detail = outcome.detail ?? "(no review output)";
1439
+ if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
1440
+ // An unfixable gate parks the task with its holder instead of
1441
+ // looping: no further resubmit invitations, one leader escalation.
1442
+ verifyFailureParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
1443
+ if (reaction.escalateToLeader) {
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
+ });
822
1454
  }
823
- ensureLivePoll();
824
- notifyChange();
825
- })
826
- .catch(() => verifyingTasks.delete(intent.taskId));
1455
+ deliverFeedback(
1456
+ intent.worker,
1457
+ `Verify still failing for ${intent.taskId}`,
1458
+ [`The completion gate for "${subject}" failed again (${reaction.count} consecutive failures).`, detail, "The task stays claimed by you. Do not resubmit or reclaim it; the leader has been notified and will decide next steps."].join("\n"),
1459
+ );
1460
+ } else {
1461
+ deliverFeedback(
1462
+ intent.worker,
1463
+ `Verify failed for ${intent.taskId}`,
1464
+ [`The completion gate for "${subject}" failed.`, detail, "Fix the issues and resubmit with task_submit."].join("\n"),
1465
+ );
1466
+ notifyTaskOutcome(subject, `verify gate failed for ${intent.taskId}`, detail);
1467
+ }
1468
+ }
1469
+ ensureLivePoll();
1470
+ notifyChange();
1471
+ }
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}`);
827
1524
  }
828
1525
 
829
1526
  function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
@@ -831,21 +1528,99 @@ function finishCompletion(intent: import("./types").TaskIntent, subject: string)
831
1528
  if (!task) return;
832
1529
  const completed = completeTask(intent.taskId, intent.result);
833
1530
  if (!completed) return;
1531
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
1532
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
834
1533
  freeTeammateFromTask(intent.worker, intent.taskId);
835
1534
  notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
836
1535
  }
837
1536
 
838
- function runVerifyCommand(command: string): Promise<{ ok: boolean; detail?: string }> {
839
- return new Promise((resolve) => {
840
- exec(command, { cwd: leaderCwd, maxBuffer: 1024 * 1024 }, (error, _stdout, stderr) => {
841
- const code = (error as { code?: number | string } | null)?.code;
842
- if (error) {
843
- resolve({ ok: false, detail: `verify exited with code ${code ?? "unknown"}: ${(stderr || error.message).slice(0, 4000)}` });
844
- return;
845
- }
846
- resolve({ ok: true });
847
- });
1537
+ // ── Verify gate: fresh one-shot reviewer with a VERDICT protocol ──
1538
+
1539
+ /** The reviewer's reply must end with exactly one of these verdict lines. */
1540
+ export const VERIFY_VERDICT_PASS = "VERDICT: PASS";
1541
+ export const VERIFY_VERDICT_FAIL = "VERDICT: FAIL";
1542
+
1543
+ export interface VerifyReviewInput {
1544
+ /** The acceptance-gate prompt (task-level or agent-role default). */
1545
+ verify: string;
1546
+ /** Board subject of the gated task. */
1547
+ taskSubject: string;
1548
+ /** The claimer's own result summary; evidence, never trusted alone. */
1549
+ workerResult: string;
1550
+ /** Working directory to review: the holder's worktree root when isolated,
1551
+ * else the leader cwd — the reviewer must inspect the claimed work's tree. */
1552
+ cwd: string;
1553
+ }
1554
+
1555
+ export type VerifyReviewOutcome =
1556
+ | { kind: "pass" }
1557
+ | { kind: "fail"; detail?: string }
1558
+ | { kind: "inconclusive"; detail?: string };
1559
+
1560
+ /** Compose the reviewer prompt: fresh context, independent checks, explicit verdict line. */
1561
+ export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
1562
+ const result = input.workerResult.trim().slice(0, 4000);
1563
+ return [
1564
+ "You are a fresh completion-gate reviewer for one teammate task on a shared board.",
1565
+ `Task subject: ${input.taskSubject}`,
1566
+ result ? `The claimer's result summary:\n${result}` : "The claimer provided no result summary.",
1567
+ "Independently verify the work against the acceptance gate below using your tools; do not trust the summary alone. Do not modify any files.",
1568
+ "Acceptance gate:",
1569
+ input.verify.trim(),
1570
+ "",
1571
+ `End your final message with exactly one verdict line: "${VERIFY_VERDICT_PASS}" if the gate holds, or "${VERIFY_VERDICT_FAIL} - <reasons>" if it does not.`,
1572
+ ].join("\n");
1573
+ }
1574
+
1575
+ /** Parse the reviewer's reply into a gate outcome, scanning from the final
1576
+ * line upward. PASS must be an exact verdict line ("VERDICT: PASS", no
1577
+ * trailing content) so a contradictory suffix cannot sneak through; FAIL
1578
+ * carries its reasons. A missing or malformed verdict fails the gate so an
1579
+ * ambiguous review never completes a task. */
1580
+ export function parseVerifyVerdict(text: string): VerifyReviewOutcome {
1581
+ const lines = text.trim().split("\n");
1582
+ for (let index = lines.length - 1; index >= 0; index--) {
1583
+ const candidate = lines[index].trim();
1584
+ if (/^verdict:\s*pass$/i.test(candidate)) return { kind: "pass" };
1585
+ const failMatch = /^verdict:\s*fail\b[\s:-]*(.*)$/i.exec(candidate);
1586
+ if (failMatch) {
1587
+ return { kind: "fail", detail: truncated(failMatch[1].trim()) || "(no reasons given)" };
1588
+ }
1589
+ }
1590
+ return {
1591
+ kind: "inconclusive",
1592
+ detail: `(no ${VERIFY_VERDICT_PASS}/FAIL verdict line in the review)\n${truncated(text.trim())}`,
1593
+ };
1594
+ }
1595
+
1596
+ function truncated(text: string, cap = 4000): string {
1597
+ const suffix = "\n…[truncated]";
1598
+ return text.length <= cap ? text : text.slice(0, Math.max(0, cap - suffix.length)) + suffix;
1599
+ }
1600
+
1601
+ /** Run the gate as a one-shot Pi worker: a brand-new context that inspects
1602
+ * the working tree itself before answering. Uses the team's model resolution
1603
+ * chain (team default, else Pi default) rather than any role pin. */
1604
+ export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyReviewOutcome> {
1605
+ const outcome = await runPiWorker({
1606
+ prompt: buildVerifyReviewPrompt(input),
1607
+ cwd: input.cwd || leaderCwd,
1608
+ model: resolveSpawnModel(undefined, getTeamDefaultModel(), leaderModelRef()).model,
848
1609
  });
1610
+ // A reviewer that did not exit cleanly produced no trustworthy verdict,
1611
+ // even if partial output happens to contain a PASS line.
1612
+ if (outcome.exitCode !== 0) {
1613
+ return { kind: "fail", detail: truncated(`reviewer exited with code ${outcome.exitCode}: ${(outcome.stderr || outcome.text || "no output").trim()}`) };
1614
+ }
1615
+ return parseVerifyVerdict(outcome.text);
1616
+ }
1617
+
1618
+ /** Active gate runner; tests inject a stub through setVerifyGateRunner. */
1619
+ let verifyGateRunner: (input: VerifyReviewInput) => Promise<VerifyReviewOutcome> = runVerifyReview;
1620
+
1621
+ /** Replace the completion-gate runner (test seam); undefined restores the real reviewer. */
1622
+ export function setVerifyGateRunner(runner: ((input: VerifyReviewInput) => Promise<VerifyReviewOutcome>) | undefined): void {
1623
+ verifyGateRunner = runner ?? runVerifyReview;
849
1624
  }
850
1625
 
851
1626
  function freeTeammateFromTask(workerName: string, taskId: string): void {
@@ -868,30 +1643,87 @@ function findLivingTeammate(intent: import("./types").TaskIntent): Teammate | un
868
1643
 
869
1644
  // ── Wake-ups ──────────────────────────────────────────────────────
870
1645
 
1646
+ /** Claimable tasks the teammate has not been notified about yet. */
1647
+ export function freshClaimableTasks<T extends { id: string }>(
1648
+ noticedIds: readonly string[] | undefined,
1649
+ tasks: readonly T[],
1650
+ ): T[] {
1651
+ const noticed = new Set(noticedIds ?? []);
1652
+ return tasks.filter((task) => !noticed.has(task.id));
1653
+ }
1654
+
1655
+ /** Retain only noticed ids that are still claimable, so a long board's history
1656
+ * can never evict a live one and resurrect an old wake-up. */
1657
+ export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds: ReadonlySet<string>): string[] {
1658
+ return noticedIds.filter((id) => claimableIds.has(id));
1659
+ }
1660
+
1661
+ function markTasksNoticed(name: string, taskIds: string[]): void {
1662
+ if (taskIds.length === 0) return;
1663
+ const teammate = getTeammate(name);
1664
+ if (!teammate) return;
1665
+ const live = retainLiveNoticedIds(teammate.noticedTaskIds ?? [], new Set(claimableTasks().map((task) => task.id)));
1666
+ const merged = [...live, ...taskIds];
1667
+ while (merged.length > MAX_NOTICED_TASK_IDS) merged.shift();
1668
+ updateTeammate(name, { noticedTaskIds: merged });
1669
+ }
1670
+
1671
+ /** A released task becomes noticeable again for every living teammate. */
1672
+ function rearmTaskNotice(taskId: string): void {
1673
+ for (const teammate of livingTeammates()) {
1674
+ if (teammate.noticedTaskIds?.includes(taskId)) {
1675
+ updateTeammate(teammate.name, { noticedTaskIds: teammate.noticedTaskIds.filter((id) => id !== taskId) });
1676
+ }
1677
+ }
1678
+ }
1679
+
871
1680
  /**
872
1681
  * Compose one wake-up prompt per idle teammate from queued inbox deliveries
873
- * plus a paced claimable-task notice. Teammates with nothing to do are never
874
- * woken.
1682
+ * plus a board notice covering only claimable work that teammate has never
1683
+ * been shown. Teammates with nothing new are never woken.
875
1684
  */
876
- export function wakeIdleTeammates(): void {
877
- const claimable = claimableTasks();
1685
+ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
1686
+ const notified: string[] = [];
878
1687
  for (const teammate of idleTeammates()) {
879
1688
  const deliveries = pendingDeliveries.get(teammate.name) ?? [];
880
- const dueNotice = claimable.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));
881
1697
  if (deliveries.length === 0 && !dueNotice) continue;
882
- const prompt = buildWakePrompt(deliveries, claimable, dueNotice);
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);
1705
+ const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
883
1706
  if (!deliverPrompt(teammate.name, prompt)) continue;
1707
+ for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
1708
+ notified.push(teammate.name);
884
1709
  pendingDeliveries.delete(teammate.name);
1710
+ if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
885
1711
  // A delivered prompt is fresh activity: restart the silence clock so a
886
1712
  // long-idle teammate is never insta-flagged as stalled on wake.
887
1713
  updateTeammate(teammate.name, {
888
1714
  status: "working",
889
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 }),
890
1721
  lastOutputAt: Date.now(),
891
1722
  stallNoticeSentAt: undefined,
892
1723
  ...(dueNotice ? { lastNoticeAt: Date.now() } : {}),
893
1724
  });
894
1725
  }
1726
+ return notified;
895
1727
  }
896
1728
 
897
1729
  function noticeDue(teammate: Teammate): boolean {
@@ -921,17 +1753,91 @@ function formatDelivery(messages: InboxMessage[]): string {
921
1753
 
922
1754
  // ── Board helpers shared with tools.ts ────────────────────────────
923
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
+ */
924
1798
  export function createBoardTask(input: {
925
1799
  subject: string;
926
1800
  description?: string;
927
1801
  dependsOn?: string[];
928
1802
  verify?: string;
929
- }): { ok: true; id: string } | { ok: false; error: string } {
1803
+ resources?: string[];
1804
+ supersedes?: string[];
1805
+ }): BoardTaskCreationResult | { ok: false; error: string } {
930
1806
  const created = createTask(input);
931
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);
932
1830
  publishStateSnapshot();
933
1831
  notifyChange();
934
- 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
+ };
935
1841
  }
936
1842
 
937
1843
  export function boardOverview(): Array<import("./types").BoardTask> {