@fradser/pi-agent-teams 0.9.2 → 1.0.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 +137 -58
- package/package.json +13 -5
- package/references/agent-roles.md +23 -13
- package/src/activity.ts +20 -11
- package/src/agent-actions.ts +61 -36
- package/src/agent-host.ts +64 -0
- package/src/capability-tools.ts +34 -0
- package/src/guidance.ts +49 -37
- package/src/index.ts +149 -30
- package/src/recipient.ts +4 -4
- package/src/state.ts +163 -607
- package/src/statefile.ts +28 -244
- package/src/team-machine.ts +827 -535
- package/src/tool-copy.ts +26 -34
- package/src/tools.ts +57 -210
- package/src/types.ts +56 -157
- package/src/ui.ts +85 -59
- package/src/worker.ts +152 -79
- package/src/agents.ts +0 -296
- package/src/leader-reports.ts +0 -92
- package/src/spawner.ts +0 -751
- package/src/work-context.ts +0 -88
- package/src/worker-tools.ts +0 -4
- package/src/worktree.ts +0 -164
package/src/worker.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { isTaskReplaced } from "@fradser/pi-tasks";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* Agent capabilities. A single addressed agent_event primitive covers
|
|
3
4
|
* leader reports and peer mail. Task-list registration is shared by leader
|
|
4
5
|
* and worker processes; claiming and submitting remain worker-only.
|
|
5
6
|
*/
|
|
@@ -8,34 +9,41 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
8
9
|
import * as fs from "node:fs";
|
|
9
10
|
import * as path from "node:path";
|
|
10
11
|
import { truncateTail, type ExtensionAPI, type MessageEndEvent } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { setOwnedTools } from "@fradser/pi-kit";
|
|
13
|
+
import { isTaskStatus, resourcesConflict, type TaskStatus } from "@fradser/pi-tasks";
|
|
11
14
|
import { messageRow, workerWorkRow } from "./tool-copy.ts";
|
|
12
15
|
import { emptyToolCall, renderCoordinationRow, textOf } from "./tool-render.ts";
|
|
13
16
|
import { resolveRecipient } from "./recipient.ts";
|
|
14
|
-
import { appendInboxMessage, appendWorkerEvent,
|
|
17
|
+
import { appendInboxMessage, appendWorkerEvent, readRoster } from "./statefile.ts";
|
|
18
|
+
import { createTaskIntent, readTasksFile } from "@fradser/pi-tasks";
|
|
15
19
|
import {
|
|
16
20
|
AgentEventParams,
|
|
17
21
|
LEADER_RECIPIENT,
|
|
18
22
|
messageTitle,
|
|
19
23
|
WorkerWorkToolParams,
|
|
20
|
-
type
|
|
24
|
+
type Task,
|
|
21
25
|
} from "./types.ts";
|
|
22
26
|
|
|
23
|
-
const
|
|
24
|
-
|
|
27
|
+
const TASK_DISCLOSURE_TOOLS = ["task"] as const;
|
|
28
|
+
/** Why the tasks tool is shown to this worker. `held` means it already
|
|
29
|
+
* owns an assignment, which is a fact about this worker rather than a task
|
|
30
|
+
* status — the two were never the same state and must not share a word. */
|
|
31
|
+
type TaskDisclosure = "none" | "notice" | "held";
|
|
25
32
|
|
|
26
33
|
export interface WorkerToolDisclosure {
|
|
27
|
-
update(prompt
|
|
34
|
+
update(prompt?: string): void;
|
|
28
35
|
reset(): void;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
32
|
-
let state:
|
|
39
|
+
let state: TaskDisclosure = "none";
|
|
33
40
|
const apply = () => {
|
|
34
41
|
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
const revealed = state === "notice" || state === "held" ? ["task"] : [];
|
|
43
|
+
// `work` is re-asserted from the owned set rather than the snapshot, so this
|
|
44
|
+
// disclosure write cannot deactivate a tool the harness has not published
|
|
45
|
+
// yet. See setOwnedTools.
|
|
46
|
+
setOwnedTools(pi, { owned: TASK_DISCLOSURE_TOOLS, toggled: revealed, enabled: revealed.length > 0 });
|
|
39
47
|
};
|
|
40
48
|
return {
|
|
41
49
|
update(prompt) {
|
|
@@ -44,8 +52,12 @@ function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
|
44
52
|
? readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)
|
|
45
53
|
: undefined;
|
|
46
54
|
const assignment = rosterEntry?.assignment;
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
// A superseded holder still owns cancellation authority and must be
|
|
56
|
+
// able to acknowledge it; hiding work here strands its resource lease.
|
|
57
|
+
if (binding && rosterEntry && rosterEntry.spawnId === binding.spawnId
|
|
58
|
+
&& rosterEntry.status !== "stopped" && assignment) {
|
|
59
|
+
state = "held";
|
|
60
|
+
} else if (!assignment && prompt?.includes("=== TASK NOTICE ===")) state = "notice";
|
|
49
61
|
else state = "none";
|
|
50
62
|
apply();
|
|
51
63
|
},
|
|
@@ -62,7 +74,7 @@ export interface WorkerBinding {
|
|
|
62
74
|
outbox: string;
|
|
63
75
|
inbox: string;
|
|
64
76
|
rosterFile: string;
|
|
65
|
-
|
|
77
|
+
tasksFile: string;
|
|
66
78
|
claimsDir: string;
|
|
67
79
|
submissionsDir: string;
|
|
68
80
|
}
|
|
@@ -73,7 +85,7 @@ const REQUIRED_ENV = [
|
|
|
73
85
|
"PI_TEAMMATE_OUTBOX_FILE",
|
|
74
86
|
"PI_TEAMMATE_INBOX_FILE",
|
|
75
87
|
"PI_TEAMMATE_ROSTER_FILE",
|
|
76
|
-
"
|
|
88
|
+
"PI_TEAMMATE_TASKS_FILE",
|
|
77
89
|
"PI_TEAMMATE_CLAIMS_DIR",
|
|
78
90
|
"PI_TEAMMATE_SUBMISSIONS_DIR",
|
|
79
91
|
] as const;
|
|
@@ -81,35 +93,40 @@ const REQUIRED_ENV = [
|
|
|
81
93
|
export function workerBinding(): WorkerBinding | undefined {
|
|
82
94
|
const values = REQUIRED_ENV.map((name) => process.env[name]);
|
|
83
95
|
if (values.some((value) => !value)) return undefined;
|
|
84
|
-
const [worker, spawnId, outbox, inbox, rosterFile,
|
|
85
|
-
return { worker, spawnId, outbox, inbox, rosterFile,
|
|
96
|
+
const [worker, spawnId, outbox, inbox, rosterFile, tasksFile, claimsDir, submissionsDir] = values as string[];
|
|
97
|
+
return { worker, spawnId, outbox, inbox, rosterFile, tasksFile, claimsDir, submissionsDir };
|
|
86
98
|
}
|
|
87
99
|
|
|
88
|
-
function
|
|
89
|
-
const
|
|
90
|
-
return Object.values(
|
|
100
|
+
function loadWorkerTasks(binding: WorkerBinding): Task[] {
|
|
101
|
+
const tasks = readTasksFile(binding.tasksFile);
|
|
102
|
+
return Object.values(tasks?.tasks ?? {}).map(task => {
|
|
103
|
+
// Workers read the leader's file without restoring the shared store. Legacy
|
|
104
|
+
// files can still be present until its next mutation, so normalize status
|
|
105
|
+
// here without resetting an active participant's holding.
|
|
106
|
+
const raw: unknown = task.status;
|
|
107
|
+
const status = raw === "recovery_hold" ? "blocked"
|
|
108
|
+
: raw === "superseded" ? (task.supersededBy ? "pending" : "blocked")
|
|
109
|
+
: isTaskStatus(raw) ? raw : "blocked";
|
|
110
|
+
return { ...task, status, ...(raw === "superseded" && !task.supersededBy ? { replacementUnresolved: true } : {}) };
|
|
111
|
+
}).sort((a, b) => a.id.localeCompare(b.id));
|
|
91
112
|
}
|
|
92
113
|
|
|
93
|
-
function dependenciesMet(task:
|
|
114
|
+
function dependenciesMet(task: Task, tasks: Map<string, Task>): boolean {
|
|
94
115
|
return task.dependsOn.every((dep) => tasks.get(dep)?.status === "completed");
|
|
95
116
|
}
|
|
96
117
|
|
|
97
|
-
function taskCounts(tasks:
|
|
98
|
-
const counts = { pending: 0,
|
|
99
|
-
for (const task of tasks) counts[task.status] += 1;
|
|
100
|
-
const claimable = tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId)).length;
|
|
101
|
-
return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) ·
|
|
118
|
+
function taskCounts(tasks: Task[], byId: Map<string, Task>): string {
|
|
119
|
+
const counts: Record<TaskStatus, number> = { pending: 0, in_progress: 0, blocked: 0, completed: 0, cancelled: 0 };
|
|
120
|
+
for (const task of tasks) if (!isTaskReplaced(task)) counts[task.status] += 1;
|
|
121
|
+
const claimable = tasks.filter((task) => !isTaskReplaced(task) && task.status === "pending" && dependenciesMet(task, byId)).length;
|
|
122
|
+
return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) · in_progress=${counts.in_progress} · blocked=${counts.blocked} · completed=${counts.completed} · cancelled=${counts.cancelled} · superseded=${tasks.filter(isTaskReplaced).length}`;
|
|
102
123
|
}
|
|
103
124
|
|
|
104
|
-
function
|
|
105
|
-
return (left ?? []).some((a) => (right ?? []).some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function claimRejection(binding: WorkerBinding, task: BoardTask): string | undefined {
|
|
125
|
+
function claimRejection(binding: WorkerBinding, task: Task): string | undefined {
|
|
109
126
|
const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
|
|
110
127
|
if (self?.assignment) {
|
|
111
128
|
const state = self.assignment.closed ? "closed pending leader reopen" : "active";
|
|
112
|
-
return `You already own ${state} ${self.assignment.kind} assignment "${self.assignment.id}". Finish it through its matching protocol before claiming
|
|
129
|
+
return `You already own ${state} ${self.assignment.kind} assignment "${self.assignment.id}". Finish it through its matching protocol before claiming task work.`;
|
|
113
130
|
}
|
|
114
131
|
const conflict = readRoster(binding.rosterFile).find((entry) => entry.name !== binding.worker
|
|
115
132
|
&& entry.assignment && !entry.assignment.closed
|
|
@@ -120,14 +137,19 @@ function claimRejection(binding: WorkerBinding, task: BoardTask): string | undef
|
|
|
120
137
|
return undefined;
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
function taskStatusLabel(task:
|
|
140
|
+
function taskStatusLabel(task: Task, tasks: Map<string, Task>): string {
|
|
141
|
+
// `blocked` is a state of its own, so it is named as one. A task in it
|
|
142
|
+
// was attempted and the attempt did not stand: it is not claimable work, and
|
|
143
|
+
// calling it "pending" hid that from the participant reading this list.
|
|
144
|
+
if (isTaskReplaced(task)) return task.supersededBy ? `replaced-by=${task.supersededBy}` : "replacement-unresolved";
|
|
145
|
+
if (task.status === "blocked") return "blocked";
|
|
124
146
|
if (task.status === "pending") return dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
|
|
125
147
|
return task.status;
|
|
126
148
|
}
|
|
127
149
|
|
|
128
|
-
export function
|
|
150
|
+
export function renderTaskList(tasks: Task[]): string {
|
|
129
151
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
130
|
-
const lines = [`
|
|
152
|
+
const lines = [`TASKS · current session`, `SUMMARY · ${taskCounts(tasks, byId)}`];
|
|
131
153
|
if (tasks.length === 0) return `${lines.join("\n")}\nTASKS\n(no tasks)`;
|
|
132
154
|
lines.push("TASKS");
|
|
133
155
|
for (const task of tasks) {
|
|
@@ -176,30 +198,43 @@ function automaticResultBody(binding: WorkerBinding, assignmentId: string, body:
|
|
|
176
198
|
|
|
177
199
|
function createWorkerReports(pi: ExtensionAPI) {
|
|
178
200
|
const initial = workerBinding();
|
|
179
|
-
const
|
|
201
|
+
const initialSelf = initial ? readRoster(initial.rosterFile).find((entry) => entry.name === initial.worker && entry.spawnId === initial.spawnId) : undefined;
|
|
202
|
+
const initialAssignment = initialSelf?.assignment;
|
|
180
203
|
let assignmentId = initialAssignment?.id;
|
|
204
|
+
let taskId = initialSelf?.currentTaskId;
|
|
181
205
|
let reportClosed = initialAssignment?.closed === true;
|
|
206
|
+
let terminalOutcome: "completed" | "failed" | undefined;
|
|
182
207
|
let pending: { binding: WorkerBinding; assignmentId: string; timestamp: number; awaitingFinal?: boolean; outcome?: AutomaticResult } | undefined;
|
|
183
208
|
const finalizedMessages = new WeakSet<AssistantResponse>();
|
|
184
209
|
const currentAssignment = (binding: WorkerBinding) => {
|
|
185
|
-
const self = readRoster(binding.rosterFile)
|
|
210
|
+
const self = readRoster(binding.rosterFile)
|
|
211
|
+
.find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
|
|
186
212
|
const assignment = self?.assignment;
|
|
187
213
|
return !reportClosed && self?.spawnId === binding.spawnId && self.status !== "stopped"
|
|
188
214
|
&& assignment && assignment.id === assignmentId && !assignment.closed ? assignment : undefined;
|
|
189
215
|
};
|
|
190
|
-
const close = () => {
|
|
216
|
+
const close = (outcome: "completed" | "failed") => {
|
|
191
217
|
reportClosed = true;
|
|
218
|
+
terminalOutcome = outcome;
|
|
192
219
|
pending = undefined;
|
|
193
220
|
};
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
221
|
+
const authorize = (binding: WorkerBinding, cancellation = false) => {
|
|
222
|
+
const sameBinding = initial && binding.worker === initial.worker && binding.spawnId === initial.spawnId
|
|
223
|
+
&& binding.outbox === initial.outbox && binding.rosterFile === initial.rosterFile;
|
|
224
|
+
const self = readRoster(binding.rosterFile)
|
|
225
|
+
.find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
|
|
226
|
+
if (!sameBinding || !self || self.status === "stopped") {
|
|
227
|
+
throw new Error("Work authority rejected: this is not the bound living worker incarnation.");
|
|
228
|
+
}
|
|
229
|
+
if (self.assignment?.id !== assignmentId || (taskId !== undefined && self.currentTaskId !== taskId)) {
|
|
230
|
+
throw new Error("Work authority rejected: this turn belongs to a different assignment. Wait for its fresh assignment marker.");
|
|
200
231
|
}
|
|
201
|
-
|
|
202
|
-
|
|
232
|
+
const cancelled = cancellation && assignmentId && self.currentTaskId
|
|
233
|
+
&& (self.assignment?.closed || loadWorkerTasks(binding).some((task) => task.id === self.currentTaskId && isTaskReplaced(task)));
|
|
234
|
+
if ((reportClosed || self.assignment?.closed) && !(cancelled && terminalOutcome !== "failed")) {
|
|
235
|
+
throw new Error("Leader report rejected: this assignment already has a terminal report or is closed. Stop; no repeated acknowledgment is required.");
|
|
236
|
+
}
|
|
237
|
+
return { assignmentId, taskId: taskId ?? self.currentTaskId };
|
|
203
238
|
};
|
|
204
239
|
pi.on("message_start", ({ message }) => {
|
|
205
240
|
const binding = workerBinding();
|
|
@@ -208,12 +243,27 @@ function createWorkerReports(pi: ExtensionAPI) {
|
|
|
208
243
|
const content = message.content;
|
|
209
244
|
const text = typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
210
245
|
const marker = /^\[agent-teams-assignment:([^\n]+)\]\n/.exec(text);
|
|
211
|
-
const
|
|
246
|
+
const self = readRoster(binding.rosterFile)
|
|
247
|
+
.find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
|
|
248
|
+
const current = self?.assignment;
|
|
212
249
|
if (marker) {
|
|
213
250
|
const id = marker[1] === "none" ? undefined : marker[1];
|
|
214
251
|
if (id !== current?.id) return;
|
|
215
|
-
if (id !== assignmentId
|
|
252
|
+
if (id !== assignmentId) {
|
|
253
|
+
reportClosed = current?.closed === true;
|
|
254
|
+
terminalOutcome = undefined;
|
|
255
|
+
}
|
|
216
256
|
assignmentId = id;
|
|
257
|
+
taskId = self?.currentTaskId;
|
|
258
|
+
pending = undefined;
|
|
259
|
+
} else if (!current) {
|
|
260
|
+
// The harness retired this attempt without a replacement marker: this is a
|
|
261
|
+
// fresh unassigned turn (peer mail, tasks notice, ordinary discussion).
|
|
262
|
+
// Holding the previous terminal state here would dead-end the worker.
|
|
263
|
+
assignmentId = undefined;
|
|
264
|
+
taskId = undefined;
|
|
265
|
+
reportClosed = false;
|
|
266
|
+
terminalOutcome = undefined;
|
|
217
267
|
pending = undefined;
|
|
218
268
|
}
|
|
219
269
|
const assignment = currentAssignment(binding);
|
|
@@ -249,31 +299,33 @@ function createWorkerReports(pi: ExtensionAPI) {
|
|
|
249
299
|
assignmentId: pending.assignmentId, timestamp: Date.now(), status: result.status,
|
|
250
300
|
body: automaticResultBody(binding, pending.assignmentId, result.body),
|
|
251
301
|
});
|
|
252
|
-
close();
|
|
302
|
+
close(result.status);
|
|
253
303
|
});
|
|
254
304
|
return {
|
|
255
305
|
close,
|
|
306
|
+
authorize,
|
|
256
307
|
send(binding: WorkerBinding, body: string, status: import("./types.ts").WorkerReportEvent["status"]) {
|
|
308
|
+
const authority = authorize(binding, status === "failed");
|
|
257
309
|
appendWorkerEvent(binding.outbox, {
|
|
258
|
-
assignmentId:
|
|
310
|
+
assignmentId: authority.assignmentId, id: randomUUID(), type: "message",
|
|
259
311
|
worker: binding.worker, spawnId: binding.spawnId, body, status, timestamp: Date.now(),
|
|
260
312
|
});
|
|
261
313
|
const isTerminal = status === "completed" || status === "failed";
|
|
262
|
-
if (isTerminal) close();
|
|
314
|
+
if (isTerminal) close(status);
|
|
263
315
|
return isTerminal;
|
|
264
316
|
},
|
|
265
317
|
reset() { pending = undefined; },
|
|
266
318
|
};
|
|
267
319
|
}
|
|
268
320
|
|
|
269
|
-
export function
|
|
321
|
+
export function registerChildCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
270
322
|
const disclosure = createWorkerToolDisclosure(pi);
|
|
271
323
|
const reports = createWorkerReports(pi);
|
|
272
324
|
pi.registerTool({
|
|
273
|
-
name: "
|
|
325
|
+
name: "message",
|
|
274
326
|
promptSnippet: "Send an event or message to a participant",
|
|
275
327
|
label: "Agent Event",
|
|
276
|
-
description: "Communication-only interface across Leader, Worker, and Peers.
|
|
328
|
+
description: "Communication-only interface across Leader, Worker, and Peers. Task completion is reported automatically after settlement or delivered with task action=complete.",
|
|
277
329
|
parameters: AgentEventParams,
|
|
278
330
|
renderShell: "self",
|
|
279
331
|
renderCall: emptyToolCall,
|
|
@@ -287,14 +339,15 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
287
339
|
async execute(_toolCallId, params) {
|
|
288
340
|
const binding = workerBinding();
|
|
289
341
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
342
|
+
const authority = reports.authorize(binding);
|
|
290
343
|
if (!params.to || params.to === LEADER_RECIPIENT) {
|
|
291
344
|
appendWorkerEvent(binding.outbox, {
|
|
292
|
-
assignmentId:
|
|
293
|
-
body: params.
|
|
345
|
+
assignmentId: authority.assignmentId, id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
|
|
346
|
+
body: params.body, status: params.kind ?? "inform", timestamp: Date.now(),
|
|
294
347
|
});
|
|
295
348
|
return {
|
|
296
|
-
content: [{ type: "text", text: `MESSAGING\nREPORT · to=leader ·
|
|
297
|
-
details: { to: LEADER_RECIPIENT,
|
|
349
|
+
content: [{ type: "text", text: `MESSAGING\nREPORT · to=leader · kind=${params.kind ?? "inform"}\nNEXT · harness will deliver this message; Task completion is automatic or uses task complete` }],
|
|
350
|
+
details: { to: LEADER_RECIPIENT, kind: params.kind ?? "inform", outcome: "queued" },
|
|
298
351
|
};
|
|
299
352
|
}
|
|
300
353
|
const recipient = resolveRecipient(params.to, readRoster(binding.rosterFile));
|
|
@@ -304,32 +357,49 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
304
357
|
appendInboxMessage(recipientInbox, {
|
|
305
358
|
id: randomUUID(),
|
|
306
359
|
from: binding.worker,
|
|
307
|
-
subject: messageTitle(params.
|
|
308
|
-
body: params.
|
|
360
|
+
subject: messageTitle(params.body),
|
|
361
|
+
body: params.body,
|
|
309
362
|
...(recipient.spawnId ? { toSpawnId: recipient.spawnId } : {}),
|
|
310
363
|
});
|
|
311
364
|
return {
|
|
312
365
|
content: [{ type: "text", text: `MESSAGING\nQUEUED · to=@${to}\nNEXT · harness will route the inbox message into a recipient turn` }],
|
|
313
|
-
details: { to, outcome: "queued",
|
|
366
|
+
details: { to, outcome: "queued", kind: params.kind ?? "inform" },
|
|
314
367
|
};
|
|
315
368
|
},
|
|
316
369
|
});
|
|
317
370
|
|
|
318
|
-
async function queueWorkClaim(id: string | undefined, presentation: "
|
|
371
|
+
async function queueWorkClaim(id: string | undefined, presentation: "task" | "work") {
|
|
319
372
|
const binding = workerBinding();
|
|
320
373
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
374
|
+
// Claims pass the same attempt authority as sends and submissions: a closed or
|
|
375
|
+
// terminal attempt must not queue new task work even when work is re-disclosed.
|
|
376
|
+
reports.authorize(binding);
|
|
321
377
|
const label = presentation === "work" ? "Work Item" : "Task";
|
|
322
378
|
const empty = presentation === "work" ? "No claimable Work Item right now." : "No claimable task right now.";
|
|
323
|
-
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
|
|
324
|
-
const tasks =
|
|
379
|
+
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 tasks again.";
|
|
380
|
+
const tasks = loadWorkerTasks(binding);
|
|
325
381
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
326
382
|
const candidates = id
|
|
327
383
|
? tasks.filter((task) => task.id === id)
|
|
328
|
-
: tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId));
|
|
384
|
+
: tasks.filter((task) => !isTaskReplaced(task) && task.status === "pending" && dependenciesMet(task, byId));
|
|
329
385
|
if (candidates.length === 0) {
|
|
330
|
-
|
|
386
|
+
// Say why nothing is claimable: a task set whose only pending Work is held for
|
|
387
|
+
// Leader recovery must not read as an ordinary empty tasks.
|
|
388
|
+
const held = loadWorkerTasks(binding).filter((task) => task.status === "blocked");
|
|
389
|
+
if (!id && held.length > 0) {
|
|
390
|
+
throw new Error(`No claimable ${label.toLowerCase()} right now. Held for explicit leader recovery: ${held.map((task) => task.id).join(", ")}.`);
|
|
391
|
+
}
|
|
392
|
+
throw new Error(id ? `${label} "${id}" was not found${presentation === "task" ? " in the task list" : ""}.` : empty);
|
|
331
393
|
}
|
|
332
394
|
for (const task of candidates) {
|
|
395
|
+
if (task.status === "blocked") {
|
|
396
|
+
if (id) throw new Error(`Work Item "${task.id}" requires explicit leader Work assignment after failure.`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (isTaskReplaced(task)) {
|
|
400
|
+
if (id) throw new Error(task.supersededBy ? `Work Item "${task.id}" was replaced by "${task.supersededBy}".` : `Work Item "${task.id}" has an unresolved replacement target.`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
333
403
|
if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
|
|
334
404
|
const rejected = claimRejection(binding, task);
|
|
335
405
|
if (rejected) {
|
|
@@ -337,12 +407,12 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
337
407
|
continue;
|
|
338
408
|
}
|
|
339
409
|
const won = createTaskIntent(binding.claimsDir, task.id, {
|
|
340
|
-
taskId: task.id,
|
|
410
|
+
taskId: task.id, participant: binding.worker, spawnId: binding.spawnId, timestamp: Date.now(),
|
|
341
411
|
});
|
|
342
412
|
if (won) {
|
|
343
413
|
const content = presentation === "work"
|
|
344
414
|
? `WORK · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness claim acceptance before starting Work`
|
|
345
|
-
: `
|
|
415
|
+
: `TASKS · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness Claim accepted feedback; do not start work until it arrives`;
|
|
346
416
|
const details = presentation === "work"
|
|
347
417
|
? { action: "claim", outcome: "queued", id: task.id, subject: task.subject, worker: binding.worker }
|
|
348
418
|
: { taskId: task.id, subject: task.subject, worker: binding.worker };
|
|
@@ -354,7 +424,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
354
424
|
}
|
|
355
425
|
|
|
356
426
|
pi.registerTool({
|
|
357
|
-
name: "
|
|
427
|
+
name: "task",
|
|
358
428
|
renderShell: "self",
|
|
359
429
|
renderCall: emptyToolCall,
|
|
360
430
|
renderResult(result, options, theme, context) {
|
|
@@ -373,8 +443,9 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
373
443
|
const binding = workerBinding();
|
|
374
444
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
375
445
|
if (params.action === "list") {
|
|
376
|
-
const tasks =
|
|
377
|
-
const
|
|
446
|
+
const tasks = loadWorkerTasks(binding);
|
|
447
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
448
|
+
const lines = tasks.map((task) => `- ${task.id} · ${taskStatusLabel(task, byId)} · ${task.subject}`);
|
|
378
449
|
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
450
|
}
|
|
380
451
|
if (params.action === "claim") return queueWorkClaim(params.id, "work");
|
|
@@ -387,18 +458,20 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
387
458
|
},
|
|
388
459
|
});
|
|
389
460
|
|
|
390
|
-
async function queueWorkSubmission(input: { taskId: string; status: "completed" | "failed"; result?: string; presentation: "
|
|
461
|
+
async function queueWorkSubmission(input: { taskId: string; status: "completed" | "failed"; result?: string; presentation: "task" | "work" }) {
|
|
391
462
|
const binding = workerBinding();
|
|
392
463
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
393
|
-
const
|
|
464
|
+
const authority = reports.authorize(binding, input.status === "failed");
|
|
465
|
+
if (!authority.assignmentId || authority.taskId !== input.taskId) throw new Error("This turn does not own the submitted Work Item.");
|
|
466
|
+
const assignmentId = authority.assignmentId;
|
|
394
467
|
const won = createTaskIntent(binding.submissionsDir, input.taskId, {
|
|
395
|
-
taskId: input.taskId,
|
|
468
|
+
taskId: input.taskId, participant: binding.worker, spawnId: binding.spawnId, assignmentId,
|
|
396
469
|
status: input.status, result: input.result, timestamp: Date.now(),
|
|
397
470
|
});
|
|
398
471
|
if (!won) throw new Error(`A submission for "${input.taskId}" is already pending.`);
|
|
399
|
-
reports.close();
|
|
472
|
+
reports.close(input.status);
|
|
400
473
|
disclosure.reset();
|
|
401
|
-
const task =
|
|
474
|
+
const task = loadWorkerTasks(binding).find((candidate) => candidate.id === input.taskId);
|
|
402
475
|
const roleVerify = process.env.PI_TEAMMATE_VERIFY_DEFAULT?.trim();
|
|
403
476
|
const verify = input.status === "completed"
|
|
404
477
|
? task?.verify
|
|
@@ -407,12 +480,12 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
407
480
|
? "VERIFY · queued (role gate)"
|
|
408
481
|
: "VERIFY · none configured"
|
|
409
482
|
: "VERIFY · skipped (failed submission)";
|
|
410
|
-
const next =
|
|
483
|
+
const next = "NEXT · End this turn. The harness settles the outcome; do not repeat submission or cancellation acknowledgments.";
|
|
411
484
|
const content = input.presentation === "work"
|
|
412
485
|
? `WORK · current session\nSUBMISSION INTENT QUEUED · ${input.taskId} · ${input.status === "completed" ? "success" : "failed"}\n${verify}\n${next}`
|
|
413
|
-
: `
|
|
486
|
+
: `TASKS · current session\nSUBMITTED · ${input.taskId} · ${input.status}\n${verify}\n${next}`;
|
|
414
487
|
// The subject rides along in details: a worker process never hydrates the
|
|
415
|
-
// leader's in-memory
|
|
488
|
+
// leader's in-memory tasks, so its row cannot look the task up by itself.
|
|
416
489
|
const details = input.presentation === "work"
|
|
417
490
|
? { action: "submit", outcome: "queued", id: input.taskId, subject: task?.subject, status: input.status === "completed" ? "success" : "failed", verify: Boolean(task?.verify || roleVerify) }
|
|
418
491
|
: { taskId: input.taskId, subject: task?.subject, status: input.status, verify: Boolean(task?.verify || roleVerify) };
|