@fradser/pi-agent-teams 0.8.3 → 0.9.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.
@@ -10,17 +10,23 @@ import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
- import { modelLabel, runPiWorker } from "@fradser/pi-kit";
13
+
14
+ /** pi thinking levels, mirrored from @earendil-works/pi-agent-core. */
15
+ type LeaderThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
16
+ const THINKING_LEVELS: readonly LeaderThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
17
+ import { modelLabel, runPiWorker, type RunPiWorkerOptions } from "@fradser/pi-kit";
14
18
  import { MODEL_INHERIT_ALIAS, discoverAgents, persistAgentDefinition, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "./agents.ts";
15
19
  import {
16
20
  activeAssignmentConflict,
17
21
  applyClaimIntent,
18
22
  assignTeammate,
23
+ boardRevision,
19
24
  claimableTasks,
20
- clearStateDirty,
21
25
  clearWorkerRunEvents,
22
26
  completeTask,
23
27
  createTask,
28
+ createDirectWork,
29
+ discardDirectWork,
24
30
  deliverToLeader,
25
31
  getPeerInboxOffset,
26
32
  getState,
@@ -29,18 +35,25 @@ import {
29
35
  idleTeammates,
30
36
  isPeerDelivered,
31
37
  isValidTeammateName,
32
- listTasks,
38
+ listTeammates,
33
39
  livingTeammates,
34
40
  loadBoard,
41
+ markBoardChanged,
35
42
  markPeerDelivered,
36
- markStateDirty,
43
+ getTask,
37
44
  normalizeResources,
45
+ progressRevision,
38
46
  registerTeammate,
39
47
  releaseTask,
40
48
  releaseTasksOf,
49
+ reclaimDirectWork,
50
+ taskDependenciesMet,
41
51
  receiveWorkerMessage,
52
+ reopenCompletedWork,
42
53
  setPeerInboxOffset,
54
+ rosterRevision,
43
55
  setPeerDeliveryState,
56
+ stateRevision,
44
57
  updateTeammate,
45
58
  updateTeammateProgress,
46
59
  } from "./state.ts";
@@ -65,22 +78,26 @@ import {
65
78
  } from "./statefile.ts";
66
79
  import { isWorkerEvent } from "./types.ts";
67
80
  import {
81
+ deliverFreshAssignment,
68
82
  deliverPrompt,
69
83
  isCleanExit,
84
+ isFreshAssignmentPending,
70
85
  resolveWorkerTools,
71
86
  sendWorkerSteer,
87
+ sendWorkerFollowUp,
72
88
  spawnResident,
73
89
  terminateAllTeammates,
74
90
  terminateTeammate,
75
91
  unknownWorkerTools,
76
92
  WORKER_TOOL_UNIVERSE,
77
93
  type WorkerProcessResult,
94
+ type ResidentSpawnOptions,
78
95
  } from "./spawner.ts";
79
96
  import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree.ts";
80
97
  import { messageTitle, type InboxMessage, type Teammate, type WorkerAssignment, type WorkerUsage } from "./types.ts";
81
- import type { FollowUpReport } from "./follow-up-queue.ts";
98
+ import { exactSessionRoute, resolveExactSession } from "./recipient.ts";
99
+ import type { LeaderReport } from "./leader-reports.ts";
82
100
 
83
- export const MAX_SESSION_WORKERS = 8;
84
101
  /** Harness coordination cadence: outbox drain every tick, notices paced. */
85
102
  const LIVE_POLL_MS = 500;
86
103
  /** Minimum gap between claimable-task notices per teammate. One-shot noticing
@@ -96,17 +113,10 @@ export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
96
113
  const MAX_NOTICED_TASK_IDS = 256;
97
114
  /** Claimable tasks listed in one wake-prompt board notice. */
98
115
  const WAKE_NOTICE_TASK_LIMIT = 10;
99
- /** Silence is not a work deadline: active stream output keeps a teammate alive.
100
- * The watchdog may only notify; termination decisions belong to the leader model. */
101
- const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
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);
116
+ /** Console-only silence marker: after this long without output the roster
117
+ * shows "stalled". It never notifies the leader or terminates anything. */
118
+ const DEFAULT_STALL_SILENCE_MS = 5 * 60 * 1000;
119
+ const STALL_SILENCE_MS = readDurationEnv("PI_TEAMMATE_STALL_SILENCE_MS", DEFAULT_STALL_SILENCE_MS);
110
120
  /** Fully-consumed inboxes larger than this are truncated. */
111
121
  const INBOX_COMPACT_BYTES = 256 * 1024;
112
122
 
@@ -122,15 +132,6 @@ export function stallSilenceMs(teammate: Pick<Teammate, "lastOutputAt">, now = D
122
132
  return Math.max(0, now - teammate.lastOutputAt);
123
133
  }
124
134
 
125
- export function isStallThresholdReached(
126
- teammate: Pick<Teammate, "lastOutputAt">,
127
- now: number,
128
- thresholdMs: number,
129
- ): boolean {
130
- const silence = stallSilenceMs(teammate, now);
131
- return thresholdMs > 0 && silence !== undefined && silence >= thresholdMs;
132
- }
133
-
134
135
  export function formatSilenceDuration(milliseconds: number): string {
135
136
  const totalMinutes = Math.floor(Math.max(0, milliseconds) / 60_000);
136
137
  if (totalMinutes < 1) return "less than 1m";
@@ -140,42 +141,9 @@ export function formatSilenceDuration(milliseconds: number): string {
140
141
  return `${minutes}m`;
141
142
  }
142
143
 
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).`;
144
+ /** Console-only silence threshold for the roster "stalled" marker. */
145
+ export function stallThresholdMs(_teammate: Pick<Teammate, "modelOutputSeen" | "activeTool">): number {
146
+ return STALL_SILENCE_MS;
179
147
  }
180
148
 
181
149
  let livePollTimer: ReturnType<typeof setInterval> | undefined;
@@ -185,20 +153,81 @@ let boardFile = "";
185
153
  let leaderCwd = "";
186
154
  /** Live view of the leader session's current model, resolved at spawn time. */
187
155
  let leaderModelRef: () => string | undefined = () => undefined;
188
- let sendUpdate: (report: FollowUpReport) => void = () => {};
189
- let archiveQueuedReports: (spawnId: string) => FollowUpReport[] = () => [];
156
+ let leaderThinkingLevelRef: () => LeaderThinkingLevel | undefined = () => undefined;
157
+ let sendUpdate: (report: LeaderReport) => void = () => {};
190
158
  let notifyChange: () => void = () => {};
191
159
 
192
160
  const pendingShutdowns = new Set<string>();
161
+ const confirmedStopTimes = new Map<string, number>();
162
+ /** Confirmed-stop and finish bookkeeping is per incarnation or per assignment
163
+ * attempt, so a long session accumulates one entry each. Keep the same bounded
164
+ * FIFO shape the peer-delivery dedupe uses instead of growing without limit. */
165
+ const MAX_RUNTIME_BOOKKEEPING_ENTRIES = 512;
166
+
167
+ function rememberBounded<T>(entries: Map<string, T> | Set<string>, key: string, value?: T): void {
168
+ if (entries instanceof Map) entries.set(key, value as T);
169
+ else entries.add(key);
170
+ for (const oldest of entries.keys()) {
171
+ if (entries.size <= MAX_RUNTIME_BOOKKEEPING_ENTRIES) break;
172
+ entries.delete(oldest);
173
+ }
174
+ }
175
+ const closeFinalizations = new Map<string, Promise<void>>();
176
+
177
+ export function getConfirmedStopTime(spawnId: string): number | undefined {
178
+ return confirmedStopTimes.get(spawnId);
179
+ }
193
180
  /** Task ids under verification, bound to one exact submission via token so a
194
181
  * 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 }>();
182
+ const verifyingTasks = new Map<string, { worker: string; spawnId: string; assignmentId: string; submissionId: string; token: string }>();
183
+ /** In-flight gate reviewers by task id. Invalidating a holding's authority must
184
+ * also stop its reviewer child, not merely discard the verdict it returns. */
185
+ const verifyAborts = new Map<string, { controller: AbortController; submissionId: string }>();
186
+
187
+ /** Drop any in-flight gate for one task and stop its reviewer process. Every
188
+ * invalidation path uses this so Work authority and child processes cannot
189
+ * diverge. */
190
+ function invalidateVerifyGate(taskId: string): void {
191
+ verifyingTasks.delete(taskId);
192
+ const active = verifyAborts.get(taskId);
193
+ if (!active) return;
194
+ verifyAborts.delete(taskId);
195
+ active.controller.abort();
196
+ }
197
+
198
+ /** Start one gate attempt, aborting any earlier reviewer for the same task. */
199
+ function runVerifyGate(
200
+ intent: import("./types").TaskIntent,
201
+ subject: string,
202
+ submissionId: string,
203
+ token: string,
204
+ input: VerifyReviewInput,
205
+ ): void {
206
+ const previous = verifyAborts.get(intent.taskId);
207
+ if (previous) {
208
+ verifyAborts.delete(intent.taskId);
209
+ previous.controller.abort();
210
+ }
211
+ const controller = new AbortController();
212
+ verifyAborts.set(intent.taskId, { controller, submissionId });
213
+ // A reviewer crash is a concrete failed gate. A missing verdict is handled
214
+ // separately as inconclusive so prose-format drift cannot count as a defect.
215
+ Promise.resolve()
216
+ .then(() => verifyGateRunner({ ...input, signal: controller.signal }))
217
+ .then((outcome) => resolveGateOutcome(intent, subject, submissionId, token, outcome))
218
+ .catch((error) => resolveGateOutcome(intent, subject, submissionId, token, {
219
+ kind: "fail",
220
+ detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
221
+ }));
222
+ }
223
+ type PendingSubmission = import("./types").TaskIntent & { assignmentId: string; verify: string | undefined };
224
+ /** Submitted Work waits for its authoring Worker to settle before a gate can
225
+ * inspect its files. This applies to direct automatic and board submissions. */
226
+ const pendingSubmissions = new Map<string, PendingSubmission>();
196
227
  /** Idle nudges already fired per teammate incarnation (one per transition). */
197
228
  const idleNudgesSent = new Set<string>();
198
- /** One finish entry per spawn incarnation; repeated terminal reports stay ordinary report rows. */
229
+ /** One finish entry per assignment attempt; repeated terminal reports stay ordinary report rows. */
199
230
  const announcedFinishKeys = new Set<string>();
200
- /** Incarnations whose terminal report reached the leader pipeline, queued or dispatched. */
201
- const terminalReportKeys = new Set<string>();
202
231
  /** Consecutive verify failures per taskId:spawnId holder incarnation. */
203
232
  const verifyFailures = new Map<string, VerifyFailureRecord>();
204
233
  /** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
@@ -209,28 +238,58 @@ const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
209
238
  // ── Lifecycle ─────────────────────────────────────────────────────
210
239
 
211
240
  export interface MachineHooks {
212
- sendUpdate: (report: FollowUpReport) => void;
213
- /** Suppress reports whose delivery remains harness-owned when shutdown starts. */
214
- archiveQueuedReports?: (spawnId: string) => FollowUpReport[];
241
+ sendUpdate: (report: LeaderReport) => void;
215
242
  notifyChange: () => void;
216
243
  }
217
244
 
218
245
  export function initTeamMachine(
219
- ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model">,
246
+ ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model" | "thinkingLevel">,
220
247
  hooks: MachineHooks,
221
248
  ): void {
222
249
  generation++;
223
250
  leaderCwd = ctx.cwd || process.cwd();
224
251
  leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
252
+ leaderThinkingLevelRef = () => ctx.thinkingLevel;
225
253
  const sessionFile = ctx.sessionManager?.getSessionFile();
226
254
  runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
227
255
  boardFile = boardFilePath(sessionFile, leaderCwd);
228
256
  sendUpdate = hooks.sendUpdate;
229
- archiveQueuedReports = hooks.archiveQueuedReports ?? (() => []);
230
257
  notifyChange = hooks.notifyChange;
231
- // Resume: reload a persisted board; claims die with their holders.
232
- const persisted = readBoardFile(boardFile);
233
- if (persisted) loadBoard(persisted.tasks);
258
+ // The board and runtime dir are keyed per session, so two Leader processes
259
+ // holding the same session would overwrite each other's single-writer state.
260
+ // Detection is a report, not a block: the harness never takes ownership away
261
+ // from a session the user may legitimately be resuming.
262
+ claimLeaderLease();
263
+ // Resume: reload a persisted board; claims die with their holders. A board
264
+ // that cannot be loaded is archived rather than silently discarded or allowed
265
+ // to prevent extension reload.
266
+ try {
267
+ const persisted = readBoardFile(boardFile);
268
+ if (persisted) loadBoard(persisted.tasks);
269
+ } catch (error) {
270
+ const detail = error instanceof Error ? error.message : String(error);
271
+ let preservation: string;
272
+ try {
273
+ const archived = `${boardFile}.incompatible-${Date.now()}`;
274
+ fs.renameSync(boardFile, archived);
275
+ preservation = `Archived preserved snapshot: ${archived}`;
276
+ } catch {
277
+ // An unusable agents directory must still not turn preservation into a
278
+ // fatal startup error: report where the snapshot stayed instead.
279
+ preservation = `The preserved snapshot could not be archived and stays in place: ${boardFile}`;
280
+ }
281
+ deliverToLeader({ from: "harness", subject: "Incompatible Work snapshot", body: `${detail}\n${preservation}` });
282
+ }
283
+ }
284
+
285
+ /** Re-bind the leader context closures after a mid-session model or
286
+ * thinking-level switch, so later spawns resolve against the current
287
+ * session configuration instead of the session_start snapshot. */
288
+ export function syncLeaderContext(
289
+ ctx: Pick<ExtensionContext, "model" | "thinkingLevel">,
290
+ ): void {
291
+ leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
292
+ leaderThinkingLevelRef = () => ctx.thinkingLevel;
234
293
  }
235
294
 
236
295
  export function shutdownTeamMachine(): void {
@@ -241,31 +300,43 @@ export function shutdownTeamMachine(): void {
241
300
  boardFile = "";
242
301
  leaderCwd = "";
243
302
  leaderModelRef = () => undefined;
303
+ leaderThinkingLevelRef = () => undefined;
244
304
  setVerifyGateRunner(undefined);
245
305
  sendUpdate = () => {};
246
- archiveQueuedReports = () => [];
247
306
  notifyChange = () => {};
248
307
  pendingShutdowns.clear();
308
+ confirmedStopTimes.clear();
309
+ closeFinalizations.clear();
249
310
  verifyingTasks.clear();
311
+ for (const active of verifyAborts.values()) active.controller.abort();
312
+ verifyAborts.clear();
313
+ pendingSubmissions.clear();
250
314
  idleNudgesSent.clear();
251
315
  selfFinalizeAttempts.clear();
252
316
  pendingDeliveries.clear();
253
317
  verifyFailures.clear();
254
318
  inconclusiveParks.clear();
255
319
  verifyFailureParks.clear();
320
+ unexpectedExecutionParks.clear();
256
321
  announcedFinishKeys.clear();
257
- terminalReportKeys.clear();
322
+ snapshotFailure = undefined;
323
+ writtenStateRevision = -1;
324
+ writtenRosterRevision = -1;
325
+ writtenBoardRevision = -1;
326
+ writtenProgressRevision = -1;
327
+ lastStateWriteAt = 0;
258
328
  }
259
329
 
260
330
  // ── Spawn model resolution ────────────────────────────────────
261
331
 
262
332
  /** How a spawn's effective model was chosen. */
263
- export type SpawnModelSource = "pin" | "inherit" | "team-default" | "none";
333
+ export type SpawnModelSource = "pin" | "inherit" | "team-default" | "leader-session" | "none";
264
334
 
265
335
  /**
266
336
  * Resolve the effective spawn model. Precedence: explicit role pin beats the
267
337
  * `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.
338
+ * default set from the console; if unset, falls back to the current session's
339
+ * model; with none of these Pi picks its own default.
269
340
  * The value is resolved at spawn time so mid-session leader model switches
270
341
  * apply to later spawns.
271
342
  */
@@ -279,12 +350,30 @@ export function resolveSpawnModel(
279
350
  if (pin && leaderModel) return { model: leaderModel, source: "inherit" };
280
351
  const fallback = teamDefault?.trim();
281
352
  if (fallback) return { model: fallback, source: "team-default" };
353
+ const currentLeader = leaderModel?.trim();
354
+ if (currentLeader) return { model: currentLeader, source: "leader-session" };
282
355
  return { model: undefined, source: "none" };
283
356
  }
284
357
 
285
358
  /** The leader session's current model reference, when one is selected. */
286
359
  export function currentLeaderModelRef(): string | undefined {
287
- return leaderModelRef();
360
+ const fromRef = leaderModelRef();
361
+ if (fromRef) return fromRef;
362
+ if (process.env.PI_PROVIDER && process.env.PI_MODEL) {
363
+ return `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}`;
364
+ }
365
+ return process.env.PI_MODEL;
366
+ }
367
+
368
+ /** The leader session's current thinking level, when one is selected. */
369
+ export function currentLeaderThinkingLevel(): LeaderThinkingLevel | undefined {
370
+ const fromRef = leaderThinkingLevelRef();
371
+ if (fromRef) return fromRef;
372
+ const fromEnv = process.env.PI_REASONING_LEVEL?.trim();
373
+ if (fromEnv && THINKING_LEVELS.includes(fromEnv as LeaderThinkingLevel)) {
374
+ return fromEnv as LeaderThinkingLevel;
375
+ }
376
+ return undefined;
288
377
  }
289
378
 
290
379
  /** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
@@ -296,7 +385,7 @@ export async function teardownTeammates(): Promise<string[]> {
296
385
  const results = await terminateAllTeammates();
297
386
  const diagnostics: string[] = [];
298
387
  for (const result of results) {
299
- if (!result.confirmedClosed) diagnostics.push(`Teammate ${result.name} could not be confirmed closed.`);
388
+ if (!result.confirmedClosed) diagnostics.push(`Agent ${result.name} could not be confirmed closed.`);
300
389
  updateTeammate(result.name, { status: "stopped" });
301
390
  }
302
391
  return diagnostics;
@@ -319,22 +408,115 @@ export function runtimeDirPath(): string {
319
408
  return file.slice(0, Math.max(file.lastIndexOf("/"), 0));
320
409
  }
321
410
 
411
+ /** Consecutive snapshot writes that failed. The tick retries, so only a
412
+ * persistent failure (no successful write in this many attempts) is reported
413
+ * instead of being silently invisible. */
414
+ const SNAPSHOT_FAILURE_REPORT_AFTER = 3;
415
+ let snapshotFailure: { count: number; reported: boolean; detail: string } | undefined;
416
+ /** Revisions already on disk. -1 makes the first flush after a (re)init write
417
+ * every artifact. */
418
+ let writtenStateRevision = -1;
419
+ let writtenRosterRevision = -1;
420
+ let writtenBoardRevision = -1;
421
+ let writtenProgressRevision = -1;
422
+ let lastStateWriteAt = 0;
423
+ /** Roughly 2 poll ticks: the debug snapshot follows progress, but not per token. */
424
+ export const STATE_SNAPSHOT_MIN_INTERVAL_MS = 1_000;
425
+
426
+ /** Runtime dir that holds the leader lease for the current session. */
427
+ function leaderLockPath(stateFile: string): string {
428
+ return path.join(path.dirname(stateFile), "leader.pid");
429
+ }
430
+
431
+ function isProcessAlive(pid: number): boolean {
432
+ try {
433
+ process.kill(pid, 0);
434
+ return true;
435
+ } catch (error) {
436
+ return (error as NodeJS.ErrnoException).code === "EPERM";
437
+ }
438
+ }
439
+
440
+ /** Record this process as the session's Leader, warning when another live one
441
+ * still holds the same session. The lease dies with the runtime dir. */
442
+ function claimLeaderLease(): void {
443
+ const lock = leaderLockPath(requireStateFile());
444
+ let previous: number | undefined;
445
+ try {
446
+ const parsed = Number(fs.readFileSync(lock, "utf-8").trim());
447
+ if (Number.isInteger(parsed) && parsed > 0 && parsed !== process.pid) previous = parsed;
448
+ } catch {
449
+ // No lease yet, or an unreadable one: this process takes it either way.
450
+ }
451
+ try {
452
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
453
+ fs.writeFileSync(lock, `${process.pid}\n`, { encoding: "utf-8", mode: 0o600 });
454
+ } catch {
455
+ return;
456
+ }
457
+ if (previous !== undefined && isProcessAlive(previous)) {
458
+ deliverToLeader({
459
+ from: "harness",
460
+ subject: "Another Leader process shares this session",
461
+ body: `Leader process ${previous} is still running for this session's Work directory. Two Leaders writing one session would overwrite each other's Work state; finish or stop the other Pi session (or confirm it exited) before editing Work here.`,
462
+ });
463
+ }
464
+ }
465
+
322
466
  function flushSnapshots(): void {
323
467
  const stateFile = requireStateFile();
468
+ const rosterRev = rosterRevision();
469
+ const boardRev = boardRevision();
470
+ const stateRev = stateRevision();
471
+ const progressRev = progressRevision();
472
+ const needsRoster = rosterRev !== writtenRosterRevision;
473
+ const needsBoard = boardRev !== writtenBoardRevision;
474
+ // The whole-state file exists for forensics; nothing in the runtime reads it
475
+ // back. It still tracks streamed progress, but at a bounded rate instead of
476
+ // rewriting the state on every token.
477
+ const needsState = stateRev !== writtenStateRevision
478
+ || (progressRev !== writtenProgressRevision && Date.now() - lastStateWriteAt >= STATE_SNAPSHOT_MIN_INTERVAL_MS);
479
+ if (!needsRoster && !needsBoard && !needsState) return;
324
480
  try {
325
- writeStateFile(stateFile, getState());
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
- })));
334
- if (boardFile) writeBoardFile(boardFile, getState().tasks);
335
- clearStateDirty();
336
- } catch {
337
- // Keep the dirty bit set so the next poll retries the snapshot.
481
+ if (needsState) writeStateFile(stateFile, getState());
482
+ if (needsRoster) {
483
+ writeRoster(rosterPath(stateFile), livingTeammates().map((t) => ({
484
+ name: t.name,
485
+ agent: t.agent,
486
+ spawnId: t.spawnId,
487
+ status: t.status,
488
+ tools: t.tools,
489
+ currentTaskId: t.currentTaskId,
490
+ assignment: t.assignment,
491
+ })));
492
+ }
493
+ if (needsBoard && boardFile) writeBoardFile(boardFile, getState().tasks);
494
+ // Only a fully successful flush advances the revisions, so a failed write
495
+ // stays dirty and the next poll retries it.
496
+ if (needsState) {
497
+ writtenStateRevision = stateRev;
498
+ writtenProgressRevision = progressRev;
499
+ lastStateWriteAt = Date.now();
500
+ }
501
+ if (needsRoster) writtenRosterRevision = rosterRev;
502
+ if (needsBoard) writtenBoardRevision = boardRev;
503
+ snapshotFailure = undefined;
504
+ } catch (error) {
505
+ // The next poll retries. A transient failure stays quiet, but a persistent
506
+ // one is surfaced once: durable Work state silently diverging from memory
507
+ // is a decision the Leader must be able to make.
508
+ const detail = error instanceof Error ? error.message : String(error);
509
+ const count = (snapshotFailure?.count ?? 0) + 1;
510
+ const alreadyReported = snapshotFailure?.reported ?? false;
511
+ snapshotFailure = { count, reported: alreadyReported, detail };
512
+ if (count >= SNAPSHOT_FAILURE_REPORT_AFTER && !alreadyReported) {
513
+ snapshotFailure.reported = true;
514
+ deliverToLeader({
515
+ from: "harness",
516
+ subject: "Work snapshot writes are failing",
517
+ body: `The durable Work snapshot under ${path.dirname(stateFile)} has failed ${count} consecutive writes (${detail}). In-memory Work state is still authoritative for this session; the files on disk are stale. Check disk space and permissions before ending the session.`,
518
+ });
519
+ }
338
520
  }
339
521
  }
340
522
 
@@ -364,7 +546,6 @@ function tick(): void {
364
546
  processTaskIntents();
365
547
  routePeerInboxes();
366
548
  wakeIdleTeammates();
367
- checkStalledTeammates();
368
549
  flushSnapshots();
369
550
  ensureLivePoll();
370
551
  notifyChange();
@@ -375,15 +556,21 @@ function tick(): void {
375
556
  export function spawnTeammate(input: {
376
557
  name: string;
377
558
  agent: string;
559
+ workId?: string;
560
+ /** Internal binding of an existing Work Item, never exposed as a public field. */
561
+ existingWorkId?: string;
562
+ context?: ResidentSpawnOptions["context"];
563
+ model?: string;
378
564
  prompt?: string;
379
565
  resources?: string[];
566
+ verify?: string;
380
567
  handoffFrom?: string;
381
568
  definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
382
569
  tools?: string[];
383
570
  persist?: boolean;
384
571
  persistScope?: "project" | "project-local";
385
572
  };
386
- }): { ok: true; teammate: Teammate } | { ok: false; error: string } {
573
+ }): { ok: true; teammate: Teammate; readiness?: Promise<string | undefined> } | { ok: false; error: string } {
387
574
  const stateFile = requireStateFile();
388
575
  const invalid = validateSpawnInput(input);
389
576
  if (invalid) return { ok: false, error: invalid };
@@ -394,6 +581,10 @@ export function spawnTeammate(input: {
394
581
  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
582
  }
396
583
  }
584
+ const existingWork = input.existingWorkId ? getState().tasks[input.existingWorkId] : undefined;
585
+ if (input.existingWorkId && (!existingWork || existingWork.status !== "pending" || !taskDependenciesMet(existingWork))) {
586
+ return { ok: false, error: `Work Item "${input.existingWorkId}" is not pending and claimable.` };
587
+ }
397
588
  const resolved = resolveAgent(input.agent, leaderCwd);
398
589
  // Reject tool ids the bare child could never grant before any side effect:
399
590
  // a silent --tools drop here is how reviewers end up blind mid-audit.
@@ -434,9 +625,11 @@ export function spawnTeammate(input: {
434
625
 
435
626
  const spawnId = randomUUID();
436
627
  const effectiveKickoff = buildSuccessorHandoff(input.prompt, input.handoffFrom);
437
- const directResources = effectiveKickoff?.trim()
438
- ? resolveDirectResources(input.resources, input.handoffFrom)
439
- : [];
628
+ const directResources = input.existingWorkId
629
+ ? existingWork!.resources
630
+ : effectiveKickoff?.trim()
631
+ ? resolveDirectResources(input.resources, input.handoffFrom)
632
+ : [];
440
633
  const conflict = activeAssignmentConflict(directResources);
441
634
  if (conflict) {
442
635
  discardWorktreeQuietly(input.name);
@@ -448,48 +641,87 @@ export function spawnTeammate(input: {
448
641
  return { ok: false, error: registered.error };
449
642
  }
450
643
 
451
- const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
644
+ const spawnModel = resolveSpawnModel(input.model ?? agent.model, getTeamDefaultModel(), currentLeaderModelRef());
452
645
  // Record the grant before the first wake: a role derived without tools shows
453
646
  // its narrow capability-only allowlist right on the spawn surface.
454
647
  const assignment = directAssignment(effectiveKickoff, directResources, spawnId);
648
+ if (assignment) {
649
+ const directWork = input.existingWorkId
650
+ ? reclaimDirectWork(input.existingWorkId, input.name, assignment, "pending")
651
+ : createDirectWork({
652
+ id: getTeammate(input.name)?.workId ?? `work:${spawnId}`,
653
+ subject: effectiveKickoff ? messageTitle(effectiveKickoff) : "Direct work",
654
+ description: effectiveKickoff,
655
+ resources: directResources,
656
+ verify: input.verify,
657
+ workerName: input.name,
658
+ assignment,
659
+ });
660
+ if (!directWork.ok) {
661
+ updateTeammate(input.name, { status: "stopped" });
662
+ discardWorktreeQuietly(input.name);
663
+ return { ok: false, error: directWork.error };
664
+ }
665
+ }
455
666
  updateTeammate(input.name, {
456
667
  model: spawnModel.model,
668
+ context: input.context === undefined ? "fresh" : "fork",
457
669
  tools: resolveWorkerTools(agent.tools),
458
- assignment,
670
+ ...(assignment ? {} : { assignment }),
459
671
  });
460
672
  // Flush before the kickoff is written: a fast child must not read a stale
461
673
  // worker-readable roster missing its own entry or tool grant.
462
674
  publishStateSnapshot();
463
675
 
676
+ // Settles from native control events, never a polling loop or a model turn.
677
+ // Resolve failures as values so internal synchronous spawn callers cannot
678
+ // produce an unhandled rejection when they do not await startup.
679
+ let settleReadiness!: (error: string | undefined) => void;
680
+ const readiness = new Promise<string | undefined>((resolve) => { settleReadiness = resolve; });
464
681
  const started = spawnResident({
465
682
  workerName: input.name,
466
- description: buildKickoffPrompt(
683
+ description: assignment ? `[agent-teams-assignment:${assignment.id}]\n` + buildKickoffPrompt(
467
684
  input.name,
468
685
  input.agent,
469
686
  agent.prompt,
470
687
  effectiveKickoff,
471
688
  isolation,
472
- ),
689
+ ) : undefined,
473
690
  model: spawnModel.model,
691
+ thinking: currentLeaderThinkingLevel(),
692
+ context: input.context,
474
693
  tools: agent.tools,
475
694
  cwd: workerCwd,
476
695
  env: teammateEnv(stateFile, input, spawnId, agent.verify),
477
- onUpdate: (progress) => applyProgress(input.name, spawnId, progress),
478
- onExit: (result) => void handleTeammateClose(input.name, spawnId, result),
696
+ onUpdate: (progress) => {
697
+ applyProgress(input.name, spawnId, progress);
698
+ if (progress.controlError) settleReadiness(progress.controlError);
699
+ else if (progress.ready) settleReadiness(undefined);
700
+ },
701
+ onError: (error) => {
702
+ settleReadiness(`Resident startup readiness failed: ${error.message}`);
703
+ if (getTeammate(input.name)?.spawnId !== spawnId) return;
704
+ if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${error.message}`);
705
+ failSpawn(input.name, error.message, input.existingWorkId);
706
+ },
707
+ onExit: (result) => {
708
+ settleReadiness("Resident exited before startup readiness was acknowledged.");
709
+ closeFinalizations.set(spawnId, handleTeammateClose(input.name, spawnId, result));
710
+ },
479
711
  });
480
712
  if ("error" in started) {
481
- failSpawn(input.name, started.error);
713
+ if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${started.error}`);
714
+ else if (assignment) discardDirectWork(getTeammate(input.name)?.workId ?? `work:${spawnId}`, input.name);
715
+ failSpawn(input.name, started.error, input.existingWorkId);
482
716
  return { ok: false, error: started.error };
483
717
  }
484
718
  updateTeammate(input.name, { pid: started.pid });
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).
719
+ // Keep starting until model activity or the unassigned child's native
720
+ // readiness acknowledgement arrives; spawning alone does not prove idle.
489
721
  publishStateSnapshot();
490
722
  ensureLivePoll();
491
723
  notifyChange();
492
- return { ok: true, teammate: getTeammate(input.name)! };
724
+ return { ok: true, teammate: getTeammate(input.name)!, ...(assignment ? {} : { readiness }) };
493
725
  }
494
726
 
495
727
  /** True when a spawn's inline definition should create or replace the role:
@@ -519,7 +751,7 @@ export function unknownAgentError(name: string, cwd: string): string {
519
751
  `Agent "${name}" not found in any scope.`,
520
752
  `Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
521
753
  '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.',
754
+ 'Recover by retrying agent action=delegate or start 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
755
  `Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
524
756
  ].join(' ');
525
757
  }
@@ -531,14 +763,11 @@ function validateSpawnInput(input: { name: string }): string | undefined {
531
763
  if (livingTeammates().some((t) => t.name === input.name)) {
532
764
  return `A living teammate named "${input.name}" already exists.`;
533
765
  }
534
- if (livingTeammates().length >= MAX_SESSION_WORKERS) {
535
- return `Session cap reached: at most ${MAX_SESSION_WORKERS} teammates may be alive at once.`;
536
- }
537
766
  return undefined;
538
767
  }
539
768
 
540
769
  function newTeammate(
541
- input: { name: string; agent: string },
770
+ input: { name: string; agent: string; workId?: string },
542
771
 
543
772
  spawnId: string,
544
773
  isolation: Teammate["isolation"],
@@ -548,6 +777,7 @@ function newTeammate(
548
777
  name: input.name,
549
778
  agent: input.agent,
550
779
  spawnId,
780
+ workId: input.workId,
551
781
  pid: 0,
552
782
  status: "starting",
553
783
  cwd: workerCwd,
@@ -634,11 +864,21 @@ export function buildKickoffPrompt(
634
864
  ].filter(Boolean).join(" ");
635
865
  const roleSection = `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt}`;
636
866
  const taskSection = 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.`
638
- : "=== KICKOFF TASK ===\n(none yet — check the task board with task_list and claim suitable work with task_claim)";
867
+ ? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly. Do not call work action=list or check the task board unless this task explicitly instructs you to do so.`
868
+ : "=== KICKOFF TASK ===\n(none yet — inspect Work with action=list and claim suitable Work with action=claim)";
639
869
  return `${header}\n\n${roleSection}\n\n${taskSection}`;
640
870
  }
641
871
 
872
+ function buildFreshAssignmentPrompt(teammate: Teammate, assignmentId: string, task: string): string {
873
+ return `[agent-teams-assignment:${assignmentId}]\n${buildKickoffPrompt(
874
+ teammate.name,
875
+ teammate.agent,
876
+ resolveAgent(teammate.agent, leaderCwd)?.prompt ?? "",
877
+ task,
878
+ teammate.isolation,
879
+ )}`;
880
+ }
881
+
642
882
  function discardWorktreeQuietly(name: string): void {
643
883
  const handle = liveWorktrees.get(name);
644
884
  if (!handle || "error" in handle) return;
@@ -651,62 +891,65 @@ function boardDirectory(): string {
651
891
  return path.dirname(boardFile);
652
892
  }
653
893
 
654
- function applyProgress(name: string, spawnId: string, progress: {
894
+ export function applyProgress(name: string, spawnId: string, progress: {
655
895
  text: string;
656
896
  activeTool?: string;
657
897
  liveThinking?: string;
658
898
  turns: number;
659
899
  finalResponse?: boolean;
660
900
  modelOutputSeen?: boolean;
901
+ ready?: boolean;
661
902
  usage?: WorkerUsage;
903
+ controlError?: string;
662
904
  }): void {
663
905
  const teammate = getTeammate(name);
664
906
  // A stale callback from an older incarnation must not touch the current one.
665
907
  if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
908
+ if (progress.controlError) {
909
+ updateTeammate(name, { error: progress.controlError });
910
+ deliverDiagnostic(name, progress.controlError);
911
+ sendUpdate({ teammate: name, spawnId, body: progress.controlError, origin: "harness", harnessEvent: { type: "control-rejected", subject: `@${name} control rejected` }, finished: false });
912
+ }
666
913
  updateTeammateProgress(name, teammate.spawnId, {
667
914
  liveText: progress.text,
668
915
  activeTool: progress.activeTool,
669
916
  liveThinking: progress.liveThinking,
670
917
  turns: progress.turns,
671
- sequenceEnded: progress.finalResponse === true ? true : undefined,
918
+ sequenceEnded: progress.finalResponse,
672
919
  modelOutputSeen: progress.modelOutputSeen,
673
920
  usage: progress.usage,
674
921
  });
675
- updateTeammate(name, { lastOutputAt: Date.now(), stallNoticeSentAt: undefined });
922
+ updateTeammate(name, { lastOutputAt: Date.now(), ...(progress.finalResponse === false ? { status: "working" } : {}) });
923
+ if (progress.finalResponse === false && teammate.currentTaskId && verifyingTasks.has(teammate.currentTaskId)) {
924
+ const holding = `${teammate.currentTaskId}:${spawnId}`;
925
+ invalidateVerifyGate(teammate.currentTaskId);
926
+ unexpectedExecutionParks.add(holding);
927
+ sendUpdate({
928
+ teammate: name,
929
+ spawnId,
930
+ origin: "harness",
931
+ harnessEvent: { type: "review-invalidated", subject: `Verification invalidated · ${getTask(teammate.currentTaskId)?.subject ?? "Work"}` },
932
+ body: `Observed new execution for @${name} while verification was in flight. The review was invalidated; Work "${teammate.currentTaskId}" remains held until explicit leader direction authorizes a revision.`,
933
+ finished: false,
934
+ });
935
+ }
676
936
  if (progress.finalResponse && teammate.status !== "idle") {
677
937
  updateTeammate(name, { status: "idle", activeTool: undefined });
938
+ drainTeammateOutboxes(name);
939
+ processTaskIntents();
940
+ const pending = pendingSubmissions.get(name);
941
+ if (pending?.spawnId === spawnId && getTeammate(name)?.assignment?.id === pending.assignmentId) {
942
+ pendingSubmissions.delete(name);
943
+ archiveDeferredDeliveries(getTeammate(name)!);
944
+ beginVerifyOrComplete(pending, pending.verify);
945
+ }
678
946
  nudgeIfUnfinalized(name, spawnId);
679
947
  }
680
- ensureLivePoll();
681
- }
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
-
698
- function checkStalledTeammates(now = Date.now()): void {
699
- if (STALL_NOTICE_MS <= 0) return;
700
- for (const teammate of livingTeammates()) {
701
- if (teammate.status !== "working" && teammate.status !== "starting") continue;
702
- const silence = stallSilenceMs(teammate, now);
703
- if (silence === undefined || teammate.stallNoticeSentAt !== undefined) continue;
704
- if (silence < stallThresholdMs(teammate)) continue;
705
- const body = stallNoticeBody(teammate, silence, now);
706
- updateTeammate(teammate.name, { stallNoticeSentAt: now });
707
- deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
708
- sendUpdate(formatAgentHealthReport("stalled", teammate, body, silence));
948
+ if (progress.ready || progress.controlError) {
949
+ publishStateSnapshot();
950
+ notifyChange();
709
951
  }
952
+ ensureLivePoll();
710
953
  }
711
954
 
712
955
  interface VerifyFailureRecord {
@@ -716,64 +959,39 @@ interface VerifyFailureRecord {
716
959
 
717
960
  /** One verdict-only retry per exact verify submission. */
718
961
  const inconclusiveVerifications = new Map<string, number>();
719
- /** Two inconclusive reviews park a holding until the leader explicitly steers it. */
962
+ /** Two inconclusive reviews park a holding until explicit Work recovery. */
720
963
  const inconclusiveParks = new Map<string, { worker: string; spawnId: string }>();
721
- /** Two explicit verify failures also require leader direction before retry. */
964
+ /** Repeated verify failures retain authority until explicit Work recovery. */
722
965
  const verifyFailureParks = new Map<string, { worker: string; spawnId: string }>();
966
+ /** Owner execution observed during review; explicit Leader recovery is required. */
967
+ const unexpectedExecutionParks = new Set<string>();
723
968
 
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
- }
969
+ type FinishIdentity = Pick<LeaderReport, "teammate" | "agent" | "spawnId" | "assignmentId" | "finished">;
736
970
 
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"}`);
971
+ function finishKey(report: FinishIdentity): string {
972
+ return `${report.teammate ?? report.agent ?? "teammate"}:${report.spawnId ?? "session"}:${report.assignmentId ?? "unassigned"}`;
741
973
  }
742
974
 
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"}`);
975
+ /** Announce each assignment attempt once, including later work in the same process. */
976
+ export function markTeammateFinished(report: FinishIdentity): boolean {
977
+ if (!report.finished) return false;
978
+ const key = finishKey(report);
979
+ if (announcedFinishKeys.has(key)) return false;
980
+ rememberBounded(announcedFinishKeys, key);
981
+ return true;
758
982
  }
759
983
 
760
- /** True when the teammate's last leader-bound report lacks a terminal status. */
984
+ /** Only an open current assignment can owe a final result. */
761
985
  export function hasUnfinalizedReport(name: string): boolean {
762
- const mailbox = getState().leaderMailbox;
763
- for (let i = mailbox.length - 1; i >= 0; i--) {
764
- if (mailbox[i].from !== name) continue;
765
- return mailbox[i].status !== "completed" && mailbox[i].status !== "failed";
766
- }
767
- return false;
986
+ const teammate = getTeammate(name);
987
+ return Boolean(teammate?.assignment && !teammate.assignment.closed
988
+ && !teammate.reportSequenceEnded && !verificationFrozen(teammate));
768
989
  }
769
990
 
770
991
  /** One light reminder per idle transition when work looks unfinished. */
771
992
  function nudgeIfUnfinalized(name: string, spawnId: string): void {
772
- const key = `${name}:${spawnId}`;
773
- // The terminal report may have been written to the outbox file but not yet
774
- // drained into the leader mailbox by the next tick; drain before deciding.
775
- drainTeammateOutboxes();
776
993
  if (!hasUnfinalizedReport(name)) return;
994
+ const key = `${name}:${spawnId}:${getTeammate(name)?.assignment?.id}`;
777
995
  if (!selfFinalizeAttempts.has(key)) {
778
996
  // First miss: give the worker one chance to fix its own bookkeeping
779
997
  // before bothering the leader. The inbox message wakes it on the next tick.
@@ -781,13 +999,13 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
781
999
  deliverFeedback(
782
1000
  name,
783
1001
  "Assignment not finalized",
784
- '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.',
1002
+ `Current assignment "${getTeammate(name)?.assignment?.id ?? "unassigned"}" has no automatic final result yet. Return a final answer or submit the current Work outcome.`,
785
1003
  );
786
1004
  return;
787
1005
  }
788
1006
  if (idleNudgesSent.has(key)) return;
789
1007
  idleNudgesSent.add(key);
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.`;
1008
+ const reminder = `@${name} is idle but current assignment "${getTeammate(name)?.assignment?.id ?? "unassigned"}" has no terminal report after one finalization request. Decide whether to resume or release the assignment.`;
791
1009
  deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
792
1010
  sendUpdate({
793
1011
  teammate: name,
@@ -798,8 +1016,13 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
798
1016
  });
799
1017
  }
800
1018
 
801
- function failSpawn(name: string, error: string): void {
1019
+ function failSpawn(name: string, error: string, existingWorkId?: string): void {
802
1020
  const teammate = getTeammate(name);
1021
+ if (!existingWorkId && teammate?.assignment?.kind === "direct" && teammate.currentTaskId) {
1022
+ discardDirectWork(teammate.currentTaskId, name);
1023
+ } else {
1024
+ releaseTasksOf(name, `Agent failed to start: ${error}`);
1025
+ }
803
1026
  discardWorktreeQuietly(name);
804
1027
  updateTeammate(name, { status: "stopped", error });
805
1028
  if (teammate) {
@@ -808,8 +1031,8 @@ function failSpawn(name: string, error: string): void {
808
1031
  }
809
1032
  deliverToLeader({
810
1033
  from: name,
811
- subject: "Teammate failed to start",
812
- body: `Teammate @${name} could not start.\nError: ${error}`,
1034
+ subject: "Agent failed to start",
1035
+ body: `Agent @${name} could not start.\nError: ${error}`,
813
1036
  });
814
1037
  publishStateSnapshot();
815
1038
  notifyChange();
@@ -817,44 +1040,53 @@ function failSpawn(name: string, error: string): void {
817
1040
 
818
1041
  // ── Shutdown and close ────────────────────────────────────────────
819
1042
 
1043
+ export async function shutdownTeammateExact(name: string, spawnId: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
1044
+ const existing = getTeammate(name);
1045
+ if (!existing || existing.spawnId !== spawnId || existing.status === "stopped") {
1046
+ return { ok: false, error: `No living session named "${exactSessionRoute(name, spawnId)}".` };
1047
+ }
1048
+ return shutdownTeammate(name);
1049
+ }
1050
+
820
1051
  export async function shutdownTeammate(name: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
821
1052
  const teammate = getTeammate(name);
822
1053
  if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
823
1054
  pendingShutdowns.add(name);
824
- archiveReportsForConsole(archiveQueuedReports(teammate.spawnId));
825
1055
  const terminated = await terminateTeammate(name);
826
- if (!terminated) {
1056
+ if (terminated.outcome === "unconfirmed") {
1057
+ return { ok: false, error: `Agent @${name} could not be confirmed closed; shutdown remains pending.` };
1058
+ }
1059
+ if (terminated.outcome === "missing" && !confirmedStopTimes.has(teammate.spawnId)) {
1060
+ rememberBounded(confirmedStopTimes, teammate.spawnId, Date.now());
827
1061
  // The child was already gone; synthesize the close bookkeeping.
828
1062
  pendingShutdowns.delete(name);
829
- const released = releaseTasksOf(name, "Teammate was shut down.");
1063
+ const released = releaseTasksOf(name, "Agent was stopped.");
830
1064
  for (const task of released) {
831
- verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
832
1065
  clearInconclusiveForHolding(task.id, teammate.spawnId);
833
- verifyingTasks.delete(task.id);
1066
+ invalidateVerifyGate(task.id);
834
1067
  rearmTaskNotice(task.id);
835
1068
  }
836
1069
  updateTeammate(name, { status: "stopped", activeTool: undefined });
1070
+ pendingDeliveries.delete(name);
837
1071
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
838
1072
  selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
839
1073
  // No close event will fire for an already-gone child, so this branch is
840
1074
  // the only chance to put the shutdown summary on the delivery channel.
841
1075
  const summary = summarizeShutdown(name, released.length, 0, undefined);
842
- deliverToLeader({ from: name, subject: "Teammate shut down", body: summary });
1076
+ deliverToLeader({ from: name, subject: "Agent stopped", body: summary });
843
1077
  publishStateSnapshot();
844
1078
  notifyChange();
845
1079
  return { ok: true, body: summary };
846
1080
  }
847
- // Close finalization completes state transitions and reporting.
848
- return {
849
- ok: true,
850
- body: `Shutdown requested for @${name}.`,
851
- };
1081
+ await closeFinalizations.get(teammate.spawnId);
1082
+ return { ok: true, body: `Agent @${name} stopped.` };
852
1083
  }
853
1084
 
854
1085
  async function handleTeammateClose(name: string, spawnId: string, result: WorkerProcessResult): Promise<void> {
855
1086
  if (!runtimeStateFile) return;
856
1087
  const teammate = getTeammate(name);
857
1088
  if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
1089
+ rememberBounded(confirmedStopTimes, spawnId, Date.now());
858
1090
  const requested = pendingShutdowns.has(name);
859
1091
  const crashed = !requested && !isCleanExit(result);
860
1092
 
@@ -864,15 +1096,15 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
864
1096
  // written before close is lost.
865
1097
  drainTeammateOutboxes();
866
1098
 
867
- const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
1099
+ const released = releaseTasksOf(name, requested ? "Agent was stopped." : "Agent stopped unexpectedly.");
868
1100
  for (const task of released) {
869
- verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
870
1101
  clearInconclusiveForHolding(task.id, teammate.spawnId);
871
- verifyingTasks.delete(task.id);
1102
+ invalidateVerifyGate(task.id);
872
1103
  rearmTaskNotice(task.id);
873
1104
  }
874
1105
  pendingShutdowns.delete(name);
875
1106
  // A stopped teammate must not keep the poll loop or its queue alive.
1107
+ pendingSubmissions.delete(name);
876
1108
  pendingDeliveries.delete(name);
877
1109
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
878
1110
  selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
@@ -885,16 +1117,12 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
885
1117
  removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
886
1118
 
887
1119
  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
1120
  const summary = summarizeShutdown(name, released.length, result.exitCode, result.usage);
893
- deliverToLeader({ from: name, subject: "Teammate shut down", body: summary });
1121
+ deliverToLeader({ from: name, subject: "Agent stopped", body: summary });
894
1122
  // A requested shutdown is already represented by the tool lifecycle row;
895
1123
  // keep its summary in the console mailbox without starting a leader turn.
896
1124
  } else {
897
- deliverToLeader({ from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
1125
+ deliverToLeader({ from: name, subject: "Agent stopped unexpectedly", body: crashDiagnostic(name, result, released) });
898
1126
  const closeReport = {
899
1127
  teammate: name,
900
1128
  spawnId: teammate.spawnId,
@@ -903,7 +1131,6 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
903
1131
  harnessEvent: { type: "unexpected-stop", subject: `@${name} stopped unexpectedly` },
904
1132
  finished: false,
905
1133
  };
906
- recordTerminalReport(closeReport);
907
1134
  sendUpdate(closeReport);
908
1135
  }
909
1136
  publishStateSnapshot();
@@ -918,31 +1145,20 @@ function closeErrorText(result: WorkerProcessResult, crashed: boolean): string |
918
1145
 
919
1146
  function crashDiagnostic(name: string, result: WorkerProcessResult, released: Array<{ id: string }>): string {
920
1147
  return [
921
- `Teammate @${name} closed without a shutdown request.`,
1148
+ `Agent @${name} closed without a shutdown request.`,
922
1149
  result.stderr?.trim() ? `stderr: ${result.stderr.trim()}` : undefined,
923
1150
  released.length > 0 ? `Released claimed task(s): ${released.map((t) => t.id).join(", ")}.` : undefined,
924
1151
  result.stdout?.trim() ? `Last output: ${result.stdout.trim()}` : undefined,
925
1152
  ].filter(Boolean).join("\n");
926
1153
  }
927
1154
 
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
-
939
1155
  function summarizeShutdown(
940
1156
  name: string,
941
1157
  releasedCount: number,
942
1158
  exitCode: number | null | undefined,
943
1159
  usage?: import("./types").WorkerUsage,
944
1160
  ): string {
945
- const lines = [`Teammate @${name} shut down (exit code ${exitCode ?? "unknown"}).`];
1161
+ const lines = [`Agent @${name} stopped (exit code ${exitCode ?? "unknown"}).`];
946
1162
  if (releasedCount > 0) lines.push(`Released claimed task(s): ${releasedCount}.`);
947
1163
  if (usage) lines.push(`Lifetime usage: ${usage.totalTokens} tokens, $${usage.cost.toFixed(4)}.`);
948
1164
  return lines.join("\n");
@@ -978,8 +1194,8 @@ async function finalizeWorktree(name: string): Promise<void> {
978
1194
  from: name,
979
1195
  subject: "Worktree diff captured",
980
1196
  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)`,
1197
+ ? `Agent @${name}'s worktree diff:\n\n=== Worktree changes ===\n${captured.diff.diffStat}\n\n${captured.diff.patch}`
1198
+ : `Agent @${name}'s worktree diff:\n(no worktree changes)`,
983
1199
  });
984
1200
  // Changed work must reach the leader even though the worktree directory is
985
1201
  // gone: dispatch a bounded preview plus the branch retrieval command. A
@@ -1016,33 +1232,31 @@ async function finalizeWorktree(name: string): Promise<void> {
1016
1232
 
1017
1233
  // ── Report outbox draining ────────────────────────────────────────
1018
1234
 
1019
- /** Drain validated report events from every living teammate's outbox. */
1020
- export function drainTeammateOutboxes(): void {
1235
+ /** Poll one batch per worker; explicit control drains its recipient's existing snapshot. */
1236
+ export function drainTeammateOutboxes(recipient?: string): void {
1021
1237
  const stateFile = requireStateFile();
1022
- let changed = false;
1023
1238
  for (const teammate of livingTeammates()) {
1024
1239
  const key = `${teammate.name}:${teammate.spawnId}`;
1240
+ const file = workerOutboxPath(stateFile, teammate.name, teammate.spawnId);
1241
+ const endOffset = teammate.name === recipient ? fs.statSync(file, { throwIfNoEntry: false })?.size ?? 0 : undefined;
1025
1242
  const offsets = getState().workerEventOffsets;
1026
- const previousOffset = offsets[key] ?? 0;
1027
- const { records, nextOffset, diagnostics } = readJsonlBatch(
1028
- workerOutboxPath(stateFile, teammate.name, teammate.spawnId),
1029
- previousOffset,
1030
- );
1031
- if (nextOffset !== previousOffset) {
1032
- offsets[key] = nextOffset;
1033
- changed = true;
1034
- }
1035
- for (const diagnostic of diagnostics) deliverDiagnostic(teammate.name, diagnostic);
1036
- for (const record of records) {
1037
- if (applyOutboxRecord(teammate, record)) changed = true;
1243
+ while (true) {
1244
+ const previousOffset = offsets[key] ?? 0;
1245
+ const { records, nextOffset, diagnostics } = readJsonlBatch(file, previousOffset, endOffset);
1246
+ if (nextOffset !== previousOffset) {
1247
+ offsets[key] = nextOffset;
1248
+ }
1249
+ for (const diagnostic of diagnostics) deliverDiagnostic(teammate.name, diagnostic);
1250
+ for (const record of records) {
1251
+ applyOutboxRecord(teammate, record);
1252
+ }
1253
+ if (endOffset === undefined || nextOffset >= endOffset || nextOffset <= previousOffset) break;
1038
1254
  }
1039
1255
  }
1040
- // Persist on transitions, not per poll tick.
1041
- if (changed) markStateDirty();
1042
1256
  }
1043
1257
 
1044
1258
  function deliverDiagnostic(from: string, detail: string): void {
1045
- deliverToLeader({ from, subject: "Teammate channel diagnostic", body: detail });
1259
+ deliverToLeader({ from, subject: "Agent channel diagnostic", body: detail });
1046
1260
  }
1047
1261
 
1048
1262
  function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
@@ -1052,42 +1266,36 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
1052
1266
  const eventKey = `${teammate.spawnId}:${record.id}`;
1053
1267
  if (ids[eventKey]) return false;
1054
1268
  ids[eventKey] = teammate.spawnId;
1055
- markStateDirty();
1056
1269
 
1057
- if (teammate.reportSequenceEnded) return false;
1270
+ const reportAssignmentId = teammate.assignment?.id ?? teammate.lastAssignment?.id;
1271
+ if (record.assignmentId !== reportAssignmentId || teammate.reportSequenceEnded) return false;
1058
1272
  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
- );
1273
+ const status = record.status;
1274
+ if (status === "completed" || status === "failed") {
1275
+ const taskId = teammate.currentTaskId;
1276
+ const task = taskId ? getTask(taskId) : undefined;
1277
+ if (teammate.assignment && taskId && task?.claimedBy === teammate.name
1278
+ && (task.status === "claimed" || task.status === "superseded")) {
1279
+ applySubmissionMarker({
1280
+ taskId, worker: teammate.name, spawnId: teammate.spawnId, assignmentId: teammate.assignment.id,
1281
+ status, result: record.body, timestamp: record.timestamp ?? Date.now(),
1282
+ });
1283
+ } else {
1284
+ receiveWorkerMessage(record, { archived: true });
1077
1285
  }
1286
+ return true;
1078
1287
  }
1079
1288
  receiveWorkerMessage({
1080
1289
  id: record.id,
1081
1290
  type: "message",
1082
1291
  worker: teammate.name,
1083
1292
  spawnId: teammate.spawnId,
1293
+ assignmentId: record.assignmentId,
1084
1294
  body: record.body,
1085
1295
  status: record.status,
1086
1296
  timestamp: record.timestamp,
1087
1297
  }, { 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;
1298
+ // Informational events carry no Work acceptance authority.
1091
1299
  const report = {
1092
1300
  teammate: teammate.name,
1093
1301
  agent: teammate.agent,
@@ -1095,13 +1303,14 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
1095
1303
  body: record.body,
1096
1304
  origin: "teammate" as const,
1097
1305
  eventId: record.id,
1306
+ assignmentId: record.assignmentId,
1307
+ workId: teammate.workId,
1098
1308
  status: record.status,
1099
1309
  timestamp: record.timestamp ?? Date.now(),
1100
- finished,
1310
+ finished: false,
1101
1311
  ...(archived ? { archived: true } : {}),
1102
1312
  };
1103
- if (finished) recordTerminalReport(report);
1104
- sendUpdate(report);
1313
+ if (!archived) sendUpdate(report);
1105
1314
  return true;
1106
1315
  }
1107
1316
 
@@ -1115,6 +1324,7 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
1115
1324
  export function routePeerInboxes(): void {
1116
1325
  const stateFile = requireStateFile();
1117
1326
  for (const teammate of livingTeammates()) {
1327
+ if (pendingShutdowns.has(teammate.name)) continue;
1118
1328
  const inboxName = teammate.name;
1119
1329
  const inbox = inboxPath(stateFile, inboxName);
1120
1330
  const offset = getPeerInboxOffset(inboxName);
@@ -1128,6 +1338,18 @@ export function routePeerInboxes(): void {
1128
1338
  const message = parseInboxMessage(record);
1129
1339
  if (!message || isPeerDelivered(inboxName, message.id)) continue;
1130
1340
  markPeerDelivered(inboxName, message.id);
1341
+ // Mail addressed to an earlier incarnation is not this resident's to read.
1342
+ // Dropping it is recorded once so a replacement never inherits stale
1343
+ // intent, and the sender — never the leader's context — is told.
1344
+ if (message.toSpawnId && message.toSpawnId !== teammate.spawnId) {
1345
+ setPeerDeliveryState(message.id, "dropped");
1346
+ deliverFeedback(
1347
+ message.from,
1348
+ "Message not delivered",
1349
+ `Your message "${message.subject}" was addressed to an earlier incarnation of @${inboxName} that is no longer running, so it was not delivered. Re-resolve the recipient and send it again.`,
1350
+ );
1351
+ continue;
1352
+ }
1131
1353
  dispatchInboxMessage(teammate, message);
1132
1354
  }
1133
1355
  }
@@ -1138,12 +1360,61 @@ function parseInboxMessage(record: unknown): InboxMessage | undefined {
1138
1360
  const candidate = record as Partial<InboxMessage>;
1139
1361
  if (typeof candidate.id !== "string" || typeof candidate.from !== "string") return undefined;
1140
1362
  if (typeof candidate.subject !== "string" || typeof candidate.body !== "string") return undefined;
1363
+ if (candidate.toSpawnId !== undefined && typeof candidate.toSpawnId !== "string") return undefined;
1141
1364
  return { ...candidate, timestamp: candidate.timestamp ?? Date.now() } as InboxMessage;
1142
1365
  }
1143
1366
 
1367
+ function submissionPending(teammate: Teammate): boolean {
1368
+ const pending = pendingSubmissions.get(teammate.name);
1369
+ if (pending?.spawnId === teammate.spawnId && pending.assignmentId === teammate.assignment?.id) return true;
1370
+ const task = teammate.currentTaskId ? getState().tasks[teammate.currentTaskId] : undefined;
1371
+ return teammate.assignment?.kind === "direct" && teammate.assignment.closed === true
1372
+ && task?.status === "claimed" && task.claimedBy === teammate.name;
1373
+ }
1374
+
1375
+ function gateInFlight(teammate: Teammate): boolean {
1376
+ return teammate.currentTaskId !== undefined && verifyingTasks.has(teammate.currentTaskId);
1377
+ }
1378
+
1379
+ function verificationFrozen(teammate: Teammate): boolean {
1380
+ const taskId = teammate.currentTaskId;
1381
+ if (!taskId) return false;
1382
+ const holding = `${taskId}:${teammate.spawnId}`;
1383
+ return submissionPending(teammate) || gateInFlight(teammate) || verifyFailureParks.has(holding) || unexpectedExecutionParks.has(holding) || inconclusiveParks.has(holding);
1384
+ }
1385
+
1386
+ function archiveDeferredDeliveries(teammate: Teammate): void {
1387
+ const deliveries = pendingDeliveries.get(teammate.name) ?? [];
1388
+ if (deliveries.length === 0) return;
1389
+ pendingDeliveries.delete(teammate.name);
1390
+ const task = teammate.currentTaskId ? getState().tasks[teammate.currentTaskId] : undefined;
1391
+ if (task) {
1392
+ task.deferredMessages = [
1393
+ ...(task.deferredMessages ?? []),
1394
+ ...deliveries.map(({ from, subject, body, timestamp }) => ({ from, subject, body, timestamp })),
1395
+ ].slice(-20);
1396
+ task.updatedAt = Date.now();
1397
+ }
1398
+ for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
1399
+ markBoardChanged();
1400
+ }
1401
+
1144
1402
  function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
1145
- const routed = teammate.status === "working"
1146
- ? sendWorkerSteer(teammate.name, formatDelivery([message]))
1403
+ if (verificationFrozen(teammate)) {
1404
+ setPeerDeliveryState(message.id, "queued");
1405
+ const queued = pendingDeliveries.get(teammate.name) ?? [];
1406
+ queued.push(message);
1407
+ pendingDeliveries.set(teammate.name, queued);
1408
+ ensureLivePoll();
1409
+ return;
1410
+ }
1411
+ const marker = teammate.assignment ? `[agent-teams-assignment:${teammate.assignment.id}]\n` : "";
1412
+ const routed = !teammate.reportSequenceEnded && (
1413
+ teammate.status === "working"
1414
+ || teammate.status === "starting"
1415
+ || isFreshAssignmentPending(teammate.name)
1416
+ )
1417
+ ? sendWorkerFollowUp(teammate.name, `${marker}${formatDelivery([message])}`)
1147
1418
  : false;
1148
1419
  if (routed) {
1149
1420
  setPeerDeliveryState(message.id, "routed");
@@ -1156,7 +1427,7 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
1156
1427
  ensureLivePoll();
1157
1428
  }
1158
1429
 
1159
- /** Route the leader's addressed send_message through the same delivery path. */
1430
+ /** Route the leader's addressed agent_event through the same delivery path. */
1160
1431
  export type MessageRoutingOutcome = "steered" | "queued";
1161
1432
 
1162
1433
  export type SendLeaderMessageResult =
@@ -1168,23 +1439,146 @@ export type SendLeaderMessageResult =
1168
1439
  * mailbox entry carries terminal status. Lets the leader read the report
1169
1440
  * directly instead of steering the teammate into a duplicate resend. */
1170
1441
  function recordedTerminalReportBody(name: string): string | undefined {
1442
+ const teammate = getTeammate(name);
1443
+ const assignmentId = teammate?.assignment?.id ?? teammate?.lastAssignment?.id;
1171
1444
  const mailbox = getState().leaderMailbox;
1172
1445
  for (let i = mailbox.length - 1; i >= 0; i--) {
1173
1446
  const message = mailbox[i];
1174
- if (message.from !== name) continue;
1175
- return message.status === "completed" || message.status === "failed" ? message.body : undefined;
1447
+ if (message.archived || message.from !== name || message.assignmentId !== assignmentId || message.spawnId !== teammate?.spawnId) continue;
1448
+ if (message.status === "completed" || message.status === "failed") return message.body;
1176
1449
  }
1177
- return undefined;
1450
+ const taskId = teammate?.lastTaskId;
1451
+ const task = taskId ? getTask(taskId) : undefined;
1452
+ return task?.status === "completed" ? task.result : undefined;
1453
+ }
1454
+
1455
+ export type AssignExistingWorkResult =
1456
+ | { ok: true; workId: string; owner: string; assignmentId: string }
1457
+ | { ok: false; error: string };
1458
+
1459
+ /** Assign one pending Work Item to an exact idle resident through a fresh Pi
1460
+ * session. The record is claimed before reset; reset failure releases it. */
1461
+ export function assignExistingWork(workId: string, session: string): AssignExistingWorkResult {
1462
+ const teammate = resolveExactSession(session, listTeammates());
1463
+ if (!teammate) return { ok: false, error: "Work assignment requires a current exact session route." };
1464
+ const owner = teammate.name;
1465
+ const task = getState().tasks[workId];
1466
+ if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${owner}".` };
1467
+ if (task?.status === "claimed" && task.claimedBy === owner
1468
+ && teammate.currentTaskId === workId && teammate.assignment && !teammate.assignment.closed) {
1469
+ return { ok: true, workId, owner, assignmentId: teammate.assignment.id };
1470
+ }
1471
+ if (teammate.status !== "idle" || teammate.assignment || isFreshAssignmentPending(owner)) {
1472
+ return { ok: false, error: `@${owner} is not an idle session available for Work assignment.` };
1473
+ }
1474
+ if (!task || task.status !== "pending") return { ok: false, error: `Work Item "${workId}" is not pending.` };
1475
+ if (!taskDependenciesMet(task)) return { ok: false, error: `Work Item "${workId}" has unmet dependencies.` };
1476
+ if (activeAssignmentConflict(task.resources, owner)) return { ok: false, error: `Work Item "${workId}" conflicts with active Work resources.` };
1477
+ const assignment = { id: `direct:${randomUUID()}`, kind: "direct" as const, resources: task.resources };
1478
+ const claimed = reclaimDirectWork(workId, owner, assignment, "pending");
1479
+ if (!claimed.ok) return claimed;
1480
+ updateTeammate(owner, { reportSequenceEnded: false });
1481
+ flushSnapshots();
1482
+ const prompt = buildFreshAssignmentPrompt(teammate, assignment.id, [
1483
+ `Work Item ${task.id}: ${task.subject}`,
1484
+ task.description,
1485
+ "Complete this assignment and return the result as your final answer.",
1486
+ ].filter(Boolean).join("\n\n"));
1487
+ void deliverFreshAssignment(owner, prompt).then((sent) => {
1488
+ if (sent) {
1489
+ updateTeammate(owner, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1490
+ return;
1491
+ }
1492
+ if (getTeammate(owner)?.assignment?.id === assignment.id) {
1493
+ releaseTask(workId, "Pi session reset failed before the assigned Work started.");
1494
+ }
1495
+ deliverDiagnostic(owner, "Pi session reset failed; the assigned Work was not delivered.");
1496
+ });
1497
+ return { ok: true, workId, owner, assignmentId: assignment.id };
1498
+ }
1499
+
1500
+ export type ReopenExistingWorkResult =
1501
+ | { ok: true; workId: string; subject: string; resources: string[] }
1502
+ | { ok: false; error: string };
1503
+
1504
+ /** Reopen only a completed Work Item. Active, parked, superseded, and released
1505
+ * states remain governed by their explicit lifecycle transitions. */
1506
+ export function reopenExistingWork(workId: string): ReopenExistingWorkResult {
1507
+ const reopened = reopenCompletedWork(workId);
1508
+ if (!reopened.ok) return reopened;
1509
+ publishStateSnapshot();
1510
+ ensureLivePoll();
1511
+ notifyChange();
1512
+ return { ok: true, workId: reopened.task.id, subject: reopened.task.subject, resources: reopened.task.resources };
1513
+ }
1514
+
1515
+ export type ReleaseExistingWorkResult =
1516
+ | { ok: true; workId: string; subject: string; resources: string[]; holderStillRunning?: string }
1517
+ | { ok: false; error: string };
1518
+
1519
+ /** Return one claimed Work Item to pending. This invalidates all in-memory
1520
+ * submission/review authority before the single-writer state release. */
1521
+ export function releaseExistingWork(workId: string, reason: string): ReleaseExistingWorkResult {
1522
+ const task = getState().tasks[workId];
1523
+ if (!task || task.status !== "claimed") return { ok: false, error: `Work Item "${workId}" is not claimed.` };
1524
+ const holder = task.claimedBy ? getTeammate(task.claimedBy) : undefined;
1525
+ let holderStillRunning: string | undefined;
1526
+ if (holder) {
1527
+ pendingSubmissions.delete(holder.name);
1528
+ invalidateVerifyGate(workId);
1529
+ clearInconclusiveForHolding(workId, holder.spawnId);
1530
+ archiveDeferredDeliveries(holder);
1531
+ sendWorkerSteer(holder.name, `Work Item "${workId}" was released by the leader: ${reason}. Stop work on this assignment.`);
1532
+ deliverFeedback(holder.name, `Work Item "${workId}" released`, `Work Item "${workId}" was released by the leader: ${reason}.`);
1533
+ // Release frees the resource immediately by contract. When the holder is
1534
+ // still working, its in-flight tool batch may still be writing, so the
1535
+ // residual window is reported instead of being silently implied safe.
1536
+ holderStillRunning = holder.status === "working" ? holder.name : undefined;
1537
+ }
1538
+ const released = releaseTask(workId, reason);
1539
+ if (!released) return { ok: false, error: `Work Item "${workId}" could not be released.` };
1540
+ rearmTaskNotice(workId);
1541
+ publishStateSnapshot();
1542
+ ensureLivePoll();
1543
+ notifyChange();
1544
+ return { ok: true, workId: released.id, subject: released.subject, resources: released.resources, ...(holderStillRunning ? { holderStillRunning } : {}) };
1178
1545
  }
1179
1546
 
1180
1547
  export function sendLeaderMessage(
1181
1548
  to: string,
1182
1549
  message: string,
1183
- options?: { reopen?: boolean; resources?: string[] },
1550
+ options?: { reopen?: boolean | "if-closed"; resources?: string[]; workId?: string },
1184
1551
  ): SendLeaderMessageResult {
1552
+ drainTeammateOutboxes(to);
1185
1553
  const teammate = getTeammate(to);
1186
1554
  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) {
1555
+ if (pendingShutdowns.has(to)) return { ok: false, error: `Agent @${to} is stopping; new work is not accepted.` };
1556
+ // Explicit work control chooses after draining, not from a stale caller snapshot.
1557
+ const selectedWorkId = options?.workId ?? teammate.workId;
1558
+ const unexpectedParkKey = selectedWorkId ? `${selectedWorkId}:${teammate.spawnId}` : undefined;
1559
+ const recoveringUnexpectedExecution = options?.reopen === true && unexpectedParkKey !== undefined
1560
+ && unexpectedExecutionParks.has(unexpectedParkKey);
1561
+ if (verificationFrozen(teammate) && !recoveringUnexpectedExecution) {
1562
+ const envelope: InboxMessage = {
1563
+ id: randomUUID(), from: "leader", subject: messageTitle(message), body: message, timestamp: Date.now(), toSpawnId: teammate.spawnId,
1564
+ };
1565
+ const queued = pendingDeliveries.get(teammate.name) ?? [];
1566
+ queued.unshift(envelope);
1567
+ pendingDeliveries.set(teammate.name, queued);
1568
+ ensureLivePoll();
1569
+ return { ok: true, outcome: "queued" };
1570
+ }
1571
+ const selectedExistingWork = selectedWorkId ? getState().tasks[selectedWorkId] : undefined;
1572
+ const reopen = options?.reopen === "if-closed"
1573
+ ? teammate.assignment?.closed === true || selectedExistingWork?.status === "completed"
1574
+ : options?.reopen;
1575
+ if (options?.reopen === undefined && (!teammate.assignment || teammate.assignment.closed)) {
1576
+ const prior = recordedTerminalReportBody(to);
1577
+ if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
1578
+ return { ok: false, error: `@${to} has no open assignment. Use work action=assign for pending Work, or work action=reopen then assign for completed Work.` };
1579
+ }
1580
+ const retryReleasedWork = selectedExistingWork?.status === "pending" && teammate.assignment === undefined;
1581
+ if (teammate.assignment?.kind === "direct" && teammate.assignment.closed && !reopen && !retryReleasedWork) {
1188
1582
  const prior = recordedTerminalReportBody(to);
1189
1583
  if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
1190
1584
  return { ok: false, error: `@${to} has a closed direct assignment. Use reopen=true for an explicit next assignment.` };
@@ -1193,31 +1587,63 @@ export function sendLeaderMessage(
1193
1587
  // able to steer it for verify feedback or an inconclusive decision without
1194
1588
  // replacing its assignment.
1195
1589
  const activeBoardHolder = teammate.assignment?.kind === "board" && !teammate.assignment.closed;
1196
- if (teammate.reportSequenceEnded && !options?.reopen && !activeBoardHolder) {
1590
+ if (teammate.reportSequenceEnded && !reopen && !retryReleasedWork && !activeBoardHolder) {
1197
1591
  const prior = recordedTerminalReportBody(to);
1198
1592
  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.` };
1593
+ return { ok: false, error: `@${to} already sent a terminal report. Use work action=reopen followed by work action=assign for another Work attempt.` };
1200
1594
  }
1201
1595
  const priorTerminalReport = teammate.reportSequenceEnded ? recordedTerminalReportBody(to) : undefined;
1202
1596
  const boardParkKey = teammate.currentTaskId ? `${teammate.currentTaskId}:${teammate.spawnId}` : undefined;
1203
- if (options?.reopen && teammate.assignment && !teammate.assignment.closed) {
1597
+ if ((reopen || retryReleasedWork) && selectedWorkId && verifyingTasks.has(selectedWorkId)) {
1598
+ return { ok: false, error: `Work Item "${selectedWorkId}" is awaiting verification; wait for the gate outcome before reopening.` };
1599
+ }
1600
+ if (reopen && teammate.assignment && !teammate.assignment.closed && !recoveringUnexpectedExecution) {
1204
1601
  const action = teammate.assignment.kind === "board"
1205
- ? `task_submit or be released/superseded`
1602
+ ? `work action=submit or be released/superseded`
1206
1603
  : "send a terminal report or be explicitly released";
1207
1604
  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
1605
  }
1209
- const opensDirectAssignment = options?.reopen || teammate.assignment === undefined;
1606
+ const opensDirectAssignment = reopen || retryReleasedWork || teammate.assignment === undefined;
1210
1607
  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, {
1608
+ const existingWork = getState().tasks[selectedWorkId ?? ""];
1609
+ const reclaimingExistingWork = (reopen && (existingWork?.status === "completed" || recoveringUnexpectedExecution)) || retryReleasedWork;
1610
+ const resources = reclaimingExistingWork
1611
+ ? existingWork.resources
1612
+ : normalizeResources(options?.resources);
1613
+ const assignment = {
1217
1614
  id: `direct:${randomUUID()}`,
1218
- kind: "direct",
1615
+ kind: "direct" as const,
1219
1616
  resources,
1220
- }, undefined);
1617
+ };
1618
+ if (reclaimingExistingWork) {
1619
+ const reclaimed = reclaimDirectWork(
1620
+ selectedWorkId ?? "",
1621
+ to,
1622
+ assignment,
1623
+ retryReleasedWork ? "pending" : recoveringUnexpectedExecution ? "claimed" : "completed",
1624
+ recoveringUnexpectedExecution,
1625
+ );
1626
+ if (!reclaimed.ok) return reclaimed;
1627
+ } else {
1628
+ const conflict = activeAssignmentConflict(resources, to);
1629
+ if (conflict) {
1630
+ return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
1631
+ }
1632
+ if (selectedWorkId) {
1633
+ const created = createDirectWork({
1634
+ id: selectedWorkId,
1635
+ subject: messageTitle(message),
1636
+ description: message,
1637
+ resources,
1638
+ workerName: to,
1639
+ assignment,
1640
+ });
1641
+ if (!created.ok) return created;
1642
+ updateTeammate(to, { workId: selectedWorkId });
1643
+ } else {
1644
+ assignTeammate(to, assignment, undefined);
1645
+ }
1646
+ }
1221
1647
  updateTeammate(to, { reportSequenceEnded: false });
1222
1648
  }
1223
1649
  // A successfully accepted leader message is the explicit direction that
@@ -1225,19 +1651,40 @@ export function sendLeaderMessage(
1225
1651
  if (boardParkKey) {
1226
1652
  inconclusiveParks.delete(boardParkKey);
1227
1653
  verifyFailureParks.delete(boardParkKey);
1654
+ unexpectedExecutionParks.delete(boardParkKey);
1228
1655
  }
1229
1656
  if (activeBoardHolder) updateTeammate(to, { reportSequenceEnded: false });
1230
1657
  const envelope: InboxMessage = {
1231
1658
  id: randomUUID(),
1232
1659
  from: "leader",
1233
1660
  subject: messageTitle(message),
1234
- body: message,
1661
+ body: `${opensDirectAssignment || (activeBoardHolder && priorTerminalReport !== undefined) ? `[agent-teams-assignment:${teammate.assignment?.id ?? "none"}]\n` : ""}${message}`,
1235
1662
  timestamp: Date.now(),
1663
+ toSpawnId: teammate.spawnId,
1236
1664
  };
1237
- const steered = teammate.status === "working" && sendWorkerSteer(teammate.name, formatDelivery([envelope]));
1665
+ flushSnapshots();
1666
+ if (opensDirectAssignment) {
1667
+ void deliverFreshAssignment(teammate.name, formatDelivery([envelope])).then((sent) => {
1668
+ if (sent) {
1669
+ updateTeammate(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1670
+ return;
1671
+ }
1672
+ const current = getTeammate(to);
1673
+ const currentTaskId = current?.currentTaskId;
1674
+ if (current?.assignment?.id === teammate.assignment?.id && currentTaskId) {
1675
+ releaseTask(currentTaskId, "Pi session reset failed before the new Assignment Attempt started.");
1676
+ } else if (current?.assignment?.id === teammate.assignment?.id) {
1677
+ assignTeammate(to, undefined, undefined);
1678
+ }
1679
+ deliverDiagnostic(to, "Pi session reset failed; the new Assignment Attempt was not delivered.");
1680
+ });
1681
+ return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
1682
+ }
1683
+ const steered = sendWorkerSteer(teammate.name, formatDelivery([envelope]));
1684
+ if (steered) updateTeammate(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1238
1685
  if (steered) return { ok: true, outcome: "steered", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
1239
1686
  const queued = pendingDeliveries.get(teammate.name) ?? [];
1240
- queued.push(envelope);
1687
+ queued.unshift(envelope);
1241
1688
  pendingDeliveries.set(teammate.name, queued);
1242
1689
  ensureLivePoll();
1243
1690
  return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
@@ -1251,8 +1698,15 @@ function continueMaybeCompact(inbox: string, inboxName: string, offset: number):
1251
1698
  return;
1252
1699
  }
1253
1700
  // Truncate only fully-consumed inboxes; message ids make replay safe.
1701
+ // Rotation, not truncation: a teammate appending concurrently would lose its
1702
+ // message inside the stat/truncate window, while a rename always preserves
1703
+ // the records on disk and lets the next append start a fresh file.
1254
1704
  if (size > INBOX_COMPACT_BYTES && offset >= size) {
1255
- fs.truncateSync(inbox, 0);
1705
+ try {
1706
+ fs.renameSync(inbox, `${inbox}.compacted-${Date.now()}`);
1707
+ } catch {
1708
+ return;
1709
+ }
1256
1710
  setPeerInboxOffset(inboxName, 0);
1257
1711
  }
1258
1712
  }
@@ -1294,19 +1748,30 @@ export function processTaskIntents(): void {
1294
1748
 
1295
1749
  function applyClaimMarker(intent: import("./types").TaskIntent): void {
1296
1750
  const sender = findLivingTeammate(intent);
1297
- if (!sender) return;
1751
+ if (!sender || pendingShutdowns.has(sender.name)) return;
1298
1752
  const outcome = applyClaimIntent(intent);
1299
1753
  if (outcome.applied) {
1300
- verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
1301
1754
  clearInconclusiveForHolding(intent.taskId, intent.spawnId);
1302
1755
  // A new holding must not inherit an in-flight gate from a previous one.
1303
- verifyingTasks.delete(intent.taskId);
1756
+ invalidateVerifyGate(intent.taskId);
1304
1757
  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
- );
1758
+ const teammate = getTeammate(intent.worker);
1759
+ const task = getState().tasks[intent.taskId];
1760
+ if (!teammate?.assignment || !task) return;
1761
+ const kickoff = [
1762
+ `Claim accepted for Work Item ${task.id}: ${task.subject}`,
1763
+ task.description,
1764
+ "Complete this assignment with work action=submit.",
1765
+ ].filter(Boolean).join("\n\n");
1766
+ const assignmentId = teammate.assignment.id;
1767
+ flushSnapshots();
1768
+ void deliverFreshAssignment(teammate.name, buildFreshAssignmentPrompt(teammate, assignmentId, kickoff)).then((sent) => {
1769
+ if (sent) return;
1770
+ if (getTeammate(teammate.name)?.assignment?.id === assignmentId) {
1771
+ releaseTask(task.id, "Pi session reset failed before the claimed Assignment Attempt started.");
1772
+ }
1773
+ deliverDiagnostic(teammate.name, "Pi session reset failed; the claimed Assignment Attempt was not delivered.");
1774
+ });
1310
1775
  return;
1311
1776
  }
1312
1777
  deliverFeedback(intent.worker, "Claim rejected", outcome.reason ?? "The task is no longer available.");
@@ -1318,8 +1783,10 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
1318
1783
  return;
1319
1784
  }
1320
1785
  const sender = findLivingTeammate(intent);
1321
- if (!sender) return;
1786
+ if (!sender || !sender.assignment || intent.assignmentId !== sender.assignment.id) return;
1322
1787
  const task = getState().tasks[intent.taskId];
1788
+ const pending = pendingSubmissions.get(intent.worker);
1789
+ if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId) return;
1323
1790
  if (!task || (task.status !== "claimed" && task.status !== "superseded") || task.claimedBy !== intent.worker) {
1324
1791
  deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
1325
1792
  return;
@@ -1329,20 +1796,24 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
1329
1796
  deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}". Stop work and submit failed to acknowledge cancellation.`);
1330
1797
  return;
1331
1798
  }
1332
- releaseTask(intent.taskId, intent.result?.trim() || "Superseded task cancellation acknowledged.");
1333
- clearInconclusiveForHolding(intent.taskId, intent.spawnId);
1334
- verifyingTasks.delete(intent.taskId);
1799
+ if (sender.sequenceEnded === false) {
1800
+ pendingSubmissions.set(intent.worker, { ...intent, assignmentId: sender.assignment.id, verify: undefined });
1801
+ } else {
1802
+ releaseSupersededHolding(intent);
1803
+ }
1335
1804
  return;
1336
1805
  }
1337
1806
  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";
1807
+ if (intent.status === "completed" && (inconclusiveParks.has(parkKey) || verifyFailureParks.has(parkKey) || unexpectedExecutionParks.has(parkKey))) {
1808
+ const reason = unexpectedExecutionParks.has(parkKey)
1809
+ ? "unexpected execution during verification"
1810
+ : inconclusiveParks.has(parkKey)
1811
+ ? "two inconclusive reviews"
1812
+ : "two explicit verification failures";
1342
1813
  deliverFeedback(
1343
1814
  intent.worker,
1344
1815
  "Submission rejected while verification is parked",
1345
- `Task "${intent.taskId}" has ${reason}. Wait for an explicit leader steer before submitting another completed outcome.`,
1816
+ `Task "${intent.taskId}" has ${reason}. Wait for explicit Work release and reassignment before submitting another completed outcome.`,
1346
1817
  );
1347
1818
  return;
1348
1819
  }
@@ -1354,17 +1825,11 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
1354
1825
  );
1355
1826
  return;
1356
1827
  }
1357
- if (intent.status === "failed") {
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);
1363
- freeTeammateFromTask(intent.worker, intent.taskId);
1364
- notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
1365
- return;
1366
- }
1367
- beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
1828
+ const verify = intent.status === "completed" ? task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify : undefined;
1829
+ const assignmentId = sender.assignment?.id;
1830
+ if (!assignmentId) return;
1831
+ if (sender.sequenceEnded === false) pendingSubmissions.set(intent.worker, { ...intent, assignmentId, verify });
1832
+ else beginVerifyOrComplete(intent, verify);
1368
1833
  }
1369
1834
 
1370
1835
  /** Fold one more verify failure into bookkeeping. The first failure invites a
@@ -1378,11 +1843,52 @@ export function reactToVerifyFailure(
1378
1843
  return { count, escalated, escalateToLeader: escalated && previous?.escalated !== true };
1379
1844
  }
1380
1845
 
1846
+ function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: string): boolean {
1847
+ const teammate = getTeammate(intent.worker);
1848
+ const task = getState().tasks[intent.taskId];
1849
+ if (!teammate || teammate.spawnId !== intent.spawnId || teammate.assignment?.kind !== "direct" || !task) return false;
1850
+ const assignment = {
1851
+ id: `direct:${randomUUID()}`,
1852
+ kind: "direct" as const,
1853
+ resources: task.resources,
1854
+ };
1855
+ assignTeammate(intent.worker, assignment, intent.taskId);
1856
+ updateTeammate(intent.worker, { reportSequenceEnded: false });
1857
+ flushSnapshots();
1858
+ const revisionBrief = [
1859
+ `The completion gate failed for Work Item ${intent.taskId}: ${task.subject}. Revise the result and return a new final answer.`,
1860
+ task.description,
1861
+ task.verify ? `Verification criteria:\n${task.verify}` : undefined,
1862
+ `Review failure detail:\n${detail}`,
1863
+ ].filter(Boolean).join("\n\n");
1864
+ const prompt = buildFreshAssignmentPrompt(
1865
+ teammate,
1866
+ assignment.id,
1867
+ revisionBrief,
1868
+ );
1869
+ void deliverFreshAssignment(teammate.name, prompt).then((sent) => {
1870
+ if (sent) return;
1871
+ if (getTeammate(teammate.name)?.assignment?.id === assignment.id) {
1872
+ releaseTask(intent.taskId, "Pi session reset failed before the verification revision started.");
1873
+ }
1874
+ deliverDiagnostic(teammate.name, "Pi session reset failed; the verification revision was not delivered.");
1875
+ });
1876
+ return true;
1877
+ }
1878
+
1381
1879
  function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
1382
1880
  const task = getState().tasks[intent.taskId];
1383
1881
  if (!task) return;
1882
+ if (task.status === "superseded") {
1883
+ if (intent.status === "failed") releaseSupersededHolding(intent);
1884
+ return;
1885
+ }
1886
+ if (intent.status === "failed") {
1887
+ finishFailure(intent);
1888
+ return;
1889
+ }
1384
1890
  if (!verify?.trim()) {
1385
- finishCompletion(intent, task.subject);
1891
+ finishCompletion(intent);
1386
1892
  return;
1387
1893
  }
1388
1894
  // The gate is bound to this exact submission via a unique token: a
@@ -1390,22 +1896,16 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
1390
1896
  // must not let the stale result complete the new holding.
1391
1897
  const submissionId = `${intent.taskId}:${intent.spawnId}:${randomUUID()}`;
1392
1898
  const token = randomUUID();
1393
- verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, submissionId, token });
1899
+ const assignmentId = getTeammate(intent.worker)?.assignment?.id;
1900
+ if (!assignmentId) return;
1901
+ verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, assignmentId, submissionId, token });
1394
1902
  const input: VerifyReviewInput = {
1395
1903
  verify,
1396
1904
  taskSubject: task.subject,
1397
1905
  workerResult: intent.result ?? "",
1398
1906
  cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
1399
1907
  };
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
- }));
1908
+ runVerifyGate(intent, task.subject, submissionId, token, input);
1409
1909
  }
1410
1910
 
1411
1911
  /** Apply one gate outcome to its submission, guarded by the binding token. */
@@ -1419,14 +1919,20 @@ function resolveGateOutcome(
1419
1919
  const active = verifyingTasks.get(intent.taskId);
1420
1920
  if (active?.token !== token || active.submissionId !== submissionId) return;
1421
1921
  verifyingTasks.delete(intent.taskId);
1922
+ const registered = verifyAborts.get(intent.taskId);
1923
+ if (registered?.submissionId === submissionId) verifyAborts.delete(intent.taskId);
1422
1924
  const current = getState().tasks[intent.taskId];
1925
+ const holder = getTeammate(intent.worker);
1423
1926
  const stillHolds = current?.status === "claimed"
1424
1927
  && current.claimedBy === intent.worker
1425
- && getTeammate(intent.worker)?.spawnId === intent.spawnId;
1928
+ && holder?.spawnId === intent.spawnId
1929
+ && holder.assignment?.id === active.assignmentId
1930
+ && !pendingShutdowns.has(intent.worker);
1426
1931
  if (!stillHolds) return;
1427
1932
  if (outcome.kind === "pass") {
1428
1933
  inconclusiveVerifications.delete(submissionId);
1429
- finishCompletion(intent, subject);
1934
+ archiveDeferredDeliveries(holder);
1935
+ finishCompletion(intent);
1430
1936
  } else if (outcome.kind === "inconclusive") {
1431
1937
  const detail = outcome.detail ?? "(reviewer omitted a machine-readable verdict)";
1432
1938
  requestVerifyVerdict(intent, subject, submissionId, detail);
@@ -1458,11 +1964,18 @@ function resolveGateOutcome(
1458
1964
  [`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
1965
  );
1460
1966
  } 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
- );
1967
+ const directRevision = authorizeDirectRevision(intent, detail);
1968
+ if (!directRevision) {
1969
+ deliverFeedback(
1970
+ intent.worker,
1971
+ `Verify failed for ${intent.taskId}`,
1972
+ [
1973
+ `The completion gate for "${subject}" failed.`,
1974
+ detail,
1975
+ "Fix the issues and resubmit with work action=submit.",
1976
+ ].join("\n"),
1977
+ );
1978
+ }
1466
1979
  notifyTaskOutcome(subject, `verify gate failed for ${intent.taskId}`, detail);
1467
1980
  }
1468
1981
  }
@@ -1482,20 +1995,16 @@ function requestVerifyVerdict(
1482
1995
  const task = getState().tasks[intent.taskId];
1483
1996
  if (!task) return;
1484
1997
  const token = randomUUID();
1485
- verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, submissionId, token });
1998
+ const assignmentId = getTeammate(intent.worker)?.assignment?.id;
1999
+ if (!assignmentId) return;
2000
+ verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, assignmentId, submissionId, token });
1486
2001
  const input: VerifyReviewInput = {
1487
2002
  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
2003
  taskSubject: task.subject,
1489
2004
  workerResult: intent.result ?? "",
1490
2005
  cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
1491
2006
  };
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
- }));
2007
+ runVerifyGate(intent, subject, submissionId, token, input);
1499
2008
  return;
1500
2009
  }
1501
2010
  inconclusiveParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
@@ -1503,7 +2012,7 @@ function requestVerifyVerdict(
1503
2012
  deliverFeedback(
1504
2013
  intent.worker,
1505
2014
  `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}`,
2015
+ `The reviewer twice omitted a machine-readable verdict. Your task remains claimed without a verify failure. Wait for explicit Work release and reassignment; ordinary messages do not authorize another submission.\n\n${detail}`,
1507
2016
  );
1508
2017
  sendUpdate({
1509
2018
  teammate: "task-board",
@@ -1514,24 +2023,77 @@ function requestVerifyVerdict(
1514
2023
  });
1515
2024
  }
1516
2025
 
2026
+ /** Drop every per-holding gate record for one task incarnation. Every authority
2027
+ * invalidation routes through here so no counter or park outlives the holding
2028
+ * it was created for. */
1517
2029
  function clearInconclusiveForHolding(taskId: string, spawnId: string): void {
1518
2030
  const prefix = `${taskId}:${spawnId}:`;
1519
2031
  for (const key of inconclusiveVerifications.keys()) {
1520
2032
  if (key.startsWith(prefix)) inconclusiveVerifications.delete(key);
1521
2033
  }
1522
- inconclusiveParks.delete(`${taskId}:${spawnId}`);
1523
- verifyFailureParks.delete(`${taskId}:${spawnId}`);
2034
+ const holding = `${taskId}:${spawnId}`;
2035
+ inconclusiveParks.delete(holding);
2036
+ verifyFailureParks.delete(holding);
2037
+ unexpectedExecutionParks.delete(holding);
2038
+ verifyFailures.delete(holding);
1524
2039
  }
1525
2040
 
1526
- function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
2041
+ function finishCompletion(intent: import("./types").TaskIntent): void {
1527
2042
  const task = getState().tasks[intent.taskId];
1528
2043
  if (!task) return;
2044
+ const teammate = getTeammate(intent.worker);
2045
+ const assignmentId = teammate?.assignment?.id;
2046
+ if (!teammate || !assignmentId) return;
2047
+ retirePendingSubmission(intent);
1529
2048
  const completed = completeTask(intent.taskId, intent.result);
1530
2049
  if (!completed) return;
1531
- verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
1532
2050
  clearInconclusiveForHolding(intent.taskId, intent.spawnId);
1533
2051
  freeTeammateFromTask(intent.worker, intent.taskId);
1534
- notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
2052
+ announceWorkOutcome(intent, teammate, assignmentId);
2053
+ }
2054
+
2055
+ function retirePendingSubmission(intent: import("./types").TaskIntent): void {
2056
+ const pending = pendingSubmissions.get(intent.worker);
2057
+ if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId) {
2058
+ pendingSubmissions.delete(intent.worker);
2059
+ }
2060
+ }
2061
+
2062
+ function releaseSupersededHolding(intent: import("./types").TaskIntent): void {
2063
+ retirePendingSubmission(intent);
2064
+ releaseTask(intent.taskId, intent.result?.trim() || "Superseded task cancellation acknowledged.");
2065
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2066
+ invalidateVerifyGate(intent.taskId);
2067
+ }
2068
+
2069
+ function finishFailure(intent: import("./types").TaskIntent): void {
2070
+ const teammate = getTeammate(intent.worker);
2071
+ const assignmentId = teammate?.assignment?.id;
2072
+ if (!teammate || !assignmentId) return;
2073
+ retirePendingSubmission(intent);
2074
+ const released = releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.");
2075
+ if (!released) return;
2076
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2077
+ invalidateVerifyGate(intent.taskId);
2078
+ rearmTaskNotice(intent.taskId);
2079
+ announceWorkOutcome(intent, teammate, assignmentId);
2080
+ }
2081
+
2082
+ function announceWorkOutcome(intent: import("./types").TaskIntent, teammate: Teammate, assignmentId: string): void {
2083
+ updateTeammate(intent.worker, { reportSequenceEnded: true });
2084
+ const eventId = randomUUID();
2085
+ const status = intent.status === "failed" ? "failed" : "completed";
2086
+ const report: LeaderReport = {
2087
+ teammate: intent.worker, agent: teammate.agent, spawnId: intent.spawnId,
2088
+ workId: intent.taskId, assignmentId, status, finished: true,
2089
+ origin: "teammate", eventId, timestamp: intent.timestamp,
2090
+ body: intent.result || "(no result summary submitted)",
2091
+ };
2092
+ receiveWorkerMessage({
2093
+ id: eventId, type: "message", worker: intent.worker, spawnId: intent.spawnId,
2094
+ assignmentId, status, body: report.body, timestamp: report.timestamp,
2095
+ });
2096
+ sendUpdate(report);
1535
2097
  }
1536
2098
 
1537
2099
  // ── Verify gate: fresh one-shot reviewer with a VERDICT protocol ──
@@ -1550,6 +2112,10 @@ export interface VerifyReviewInput {
1550
2112
  /** Working directory to review: the holder's worktree root when isolated,
1551
2113
  * else the leader cwd — the reviewer must inspect the claimed work's tree. */
1552
2114
  cwd: string;
2115
+ /** Aborted when the holding's authority is invalidated (release, stop, new
2116
+ * claim, supersession, or resumed owner execution), so an orphaned reviewer
2117
+ * cannot keep a child process running against a tree it no longer judges. */
2118
+ signal?: AbortSignal;
1553
2119
  }
1554
2120
 
1555
2121
  export type VerifyReviewOutcome =
@@ -1598,15 +2164,32 @@ function truncated(text: string, cap = 4000): string {
1598
2164
  return text.length <= cap ? text : text.slice(0, Math.max(0, cap - suffix.length)) + suffix;
1599
2165
  }
1600
2166
 
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({
2167
+ /** Inspection grant for the gate reviewer: it may run the project's own checks
2168
+ * but holds no `edit`/`write` tool. The limit is the tool set, not the file
2169
+ * system: a shell can still write, so treat this as intent-level, not enforced. */
2170
+ export const VERIFY_REVIEW_TOOLS: readonly string[] = ["read", "bash", "grep", "find", "ls"];
2171
+
2172
+ /** Complete worker options for one gate review. The reviewer runs as a bare Pi
2173
+ * child: no extension, skill, prompt-template, context-file, or theme discovery
2174
+ * and no persisted session, so judging a gate cannot load the leader's extension
2175
+ * surface, run automatic memory learning, write memory files, or leave a session
2176
+ * record behind. Uses the team's model resolution chain (team default, else the
2177
+ * leader model) rather than any role pin. */
2178
+ export function buildVerifyReviewWorkerOptions(input: VerifyReviewInput): RunPiWorkerOptions {
2179
+ return {
1606
2180
  prompt: buildVerifyReviewPrompt(input),
1607
2181
  cwd: input.cwd || leaderCwd,
1608
- model: resolveSpawnModel(undefined, getTeamDefaultModel(), leaderModelRef()).model,
1609
- });
2182
+ tools: [...VERIFY_REVIEW_TOOLS],
2183
+ minimal: true,
2184
+ model: resolveSpawnModel(undefined, getTeamDefaultModel(), currentLeaderModelRef()).model,
2185
+ ...(input.signal ? { signal: input.signal } : {}),
2186
+ };
2187
+ }
2188
+
2189
+ /** Run the gate as a one-shot bare Pi worker: a brand-new context that inspects
2190
+ * the working tree itself before answering. */
2191
+ export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyReviewOutcome> {
2192
+ const outcome = await runPiWorker(buildVerifyReviewWorkerOptions(input));
1610
2193
  // A reviewer that did not exit cleanly produced no trustworthy verdict,
1611
2194
  // even if partial output happens to contain a PASS line.
1612
2195
  if (outcome.exitCode !== 0) {
@@ -1685,6 +2268,7 @@ function rearmTaskNotice(taskId: string): void {
1685
2268
  export function wakeIdleTeammates(immediateTaskId?: string): string[] {
1686
2269
  const notified: string[] = [];
1687
2270
  for (const teammate of idleTeammates()) {
2271
+ if (pendingShutdowns.has(teammate.name) || verificationFrozen(teammate)) continue;
1688
2272
  const deliveries = pendingDeliveries.get(teammate.name) ?? [];
1689
2273
  // A terminal direct assignment stays closed until explicit reopen; a board
1690
2274
  // holder must submit rather than drifting into more board work.
@@ -1702,7 +2286,10 @@ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
1702
2286
  ...fresh.filter((task) => task.id !== immediateTaskId),
1703
2287
  ];
1704
2288
  const noticed = prioritized.slice(0, WAKE_NOTICE_TASK_LIMIT);
1705
- const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
2289
+ const role = !teammate.assignment && teammate.turns === 0
2290
+ ? `=== ROLE PROMPT (${teammate.agent}) ===\n${resolveAgent(teammate.agent, leaderCwd)?.prompt ?? ""}\n\n`
2291
+ : "";
2292
+ const prompt = `${teammate.assignment ? `[agent-teams-assignment:${teammate.assignment.id}]\n` : ""}${role}${buildWakePrompt(deliveries, noticed, dueNotice)}`;
1706
2293
  if (!deliverPrompt(teammate.name, prompt)) continue;
1707
2294
  for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
1708
2295
  notified.push(teammate.name);
@@ -1714,12 +2301,11 @@ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
1714
2301
  status: "working",
1715
2302
  sequenceEnded: false,
1716
2303
  // A generic peer/harness/board wake-up never opens a terminal direct
1717
- // assignment. Only leader send_message(reopen=true) owns that transition.
2304
+ // assignment. Only an explicit Work assignment owns that transition.
1718
2305
  ...(teammate.assignment?.kind === "direct" && teammate.assignment.closed
1719
2306
  ? {}
1720
2307
  : { reportSequenceEnded: false }),
1721
2308
  lastOutputAt: Date.now(),
1722
- stallNoticeSentAt: undefined,
1723
2309
  ...(dueNotice ? { lastNoticeAt: Date.now() } : {}),
1724
2310
  });
1725
2311
  }
@@ -1741,14 +2327,18 @@ export function buildWakePrompt(
1741
2327
  }
1742
2328
  if (includeNotice && claimable.length > 0) {
1743
2329
  const listed = claimable.slice(0, 10).map((task) => `${task.id} (${task.subject})`).join(", ");
1744
- sections.push(`=== BOARD NOTICE ===\nUnclaimed tasks: ${listed}\nUse task_list for details and task_claim to take one if appropriate for your role.`);
2330
+ sections.push(`=== BOARD NOTICE ===\nUnclaimed tasks: ${listed}\nUse work action=list for details and work action=claim to take one if appropriate for your role.`);
1745
2331
  }
1746
2332
  if (sections.length === 0) return "";
1747
- return `Wake up. New activity for you:\n\n${sections.join("\n\n")}\n\nHandle the items above, then go idle again.`;
2333
+ const leader = deliveries.find((message) => message.from === "leader" && message.body.startsWith("[agent-teams-assignment:"));
2334
+ const marker = leader?.body.split("\n", 1)[0];
2335
+ return `${marker ? `${marker}\n` : ""}Wake up. New activity for you:\n\n${sections.join("\n\n")}\n\nHandle the items above, then go idle again.`;
1748
2336
  }
1749
2337
 
1750
2338
  function formatDelivery(messages: InboxMessage[]): string {
1751
- return messages.map((message) => `From ${message.from} · ${message.subject}\n${message.body}`).join("\n\n---\n\n");
2339
+ const leader = messages.find((message) => message.from === "leader" && message.body.startsWith("[agent-teams-assignment:"));
2340
+ const marker = leader?.body.split("\n", 1)[0];
2341
+ return `${marker ? `${marker}\n` : ""}${messages.map((message) => `From ${message.from} · ${message.subject}\n${message.body}`).join("\n\n---\n\n")}`;
1752
2342
  }
1753
2343
 
1754
2344
  // ── Board helpers shared with tools.ts ────────────────────────────
@@ -1763,32 +2353,6 @@ export interface BoardTaskCreationResult {
1763
2353
  supersededTaskIds: string[];
1764
2354
  }
1765
2355
 
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
2356
  /** Create a task and synchronously offer it to currently-idle teammates.
1793
2357
  *
1794
2358
  * The normal poll loop still handles later dependency unlocks and queued mail,
@@ -1807,15 +2371,25 @@ export function createBoardTask(input: {
1807
2371
  if (!created.ok) return created;
1808
2372
  for (const task of created.superseded) {
1809
2373
  // 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) {
2374
+ // it submits failed or stops; cancellation is lifecycle control, so it
2375
+ // must unpark the holder before its feedback can be routed.
2376
+ const holder = task.claimedBy ? getTeammate(task.claimedBy) : undefined;
2377
+ invalidateVerifyGate(task.id);
2378
+ if (holder) unexpectedExecutionParks.delete(`${task.id}:${holder.spawnId}`);
2379
+ for (const [name, pending] of pendingSubmissions) {
2380
+ if (pending.taskId === task.id) pendingSubmissions.delete(name);
2381
+ }
2382
+ if (holder) {
2383
+ archiveDeferredDeliveries(holder);
2384
+ const holding = `${task.id}:${holder.spawnId}`;
2385
+ verifyFailureParks.delete(holding);
2386
+ inconclusiveParks.delete(holding);
1812
2387
  deliverFeedback(
1813
- task.claimedBy,
2388
+ holder.name,
1814
2389
  `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.`,
2390
+ `Work Item "${task.id}" was superseded by "${created.task.id}". Stop work immediately and use work action=submit with outcome="failed" to acknowledge cancellation and release its resources.`,
1816
2391
  );
1817
2392
  }
1818
- verifyingTasks.delete(task.id);
1819
2393
  for (const key of [...verifyFailures.keys()]) {
1820
2394
  if (key.startsWith(`${task.id}:`)) verifyFailures.delete(key);
1821
2395
  }
@@ -1840,21 +2414,6 @@ export function createBoardTask(input: {
1840
2414
  };
1841
2415
  }
1842
2416
 
1843
- export function boardOverview(): Array<import("./types").BoardTask> {
1844
- return listTasks();
1845
- }
1846
-
1847
- /** Worker-side claim attempt through an exclusive-create marker file. */
1848
- export function attemptClaim(workerName: string, spawnId: string, taskId: string): boolean {
1849
- return createTaskIntent(claimsDir(boardDirectory()), taskId, {
1850
- taskId,
1851
- worker: workerName,
1852
- spawnId,
1853
- status: "completed",
1854
- timestamp: Date.now(),
1855
- });
1856
- }
1857
-
1858
2417
  /** Worker-side submission through an exclusive-create marker file. */
1859
2418
  export function attemptSubmission(
1860
2419
  workerName: string,
@@ -1867,6 +2426,7 @@ export function attemptSubmission(
1867
2426
  taskId,
1868
2427
  worker: workerName,
1869
2428
  spawnId,
2429
+ assignmentId: getTeammate(workerName)?.assignment?.id,
1870
2430
  status,
1871
2431
  result,
1872
2432
  timestamp: Date.now(),