@fradser/pi-agent-teams 0.9.2 → 0.10.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.
package/.memory/MEMORY.md CHANGED
@@ -4,3 +4,4 @@
4
4
 
5
5
  - `team-centric-design.md` — Agent Teams current architecture: named resident RPC teammates, shared task board with atomic self-claim, direct P2P inboxes, verify-gated completion, harness-driven wake-ups. Supersedes the deleted run-centric DAG design.
6
6
  - `follow-up-queue.md` — Teammate report steering handoff: hand reports immediately to Pi's steering channel without an internal serialized follow-up queue; delivers at safe tool boundaries.
7
+ - `attempt-bound-authority.md` — Worker communication, claims, and submissions bind to the started Assignment Attempt; failed or interrupted Work is recovery-held until an explicit leader assignment, and retired nonterminal reports leave the provider projection while staying in history.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: attempt-bound-authority
3
+ description: Worker communication and submissions bind to the started Assignment Attempt; failed Work is recovery-held instead of silently re-claimable
4
+ type: project
5
+ ---
6
+
7
+ ## Why
8
+
9
+ A real session exposed four coupled lifecycle defects. After a worker was superseded or stopped, its cancelled attempts kept emitting cancellation acknowledgements into a settled assignment; the `work` tool was hidden for a retained superseded holder, so it could not acknowledge cancellation at all and its resource lease stayed stranded. Turns that had started under one Assignment Attempt adopted a newly assigned `assignmentId` from the live roster, so an old turn could send or submit as replacement Work. A failed Work Item returned to the board as plainly `pending`, so idle residents were repeatedly re-delegated the same doomed work while the leader treated the failure as already handled. Nonterminal reports from retired attempts also kept acting as current leader instructions.
10
+
11
+ ## How to apply
12
+
13
+ - `packages/agent-teams/src/worker.ts` binds a turn at `message_start`: `assignmentId`/`taskId` come from the delivered assignment marker, and `authorize()` rejects any call whose roster entry names a different attempt, Work Item, or incarnation. Reads match on `spawnId` as well as name so a replacement resident cannot lend authority.
14
+ - A bound, living, same-spawn roster entry with no assignment clears the binding for that turn: a retried attempt must not dead-end an ordinary peer/board wake.
15
+ - `send`, `queueWorkClaim`, and `queueWorkSubmission` all pass the same authority; a terminal outcome or closed assignment rejects further sends, claims, and submissions. A retained superseded holder keeps exactly one failed cancellation acknowledgement.
16
+ - `packages/agent-teams/src/state.ts` marks failed, crash-released, or stop-released Work `recoveryRequired`, which excludes it from `claimableTasks()` and wake notices; only an explicit leader `work assign` clears it. A harness start failure that never reached the worker stays ordinarily claimable.
17
+ - `packages/agent-teams/src/index.ts` keeps a retired attempt's nonterminal report in session history but removes it from the provider projection, because Pi cannot retract an already-queued steer.
18
+ - Keep BDD coverage in `features/worker-attempt-lifecycle.feature`, `features/session-history-regressions.feature`, and `features/historical-report-delivery.feature`; regressions live in `tests/test_worker_attempt_lifecycle.py`, `tests/test_session_history_regressions.py`, and `tests/test_historical_report_delivery.py`.
19
+ - Guidance states the contract in two sentences; `tests/test_prompt_budget.py` ceilings for `tmActiveLeader`/`tmWorker` were raised deliberately with that change.
package/README.md CHANGED
@@ -51,6 +51,13 @@ assignment-only scheduling mode.
51
51
  `work` owns Work lifecycle. Leaders create/list/assign/release/reopen completed
52
52
  Work/supersede; Workers list/claim/submit/release their current Work.
53
53
 
54
+ Failed, interrupted, or crash-released Work returns to the board as
55
+ `pending/recovery-required`: the evidence stays available for inspection, and the
56
+ harness deliberately excludes it from autonomous claim notices. Only an explicit
57
+ leader `work assign` opens the next attempt, so a transient failure is never
58
+ silently retried by whichever resident happens to be idle. A retained superseded
59
+ holder keeps `work` disclosed for exactly one failed cancellation acknowledgement.
60
+
54
61
  ```ts
55
62
  work({ action: "create", subject: "Fix storage", resources: ["firmware/storage"] })
56
63
  work({ action: "assign", id: "fix-storage", target: { session: "session:worker-...:spawn-..." } })
@@ -89,6 +96,10 @@ After delegation, continue independent work or end the turn: results resume the
89
96
  session automatically. Use `agent inspect` for deliberate diagnosis, not repeated
90
97
  polling or sleep loops. `agent_event` never creates or retries Work; messaging a
91
98
  completed assignment returns its recorded result without waking the worker.
99
+ Worker communication and submissions are bound to the Assignment Attempt that
100
+ started the turn, so an old turn cannot report or submit as replacement Work, and
101
+ nonterminal coordination from a retired attempt stays in session history as
102
+ evidence without acting as a current instruction.
92
103
  Recovery reuses the existing Work ID. Request explicit `work release` and await
93
104
  authoritative release before `work assign` to the chosen exact session; a queued
94
105
  release is not yet released authority. Do not create competing recovery Work via
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fradser/pi-agent-teams",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Multi-agent team system for Pi — resident teammates, shared task board, and peer-to-peer messaging",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/guidance.ts CHANGED
@@ -45,7 +45,10 @@ claim or submission is not ownership or completion; the harness is authoritative
45
45
 
46
46
  Use \`agent_event\` only for \`inform\` or \`request\` communication with the
47
47
  leader or an exact peer session. It arrives at the next safe tool boundary or wakes an idle leader. It cannot complete, release, reopen, or reassign
48
- Work. The runtime delivers ordinary final answers automatically for the current Work
48
+ Work. Communication and submission bind to the attempt that started your turn; a different
49
+ attempt, Work Item, or incarnation is rejected, not retargeted. After a submission or
50
+ cancellation acknowledgement, end the turn.
51
+ The runtime delivers ordinary final answers automatically for the current Work
49
52
  when execution settles as a successful candidate, not an independently verified result.
50
53
  If you cannot perform the requested work (including missing tools), you must use
51
54
  \`work({ action: "submit", outcome: "failed", result: "Blocker and unverified work" })\`.
@@ -116,6 +119,9 @@ For recovery, keep the existing Work ID: request explicit \`work release\` and a
116
119
 
117
120
  Observe stalled sessions in \`/agent-teams\`. The harness never reclaims, restarts, or replaces a teammate. Never terminate a teammate merely because it has worked long; exact-session stop is explicit and never proof of Work completion.
118
121
 
122
+ Failed or interrupted Work returns as \`pending/recovery-required\` and waits for an explicit
123
+ \`work assign\`; retired attempts' reports stop instructing you.
124
+
119
125
  ### Yield while teammates work
120
126
 
121
127
  Continue independent work while teammates run. If none remains, end the turn; results resume the session automatically.
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ import { buildIdleLeaderGuidance, buildTeamLeaderGuidance, WORKER_GUIDANCE } fro
11
11
  import { clearSessionAgents } from "./agents.ts";
12
12
  import { getConfirmedStopTime, initTeamMachine, markTeammateFinished, removeRuntimeDir, shutdownTeamMachine, syncLeaderContext, teardownTeammates } from "./team-machine.ts";
13
13
  import { cleanupExpiredStateDirs } from "./statefile.ts";
14
- import { livingTeammates, listTasks, resetState } from "./state.ts";
14
+ import { getTask, getTeammate, livingTeammates, listTasks, resetState } from "./state.ts";
15
15
  import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui.ts";
16
16
  import { registerLeaderTools, registerTeamCommand } from "./tools.ts";
17
17
  import { registerWorkerCapabilities, workerBinding } from "./worker.ts";
@@ -34,6 +34,45 @@ let leaderPi: ExtensionAPI | undefined;
34
34
  let leaderCtx: ExtensionContext | undefined;
35
35
 
36
36
  export const TEAMMATE_FINISHED_ENTRY_TYPE = "agent-teams-teammate-finished";
37
+ /** Audit entry for a retired attempt's nonterminal report. The evidence stays in the
38
+ * session while the context projection stops treating it as a current instruction;
39
+ * without a renderer the retained entry would be invisible in the transcript. */
40
+ export const TEAMMATE_HISTORICAL_ENTRY_TYPE = "agent-teams-historical-report";
41
+
42
+ /** One entry per retained report, so a re-delivered report cannot multiply its own
43
+ * audit trail. Bounded the same way the finish announcements are. */
44
+ const HISTORICAL_ENTRY_LIMIT = 512;
45
+ const retainedHistoricalReports = new Set<string>();
46
+ function rememberRetainedReport(report: LeaderReport): boolean {
47
+ const key = [
48
+ report.teammate ?? report.agent ?? "",
49
+ report.spawnId ?? "",
50
+ report.eventId ?? "",
51
+ report.timestamp ?? 0,
52
+ ].join(":");
53
+ if (retainedHistoricalReports.has(key)) return false;
54
+ retainedHistoricalReports.add(key);
55
+ while (retainedHistoricalReports.size > HISTORICAL_ENTRY_LIMIT) {
56
+ const oldest = retainedHistoricalReports.values().next().value;
57
+ if (oldest === undefined) break;
58
+ retainedHistoricalReports.delete(oldest);
59
+ }
60
+ return true;
61
+ }
62
+
63
+ /** Accepted outcomes remain evidence even after their process exits. Only
64
+ * nonterminal coordination loses current authority when its attempt retires; the
65
+ * source report stays in the session as the visible audit trail. */
66
+ function isHistoricalReport(report: LeaderReport): boolean {
67
+ if (report.origin === "harness" || report.harnessEvent || report.finished
68
+ || report.status === "completed" || report.status === "failed") return false;
69
+ if (!report.spawnId || !report.teammate) return false;
70
+ const worker = getTeammate(report.teammate);
71
+ if (!worker || worker.spawnId !== report.spawnId || worker.status === "stopped") return true;
72
+ if (report.assignmentId !== worker.assignment?.id || worker.assignment?.closed || worker.reportSequenceEnded) return true;
73
+ const task = worker.currentTaskId ? getTask(worker.currentTaskId) : undefined;
74
+ return task?.status === "superseded" || task?.status === "completed";
75
+ }
37
76
 
38
77
  function sendLeaderReport(report: LeaderReport): void {
39
78
  try {
@@ -85,6 +124,11 @@ export default function (pi: ExtensionAPI) {
85
124
  const name = data?.teammate ?? data?.agent ?? "teammate";
86
125
  return new Text(theme.fg("success", `Assignment for @${name} finished.`), 0, 0);
87
126
  });
127
+ pi.registerEntryRenderer(TEAMMATE_HISTORICAL_ENTRY_TYPE, (entry, _options, theme) => {
128
+ const data = entry.data as { teammate?: string; agent?: string } | undefined;
129
+ const name = data?.teammate ?? data?.agent ?? "teammate";
130
+ return new Text(theme.fg("dim", `Historical report from @${name} retained for audit; it no longer instructs.`), 0, 0);
131
+ });
88
132
  pi.registerMessageRenderer(TEAMMATE_HARNESS_MESSAGE_TYPE, (message, { expanded }, theme) => {
89
133
  const report = extractHarnessReport(message.details);
90
134
  if (!report) return new Text(String(message.content), 0, 0);
@@ -144,6 +188,27 @@ export default function (pi: ExtensionAPI) {
144
188
  registerLeaderTools(pi);
145
189
  registerTeamCommand(pi);
146
190
 
191
+ // Pi cannot retract one already-queued steer. The persisted record stays for
192
+ // audit, and this projection keeps a retired attempt's nonterminal text from
193
+ // acting as a current instruction. Deliberate trade-off: while such a record
194
+ // exists the projection is recomputed per request, so a session that retires a
195
+ // report pays a prompt/tool replay each turn. Correctness wins over cache
196
+ // warmth here; the alternative needs an SDK API to rewrite a stored message.
197
+ pi.on("context", (event) => {
198
+ let changed = false;
199
+ const messages = event.messages.flatMap((message) => {
200
+ if (message.role !== "custom" || message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return [message];
201
+ const reports = extractReports(message.details);
202
+ if (reports.length === 0) return [message];
203
+ const current = reports.filter((report) => !isHistoricalReport(report));
204
+ if (current.length === reports.length) return [message];
205
+ changed = true;
206
+ return current.length === 0 ? [] : [{ ...message, content: formatReports(current),
207
+ details: current.length === 1 ? current[0] : { reports: current } }];
208
+ });
209
+ return changed ? { messages } : undefined;
210
+ });
211
+
147
212
  pi.on("message_start", (event) => {
148
213
  if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
149
214
  const deliveredAt = Date.now();
@@ -160,6 +225,26 @@ export default function (pi: ExtensionAPI) {
160
225
  ));
161
226
  if (reports.length === 0) return;
162
227
  for (const report of reports) {
228
+ // A retired attempt's nonterminal report keeps its evidence in the session but must
229
+ // stop instructing the leader. The context projection already excludes it from
230
+ // future requests; this entry is what keeps the retained evidence visible rather
231
+ // than silently dropped, and is appended once per report at the delivery seam.
232
+ if (isHistoricalReport(report)) {
233
+ if (rememberRetainedReport(report)) {
234
+ pi.appendEntry(TEAMMATE_HISTORICAL_ENTRY_TYPE, {
235
+ teammate: report.teammate ?? report.agent,
236
+ agent: report.agent,
237
+ spawnId: report.spawnId,
238
+ assignmentId: report.assignmentId,
239
+ workId: report.workId,
240
+ eventId: report.eventId,
241
+ status: report.status,
242
+ timestamp: report.timestamp,
243
+ body: report.body,
244
+ });
245
+ }
246
+ continue;
247
+ }
163
248
  if (!markTeammateFinished(report)) continue;
164
249
  pi.appendEntry(TEAMMATE_FINISHED_ENTRY_TYPE, {
165
250
  teammate: report.teammate ?? report.agent,
package/src/state.ts CHANGED
@@ -527,7 +527,7 @@ export function pendingTasks(): BoardTask[] {
527
527
  }
528
528
 
529
529
  export function claimableTasks(): BoardTask[] {
530
- return pendingTasks().filter(taskDependenciesMet);
530
+ return pendingTasks().filter((task) => !task.recoveryRequired && taskDependenciesMet(task));
531
531
  }
532
532
 
533
533
  export function taskDependenciesMet(task: BoardTask): boolean {
@@ -602,6 +602,7 @@ export function reclaimDirectWork(
602
602
  task.claimedBy = workerName;
603
603
  task.result = undefined;
604
604
  task.errorMessage = undefined;
605
+ task.recoveryRequired = undefined;
605
606
  task.completedAt = undefined;
606
607
  task.updatedAt = Date.now();
607
608
  boardRevisionCounter++;
@@ -613,7 +614,7 @@ export function reclaimDirectWork(
613
614
  export function setTaskClaimed(taskId: string, workerName: string): BoardTask | undefined {
614
615
  const task = state.tasks[taskId];
615
616
  const teammate = getTeammate(workerName);
616
- if (!task || !teammate || task.status !== "pending" || !taskDependenciesMet(task)) return undefined;
617
+ if (!task || !teammate || task.status !== "pending" || task.recoveryRequired || !taskDependenciesMet(task)) return undefined;
617
618
  if (teammate.assignment) return undefined;
618
619
  if (activeAssignmentConflict(task.resources, workerName)) return undefined;
619
620
  task.status = "claimed";
@@ -626,7 +627,12 @@ export function setTaskClaimed(taskId: string, workerName: string): BoardTask |
626
627
  }
627
628
 
628
629
  /** Release a claimed task back to pending. A superseded holder instead
629
- * acknowledges cancellation: the task stays superseded but frees resources. */
630
+ * acknowledges cancellation: the task stays superseded but frees resources.
631
+ * `recoveryRequired` marks an attempt that ran and did not succeed (failure,
632
+ * crash, or stop), so it waits for a deliberate Leader assignment instead of
633
+ * being re-offered. A harness start failure that happened before the attempt
634
+ * reached the worker stays ordinarily claimable; a deliberate Leader release
635
+ * passes `false` for the same reason. */
630
636
  export function claimedDependents(workId: string): BoardTask[] {
631
637
  const reverse = new Map<string, string[]>();
632
638
  for (const task of Object.values(state.tasks)) {
@@ -661,6 +667,7 @@ export function reopenCompletedWork(workId: string): { ok: true; task: BoardTask
661
667
  task.result = undefined;
662
668
  task.deferredMessages = undefined;
663
669
  task.errorMessage = undefined;
670
+ task.recoveryRequired = undefined;
664
671
  task.completedAt = undefined;
665
672
  task.updatedAt = Date.now();
666
673
  boardRevisionCounter++;
@@ -668,12 +675,13 @@ export function reopenCompletedWork(workId: string): { ok: true; task: BoardTask
668
675
  return { ok: true, task };
669
676
  }
670
677
 
671
- export function releaseTask(taskId: string, errorMessage?: string): BoardTask | undefined {
678
+ export function releaseTask(taskId: string, errorMessage: string | undefined, recoveryRequired: boolean): BoardTask | undefined {
672
679
  const task = state.tasks[taskId];
673
680
  if (!task || (task.status !== "claimed" && task.status !== "superseded")) return undefined;
674
681
  const holder = task.claimedBy;
675
682
  if (task.status === "claimed") task.status = "pending";
676
683
  task.claimedBy = undefined;
684
+ task.recoveryRequired = task.status === "pending" && recoveryRequired ? true : undefined;
677
685
  if (errorMessage !== undefined) task.errorMessage = errorMessage;
678
686
  task.updatedAt = Date.now();
679
687
  boardRevisionCounter++;
@@ -690,6 +698,7 @@ export function completeTask(taskId: string, result?: string): BoardTask | undef
690
698
  task.claimedBy = undefined;
691
699
  task.result = result;
692
700
  task.errorMessage = undefined;
701
+ task.recoveryRequired = undefined;
693
702
  task.completedAt = Date.now();
694
703
  task.updatedAt = Date.now();
695
704
  boardRevisionCounter++;
@@ -703,7 +712,7 @@ export function releaseTasksOf(workerName: string, reason: string): BoardTask[]
703
712
  const released: BoardTask[] = [];
704
713
  for (const task of listTasks()) {
705
714
  if ((task.status === "claimed" || task.status === "superseded") && task.claimedBy === workerName) {
706
- releaseTask(task.id, reason);
715
+ releaseTask(task.id, reason, true);
707
716
  released.push(task);
708
717
  }
709
718
  }
@@ -724,6 +733,7 @@ export function applyClaimIntent(intent: TaskIntent): { applied: boolean; reason
724
733
  }
725
734
  if (task.status === "claimed") return { applied: false, reason: `task "${intent.taskId}" is already claimed` };
726
735
  if (task.status === "completed" || task.status === "superseded") return { applied: false, reason: `task "${intent.taskId}" is ${task.status}` };
736
+ if (task.recoveryRequired) return { applied: false, reason: `task "${intent.taskId}" requires explicit leader Work assignment after failure` };
727
737
  if (!taskDependenciesMet(task)) return { applied: false, reason: `task "${intent.taskId}" has unmet dependencies` };
728
738
  const conflict = activeAssignmentConflict(task.resources, intent.worker);
729
739
  if (conflict) return { applied: false, reason: `resource conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}"` };
@@ -752,7 +762,7 @@ export function applySubmissionIntent(intent: TaskIntent): { ok: boolean; error?
752
762
  return { ok: false, error: `task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}"; submit failed to acknowledge cancellation` };
753
763
  }
754
764
  if (intent.status === "failed") {
755
- releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.");
765
+ releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.", true);
756
766
  return { ok: true };
757
767
  }
758
768
  completeTask(intent.taskId, intent.result?.trim() || undefined);
@@ -774,6 +784,7 @@ export function loadBoard(tasks: Record<string, BoardTask>): number {
774
784
  ...task,
775
785
  resources: normalizeResources(task.resources),
776
786
  status: task.status === "claimed" ? "pending" : task.status,
787
+ recoveryRequired: task.status === "claimed" ? true : task.recoveryRequired,
777
788
  // Runtime workers and assignments die with the session. Superseded work
778
789
  // remains visible for audit but cannot retain a dead holder/resource lock.
779
790
  claimedBy: orphanedHolding ? undefined : task.claimedBy,
@@ -701,7 +701,7 @@ export function spawnTeammate(input: {
701
701
  onError: (error) => {
702
702
  settleReadiness(`Resident startup readiness failed: ${error.message}`);
703
703
  if (getTeammate(input.name)?.spawnId !== spawnId) return;
704
- if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${error.message}`);
704
+ if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${error.message}`, false);
705
705
  failSpawn(input.name, error.message, input.existingWorkId);
706
706
  },
707
707
  onExit: (result) => {
@@ -710,7 +710,7 @@ export function spawnTeammate(input: {
710
710
  },
711
711
  });
712
712
  if ("error" in started) {
713
- if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${started.error}`);
713
+ if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${started.error}`, false);
714
714
  else if (assignment) discardDirectWork(getTeammate(input.name)?.workId ?? `work:${spawnId}`, input.name);
715
715
  failSpawn(input.name, started.error, input.existingWorkId);
716
716
  return { ok: false, error: started.error };
@@ -1490,7 +1490,7 @@ export function assignExistingWork(workId: string, session: string): AssignExist
1490
1490
  return;
1491
1491
  }
1492
1492
  if (getTeammate(owner)?.assignment?.id === assignment.id) {
1493
- releaseTask(workId, "Pi session reset failed before the assigned Work started.");
1493
+ releaseTask(workId, "Pi session reset failed before the assigned Work started.", false);
1494
1494
  }
1495
1495
  deliverDiagnostic(owner, "Pi session reset failed; the assigned Work was not delivered.");
1496
1496
  });
@@ -1535,7 +1535,7 @@ export function releaseExistingWork(workId: string, reason: string): ReleaseExis
1535
1535
  // residual window is reported instead of being silently implied safe.
1536
1536
  holderStillRunning = holder.status === "working" ? holder.name : undefined;
1537
1537
  }
1538
- const released = releaseTask(workId, reason);
1538
+ const released = releaseTask(workId, reason, false);
1539
1539
  if (!released) return { ok: false, error: `Work Item "${workId}" could not be released.` };
1540
1540
  rearmTaskNotice(workId);
1541
1541
  publishStateSnapshot();
@@ -1672,7 +1672,7 @@ export function sendLeaderMessage(
1672
1672
  const current = getTeammate(to);
1673
1673
  const currentTaskId = current?.currentTaskId;
1674
1674
  if (current?.assignment?.id === teammate.assignment?.id && currentTaskId) {
1675
- releaseTask(currentTaskId, "Pi session reset failed before the new Assignment Attempt started.");
1675
+ releaseTask(currentTaskId, "Pi session reset failed before the new Assignment Attempt started.", false);
1676
1676
  } else if (current?.assignment?.id === teammate.assignment?.id) {
1677
1677
  assignTeammate(to, undefined, undefined);
1678
1678
  }
@@ -1768,7 +1768,7 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
1768
1768
  void deliverFreshAssignment(teammate.name, buildFreshAssignmentPrompt(teammate, assignmentId, kickoff)).then((sent) => {
1769
1769
  if (sent) return;
1770
1770
  if (getTeammate(teammate.name)?.assignment?.id === assignmentId) {
1771
- releaseTask(task.id, "Pi session reset failed before the claimed Assignment Attempt started.");
1771
+ releaseTask(task.id, "Pi session reset failed before the claimed Assignment Attempt started.", false);
1772
1772
  }
1773
1773
  deliverDiagnostic(teammate.name, "Pi session reset failed; the claimed Assignment Attempt was not delivered.");
1774
1774
  });
@@ -1869,7 +1869,7 @@ function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: s
1869
1869
  void deliverFreshAssignment(teammate.name, prompt).then((sent) => {
1870
1870
  if (sent) return;
1871
1871
  if (getTeammate(teammate.name)?.assignment?.id === assignment.id) {
1872
- releaseTask(intent.taskId, "Pi session reset failed before the verification revision started.");
1872
+ releaseTask(intent.taskId, "Pi session reset failed before the verification revision started.", false);
1873
1873
  }
1874
1874
  deliverDiagnostic(teammate.name, "Pi session reset failed; the verification revision was not delivered.");
1875
1875
  });
@@ -2061,7 +2061,7 @@ function retirePendingSubmission(intent: import("./types").TaskIntent): void {
2061
2061
 
2062
2062
  function releaseSupersededHolding(intent: import("./types").TaskIntent): void {
2063
2063
  retirePendingSubmission(intent);
2064
- releaseTask(intent.taskId, intent.result?.trim() || "Superseded task cancellation acknowledged.");
2064
+ releaseTask(intent.taskId, intent.result?.trim() || "Superseded task cancellation acknowledged.", false);
2065
2065
  clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2066
2066
  invalidateVerifyGate(intent.taskId);
2067
2067
  }
@@ -2071,11 +2071,12 @@ function finishFailure(intent: import("./types").TaskIntent): void {
2071
2071
  const assignmentId = teammate?.assignment?.id;
2072
2072
  if (!teammate || !assignmentId) return;
2073
2073
  retirePendingSubmission(intent);
2074
- const released = releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.");
2074
+ const released = releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.", true);
2075
2075
  if (!released) return;
2076
2076
  clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2077
2077
  invalidateVerifyGate(intent.taskId);
2078
- rearmTaskNotice(intent.taskId);
2078
+ // Failure is evidence for deliberate recovery, not a fresh work offer to
2079
+ // every idle resident. Explicit work.assign starts the next attempt.
2079
2080
  announceWorkOutcome(intent, teammate, assignmentId);
2080
2081
  }
2081
2082
 
package/src/types.ts CHANGED
@@ -116,6 +116,9 @@ export interface BoardTask {
116
116
  /** Deferred mail archived when an accepted result closes its Work Item. */
117
117
  deferredMessages?: Array<{ from: string; subject: string; body: string; timestamp: number }>;
118
118
  errorMessage?: string;
119
+ /** Failed/interrupted Work stays pending for inspection but cannot be
120
+ * autonomously claimed until the Leader deliberately authorizes recovery. */
121
+ recoveryRequired?: boolean;
119
122
  createdAt: number;
120
123
  updatedAt: number;
121
124
  completedAt?: number;
@@ -260,6 +263,10 @@ function mergeBranchSubSchemas(pool: any[]): any {
260
263
  return { anyOf: pool };
261
264
  }
262
265
 
266
+ /** The root also declares `type: "object"`. A schema that carries `properties`
267
+ * without an explicit object type is rejected by Google's GenerateContent API
268
+ * ("parameters.properties: only allowed for OBJECT type"), which the Gemini
269
+ * routes reach through their provider translators. */
263
270
  function actionUnion<T extends TObject[]>(branches: [...T], options?: Record<string, unknown>): TUnion<T> {
264
271
  const pools: Record<string, any[]> = {};
265
272
  for (const branch of branches) {
@@ -273,7 +280,7 @@ function actionUnion<T extends TObject[]>(branches: [...T], options?: Record<str
273
280
  }
274
281
  const properties: Record<string, any> = {};
275
282
  for (const [key, pool] of Object.entries(pools)) properties[key] = mergeBranchSubSchemas(pool);
276
- return Type.Union(branches, { ...options, properties } as never) as TUnion<T>;
283
+ return Type.Union(branches, { type: "object", ...options, properties } as never) as TUnion<T>;
277
284
  }
278
285
 
279
286
  /** Tolerate harnesses that deliver object or array parameters as JSON strings. */
package/src/worker.ts CHANGED
@@ -44,8 +44,12 @@ function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
44
44
  ? readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)
45
45
  : undefined;
46
46
  const assignment = rosterEntry?.assignment;
47
- if (assignment && !assignment.closed) state = "claimed";
48
- else if (!assignment && prompt.includes("=== BOARD NOTICE ===")) state = "notice";
47
+ // A superseded holder still owns cancellation authority and must be
48
+ // able to acknowledge it; hiding work here strands its resource lease.
49
+ if (binding && rosterEntry && rosterEntry.spawnId === binding.spawnId
50
+ && rosterEntry.status !== "stopped" && assignment) {
51
+ state = "claimed";
52
+ } else if (!assignment && prompt.includes("=== BOARD NOTICE ===")) state = "notice";
49
53
  else state = "none";
50
54
  apply();
51
55
  },
@@ -97,7 +101,7 @@ function dependenciesMet(task: BoardTask, tasks: Map<string, BoardTask>): boolea
97
101
  function taskCounts(tasks: BoardTask[], byId: Map<string, BoardTask>): string {
98
102
  const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
99
103
  for (const task of tasks) counts[task.status] += 1;
100
- const claimable = tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId)).length;
104
+ const claimable = tasks.filter((task) => task.status === "pending" && !task.recoveryRequired && dependenciesMet(task, byId)).length;
101
105
  return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) · claimed=${counts.claimed} · completed=${counts.completed} · superseded=${counts.superseded}`;
102
106
  }
103
107
 
@@ -121,7 +125,7 @@ function claimRejection(binding: WorkerBinding, task: BoardTask): string | undef
121
125
  }
122
126
 
123
127
  function taskStatusLabel(task: BoardTask, tasks: Map<string, BoardTask>): string {
124
- if (task.status === "pending") return dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
128
+ if (task.status === "pending") return task.recoveryRequired ? "pending/recovery-required" : dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
125
129
  return task.status;
126
130
  }
127
131
 
@@ -176,30 +180,43 @@ function automaticResultBody(binding: WorkerBinding, assignmentId: string, body:
176
180
 
177
181
  function createWorkerReports(pi: ExtensionAPI) {
178
182
  const initial = workerBinding();
179
- const initialAssignment = initial ? readRoster(initial.rosterFile).find((entry) => entry.name === initial.worker)?.assignment : undefined;
183
+ const initialSelf = initial ? readRoster(initial.rosterFile).find((entry) => entry.name === initial.worker && entry.spawnId === initial.spawnId) : undefined;
184
+ const initialAssignment = initialSelf?.assignment;
180
185
  let assignmentId = initialAssignment?.id;
186
+ let taskId = initialSelf?.currentTaskId;
181
187
  let reportClosed = initialAssignment?.closed === true;
188
+ let terminalOutcome: "completed" | "failed" | undefined;
182
189
  let pending: { binding: WorkerBinding; assignmentId: string; timestamp: number; awaitingFinal?: boolean; outcome?: AutomaticResult } | undefined;
183
190
  const finalizedMessages = new WeakSet<AssistantResponse>();
184
191
  const currentAssignment = (binding: WorkerBinding) => {
185
- const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
192
+ const self = readRoster(binding.rosterFile)
193
+ .find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
186
194
  const assignment = self?.assignment;
187
195
  return !reportClosed && self?.spawnId === binding.spawnId && self.status !== "stopped"
188
196
  && assignment && assignment.id === assignmentId && !assignment.closed ? assignment : undefined;
189
197
  };
190
- const close = () => {
198
+ const close = (outcome: "completed" | "failed") => {
191
199
  reportClosed = true;
200
+ terminalOutcome = outcome;
192
201
  pending = undefined;
193
202
  };
194
- const reportAssignment = (binding: WorkerBinding) => {
195
- const current = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment;
196
- if (current?.kind === "board" && current.id !== assignmentId) {
197
- assignmentId = current.id;
198
- reportClosed = false;
199
- pending = undefined;
203
+ const authorize = (binding: WorkerBinding, cancellation = false) => {
204
+ const sameBinding = initial && binding.worker === initial.worker && binding.spawnId === initial.spawnId
205
+ && binding.outbox === initial.outbox && binding.rosterFile === initial.rosterFile;
206
+ const self = readRoster(binding.rosterFile)
207
+ .find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
208
+ if (!sameBinding || !self || self.status === "stopped") {
209
+ throw new Error("Work authority rejected: this is not the bound living worker incarnation.");
200
210
  }
201
- if (reportClosed) throw new Error("Leader report rejected: this assignment already has a terminal report. Wait for an explicit leader assignment.");
202
- return assignmentId;
211
+ if (self.assignment?.id !== assignmentId || (taskId !== undefined && self.currentTaskId !== taskId)) {
212
+ throw new Error("Work authority rejected: this turn belongs to a different assignment. Wait for its fresh assignment marker.");
213
+ }
214
+ const cancelled = cancellation && assignmentId && self.currentTaskId
215
+ && (self.assignment?.closed || loadBoardTasks(binding).some((task) => task.id === self.currentTaskId && task.status === "superseded"));
216
+ if ((reportClosed || self.assignment?.closed) && !(cancelled && terminalOutcome !== "failed")) {
217
+ throw new Error("Leader report rejected: this assignment already has a terminal report or is closed. Stop; no repeated acknowledgment is required.");
218
+ }
219
+ return { assignmentId, taskId: taskId ?? self.currentTaskId };
203
220
  };
204
221
  pi.on("message_start", ({ message }) => {
205
222
  const binding = workerBinding();
@@ -208,12 +225,27 @@ function createWorkerReports(pi: ExtensionAPI) {
208
225
  const content = message.content;
209
226
  const text = typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
210
227
  const marker = /^\[agent-teams-assignment:([^\n]+)\]\n/.exec(text);
211
- const current = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment;
228
+ const self = readRoster(binding.rosterFile)
229
+ .find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
230
+ const current = self?.assignment;
212
231
  if (marker) {
213
232
  const id = marker[1] === "none" ? undefined : marker[1];
214
233
  if (id !== current?.id) return;
215
- if (id !== assignmentId || current?.kind === "board") reportClosed = current?.closed === true;
234
+ if (id !== assignmentId) {
235
+ reportClosed = current?.closed === true;
236
+ terminalOutcome = undefined;
237
+ }
216
238
  assignmentId = id;
239
+ taskId = self?.currentTaskId;
240
+ pending = undefined;
241
+ } else if (!current) {
242
+ // The harness retired this attempt without a replacement marker: this is a
243
+ // fresh unassigned turn (peer mail, board notice, ordinary discussion).
244
+ // Holding the previous terminal state here would dead-end the worker.
245
+ assignmentId = undefined;
246
+ taskId = undefined;
247
+ reportClosed = false;
248
+ terminalOutcome = undefined;
217
249
  pending = undefined;
218
250
  }
219
251
  const assignment = currentAssignment(binding);
@@ -249,17 +281,19 @@ function createWorkerReports(pi: ExtensionAPI) {
249
281
  assignmentId: pending.assignmentId, timestamp: Date.now(), status: result.status,
250
282
  body: automaticResultBody(binding, pending.assignmentId, result.body),
251
283
  });
252
- close();
284
+ close(result.status);
253
285
  });
254
286
  return {
255
287
  close,
288
+ authorize,
256
289
  send(binding: WorkerBinding, body: string, status: import("./types.ts").WorkerReportEvent["status"]) {
290
+ const authority = authorize(binding, status === "failed");
257
291
  appendWorkerEvent(binding.outbox, {
258
- assignmentId: reportAssignment(binding), id: randomUUID(), type: "message",
292
+ assignmentId: authority.assignmentId, id: randomUUID(), type: "message",
259
293
  worker: binding.worker, spawnId: binding.spawnId, body, status, timestamp: Date.now(),
260
294
  });
261
295
  const isTerminal = status === "completed" || status === "failed";
262
- if (isTerminal) close();
296
+ if (isTerminal) close(status);
263
297
  return isTerminal;
264
298
  },
265
299
  reset() { pending = undefined; },
@@ -287,9 +321,10 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
287
321
  async execute(_toolCallId, params) {
288
322
  const binding = workerBinding();
289
323
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
324
+ const authority = reports.authorize(binding);
290
325
  if (!params.to || params.to === LEADER_RECIPIENT) {
291
326
  appendWorkerEvent(binding.outbox, {
292
- assignmentId: readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment?.id, id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
327
+ assignmentId: authority.assignmentId, id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
293
328
  body: params.message, status: params.intent ?? "inform", timestamp: Date.now(),
294
329
  });
295
330
  return {
@@ -318,6 +353,9 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
318
353
  async function queueWorkClaim(id: string | undefined, presentation: "board" | "work") {
319
354
  const binding = workerBinding();
320
355
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
356
+ // Claims pass the same attempt authority as sends and submissions: a closed or
357
+ // terminal attempt must not queue new board work even when work is re-disclosed.
358
+ reports.authorize(binding);
321
359
  const label = presentation === "work" ? "Work Item" : "Task";
322
360
  const empty = presentation === "work" ? "No claimable Work Item right now." : "No claimable task right now.";
323
361
  const race = presentation === "work" ? "All candidate Work Items were claimed in the race. Check Work again." : "All candidate tasks were claimed in the race. Check the board again.";
@@ -325,11 +363,21 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
325
363
  const byId = new Map(tasks.map((task) => [task.id, task]));
326
364
  const candidates = id
327
365
  ? tasks.filter((task) => task.id === id)
328
- : tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId));
366
+ : tasks.filter((task) => task.status === "pending" && !task.recoveryRequired && dependenciesMet(task, byId));
329
367
  if (candidates.length === 0) {
368
+ // Say why nothing is claimable: a board whose only pending Work is held for
369
+ // Leader recovery must not read as an ordinary empty board.
370
+ const held = loadBoardTasks(binding).filter((task) => task.status === "pending" && task.recoveryRequired);
371
+ if (!id && held.length > 0) {
372
+ throw new Error(`No claimable ${label.toLowerCase()} right now. Held for explicit leader recovery: ${held.map((task) => task.id).join(", ")}.`);
373
+ }
330
374
  throw new Error(id ? `${label} "${id}" was not found${presentation === "board" ? " on the board" : ""}.` : empty);
331
375
  }
332
376
  for (const task of candidates) {
377
+ if (task.recoveryRequired) {
378
+ if (id) throw new Error(`Work Item "${task.id}" requires explicit leader Work assignment after failure.`);
379
+ continue;
380
+ }
333
381
  if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
334
382
  const rejected = claimRejection(binding, task);
335
383
  if (rejected) {
@@ -374,7 +422,8 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
374
422
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
375
423
  if (params.action === "list") {
376
424
  const tasks = loadBoardTasks(binding);
377
- const lines = tasks.map((task) => `- ${task.id} · ${task.status} · ${task.subject}`);
425
+ const byId = new Map(tasks.map((task) => [task.id, task]));
426
+ const lines = tasks.map((task) => `- ${task.id} · ${taskStatusLabel(task, byId)} · ${task.subject}`);
378
427
  return Promise.resolve({ content: [{ type: "text", text: `WORK · current session\nWORK ITEMS\n${lines.join("\n") || "(none)"}` }], details: { action: "list", outcome: "listed", count: tasks.length } });
379
428
  }
380
429
  if (params.action === "claim") return queueWorkClaim(params.id, "work");
@@ -390,13 +439,15 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
390
439
  async function queueWorkSubmission(input: { taskId: string; status: "completed" | "failed"; result?: string; presentation: "board" | "work" }) {
391
440
  const binding = workerBinding();
392
441
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
393
- const assignmentId = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment?.id;
442
+ const authority = reports.authorize(binding, input.status === "failed");
443
+ if (!authority.assignmentId || authority.taskId !== input.taskId) throw new Error("This turn does not own the submitted Work Item.");
444
+ const assignmentId = authority.assignmentId;
394
445
  const won = createTaskIntent(binding.submissionsDir, input.taskId, {
395
446
  taskId: input.taskId, worker: binding.worker, spawnId: binding.spawnId, assignmentId,
396
447
  status: input.status, result: input.result, timestamp: Date.now(),
397
448
  });
398
449
  if (!won) throw new Error(`A submission for "${input.taskId}" is already pending.`);
399
- reports.close();
450
+ reports.close(input.status);
400
451
  disclosure.reset();
401
452
  const task = loadBoardTasks(binding).find((candidate) => candidate.id === input.taskId);
402
453
  const roleVerify = process.env.PI_TEAMMATE_VERIFY_DEFAULT?.trim();
@@ -407,7 +458,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
407
458
  ? "VERIFY · queued (role gate)"
408
459
  : "VERIFY · none configured"
409
460
  : "VERIFY · skipped (failed submission)";
410
- const next = input.status === "completed" ? "NEXT · wait for the harness result" : input.presentation === "work" ? "NEXT · Work returns to pending" : "NEXT · task returns to pending";
461
+ const next = "NEXT · End this turn. The harness settles the outcome; do not repeat submission or cancellation acknowledgments.";
411
462
  const content = input.presentation === "work"
412
463
  ? `WORK · current session\nSUBMISSION INTENT QUEUED · ${input.taskId} · ${input.status === "completed" ? "success" : "failed"}\n${verify}\n${next}`
413
464
  : `BOARD · current session\nSUBMITTED · ${input.taskId} · ${input.status}\n${verify}\n${next}`;