@fradser/pi-agent-teams 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,13 @@
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 {
15
16
  applyClaimIntent,
16
17
  claimableTasks,
@@ -22,6 +23,7 @@ import {
22
23
  getPeerInboxOffset,
23
24
  getState,
24
25
  getTeammate,
26
+ getTeamDefaultModel,
25
27
  idleTeammates,
26
28
  isPeerDelivered,
27
29
  isValidTeammateName,
@@ -74,7 +76,19 @@ import type { FollowUpReport } from "./follow-up-queue.ts";
74
76
  export const MAX_SESSION_WORKERS = 8;
75
77
  /** Harness coordination cadence: outbox drain every tick, notices paced. */
76
78
  const LIVE_POLL_MS = 500;
77
- export const NOTICE_PACE_MS = 2000;
79
+ /** Minimum gap between claimable-task notices per teammate. One-shot noticing
80
+ * makes repeats rare; this floor keeps any residual burst from stampeding
81
+ * every idle teammate at once, and each wake costs a full worker turn. */
82
+ const DEFAULT_NOTICE_PACE_MS = 5 * 60 * 1000;
83
+ export const NOTICE_PACE_MS = readDurationEnv("PI_TEAMMATE_NOTICE_PACE_MS", DEFAULT_NOTICE_PACE_MS);
84
+ /** Consecutive verify failures before the harness stops inviting resubmission and escalates to the leader instead. */
85
+ export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
86
+ /** Claimable task ids remembered per teammate. markTasksNoticed prunes stale
87
+ * ids on every notice, so slots are occupied only by currently-claimable
88
+ * work; the cap is far above any realistic concurrent board size. */
89
+ const MAX_NOTICED_TASK_IDS = 256;
90
+ /** Claimable tasks listed in one wake-prompt board notice. */
91
+ const WAKE_NOTICE_TASK_LIMIT = 10;
78
92
  /** Silence is not a work deadline: active stream output keeps a teammate alive.
79
93
  * The watchdog may only notify; termination decisions belong to the leader model. */
80
94
  const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
@@ -117,14 +131,25 @@ let generation = 0;
117
131
  let runtimeStateFile = "";
118
132
  let boardFile = "";
119
133
  let leaderCwd = "";
134
+ /** Live view of the leader session's current model, resolved at spawn time. */
135
+ let leaderModelRef: () => string | undefined = () => undefined;
120
136
  let sendUpdate: (report: FollowUpReport) => void = () => {};
121
137
  let notifyChange: () => void = () => {};
122
138
 
123
139
  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 }>();
140
+ /** Task ids under verification, bound to one exact submission via token so a
141
+ * release/re-claim or newer submission invalidates any older in-flight gate. */
142
+ const verifyingTasks = new Map<string, { worker: string; spawnId: string; token: string }>();
126
143
  /** Idle nudges already fired per teammate incarnation (one per transition). */
127
144
  const idleNudgesSent = new Set<string>();
145
+ /** One finish entry per spawn incarnation; repeated terminal reports stay ordinary report rows. */
146
+ const announcedFinishKeys = new Set<string>();
147
+ /** Incarnations whose terminal report reached the leader pipeline, queued or dispatched. */
148
+ const terminalReportKeys = new Set<string>();
149
+ /** Consecutive verify failures per taskId:spawnId holder incarnation. */
150
+ const verifyFailures = new Map<string, VerifyFailureRecord>();
151
+ /** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
152
+ const selfFinalizeAttempts = new Set<string>();
128
153
  const pendingDeliveries = new Map<string, InboxMessage[]>();
129
154
  const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
130
155
 
@@ -136,11 +161,12 @@ export interface MachineHooks {
136
161
  }
137
162
 
138
163
  export function initTeamMachine(
139
- ctx: Pick<ExtensionContext, "sessionManager" | "cwd">,
164
+ ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model">,
140
165
  hooks: MachineHooks,
141
166
  ): void {
142
167
  generation++;
143
168
  leaderCwd = ctx.cwd || process.cwd();
169
+ leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
144
170
  const sessionFile = ctx.sessionManager?.getSessionFile();
145
171
  runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
146
172
  boardFile = boardFilePath(sessionFile, leaderCwd);
@@ -158,12 +184,48 @@ export function shutdownTeamMachine(): void {
158
184
  runtimeStateFile = "";
159
185
  boardFile = "";
160
186
  leaderCwd = "";
187
+ leaderModelRef = () => undefined;
188
+ setVerifyGateRunner(undefined);
161
189
  sendUpdate = () => {};
162
190
  notifyChange = () => {};
163
191
  pendingShutdowns.clear();
164
192
  verifyingTasks.clear();
165
193
  idleNudgesSent.clear();
194
+ selfFinalizeAttempts.clear();
166
195
  pendingDeliveries.clear();
196
+ verifyFailures.clear();
197
+ announcedFinishKeys.clear();
198
+ terminalReportKeys.clear();
199
+ }
200
+
201
+ // ── Spawn model resolution ────────────────────────────────────
202
+
203
+ /** How a spawn's effective model was chosen. */
204
+ export type SpawnModelSource = "pin" | "inherit" | "team-default" | "none";
205
+
206
+ /**
207
+ * Resolve the effective spawn model. Precedence: explicit role pin beats the
208
+ * `inherit` alias (the leader session's current model), which beats the team
209
+ * default set from the console; with none of these Pi picks its own default.
210
+ * The value is resolved at spawn time so mid-session leader model switches
211
+ * apply to later spawns.
212
+ */
213
+ export function resolveSpawnModel(
214
+ pinned: string | undefined,
215
+ teamDefault: string | undefined,
216
+ leaderModel: string | undefined,
217
+ ): { model?: string; source: SpawnModelSource } {
218
+ const pin = pinned?.trim();
219
+ if (pin && pin.toLowerCase() !== MODEL_INHERIT_ALIAS) return { model: pin, source: "pin" };
220
+ if (pin && leaderModel) return { model: leaderModel, source: "inherit" };
221
+ const fallback = teamDefault?.trim();
222
+ if (fallback) return { model: fallback, source: "team-default" };
223
+ return { model: undefined, source: "none" };
224
+ }
225
+
226
+ /** The leader session's current model reference, when one is selected. */
227
+ export function currentLeaderModelRef(): string | undefined {
228
+ return leaderModelRef();
167
229
  }
168
230
 
169
231
  /** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
@@ -244,12 +306,37 @@ export function spawnTeammate(input: {
244
306
  name: string;
245
307
  agent: string;
246
308
  prompt?: string;
309
+ definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
310
+ tools?: string[];
311
+ persist?: boolean;
312
+ persistScope?: "project" | "project-local";
313
+ };
247
314
  }): { ok: true; teammate: Teammate } | { ok: false; error: string } {
248
315
  const stateFile = requireStateFile();
249
316
  const invalid = validateSpawnInput(input);
250
317
  if (invalid) return { ok: false, error: invalid };
251
- const agent = resolveAgent(input.agent, leaderCwd);
252
- if (!agent) return { ok: false, error: `Agent "${input.agent}" not found in any scope.` };
318
+ const resolved = resolveAgent(input.agent, leaderCwd);
319
+ let agent: AgentDefinition | undefined = resolved;
320
+ if (input.definition && inlineDefinitionApplies(resolved)) {
321
+ try {
322
+ const generated = input.definition;
323
+ const roleInput: AgentDefinitionInput = {
324
+ name: input.agent,
325
+ description: generated.description,
326
+ tools: generated.tools ?? [],
327
+ model: generated.model,
328
+ verify: generated.verify,
329
+ worktree: generated.worktree,
330
+ prompt: generated.prompt,
331
+ };
332
+ agent = generated.persist
333
+ ? persistAgentDefinition(roleInput, generated.persistScope ?? "project-local", leaderCwd)
334
+ : registerSessionAgent(roleInput);
335
+ } catch (error) {
336
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
337
+ }
338
+ }
339
+ if (!agent) return { ok: false, error: unknownAgentError(input.agent, leaderCwd) };
253
340
 
254
341
  let isolation: Teammate["isolation"] = "none";
255
342
  let workerCwd = leaderCwd;
@@ -268,10 +355,13 @@ export function spawnTeammate(input: {
268
355
  return { ok: false, error: registered.error };
269
356
  }
270
357
 
358
+ const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
359
+ updateTeammate(input.name, { model: spawnModel.model });
360
+
271
361
  const started = spawnResident({
272
362
  workerName: input.name,
273
363
  description: buildKickoffPrompt(input.name, input.agent, agent.prompt, input.prompt, isolation),
274
- model: agent.model,
364
+ model: spawnModel.model,
275
365
  tools: agent.tools,
276
366
  cwd: workerCwd,
277
367
  env: teammateEnv(stateFile, input, spawnId, agent.verify),
@@ -291,6 +381,28 @@ export function spawnTeammate(input: {
291
381
  return { ok: true, teammate: getTeammate(input.name)! };
292
382
  }
293
383
 
384
+ /** True when a spawn's inline definition should create or replace the role:
385
+ * nothing resolved, or only a session-scoped generated role did. Filesystem
386
+ * scopes are user-owned and always win over inline input. */
387
+ export function inlineDefinitionApplies(resolved: AgentDefinition | undefined): boolean {
388
+ return !resolved || resolved.scope === "session";
389
+ }
390
+
391
+ /** Spawn failure for an unresolvable agent name. Definitions resolve live at
392
+ * spawn time, so the available-agents list in the cached session guidance can
393
+ * go stale (e.g. another session removed the file); name every checked scope
394
+ * and the recovery path. */
395
+ export function unknownAgentError(name: string, cwd: string): string {
396
+ const available = [...discoverAgents(cwd).keys()];
397
+ return [
398
+ `Agent "${name}" not found in any scope.`,
399
+ `Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
400
+ '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.',
402
+ `Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
403
+ ].join(' ');
404
+ }
405
+
294
406
  function validateSpawnInput(input: { name: string }): string | undefined {
295
407
  if (!isValidTeammateName(input.name)) {
296
408
  return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
@@ -408,13 +520,54 @@ function checkStalledTeammates(now = Date.now()): void {
408
520
  const silence = stallSilenceMs(teammate, now);
409
521
  if (silence === undefined) continue;
410
522
  if (silence < STALL_NOTICE_MS || teammate.stallNoticeSentAt !== undefined) continue;
411
- const body = `@${teammate.name} has produced no RPC output for ${formatSilenceDuration(silence)}. The child may be blocked in a provider or tool call; steer delivery is uncertain. Decide: keep waiting, steer again, or shut it down (and respawn a successor with context from the original kickoff, its mailbox reports, board claims, and the /teammate detail transcript).`;
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).`;
412
524
  updateTeammate(teammate.name, { stallNoticeSentAt: now });
413
525
  deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
414
526
  sendUpdate({ teammate: teammate.name, agent: teammate.agent, body, finished: false });
415
527
  }
416
528
  }
417
529
 
530
+ interface VerifyFailureRecord {
531
+ count: number;
532
+ escalated: boolean;
533
+ }
534
+
535
+ /** Record one finish entry per spawn incarnation; later terminal reports from
536
+ * the same resident stay ordinary report rows. */
537
+ export function markTeammateFinished(
538
+ report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
539
+ ): boolean {
540
+ if (!report.finished) return false;
541
+ const name = report.teammate ?? report.agent ?? "teammate";
542
+ const key = `${name}:${report.spawnId ?? "session"}`;
543
+ if (announcedFinishKeys.has(key)) return false;
544
+ announcedFinishKeys.add(key);
545
+ return true;
546
+ }
547
+
548
+ /** True when this teammate's current incarnation already announced its finish entry. */
549
+ export function hasAnnouncedFinish(name: string): boolean {
550
+ const spawnId = getTeammate(name)?.spawnId;
551
+ return announcedFinishKeys.has(`${name}:${spawnId ?? "session"}`);
552
+ }
553
+
554
+ /** Remember at send time that this incarnation produced a terminal report, so
555
+ * end-of-life suppression covers reports still queued for dispatch. */
556
+ export function recordTerminalReport(
557
+ report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
558
+ ): void {
559
+ if (!report.finished) return;
560
+ const name = report.teammate ?? report.agent ?? "teammate";
561
+ terminalReportKeys.add(`${name}:${report.spawnId ?? "session"}`);
562
+ }
563
+
564
+ /** True when this teammate's current incarnation has a terminal report in the
565
+ * leader pipeline, whether or not its finish entry has been dispatched yet. */
566
+ export function hasTerminalReport(name: string): boolean {
567
+ const spawnId = getTeammate(name)?.spawnId;
568
+ return terminalReportKeys.has(`${name}:${spawnId ?? "session"}`);
569
+ }
570
+
418
571
  /** True when the teammate's last leader-bound report lacks a terminal status. */
419
572
  export function hasUnfinalizedReport(name: string): boolean {
420
573
  const mailbox = getState().leaderMailbox;
@@ -428,9 +581,24 @@ export function hasUnfinalizedReport(name: string): boolean {
428
581
  /** One light reminder per idle transition when work looks unfinished. */
429
582
  function nudgeIfUnfinalized(name: string, spawnId: string): void {
430
583
  const key = `${name}:${spawnId}`;
431
- if (idleNudgesSent.has(key) || !hasUnfinalizedReport(name)) return;
584
+ // The terminal report may have been written to the outbox file but not yet
585
+ // drained into the leader mailbox by the next tick; drain before deciding.
586
+ drainTeammateOutboxes();
587
+ if (!hasUnfinalizedReport(name)) return;
588
+ if (!selfFinalizeAttempts.has(key)) {
589
+ // First miss: give the worker one chance to fix its own bookkeeping
590
+ // before bothering the leader. The inbox message wakes it on the next tick.
591
+ selfFinalizeAttempts.add(key);
592
+ deliverFeedback(
593
+ name,
594
+ "Assignment not finalized",
595
+ 'Your latest message to the leader carried no terminal status. Send send_message(to="leader", message=...) now with status="completed" or status="failed" summarizing your final result.',
596
+ );
597
+ return;
598
+ }
599
+ if (idleNudgesSent.has(key)) return;
432
600
  idleNudgesSent.add(key);
433
- const reminder = `@${name} is now idle but its last report was not marked status="completed" or "failed". Its conclusions may be stuck in the mailbox — ask it to finalize or inspect the /teammate console.`;
601
+ 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.`;
434
602
  deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
435
603
  sendUpdate({ teammate: name, body: reminder, finished: false });
436
604
  }
@@ -463,7 +631,14 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
463
631
  // The child was already gone; synthesize the close bookkeeping.
464
632
  pendingShutdowns.delete(name);
465
633
  const released = releaseTasksOf(name, "Teammate was shut down.");
634
+ for (const task of released) {
635
+ verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
636
+ verifyingTasks.delete(task.id);
637
+ rearmTaskNotice(task.id);
638
+ }
466
639
  updateTeammate(name, { status: "stopped", activeTool: undefined });
640
+ idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
641
+ selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
467
642
  publishStateSnapshot();
468
643
  notifyChange();
469
644
  return { ok: true, body: summarizeShutdown(name, released.length, 0, undefined) };
@@ -489,10 +664,16 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
489
664
  drainTeammateOutboxes();
490
665
 
491
666
  const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
667
+ for (const task of released) {
668
+ verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
669
+ verifyingTasks.delete(task.id);
670
+ rearmTaskNotice(task.id);
671
+ }
492
672
  pendingShutdowns.delete(name);
493
673
  // A stopped teammate must not keep the poll loop or its queue alive.
494
674
  pendingDeliveries.delete(name);
495
675
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
676
+ selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
496
677
  updateTeammate(name, {
497
678
  status: "stopped",
498
679
  activeTool: undefined,
@@ -506,11 +687,14 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
506
687
  : { from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
507
688
 
508
689
  if (!requested) {
509
- sendUpdate({
690
+ const report = {
510
691
  teammate: name,
692
+ spawnId: teammate.spawnId,
511
693
  body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
512
694
  finished: true,
513
- });
695
+ };
696
+ recordTerminalReport(report);
697
+ sendUpdate(report);
514
698
  }
515
699
  publishStateSnapshot();
516
700
  ensureLivePoll();
@@ -611,12 +795,15 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
611
795
  });
612
796
  if (record.status === "completed" || record.status === "failed") {
613
797
  // The assignment ends; the resident itself stays alive until sequence end.
614
- sendUpdate({
798
+ const report = {
615
799
  teammate: teammate.name,
616
800
  agent: teammate.agent,
801
+ spawnId: teammate.spawnId,
617
802
  body: record.body,
618
803
  finished: true,
619
- });
804
+ };
805
+ recordTerminalReport(report);
806
+ sendUpdate(report);
620
807
  }
621
808
  return true;
622
809
  }
@@ -748,6 +935,9 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
748
935
  if (!sender) return;
749
936
  const outcome = applyClaimIntent(intent);
750
937
  if (outcome.applied) {
938
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
939
+ // A new holding must not inherit an in-flight gate from a previous one.
940
+ verifyingTasks.delete(intent.taskId);
751
941
  updateTeammate(intent.worker, { currentTaskId: intent.taskId });
752
942
  return;
753
943
  }
@@ -764,6 +954,9 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
764
954
  }
765
955
  if (intent.status === "failed") {
766
956
  releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
957
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
958
+ verifyingTasks.delete(intent.taskId);
959
+ rearmTaskNotice(intent.taskId);
767
960
  freeTeammateFromTask(intent.worker, intent.taskId);
768
961
  notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
769
962
  return;
@@ -771,6 +964,17 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
771
964
  beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
772
965
  }
773
966
 
967
+ /** Fold one more verify failure into bookkeeping. The first failure invites a
968
+ * fix-and-resubmit cycle; from the second the harness escalates to the leader
969
+ * exactly once per holding and stays quiet after that. */
970
+ export function reactToVerifyFailure(
971
+ previous: VerifyFailureRecord | undefined,
972
+ ): VerifyFailureRecord & { escalateToLeader: boolean } {
973
+ const count = (previous?.count ?? 0) + 1;
974
+ const escalated = previous?.escalated === true || count >= VERIFY_FAILURE_ESCALATE_AFTER;
975
+ return { count, escalated, escalateToLeader: escalated && previous?.escalated !== true };
976
+ }
977
+
774
978
  function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
775
979
  const task = getState().tasks[intent.taskId];
776
980
  if (!task) return;
@@ -778,30 +982,71 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
778
982
  finishCompletion(intent, task.subject);
779
983
  return;
780
984
  }
781
- // The gate is bound to this claim: a crash/release/re-claim between submit
782
- // and verify resolution must not let an old result complete a new holder.
783
- verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId });
784
- runVerifyCommand(verify)
785
- .then((outcome) => {
786
- verifyingTasks.delete(intent.taskId);
787
- const current = getState().tasks[intent.taskId];
788
- const stillHolds = current?.status === "claimed"
789
- && current.claimedBy === intent.worker
790
- && getTeammate(intent.worker)?.spawnId === intent.spawnId;
791
- if (!stillHolds) return;
792
- if (outcome.ok) finishCompletion(intent, task.subject);
793
- else {
794
- deliverFeedback(
795
- intent.worker,
796
- `Verify failed for ${intent.taskId}`,
797
- [`The completion gate for "${task.subject}" failed.`, outcome.detail ?? "(no verify output)", "Fix the issues and resubmit with task_submit."].filter(Boolean).join("\n"),
798
- );
799
- notifyTaskOutcome(task.subject, `verify gate failed for ${intent.taskId}`, outcome.detail ?? "(no verify output)");
985
+ // The gate is bound to this exact submission via a unique token: a
986
+ // release/re-claim or newer submission between submit and verify resolution
987
+ // must not let the stale result complete the new holding.
988
+ const token = randomUUID();
989
+ verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, token });
990
+ const input: VerifyReviewInput = {
991
+ verify,
992
+ taskSubject: task.subject,
993
+ workerResult: intent.result ?? "",
994
+ cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
995
+ };
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.
998
+ Promise.resolve()
999
+ .then(() => verifyGateRunner(input))
1000
+ .then((outcome) => resolveGateOutcome(intent, task.subject, token, outcome))
1001
+ .catch((error) => resolveGateOutcome(intent, task.subject, token, {
1002
+ ok: false,
1003
+ detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
1004
+ }));
1005
+ }
1006
+
1007
+ /** Apply one gate outcome to its submission, guarded by the binding token. */
1008
+ function resolveGateOutcome(
1009
+ intent: import("./types").TaskIntent,
1010
+ subject: string,
1011
+ token: string,
1012
+ outcome: VerifyReviewOutcome,
1013
+ ): void {
1014
+ if (verifyingTasks.get(intent.taskId)?.token !== token) return;
1015
+ verifyingTasks.delete(intent.taskId);
1016
+ const current = getState().tasks[intent.taskId];
1017
+ const stillHolds = current?.status === "claimed"
1018
+ && current.claimedBy === intent.worker
1019
+ && getTeammate(intent.worker)?.spawnId === intent.spawnId;
1020
+ if (!stillHolds) return;
1021
+ if (outcome.ok) {
1022
+ finishCompletion(intent, subject);
1023
+ } else {
1024
+ const key = `${intent.taskId}:${intent.spawnId}`;
1025
+ const reaction = reactToVerifyFailure(verifyFailures.get(key));
1026
+ verifyFailures.set(key, reaction);
1027
+ const detail = outcome.detail ?? "(no review output)";
1028
+ if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
1029
+ // An unfixable gate parks the task with its holder instead of
1030
+ // looping: no further resubmit invitations, one leader escalation.
1031
+ if (reaction.escalateToLeader) {
1032
+ notifyTaskOutcome(subject, `verify failed ${reaction.count} times for ${intent.taskId}: manual attention needed`, detail);
800
1033
  }
801
- ensureLivePoll();
802
- notifyChange();
803
- })
804
- .catch(() => verifyingTasks.delete(intent.taskId));
1034
+ deliverFeedback(
1035
+ intent.worker,
1036
+ `Verify still failing for ${intent.taskId}`,
1037
+ [`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"),
1038
+ );
1039
+ } else {
1040
+ deliverFeedback(
1041
+ intent.worker,
1042
+ `Verify failed for ${intent.taskId}`,
1043
+ [`The completion gate for "${subject}" failed.`, detail, "Fix the issues and resubmit with task_submit."].join("\n"),
1044
+ );
1045
+ notifyTaskOutcome(subject, `verify gate failed for ${intent.taskId}`, detail);
1046
+ }
1047
+ }
1048
+ ensureLivePoll();
1049
+ notifyChange();
805
1050
  }
806
1051
 
807
1052
  function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
@@ -809,21 +1054,92 @@ function finishCompletion(intent: import("./types").TaskIntent, subject: string)
809
1054
  if (!task) return;
810
1055
  const completed = completeTask(intent.taskId, intent.result);
811
1056
  if (!completed) return;
1057
+ verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
812
1058
  freeTeammateFromTask(intent.worker, intent.taskId);
813
1059
  notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
814
1060
  }
815
1061
 
816
- function runVerifyCommand(command: string): Promise<{ ok: boolean; detail?: string }> {
817
- return new Promise((resolve) => {
818
- exec(command, { cwd: leaderCwd, maxBuffer: 1024 * 1024 }, (error, _stdout, stderr) => {
819
- const code = (error as { code?: number | string } | null)?.code;
820
- if (error) {
821
- resolve({ ok: false, detail: `verify exited with code ${code ?? "unknown"}: ${(stderr || error.message).slice(0, 4000)}` });
822
- return;
823
- }
824
- resolve({ ok: true });
825
- });
1062
+ // ── Verify gate: fresh one-shot reviewer with a VERDICT protocol ──
1063
+
1064
+ /** The reviewer's reply must end with exactly one of these verdict lines. */
1065
+ export const VERIFY_VERDICT_PASS = "VERDICT: PASS";
1066
+ export const VERIFY_VERDICT_FAIL = "VERDICT: FAIL";
1067
+
1068
+ export interface VerifyReviewInput {
1069
+ /** The acceptance-gate prompt (task-level or agent-role default). */
1070
+ verify: string;
1071
+ /** Board subject of the gated task. */
1072
+ taskSubject: string;
1073
+ /** The claimer's own result summary; evidence, never trusted alone. */
1074
+ workerResult: string;
1075
+ /** Working directory to review: the holder's worktree root when isolated,
1076
+ * else the leader cwd — the reviewer must inspect the claimed work's tree. */
1077
+ cwd: string;
1078
+ }
1079
+
1080
+ export type VerifyReviewOutcome = { ok: boolean; detail?: string };
1081
+
1082
+ /** Compose the reviewer prompt: fresh context, independent checks, explicit verdict line. */
1083
+ export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
1084
+ const result = input.workerResult.trim().slice(0, 4000);
1085
+ return [
1086
+ "You are a fresh completion-gate reviewer for one teammate task on a shared board.",
1087
+ `Task subject: ${input.taskSubject}`,
1088
+ result ? `The claimer's result summary:\n${result}` : "The claimer provided no result summary.",
1089
+ "Independently verify the work against the acceptance gate below using your tools; do not trust the summary alone. Do not modify any files.",
1090
+ "Acceptance gate:",
1091
+ input.verify.trim(),
1092
+ "",
1093
+ `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.`,
1094
+ ].join("\n");
1095
+ }
1096
+
1097
+ /** Parse the reviewer's reply into a gate outcome, scanning from the final
1098
+ * line upward. PASS must be an exact verdict line ("VERDICT: PASS", no
1099
+ * trailing content) so a contradictory suffix cannot sneak through; FAIL
1100
+ * carries its reasons. A missing or malformed verdict fails the gate so an
1101
+ * ambiguous review never completes a task. */
1102
+ export function parseVerifyVerdict(text: string): VerifyReviewOutcome {
1103
+ const lines = text.trim().split("\n");
1104
+ for (let index = lines.length - 1; index >= 0; index--) {
1105
+ const candidate = lines[index].trim();
1106
+ if (/^verdict:\s*pass$/i.test(candidate)) return { ok: true };
1107
+ const failMatch = /^verdict:\s*fail\b[\s:-]*(.*)$/i.exec(candidate);
1108
+ if (failMatch) {
1109
+ return { ok: false, detail: truncated(failMatch[1].trim()) || "(no reasons given)" };
1110
+ }
1111
+ }
1112
+ return { ok: false, detail: `(no ${VERIFY_VERDICT_PASS}/FAIL verdict line in the review)\n${truncated(text.trim())}` };
1113
+ }
1114
+
1115
+ function truncated(text: string, cap = 4000): string {
1116
+ const suffix = "\n…[truncated]";
1117
+ return text.length <= cap ? text : text.slice(0, Math.max(0, cap - suffix.length)) + suffix;
1118
+ }
1119
+
1120
+ /** Run the gate as a one-shot Pi worker: a brand-new context that inspects
1121
+ * the working tree itself before answering. Uses the team's model resolution
1122
+ * chain (team default, else Pi default) rather than any role pin. */
1123
+ export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyReviewOutcome> {
1124
+ const outcome = await runPiWorker({
1125
+ prompt: buildVerifyReviewPrompt(input),
1126
+ cwd: input.cwd || leaderCwd,
1127
+ model: resolveSpawnModel(undefined, getTeamDefaultModel(), leaderModelRef()).model,
826
1128
  });
1129
+ // A reviewer that did not exit cleanly produced no trustworthy verdict,
1130
+ // even if partial output happens to contain a PASS line.
1131
+ if (outcome.exitCode !== 0) {
1132
+ return { ok: false, detail: truncated(`reviewer exited with code ${outcome.exitCode}: ${(outcome.stderr || outcome.text || "no output").trim()}`) };
1133
+ }
1134
+ return parseVerifyVerdict(outcome.text);
1135
+ }
1136
+
1137
+ /** Active gate runner; tests inject a stub through setVerifyGateRunner. */
1138
+ let verifyGateRunner: (input: VerifyReviewInput) => Promise<VerifyReviewOutcome> = runVerifyReview;
1139
+
1140
+ /** Replace the completion-gate runner (test seam); undefined restores the real reviewer. */
1141
+ export function setVerifyGateRunner(runner: ((input: VerifyReviewInput) => Promise<VerifyReviewOutcome>) | undefined): void {
1142
+ verifyGateRunner = runner ?? runVerifyReview;
827
1143
  }
828
1144
 
829
1145
  function freeTeammateFromTask(workerName: string, taskId: string): void {
@@ -846,20 +1162,56 @@ function findLivingTeammate(intent: import("./types").TaskIntent): Teammate | un
846
1162
 
847
1163
  // ── Wake-ups ──────────────────────────────────────────────────────
848
1164
 
1165
+ /** Claimable tasks the teammate has not been notified about yet. */
1166
+ export function freshClaimableTasks<T extends { id: string }>(
1167
+ noticedIds: readonly string[] | undefined,
1168
+ tasks: readonly T[],
1169
+ ): T[] {
1170
+ const noticed = new Set(noticedIds ?? []);
1171
+ return tasks.filter((task) => !noticed.has(task.id));
1172
+ }
1173
+
1174
+ /** Retain only noticed ids that are still claimable, so a long board's history
1175
+ * can never evict a live one and resurrect an old wake-up. */
1176
+ export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds: ReadonlySet<string>): string[] {
1177
+ return noticedIds.filter((id) => claimableIds.has(id));
1178
+ }
1179
+
1180
+ function markTasksNoticed(name: string, taskIds: string[]): void {
1181
+ if (taskIds.length === 0) return;
1182
+ const teammate = getTeammate(name);
1183
+ if (!teammate) return;
1184
+ const live = retainLiveNoticedIds(teammate.noticedTaskIds ?? [], new Set(claimableTasks().map((task) => task.id)));
1185
+ const merged = [...live, ...taskIds];
1186
+ while (merged.length > MAX_NOTICED_TASK_IDS) merged.shift();
1187
+ updateTeammate(name, { noticedTaskIds: merged });
1188
+ }
1189
+
1190
+ /** A released task becomes noticeable again for every living teammate. */
1191
+ function rearmTaskNotice(taskId: string): void {
1192
+ for (const teammate of livingTeammates()) {
1193
+ if (teammate.noticedTaskIds?.includes(taskId)) {
1194
+ updateTeammate(teammate.name, { noticedTaskIds: teammate.noticedTaskIds.filter((id) => id !== taskId) });
1195
+ }
1196
+ }
1197
+ }
1198
+
849
1199
  /**
850
1200
  * Compose one wake-up prompt per idle teammate from queued inbox deliveries
851
- * plus a paced claimable-task notice. Teammates with nothing to do are never
852
- * woken.
1201
+ * plus a board notice covering only claimable work that teammate has never
1202
+ * been shown. Teammates with nothing new are never woken.
853
1203
  */
854
1204
  export function wakeIdleTeammates(): void {
855
- const claimable = claimableTasks();
856
1205
  for (const teammate of idleTeammates()) {
857
1206
  const deliveries = pendingDeliveries.get(teammate.name) ?? [];
858
- const dueNotice = claimable.length > 0 && noticeDue(teammate);
1207
+ const fresh = freshClaimableTasks(teammate.noticedTaskIds, claimableTasks());
1208
+ const dueNotice = fresh.length > 0 && noticeDue(teammate);
859
1209
  if (deliveries.length === 0 && !dueNotice) continue;
860
- const prompt = buildWakePrompt(deliveries, claimable, dueNotice);
1210
+ const noticed = fresh.slice(0, WAKE_NOTICE_TASK_LIMIT);
1211
+ const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
861
1212
  if (!deliverPrompt(teammate.name, prompt)) continue;
862
1213
  pendingDeliveries.delete(teammate.name);
1214
+ if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
863
1215
  // A delivered prompt is fresh activity: restart the silence clock so a
864
1216
  // long-idle teammate is never insta-flagged as stalled on wake.
865
1217
  updateTeammate(teammate.name, {