@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 +1 -0
- package/.memory/attempt-bound-authority.md +19 -0
- package/README.md +11 -0
- package/package.json +1 -1
- package/src/guidance.ts +7 -1
- package/src/index.ts +86 -1
- package/src/state.ts +17 -6
- package/src/team-machine.ts +11 -10
- package/src/types.ts +8 -1
- package/src/worker.ts +77 -26
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
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.
|
|
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
|
|
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,
|
package/src/team-machine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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)
|
|
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
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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 (
|
|
202
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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}`;
|