@fradser/pi-agent-teams 0.8.3 → 0.9.1
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 -1
- package/.memory/follow-up-queue.md +8 -11
- package/.memory/team-centric-design.md +18 -14
- package/LICENSE +21 -0
- package/README.md +119 -157
- package/package.json +2 -2
- package/references/agent-roles.md +57 -12
- package/src/activity.ts +12 -33
- package/src/agent-actions.ts +101 -0
- package/src/agents.ts +1 -6
- package/src/guidance.ts +114 -199
- package/src/index.ts +117 -101
- package/src/leader-reports.ts +92 -0
- package/src/recipient.ts +44 -0
- package/src/spawner.ts +293 -38
- package/src/state.ts +250 -69
- package/src/statefile.ts +74 -31
- package/src/team-machine.ts +984 -424
- package/src/tool-copy.ts +374 -0
- package/src/tool-render.ts +47 -22
- package/src/tools.ts +182 -161
- package/src/types.ts +104 -65
- package/src/ui.ts +32 -56
- package/src/work-context.ts +88 -0
- package/src/worker-tools.ts +4 -0
- package/src/worker.ts +251 -184
- package/src/follow-up-queue.ts +0 -301
package/src/state.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* is the sole writer of this snapshot and of board.json.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
7
8
|
import {
|
|
8
9
|
messageTitle,
|
|
10
|
+
TEAM_RUNTIME_VERSION,
|
|
9
11
|
type BoardTask,
|
|
10
12
|
type WorkerAssignment,
|
|
11
13
|
type MailboxMessage,
|
|
@@ -18,6 +20,9 @@ import {
|
|
|
18
20
|
import { nonEmpty } from "@fradser/pi-kit";
|
|
19
21
|
|
|
20
22
|
export const MAX_LEADER_MAILBOX_MESSAGES = 4096;
|
|
23
|
+
/** Total mailbox body bytes retained for forensics. A count cap alone lets a few
|
|
24
|
+
* long reports blow the snapshot up: this keeps the debug artifact bounded. */
|
|
25
|
+
export const MAX_LEADER_MAILBOX_BYTES = 8 * 1024 * 1024;
|
|
21
26
|
/** FIFO cap of remembered peer message ids per inbox (dedup guard). */
|
|
22
27
|
export const MAX_PEER_DELIVERED_IDS = 512;
|
|
23
28
|
/** Bounded forensic routing history; mailbox files retain the full peer transcript. */
|
|
@@ -26,6 +31,7 @@ export const MAX_TASK_DEPENDENCIES = 32;
|
|
|
26
31
|
|
|
27
32
|
function emptyState(): TeamState {
|
|
28
33
|
return {
|
|
34
|
+
runtimeVersion: TEAM_RUNTIME_VERSION,
|
|
29
35
|
teammates: {},
|
|
30
36
|
tasks: emptyTaskMap(),
|
|
31
37
|
leaderMailbox: [],
|
|
@@ -39,25 +45,6 @@ function emptyState(): TeamState {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
let state = emptyState();
|
|
42
|
-
let stateDirty = false;
|
|
43
|
-
|
|
44
|
-
export function markStateDirty(): void {
|
|
45
|
-
stateDirty = true;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function isStateDirty(): boolean {
|
|
49
|
-
return stateDirty;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function clearStateDirty(): void {
|
|
53
|
-
stateDirty = false;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function consumeStateDirty(): boolean {
|
|
57
|
-
const dirty = stateDirty;
|
|
58
|
-
clearStateDirty();
|
|
59
|
-
return dirty;
|
|
60
|
-
}
|
|
61
48
|
|
|
62
49
|
function nextMessageId(): string {
|
|
63
50
|
return `msg_${++state.messageCounter}`;
|
|
@@ -71,7 +58,47 @@ function emptyTaskMap(): Record<string, BoardTask> {
|
|
|
71
58
|
|
|
72
59
|
export function resetState(): void {
|
|
73
60
|
state = emptyState();
|
|
74
|
-
|
|
61
|
+
rosterRevisionCounter++;
|
|
62
|
+
boardRevisionCounter++;
|
|
63
|
+
stateRevisionCounter++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Persistence revisions ────────────────────────────────────────
|
|
67
|
+
// state.ts owns every in-memory mutation, so it is the one place that can say
|
|
68
|
+
// exactly which artifact changed. Stream progress is counted separately: a
|
|
69
|
+
// running teammate must not force the roster, the board, or the debug snapshot
|
|
70
|
+
// to be rewritten on every token. Every persisted mutation in this module bumps
|
|
71
|
+
// one of these counters, so a mutation that forgets to bump would not be written
|
|
72
|
+
// until something else changed — add the bump with any new mutator.
|
|
73
|
+
let stateRevisionCounter = 0;
|
|
74
|
+
let rosterRevisionCounter = 0;
|
|
75
|
+
let boardRevisionCounter = 0;
|
|
76
|
+
let progressRevisionCounter = 0;
|
|
77
|
+
|
|
78
|
+
/** Revision of the whole snapshot: roster, board, mailbox, and offsets. */
|
|
79
|
+
export function stateRevision(): number {
|
|
80
|
+
return stateRevisionCounter;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Revision of the worker-readable roster. */
|
|
84
|
+
export function rosterRevision(): number {
|
|
85
|
+
return rosterRevisionCounter;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Revision of the persisted board. */
|
|
89
|
+
export function boardRevision(): number {
|
|
90
|
+
return boardRevisionCounter;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Revision of streamed progress only; never persisted on its own. */
|
|
94
|
+
export function progressRevision(): number {
|
|
95
|
+
return progressRevisionCounter;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Record a board change made outside this module's task mutators. */
|
|
99
|
+
export function markBoardChanged(): void {
|
|
100
|
+
boardRevisionCounter++;
|
|
101
|
+
stateRevisionCounter++;
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
// ── Team default model ──────────────────────────────────────
|
|
@@ -84,7 +111,7 @@ export function getTeamDefaultModel(): string | undefined {
|
|
|
84
111
|
/** Set (or clear with undefined) the unified teammate model for later spawns. */
|
|
85
112
|
export function setTeamDefaultModel(ref: string | undefined): void {
|
|
86
113
|
state.defaultModel = nonEmpty(ref);
|
|
87
|
-
|
|
114
|
+
stateRevisionCounter++;
|
|
88
115
|
}
|
|
89
116
|
|
|
90
117
|
// ── Roster queries ────────────────────────────────────────────────
|
|
@@ -111,14 +138,6 @@ export function idleTeammates(): Teammate[] {
|
|
|
111
138
|
return livingTeammates().filter((t) => t.status === "idle");
|
|
112
139
|
}
|
|
113
140
|
|
|
114
|
-
export function workingTeammates(): Teammate[] {
|
|
115
|
-
return livingTeammates().filter((t) => t.status === "working" || t.status === "starting");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function findTeammateBySpawn(spawnId: string): Teammate | undefined {
|
|
119
|
-
return listTeammates().find((t) => t.spawnId === spawnId && t.status !== "stopped");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
141
|
export function registerTeammate(teammate: Teammate): { ok: true } | { ok: false; error: string } {
|
|
123
142
|
if (!isValidTeammateName(teammate.name)) {
|
|
124
143
|
return { ok: false, error: `Invalid teammate name "${teammate.name}". Use letters, digits, dots, dashes, underscores.` };
|
|
@@ -127,16 +146,41 @@ export function registerTeammate(teammate: Teammate): { ok: true } | { ok: false
|
|
|
127
146
|
return { ok: false, error: `A living teammate named "${teammate.name}" already exists.` };
|
|
128
147
|
}
|
|
129
148
|
state.teammates[teammate.name] = teammate;
|
|
130
|
-
|
|
149
|
+
rosterRevisionCounter++;
|
|
150
|
+
stateRevisionCounter++;
|
|
131
151
|
return { ok: true };
|
|
132
152
|
}
|
|
133
153
|
|
|
154
|
+
/** Fields whose change is stream progress rather than persisted state. A
|
|
155
|
+
* progress tick must not republish the roster or the board. */
|
|
156
|
+
const VOLATILE_TEAMMATE_FIELDS = new Set<string>([
|
|
157
|
+
"liveText",
|
|
158
|
+
"liveThinking",
|
|
159
|
+
"activeTool",
|
|
160
|
+
"turns",
|
|
161
|
+
"sequenceEnded",
|
|
162
|
+
"modelOutputSeen",
|
|
163
|
+
"usage",
|
|
164
|
+
"lastOutputAt",
|
|
165
|
+
"updatedAt",
|
|
166
|
+
"lastNoticeAt",
|
|
167
|
+
"noticedTaskIds",
|
|
168
|
+
]);
|
|
169
|
+
|
|
134
170
|
export function updateTeammate(name: string, patch: Partial<Teammate>): Teammate | undefined {
|
|
135
171
|
const teammate = state.teammates[name];
|
|
136
172
|
if (!teammate) return undefined;
|
|
173
|
+
const changed = (Object.keys(patch) as Array<keyof Teammate>)
|
|
174
|
+
.filter((field) => !Object.is(teammate[field], patch[field]));
|
|
137
175
|
Object.assign(teammate, patch, { updatedAt: Date.now() });
|
|
138
176
|
if (patch.status === "stopped") teammate.stoppedAt = Date.now();
|
|
139
|
-
|
|
177
|
+
// Republishing a roster that only streamed progress is pure write churn.
|
|
178
|
+
if (changed.some((field) => !VOLATILE_TEAMMATE_FIELDS.has(field as string))) {
|
|
179
|
+
rosterRevisionCounter++;
|
|
180
|
+
stateRevisionCounter++;
|
|
181
|
+
} else if (changed.length > 0) {
|
|
182
|
+
progressRevisionCounter++;
|
|
183
|
+
}
|
|
140
184
|
return teammate;
|
|
141
185
|
}
|
|
142
186
|
|
|
@@ -150,7 +194,9 @@ export function assignTeammate(
|
|
|
150
194
|
if (!teammate) return undefined;
|
|
151
195
|
const lastAssignment = assignment ?? teammate.assignment ?? teammate.lastAssignment;
|
|
152
196
|
const lastTaskId = currentTaskId ?? teammate.currentTaskId ?? teammate.lastTaskId;
|
|
153
|
-
return updateTeammate(name, { assignment, currentTaskId, lastAssignment, lastTaskId
|
|
197
|
+
return updateTeammate(name, { assignment, currentTaskId, lastAssignment, lastTaskId,
|
|
198
|
+
...(assignment && assignment.id !== teammate.assignment?.id ? { reportSequenceEnded: false } : {}),
|
|
199
|
+
});
|
|
154
200
|
}
|
|
155
201
|
|
|
156
202
|
export function updateTeammateProgress(
|
|
@@ -164,6 +210,12 @@ export function updateTeammateProgress(
|
|
|
164
210
|
): boolean {
|
|
165
211
|
const teammate = state.teammates[name];
|
|
166
212
|
if (!teammate || teammate.spawnId !== spawnId) return false;
|
|
213
|
+
const changed = teammate.liveText !== progress.liveText
|
|
214
|
+
|| teammate.activeTool !== progress.activeTool
|
|
215
|
+
|| teammate.liveThinking !== progress.liveThinking
|
|
216
|
+
|| teammate.turns !== progress.turns
|
|
217
|
+
|| (progress.sequenceEnded !== undefined && teammate.sequenceEnded !== progress.sequenceEnded)
|
|
218
|
+
|| (progress.modelOutputSeen === true && teammate.modelOutputSeen !== true);
|
|
167
219
|
teammate.liveText = progress.liveText;
|
|
168
220
|
teammate.activeTool = progress.activeTool;
|
|
169
221
|
teammate.liveThinking = progress.liveThinking;
|
|
@@ -173,9 +225,12 @@ export function updateTeammateProgress(
|
|
|
173
225
|
if (progress.usage) teammate.usage = progress.usage;
|
|
174
226
|
if (teammate.status === "starting") {
|
|
175
227
|
teammate.status = progress.sequenceEnded ? "idle" : "working";
|
|
228
|
+
// A status transition is roster state, not stream noise.
|
|
229
|
+
rosterRevisionCounter++;
|
|
230
|
+
stateRevisionCounter++;
|
|
176
231
|
}
|
|
177
232
|
teammate.updatedAt = Date.now();
|
|
178
|
-
|
|
233
|
+
if (changed) progressRevisionCounter++;
|
|
179
234
|
return true;
|
|
180
235
|
}
|
|
181
236
|
|
|
@@ -186,7 +241,19 @@ export function clearWorkerRunEvents(workerName: string, spawnId: string): void
|
|
|
186
241
|
for (const id of Object.keys(state.workerEventIds)) {
|
|
187
242
|
if (id.startsWith(`${spawnId}:`)) delete state.workerEventIds[id];
|
|
188
243
|
}
|
|
189
|
-
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Drop the oldest mailbox entries until both the count and byte caps hold. */
|
|
247
|
+
function trimLeaderMailbox(): void {
|
|
248
|
+
if (state.leaderMailbox.length > MAX_LEADER_MAILBOX_MESSAGES) {
|
|
249
|
+
state.leaderMailbox.splice(0, state.leaderMailbox.length - MAX_LEADER_MAILBOX_MESSAGES);
|
|
250
|
+
}
|
|
251
|
+
let bytes = 0;
|
|
252
|
+
for (const message of state.leaderMailbox) bytes += message.body.length + message.subject.length + 64;
|
|
253
|
+
while (bytes > MAX_LEADER_MAILBOX_BYTES && state.leaderMailbox.length > 1) {
|
|
254
|
+
const dropped = state.leaderMailbox.shift()!;
|
|
255
|
+
bytes -= dropped.body.length + dropped.subject.length + 64;
|
|
256
|
+
}
|
|
190
257
|
}
|
|
191
258
|
|
|
192
259
|
// ── Leader inbox ──────────────────────────────────────────────────
|
|
@@ -194,10 +261,8 @@ export function clearWorkerRunEvents(workerName: string, spawnId: string): void
|
|
|
194
261
|
export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">): MailboxMessage {
|
|
195
262
|
const full: MailboxMessage = { ...msg, id: nextMessageId(), timestamp: Date.now() };
|
|
196
263
|
state.leaderMailbox.push(full);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
markStateDirty();
|
|
264
|
+
trimLeaderMailbox();
|
|
265
|
+
stateRevisionCounter++;
|
|
201
266
|
return full;
|
|
202
267
|
}
|
|
203
268
|
|
|
@@ -209,6 +274,8 @@ export function receiveWorkerMessage(event: WorkerReportEvent, options?: { archi
|
|
|
209
274
|
state.leaderMailbox.push({
|
|
210
275
|
id: event.id,
|
|
211
276
|
from: event.worker,
|
|
277
|
+
assignmentId: event.assignmentId,
|
|
278
|
+
spawnId: event.spawnId,
|
|
212
279
|
subject: messageTitle(event.body),
|
|
213
280
|
body: event.body,
|
|
214
281
|
status: event.status,
|
|
@@ -217,10 +284,8 @@ export function receiveWorkerMessage(event: WorkerReportEvent, options?: { archi
|
|
|
217
284
|
// teammate spoke, not when the leader happened to drain the outbox.
|
|
218
285
|
timestamp: event.timestamp ?? Date.now(),
|
|
219
286
|
});
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
markStateDirty();
|
|
287
|
+
trimLeaderMailbox();
|
|
288
|
+
stateRevisionCounter++;
|
|
224
289
|
return true;
|
|
225
290
|
}
|
|
226
291
|
|
|
@@ -235,7 +300,7 @@ export function markPeerDelivered(inboxName: string, messageId: string): void {
|
|
|
235
300
|
ids.push(messageId);
|
|
236
301
|
while (ids.length > MAX_PEER_DELIVERED_IDS) ids.shift();
|
|
237
302
|
state.peerDeliveredIds[inboxName] = ids;
|
|
238
|
-
|
|
303
|
+
stateRevisionCounter++;
|
|
239
304
|
}
|
|
240
305
|
|
|
241
306
|
export function getPeerInboxOffset(inboxName: string): number {
|
|
@@ -244,11 +309,11 @@ export function getPeerInboxOffset(inboxName: string): number {
|
|
|
244
309
|
|
|
245
310
|
export function setPeerInboxOffset(inboxName: string, offset: number): void {
|
|
246
311
|
state.peerInboxOffsets[inboxName] = offset;
|
|
247
|
-
|
|
312
|
+
stateRevisionCounter++;
|
|
248
313
|
}
|
|
249
314
|
|
|
250
315
|
/** Record only the harness-controlled routing transition, never recipient read. */
|
|
251
|
-
export function setPeerDeliveryState(messageId: string, routing: "queued" | "routed"): void {
|
|
316
|
+
export function setPeerDeliveryState(messageId: string, routing: "queued" | "routed" | "dropped"): void {
|
|
252
317
|
state.peerDeliveryStates ??= {};
|
|
253
318
|
if (!(messageId in state.peerDeliveryStates)
|
|
254
319
|
&& Object.keys(state.peerDeliveryStates).length >= MAX_PEER_DELIVERY_STATES) {
|
|
@@ -256,10 +321,10 @@ export function setPeerDeliveryState(messageId: string, routing: "queued" | "rou
|
|
|
256
321
|
if (oldest) delete state.peerDeliveryStates[oldest];
|
|
257
322
|
}
|
|
258
323
|
state.peerDeliveryStates[messageId] = routing;
|
|
259
|
-
|
|
324
|
+
stateRevisionCounter++;
|
|
260
325
|
}
|
|
261
326
|
|
|
262
|
-
export function getPeerDeliveryState(messageId: string): "queued" | "routed" | undefined {
|
|
327
|
+
export function getPeerDeliveryState(messageId: string): "queued" | "routed" | "dropped" | undefined {
|
|
263
328
|
return state.peerDeliveryStates?.[messageId];
|
|
264
329
|
}
|
|
265
330
|
|
|
@@ -352,11 +417,16 @@ export function activeAssignmentConflict(
|
|
|
352
417
|
return livingTeammates().find((teammate) => {
|
|
353
418
|
if (teammate.name === exceptWorker) return false;
|
|
354
419
|
const assignment = teammate.assignment;
|
|
355
|
-
|
|
420
|
+
const retainsWorkAuthority = teammate.currentTaskId !== undefined
|
|
421
|
+
&& ["claimed", "superseded"].includes(state.tasks[teammate.currentTaskId]?.status ?? "");
|
|
422
|
+
return assignment !== undefined
|
|
423
|
+
&& (!assignment.closed || retainsWorkAuthority)
|
|
424
|
+
&& resourcesConflict(resources, assignment.resources);
|
|
356
425
|
});
|
|
357
426
|
}
|
|
358
427
|
|
|
359
428
|
export function createTask(input: {
|
|
429
|
+
id?: string;
|
|
360
430
|
subject: string;
|
|
361
431
|
description?: string;
|
|
362
432
|
dependsOn?: string[];
|
|
@@ -390,7 +460,8 @@ export function createTask(input: {
|
|
|
390
460
|
return { ok: false, error: `Task cannot both depend on and supersede "${selfReplacement}".` };
|
|
391
461
|
}
|
|
392
462
|
|
|
393
|
-
const id = taskIdFromSubject(subject, new Set(Object.keys(state.tasks)));
|
|
463
|
+
const id = input.id ?? taskIdFromSubject(subject, new Set(Object.keys(state.tasks)));
|
|
464
|
+
if (state.tasks[id]) return { ok: false, error: `Task id "${id}" already exists.` };
|
|
394
465
|
const migrations = new Map<string, string[]>();
|
|
395
466
|
for (const dependent of Object.values(state.tasks)) {
|
|
396
467
|
if (dependent.status === "completed" || dependent.status === "superseded") continue;
|
|
@@ -420,6 +491,8 @@ export function createTask(input: {
|
|
|
420
491
|
updatedAt: Date.now(),
|
|
421
492
|
};
|
|
422
493
|
state.tasks[id] = task;
|
|
494
|
+
boardRevisionCounter++;
|
|
495
|
+
stateRevisionCounter++;
|
|
423
496
|
const superseded: BoardTask[] = [];
|
|
424
497
|
for (const oldId of supersedes) {
|
|
425
498
|
const old = state.tasks[oldId];
|
|
@@ -438,7 +511,6 @@ export function createTask(input: {
|
|
|
438
511
|
dependent.dependsOn = migratedDependencies;
|
|
439
512
|
dependent.updatedAt = Date.now();
|
|
440
513
|
}
|
|
441
|
-
markStateDirty();
|
|
442
514
|
return { ok: true, task, superseded };
|
|
443
515
|
}
|
|
444
516
|
|
|
@@ -462,9 +534,80 @@ export function taskDependenciesMet(task: BoardTask): boolean {
|
|
|
462
534
|
return task.status === "pending" && task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
|
|
463
535
|
}
|
|
464
536
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
537
|
+
export function createDirectWork(input: {
|
|
538
|
+
id: string;
|
|
539
|
+
subject: string;
|
|
540
|
+
description?: string;
|
|
541
|
+
resources: string[];
|
|
542
|
+
verify?: string;
|
|
543
|
+
workerName: string;
|
|
544
|
+
assignment: WorkerAssignment;
|
|
545
|
+
}): { ok: true; task: BoardTask } | { ok: false; error: string } {
|
|
546
|
+
const created = createTask({
|
|
547
|
+
id: input.id,
|
|
548
|
+
subject: input.subject,
|
|
549
|
+
description: input.description,
|
|
550
|
+
resources: input.resources,
|
|
551
|
+
verify: input.verify,
|
|
552
|
+
});
|
|
553
|
+
if (!created.ok) return created;
|
|
554
|
+
const task = created.task;
|
|
555
|
+
const worker = getTeammate(input.workerName);
|
|
556
|
+
if (!worker || worker.assignment || activeAssignmentConflict(task.resources, input.workerName)) {
|
|
557
|
+
// The Work is rolled back here, so its id would render as an unresolvable
|
|
558
|
+
// handle: name the work a person can recognize instead.
|
|
559
|
+
const reason = !worker
|
|
560
|
+
? `@${input.workerName} is not a registered teammate`
|
|
561
|
+
: worker.assignment
|
|
562
|
+
? `@${input.workerName} already owns ${worker.assignment.kind} assignment "${worker.assignment.id}"`
|
|
563
|
+
: `its resources conflict with active Work`;
|
|
564
|
+
delete state.tasks[task.id];
|
|
565
|
+
return { ok: false, error: `Cannot bind direct Work "${input.subject}" to @${input.workerName}: ${reason}.` };
|
|
566
|
+
}
|
|
567
|
+
task.status = "claimed";
|
|
568
|
+
task.claimedBy = input.workerName;
|
|
569
|
+
task.updatedAt = Date.now();
|
|
570
|
+
boardRevisionCounter++;
|
|
571
|
+
stateRevisionCounter++;
|
|
572
|
+
assignTeammate(input.workerName, input.assignment, task.id);
|
|
573
|
+
return { ok: true, task };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function discardDirectWork(taskId: string, workerName: string): boolean {
|
|
577
|
+
const task = state.tasks[taskId];
|
|
578
|
+
if (!task || task.status !== "claimed" || task.claimedBy !== workerName) return false;
|
|
579
|
+
delete state.tasks[taskId];
|
|
580
|
+
boardRevisionCounter++;
|
|
581
|
+
stateRevisionCounter++;
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function reclaimDirectWork(
|
|
586
|
+
taskId: string,
|
|
587
|
+
workerName: string,
|
|
588
|
+
assignment: WorkerAssignment,
|
|
589
|
+
expectedState: "pending" | "completed" | "claimed",
|
|
590
|
+
allowActiveAssignment = false,
|
|
591
|
+
): { ok: true } | { ok: false; error: string } {
|
|
592
|
+
const task = state.tasks[taskId];
|
|
593
|
+
const worker = getTeammate(workerName);
|
|
594
|
+
const retainsSupersededWork = worker?.currentTaskId !== undefined
|
|
595
|
+
&& state.tasks[worker.currentTaskId]?.status === "superseded";
|
|
596
|
+
if (!task || task.status !== expectedState || !worker || retainsSupersededWork || (!allowActiveAssignment && worker.assignment && !worker.assignment.closed)) {
|
|
597
|
+
return { ok: false, error: `Unable to reclaim direct Work Item "${taskId}".` };
|
|
598
|
+
}
|
|
599
|
+
const conflict = activeAssignmentConflict(task.resources, workerName);
|
|
600
|
+
if (conflict) return { ok: false, error: `Resource conflict with @${conflict.name}'s active assignment.` };
|
|
601
|
+
task.status = "claimed";
|
|
602
|
+
task.claimedBy = workerName;
|
|
603
|
+
task.result = undefined;
|
|
604
|
+
task.errorMessage = undefined;
|
|
605
|
+
task.completedAt = undefined;
|
|
606
|
+
task.updatedAt = Date.now();
|
|
607
|
+
boardRevisionCounter++;
|
|
608
|
+
stateRevisionCounter++;
|
|
609
|
+
assignTeammate(workerName, assignment, task.id);
|
|
610
|
+
return { ok: true };
|
|
468
611
|
}
|
|
469
612
|
|
|
470
613
|
export function setTaskClaimed(taskId: string, workerName: string): BoardTask | undefined {
|
|
@@ -476,13 +619,55 @@ export function setTaskClaimed(taskId: string, workerName: string): BoardTask |
|
|
|
476
619
|
task.status = "claimed";
|
|
477
620
|
task.claimedBy = workerName;
|
|
478
621
|
task.updatedAt = Date.now();
|
|
479
|
-
|
|
480
|
-
|
|
622
|
+
boardRevisionCounter++;
|
|
623
|
+
stateRevisionCounter++;
|
|
624
|
+
assignTeammate(workerName, { id: `board:${randomUUID()}`, kind: "board", resources: task.resources }, task.id);
|
|
481
625
|
return task;
|
|
482
626
|
}
|
|
483
627
|
|
|
484
628
|
/** Release a claimed task back to pending. A superseded holder instead
|
|
485
629
|
* acknowledges cancellation: the task stays superseded but frees resources. */
|
|
630
|
+
export function claimedDependents(workId: string): BoardTask[] {
|
|
631
|
+
const reverse = new Map<string, string[]>();
|
|
632
|
+
for (const task of Object.values(state.tasks)) {
|
|
633
|
+
for (const dependency of task.dependsOn) {
|
|
634
|
+
const dependents = reverse.get(dependency) ?? [];
|
|
635
|
+
dependents.push(task.id);
|
|
636
|
+
reverse.set(dependency, dependents);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const visited = new Set<string>();
|
|
640
|
+
const pending = [...(reverse.get(workId) ?? [])];
|
|
641
|
+
const claimed: BoardTask[] = [];
|
|
642
|
+
while (pending.length > 0) {
|
|
643
|
+
const id = pending.shift()!;
|
|
644
|
+
if (visited.has(id)) continue;
|
|
645
|
+
visited.add(id);
|
|
646
|
+
const task = state.tasks[id];
|
|
647
|
+
if (!task) continue;
|
|
648
|
+
if (task.status === "claimed" || (task.status === "superseded" && task.claimedBy !== undefined)) claimed.push(task);
|
|
649
|
+
pending.push(...(reverse.get(id) ?? []));
|
|
650
|
+
}
|
|
651
|
+
return claimed.sort((left, right) => left.id.localeCompare(right.id));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function reopenCompletedWork(workId: string): { ok: true; task: BoardTask } | { ok: false; error: string } {
|
|
655
|
+
const task = state.tasks[workId];
|
|
656
|
+
if (!task || task.status !== "completed") return { ok: false, error: `Work Item "${workId}" is not completed.` };
|
|
657
|
+
const blockers = claimedDependents(workId);
|
|
658
|
+
if (blockers.length > 0) return { ok: false, error: `Work Item "${workId}" has active dependent Work: ${blockers.map((blocker) => blocker.id).join(", ")}.` };
|
|
659
|
+
task.status = "pending";
|
|
660
|
+
task.claimedBy = undefined;
|
|
661
|
+
task.result = undefined;
|
|
662
|
+
task.deferredMessages = undefined;
|
|
663
|
+
task.errorMessage = undefined;
|
|
664
|
+
task.completedAt = undefined;
|
|
665
|
+
task.updatedAt = Date.now();
|
|
666
|
+
boardRevisionCounter++;
|
|
667
|
+
stateRevisionCounter++;
|
|
668
|
+
return { ok: true, task };
|
|
669
|
+
}
|
|
670
|
+
|
|
486
671
|
export function releaseTask(taskId: string, errorMessage?: string): BoardTask | undefined {
|
|
487
672
|
const task = state.tasks[taskId];
|
|
488
673
|
if (!task || (task.status !== "claimed" && task.status !== "superseded")) return undefined;
|
|
@@ -491,8 +676,9 @@ export function releaseTask(taskId: string, errorMessage?: string): BoardTask |
|
|
|
491
676
|
task.claimedBy = undefined;
|
|
492
677
|
if (errorMessage !== undefined) task.errorMessage = errorMessage;
|
|
493
678
|
task.updatedAt = Date.now();
|
|
679
|
+
boardRevisionCounter++;
|
|
680
|
+
stateRevisionCounter++;
|
|
494
681
|
if (holder) assignTeammate(holder, undefined, undefined);
|
|
495
|
-
markStateDirty();
|
|
496
682
|
return task;
|
|
497
683
|
}
|
|
498
684
|
|
|
@@ -501,12 +687,14 @@ export function completeTask(taskId: string, result?: string): BoardTask | undef
|
|
|
501
687
|
if (!task || task.status !== "claimed") return undefined;
|
|
502
688
|
const holder = task.claimedBy;
|
|
503
689
|
task.status = "completed";
|
|
690
|
+
task.claimedBy = undefined;
|
|
504
691
|
task.result = result;
|
|
505
692
|
task.errorMessage = undefined;
|
|
506
693
|
task.completedAt = Date.now();
|
|
507
694
|
task.updatedAt = Date.now();
|
|
695
|
+
boardRevisionCounter++;
|
|
696
|
+
stateRevisionCounter++;
|
|
508
697
|
if (holder) assignTeammate(holder, undefined, undefined);
|
|
509
|
-
markStateDirty();
|
|
510
698
|
return task;
|
|
511
699
|
}
|
|
512
700
|
|
|
@@ -564,7 +752,7 @@ export function applySubmissionIntent(intent: TaskIntent): { ok: boolean; error?
|
|
|
564
752
|
return { ok: false, error: `task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}"; submit failed to acknowledge cancellation` };
|
|
565
753
|
}
|
|
566
754
|
if (intent.status === "failed") {
|
|
567
|
-
releaseTask(intent.taskId, intent.result?.trim() || "
|
|
755
|
+
releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.");
|
|
568
756
|
return { ok: true };
|
|
569
757
|
}
|
|
570
758
|
completeTask(intent.taskId, intent.result?.trim() || undefined);
|
|
@@ -594,16 +782,9 @@ export function loadBoard(tasks: Record<string, BoardTask>): number {
|
|
|
594
782
|
state.tasks[restored.id] = restored;
|
|
595
783
|
reloaded++;
|
|
596
784
|
}
|
|
597
|
-
if (reloaded > 0)
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
export function getSummary(): string | undefined {
|
|
602
|
-
const alive = livingTeammates();
|
|
603
|
-
if (alive.length === 0 && listTasks().length === 0) return undefined;
|
|
604
|
-
const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
|
|
605
|
-
for (const task of listTasks()) {
|
|
606
|
-
if (task.status in counts) counts[task.status as keyof typeof counts]++;
|
|
785
|
+
if (reloaded > 0) {
|
|
786
|
+
boardRevisionCounter++;
|
|
787
|
+
stateRevisionCounter++;
|
|
607
788
|
}
|
|
608
|
-
return
|
|
789
|
+
return reloaded;
|
|
609
790
|
}
|