@fradser/pi-agent-teams 0.8.0 → 0.8.2
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/follow-up-queue.md +10 -6
- package/.memory/team-centric-design.md +3 -3
- package/README.md +26 -16
- package/package.json +2 -2
- package/references/agent-roles.md +8 -2
- package/src/follow-up-queue.ts +84 -21
- package/src/guidance.ts +127 -42
- package/src/index.ts +88 -33
- package/src/spawner.ts +63 -3
- package/src/state.ts +224 -21
- package/src/statefile.ts +14 -7
- package/src/team-machine.ts +657 -81
- package/src/tool-render.ts +48 -0
- package/src/tools.ts +115 -78
- package/src/types.ts +51 -7
- package/src/ui.ts +62 -53
- package/src/worker.ts +182 -19
- package/src/worktree.ts +46 -9
package/src/state.ts
CHANGED
|
@@ -7,17 +7,21 @@
|
|
|
7
7
|
import {
|
|
8
8
|
messageTitle,
|
|
9
9
|
type BoardTask,
|
|
10
|
+
type WorkerAssignment,
|
|
10
11
|
type MailboxMessage,
|
|
11
12
|
type TaskIntent,
|
|
12
13
|
type Teammate,
|
|
13
14
|
type TeamState,
|
|
14
15
|
type WorkerReportEvent,
|
|
16
|
+
type WorkerUsage,
|
|
15
17
|
} from "./types.ts";
|
|
16
18
|
import { nonEmpty } from "@fradser/pi-kit";
|
|
17
19
|
|
|
18
20
|
export const MAX_LEADER_MAILBOX_MESSAGES = 4096;
|
|
19
21
|
/** FIFO cap of remembered peer message ids per inbox (dedup guard). */
|
|
20
22
|
export const MAX_PEER_DELIVERED_IDS = 512;
|
|
23
|
+
/** Bounded forensic routing history; mailbox files retain the full peer transcript. */
|
|
24
|
+
export const MAX_PEER_DELIVERY_STATES = 4096;
|
|
21
25
|
export const MAX_TASK_DEPENDENCIES = 32;
|
|
22
26
|
|
|
23
27
|
function emptyState(): TeamState {
|
|
@@ -30,6 +34,7 @@ function emptyState(): TeamState {
|
|
|
30
34
|
workerEventIds: {},
|
|
31
35
|
peerInboxOffsets: {},
|
|
32
36
|
peerDeliveredIds: {},
|
|
37
|
+
peerDeliveryStates: {},
|
|
33
38
|
};
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -136,10 +141,26 @@ export function updateTeammate(name: string, patch: Partial<Teammate>): Teammate
|
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
/** Merge streaming child-process progress into a living teammate. */
|
|
144
|
+
export function assignTeammate(
|
|
145
|
+
name: string,
|
|
146
|
+
assignment: WorkerAssignment | undefined,
|
|
147
|
+
currentTaskId?: string,
|
|
148
|
+
): Teammate | undefined {
|
|
149
|
+
const teammate = getTeammate(name);
|
|
150
|
+
if (!teammate) return undefined;
|
|
151
|
+
const lastAssignment = assignment ?? teammate.assignment ?? teammate.lastAssignment;
|
|
152
|
+
const lastTaskId = currentTaskId ?? teammate.currentTaskId ?? teammate.lastTaskId;
|
|
153
|
+
return updateTeammate(name, { assignment, currentTaskId, lastAssignment, lastTaskId });
|
|
154
|
+
}
|
|
155
|
+
|
|
139
156
|
export function updateTeammateProgress(
|
|
140
157
|
name: string,
|
|
141
158
|
spawnId: string,
|
|
142
|
-
progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & {
|
|
159
|
+
progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & {
|
|
160
|
+
sequenceEnded?: boolean;
|
|
161
|
+
modelOutputSeen?: boolean;
|
|
162
|
+
usage?: WorkerUsage;
|
|
163
|
+
},
|
|
143
164
|
): boolean {
|
|
144
165
|
const teammate = state.teammates[name];
|
|
145
166
|
if (!teammate || teammate.spawnId !== spawnId) return false;
|
|
@@ -148,6 +169,8 @@ export function updateTeammateProgress(
|
|
|
148
169
|
teammate.liveThinking = progress.liveThinking;
|
|
149
170
|
teammate.turns = progress.turns;
|
|
150
171
|
if (progress.sequenceEnded !== undefined) teammate.sequenceEnded = progress.sequenceEnded;
|
|
172
|
+
if (progress.modelOutputSeen) teammate.modelOutputSeen = true;
|
|
173
|
+
if (progress.usage) teammate.usage = progress.usage;
|
|
151
174
|
if (teammate.status === "starting") {
|
|
152
175
|
teammate.status = progress.sequenceEnded ? "idle" : "working";
|
|
153
176
|
}
|
|
@@ -179,7 +202,7 @@ export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">):
|
|
|
179
202
|
}
|
|
180
203
|
|
|
181
204
|
/** Apply a validated report event exactly once by event id. */
|
|
182
|
-
export function receiveWorkerMessage(event: WorkerReportEvent): boolean {
|
|
205
|
+
export function receiveWorkerMessage(event: WorkerReportEvent, options?: { archived?: boolean }): boolean {
|
|
183
206
|
const sender = getTeammate(event.worker);
|
|
184
207
|
if (!sender || sender.spawnId !== event.spawnId) return false;
|
|
185
208
|
if (state.leaderMailbox.some((message) => message.id === event.id)) return false;
|
|
@@ -189,7 +212,10 @@ export function receiveWorkerMessage(event: WorkerReportEvent): boolean {
|
|
|
189
212
|
subject: messageTitle(event.body),
|
|
190
213
|
body: event.body,
|
|
191
214
|
status: event.status,
|
|
192
|
-
|
|
215
|
+
archived: options?.archived,
|
|
216
|
+
// Keep the authored-at moment so console ordering reflects when the
|
|
217
|
+
// teammate spoke, not when the leader happened to drain the outbox.
|
|
218
|
+
timestamp: event.timestamp ?? Date.now(),
|
|
193
219
|
});
|
|
194
220
|
if (state.leaderMailbox.length > MAX_LEADER_MAILBOX_MESSAGES) {
|
|
195
221
|
state.leaderMailbox.splice(0, state.leaderMailbox.length - MAX_LEADER_MAILBOX_MESSAGES);
|
|
@@ -221,6 +247,22 @@ export function setPeerInboxOffset(inboxName: string, offset: number): void {
|
|
|
221
247
|
markStateDirty();
|
|
222
248
|
}
|
|
223
249
|
|
|
250
|
+
/** Record only the harness-controlled routing transition, never recipient read. */
|
|
251
|
+
export function setPeerDeliveryState(messageId: string, routing: "queued" | "routed"): void {
|
|
252
|
+
state.peerDeliveryStates ??= {};
|
|
253
|
+
if (!(messageId in state.peerDeliveryStates)
|
|
254
|
+
&& Object.keys(state.peerDeliveryStates).length >= MAX_PEER_DELIVERY_STATES) {
|
|
255
|
+
const oldest = Object.keys(state.peerDeliveryStates)[0];
|
|
256
|
+
if (oldest) delete state.peerDeliveryStates[oldest];
|
|
257
|
+
}
|
|
258
|
+
state.peerDeliveryStates[messageId] = routing;
|
|
259
|
+
markStateDirty();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function getPeerDeliveryState(messageId: string): "queued" | "routed" | undefined {
|
|
263
|
+
return state.peerDeliveryStates?.[messageId];
|
|
264
|
+
}
|
|
265
|
+
|
|
224
266
|
// ── Board: creation and queries ───────────────────────────────────
|
|
225
267
|
|
|
226
268
|
/** Maximum characters in a generated slug task id (suffix included). */
|
|
@@ -243,35 +285,161 @@ export function taskIdFromSubject(subject: string, taken: ReadonlySet<string>):
|
|
|
243
285
|
return id;
|
|
244
286
|
}
|
|
245
287
|
|
|
288
|
+
export function normalizeResources(resources: readonly string[] | undefined): string[] {
|
|
289
|
+
return [...new Set((resources ?? [])
|
|
290
|
+
.map((resource) => resource.trim().replace(/^\/+|\/+$/g, ""))
|
|
291
|
+
.filter(Boolean))]
|
|
292
|
+
.sort();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Follow an obsolete dependency to its latest replacement. An invalid persisted
|
|
296
|
+
* chain is rejected rather than silently making a future task unclaimable. */
|
|
297
|
+
export function canonicalDependency(taskId: string, tasks: Record<string, BoardTask>): string | undefined {
|
|
298
|
+
const seen = new Set<string>();
|
|
299
|
+
let current = taskId;
|
|
300
|
+
while (true) {
|
|
301
|
+
if (seen.has(current)) return undefined;
|
|
302
|
+
seen.add(current);
|
|
303
|
+
const task = tasks[current];
|
|
304
|
+
if (!task) return undefined;
|
|
305
|
+
if (task.status !== "superseded") return current;
|
|
306
|
+
if (!task.supersededBy) return undefined;
|
|
307
|
+
current = task.supersededBy;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function canonicalDependencies(
|
|
312
|
+
dependencies: readonly string[],
|
|
313
|
+
tasks: Record<string, BoardTask>,
|
|
314
|
+
): string[] | undefined {
|
|
315
|
+
const canonical: string[] = [];
|
|
316
|
+
for (const dependency of dependencies) {
|
|
317
|
+
const resolved = canonicalDependency(dependency, tasks);
|
|
318
|
+
if (!resolved) return undefined;
|
|
319
|
+
if (!canonical.includes(resolved)) canonical.push(resolved);
|
|
320
|
+
}
|
|
321
|
+
return canonical;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function hasDependencyCycle(graph: Map<string, readonly string[]>): boolean {
|
|
325
|
+
const visiting = new Set<string>();
|
|
326
|
+
const visited = new Set<string>();
|
|
327
|
+
const visit = (taskId: string): boolean => {
|
|
328
|
+
if (visiting.has(taskId)) return true;
|
|
329
|
+
if (visited.has(taskId)) return false;
|
|
330
|
+
visiting.add(taskId);
|
|
331
|
+
for (const dependency of graph.get(taskId) ?? []) {
|
|
332
|
+
if (graph.has(dependency) && visit(dependency)) return true;
|
|
333
|
+
}
|
|
334
|
+
visiting.delete(taskId);
|
|
335
|
+
visited.add(taskId);
|
|
336
|
+
return false;
|
|
337
|
+
};
|
|
338
|
+
return [...graph.keys()].some(visit);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `firmware/sub-node` conflicts with itself and descendants such as
|
|
342
|
+
* `firmware/sub-node/app`; unrelated siblings stay concurrently claimable. */
|
|
343
|
+
export function resourcesConflict(left: readonly string[], right: readonly string[]): boolean {
|
|
344
|
+
return left.some((a) => right.some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function activeAssignmentConflict(
|
|
348
|
+
resources: readonly string[],
|
|
349
|
+
exceptWorker?: string,
|
|
350
|
+
): Teammate | undefined {
|
|
351
|
+
if (resources.length === 0) return undefined;
|
|
352
|
+
return livingTeammates().find((teammate) => {
|
|
353
|
+
if (teammate.name === exceptWorker) return false;
|
|
354
|
+
const assignment = teammate.assignment;
|
|
355
|
+
return assignment !== undefined && !assignment.closed && resourcesConflict(resources, assignment.resources);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
246
359
|
export function createTask(input: {
|
|
247
360
|
subject: string;
|
|
248
361
|
description?: string;
|
|
249
362
|
dependsOn?: string[];
|
|
250
363
|
verify?: string;
|
|
251
|
-
|
|
364
|
+
resources?: string[];
|
|
365
|
+
supersedes?: string[];
|
|
366
|
+
}): { ok: true; task: BoardTask; superseded: BoardTask[] } | { ok: false; error: string } {
|
|
252
367
|
const subject = input.subject.trim();
|
|
253
368
|
if (!subject) return { ok: false, error: "Task subject must not be empty." };
|
|
254
|
-
const
|
|
255
|
-
|
|
369
|
+
const requestedDependencies = [...new Set(input.dependsOn ?? [])];
|
|
370
|
+
const requestedSupersedes = [...new Set(input.supersedes ?? [])];
|
|
371
|
+
if (requestedDependencies.length > MAX_TASK_DEPENDENCIES) {
|
|
256
372
|
return { ok: false, error: `Task depends on more than ${MAX_TASK_DEPENDENCIES} tasks.` };
|
|
257
373
|
}
|
|
258
|
-
for (const
|
|
259
|
-
if (!state.tasks[
|
|
374
|
+
for (const taskId of [...requestedDependencies, ...requestedSupersedes]) {
|
|
375
|
+
if (!state.tasks[taskId]) return { ok: false, error: `Task references unknown task "${taskId}".` };
|
|
376
|
+
}
|
|
377
|
+
const completedTarget = requestedSupersedes.find((taskId) => state.tasks[taskId]?.status === "completed");
|
|
378
|
+
if (completedTarget) {
|
|
379
|
+
return { ok: false, error: `Task cannot supersede completed task "${completedTarget}".` };
|
|
380
|
+
}
|
|
381
|
+
const dependsOn = canonicalDependencies(requestedDependencies, state.tasks);
|
|
382
|
+
const supersedes = canonicalDependencies(requestedSupersedes, state.tasks);
|
|
383
|
+
if (!dependsOn || !supersedes) return { ok: false, error: "Task dependency or supersession chain is invalid or cyclic." };
|
|
384
|
+
const canonicalCompletedTarget = supersedes.find((taskId) => state.tasks[taskId]?.status === "completed");
|
|
385
|
+
if (canonicalCompletedTarget) {
|
|
386
|
+
return { ok: false, error: `Task cannot supersede completed task "${canonicalCompletedTarget}".` };
|
|
387
|
+
}
|
|
388
|
+
const selfReplacement = supersedes.find((taskId) => dependsOn.includes(taskId));
|
|
389
|
+
if (selfReplacement) {
|
|
390
|
+
return { ok: false, error: `Task cannot both depend on and supersede "${selfReplacement}".` };
|
|
260
391
|
}
|
|
392
|
+
|
|
261
393
|
const id = taskIdFromSubject(subject, new Set(Object.keys(state.tasks)));
|
|
394
|
+
const migrations = new Map<string, string[]>();
|
|
395
|
+
for (const dependent of Object.values(state.tasks)) {
|
|
396
|
+
if (dependent.status === "completed" || dependent.status === "superseded") continue;
|
|
397
|
+
const migrated = dependent.dependsOn.map((dependency) => supersedes.includes(dependency) ? id : dependency);
|
|
398
|
+
if (migrated.some((dependency, index) => dependency !== dependent.dependsOn[index])) {
|
|
399
|
+
migrations.set(dependent.id, [...new Set(migrated)]);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const prospectiveGraph = new Map<string, readonly string[]>();
|
|
403
|
+
for (const existing of Object.values(state.tasks)) {
|
|
404
|
+
prospectiveGraph.set(existing.id, migrations.get(existing.id) ?? existing.dependsOn);
|
|
405
|
+
}
|
|
406
|
+
prospectiveGraph.set(id, dependsOn);
|
|
407
|
+
if (hasDependencyCycle(prospectiveGraph)) {
|
|
408
|
+
return { ok: false, error: "Task supersession would create a dependency cycle." };
|
|
409
|
+
}
|
|
410
|
+
|
|
262
411
|
const task: BoardTask = {
|
|
263
412
|
id,
|
|
264
413
|
subject,
|
|
265
414
|
description: input.description?.trim() || undefined,
|
|
266
415
|
dependsOn,
|
|
267
416
|
verify: input.verify?.trim() || undefined,
|
|
417
|
+
resources: normalizeResources(input.resources),
|
|
268
418
|
status: "pending",
|
|
269
419
|
createdAt: Date.now(),
|
|
270
420
|
updatedAt: Date.now(),
|
|
271
421
|
};
|
|
272
422
|
state.tasks[id] = task;
|
|
423
|
+
const superseded: BoardTask[] = [];
|
|
424
|
+
for (const oldId of supersedes) {
|
|
425
|
+
const old = state.tasks[oldId];
|
|
426
|
+
if (!old || old.status === "completed" || old.status === "superseded") continue;
|
|
427
|
+
old.status = "superseded";
|
|
428
|
+
old.supersededBy = id;
|
|
429
|
+
// A living holder keeps the claim and assignment until it acknowledges
|
|
430
|
+
// cancellation or stops. Releasing its resource before it receives the
|
|
431
|
+
// stop instruction would let replacement work race active file writes.
|
|
432
|
+
old.updatedAt = Date.now();
|
|
433
|
+
superseded.push(old);
|
|
434
|
+
}
|
|
435
|
+
for (const [dependentId, migratedDependencies] of migrations) {
|
|
436
|
+
const dependent = state.tasks[dependentId];
|
|
437
|
+
if (!dependent) continue;
|
|
438
|
+
dependent.dependsOn = migratedDependencies;
|
|
439
|
+
dependent.updatedAt = Date.now();
|
|
440
|
+
}
|
|
273
441
|
markStateDirty();
|
|
274
|
-
return { ok: true, task };
|
|
442
|
+
return { ok: true, task, superseded };
|
|
275
443
|
}
|
|
276
444
|
|
|
277
445
|
export function getTask(taskId: string): BoardTask | undefined {
|
|
@@ -291,7 +459,7 @@ export function claimableTasks(): BoardTask[] {
|
|
|
291
459
|
}
|
|
292
460
|
|
|
293
461
|
export function taskDependenciesMet(task: BoardTask): boolean {
|
|
294
|
-
return task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
|
|
462
|
+
return task.status === "pending" && task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
|
|
295
463
|
}
|
|
296
464
|
|
|
297
465
|
/** First claimable task, or undefined. */
|
|
@@ -301,22 +469,29 @@ export function firstClaimableTask(): BoardTask | undefined {
|
|
|
301
469
|
|
|
302
470
|
export function setTaskClaimed(taskId: string, workerName: string): BoardTask | undefined {
|
|
303
471
|
const task = state.tasks[taskId];
|
|
304
|
-
|
|
472
|
+
const teammate = getTeammate(workerName);
|
|
473
|
+
if (!task || !teammate || task.status !== "pending" || !taskDependenciesMet(task)) return undefined;
|
|
474
|
+
if (teammate.assignment) return undefined;
|
|
475
|
+
if (activeAssignmentConflict(task.resources, workerName)) return undefined;
|
|
305
476
|
task.status = "claimed";
|
|
306
477
|
task.claimedBy = workerName;
|
|
307
478
|
task.updatedAt = Date.now();
|
|
479
|
+
assignTeammate(workerName, { id: task.id, kind: "board", resources: task.resources }, task.id);
|
|
308
480
|
markStateDirty();
|
|
309
481
|
return task;
|
|
310
482
|
}
|
|
311
483
|
|
|
312
|
-
/** Release a claimed task back to pending
|
|
484
|
+
/** Release a claimed task back to pending. A superseded holder instead
|
|
485
|
+
* acknowledges cancellation: the task stays superseded but frees resources. */
|
|
313
486
|
export function releaseTask(taskId: string, errorMessage?: string): BoardTask | undefined {
|
|
314
487
|
const task = state.tasks[taskId];
|
|
315
|
-
if (!task || task.status !== "claimed") return undefined;
|
|
316
|
-
|
|
488
|
+
if (!task || (task.status !== "claimed" && task.status !== "superseded")) return undefined;
|
|
489
|
+
const holder = task.claimedBy;
|
|
490
|
+
if (task.status === "claimed") task.status = "pending";
|
|
317
491
|
task.claimedBy = undefined;
|
|
318
492
|
if (errorMessage !== undefined) task.errorMessage = errorMessage;
|
|
319
493
|
task.updatedAt = Date.now();
|
|
494
|
+
if (holder) assignTeammate(holder, undefined, undefined);
|
|
320
495
|
markStateDirty();
|
|
321
496
|
return task;
|
|
322
497
|
}
|
|
@@ -324,11 +499,13 @@ export function releaseTask(taskId: string, errorMessage?: string): BoardTask |
|
|
|
324
499
|
export function completeTask(taskId: string, result?: string): BoardTask | undefined {
|
|
325
500
|
const task = state.tasks[taskId];
|
|
326
501
|
if (!task || task.status !== "claimed") return undefined;
|
|
502
|
+
const holder = task.claimedBy;
|
|
327
503
|
task.status = "completed";
|
|
328
504
|
task.result = result;
|
|
329
505
|
task.errorMessage = undefined;
|
|
330
506
|
task.completedAt = Date.now();
|
|
331
507
|
task.updatedAt = Date.now();
|
|
508
|
+
if (holder) assignTeammate(holder, undefined, undefined);
|
|
332
509
|
markStateDirty();
|
|
333
510
|
return task;
|
|
334
511
|
}
|
|
@@ -337,7 +514,7 @@ export function completeTask(taskId: string, result?: string): BoardTask | undef
|
|
|
337
514
|
export function releaseTasksOf(workerName: string, reason: string): BoardTask[] {
|
|
338
515
|
const released: BoardTask[] = [];
|
|
339
516
|
for (const task of listTasks()) {
|
|
340
|
-
if (task.status === "claimed" && task.claimedBy === workerName) {
|
|
517
|
+
if ((task.status === "claimed" || task.status === "superseded") && task.claimedBy === workerName) {
|
|
341
518
|
releaseTask(task.id, reason);
|
|
342
519
|
released.push(task);
|
|
343
520
|
}
|
|
@@ -348,22 +525,44 @@ export function releaseTasksOf(workerName: string, reason: string): BoardTask[]
|
|
|
348
525
|
/** Apply a validated claim intent from a marker file. */
|
|
349
526
|
export function applyClaimIntent(intent: TaskIntent): { applied: boolean; reason?: string } {
|
|
350
527
|
const task = state.tasks[intent.taskId];
|
|
528
|
+
const teammate = getTeammate(intent.worker);
|
|
351
529
|
if (!task) return { applied: false, reason: `unknown task "${intent.taskId}"` };
|
|
530
|
+
if (!teammate || teammate.spawnId !== intent.spawnId || teammate.status === "stopped") {
|
|
531
|
+
return { applied: false, reason: `worker "${intent.worker}" is not a living current incarnation` };
|
|
532
|
+
}
|
|
533
|
+
if (teammate.assignment) {
|
|
534
|
+
const state = teammate.assignment.closed ? "closed pending leader reopen" : "active";
|
|
535
|
+
return { applied: false, reason: `@${intent.worker} already owns ${state} ${teammate.assignment.kind} assignment "${teammate.assignment.id}"` };
|
|
536
|
+
}
|
|
352
537
|
if (task.status === "claimed") return { applied: false, reason: `task "${intent.taskId}" is already claimed` };
|
|
353
|
-
if (task.status === "completed") return { applied: false, reason: `task "${intent.taskId}" is
|
|
538
|
+
if (task.status === "completed" || task.status === "superseded") return { applied: false, reason: `task "${intent.taskId}" is ${task.status}` };
|
|
354
539
|
if (!taskDependenciesMet(task)) return { applied: false, reason: `task "${intent.taskId}" has unmet dependencies` };
|
|
355
|
-
|
|
540
|
+
const conflict = activeAssignmentConflict(task.resources, intent.worker);
|
|
541
|
+
if (conflict) return { applied: false, reason: `resource conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}"` };
|
|
542
|
+
if (!setTaskClaimed(intent.taskId, intent.worker)) return { applied: false, reason: `task "${intent.taskId}" could not be claimed` };
|
|
356
543
|
return { applied: true };
|
|
357
544
|
}
|
|
358
545
|
|
|
359
546
|
/** Validate and apply a submission intent; verify gating happens in the caller. */
|
|
360
547
|
export function applySubmissionIntent(intent: TaskIntent): { ok: boolean; error?: string } {
|
|
548
|
+
if (intent.status !== "completed" && intent.status !== "failed") {
|
|
549
|
+
return { ok: false, error: `task "${intent.taskId}" has invalid submission status` };
|
|
550
|
+
}
|
|
551
|
+
const teammate = getTeammate(intent.worker);
|
|
552
|
+
if (!teammate || teammate.spawnId !== intent.spawnId || teammate.status === "stopped") {
|
|
553
|
+
return { ok: false, error: `worker "${intent.worker}" is not a living current incarnation` };
|
|
554
|
+
}
|
|
361
555
|
const task = state.tasks[intent.taskId];
|
|
362
556
|
if (!task) return { ok: false, error: `unknown task "${intent.taskId}"` };
|
|
363
|
-
if (task.status !== "claimed"
|
|
557
|
+
if (task.status !== "claimed" && task.status !== "superseded") {
|
|
558
|
+
return { ok: false, error: `task "${intent.taskId}" is not claimed` };
|
|
559
|
+
}
|
|
364
560
|
if (task.claimedBy !== intent.worker) {
|
|
365
561
|
return { ok: false, error: `task "${intent.taskId}" is claimed by ${task.claimedBy ?? "someone else"}` };
|
|
366
562
|
}
|
|
563
|
+
if (task.status === "superseded" && intent.status !== "failed") {
|
|
564
|
+
return { ok: false, error: `task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}"; submit failed to acknowledge cancellation` };
|
|
565
|
+
}
|
|
367
566
|
if (intent.status === "failed") {
|
|
368
567
|
releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
|
|
369
568
|
return { ok: true };
|
|
@@ -382,10 +581,14 @@ export function getState(): TeamState {
|
|
|
382
581
|
export function loadBoard(tasks: Record<string, BoardTask>): number {
|
|
383
582
|
let reloaded = 0;
|
|
384
583
|
for (const task of Object.values(tasks)) {
|
|
584
|
+
const orphanedHolding = task.status === "claimed" || task.status === "superseded";
|
|
385
585
|
const restored: BoardTask = {
|
|
386
586
|
...task,
|
|
587
|
+
resources: normalizeResources(task.resources),
|
|
387
588
|
status: task.status === "claimed" ? "pending" : task.status,
|
|
388
|
-
|
|
589
|
+
// Runtime workers and assignments die with the session. Superseded work
|
|
590
|
+
// remains visible for audit but cannot retain a dead holder/resource lock.
|
|
591
|
+
claimedBy: orphanedHolding ? undefined : task.claimedBy,
|
|
389
592
|
updatedAt: Date.now(),
|
|
390
593
|
};
|
|
391
594
|
state.tasks[restored.id] = restored;
|
|
@@ -398,9 +601,9 @@ export function loadBoard(tasks: Record<string, BoardTask>): number {
|
|
|
398
601
|
export function getSummary(): string | undefined {
|
|
399
602
|
const alive = livingTeammates();
|
|
400
603
|
if (alive.length === 0 && listTasks().length === 0) return undefined;
|
|
401
|
-
const counts = { pending: 0, claimed: 0, completed: 0 };
|
|
604
|
+
const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
|
|
402
605
|
for (const task of listTasks()) {
|
|
403
606
|
if (task.status in counts) counts[task.status as keyof typeof counts]++;
|
|
404
607
|
}
|
|
405
|
-
return `${alive.length} teammate(s) alive | board: ${counts.pending} pending / ${counts.claimed} claimed / ${counts.completed} completed`;
|
|
608
|
+
return `${alive.length} teammate(s) alive | board: ${counts.pending} pending / ${counts.claimed} claimed / ${counts.completed} completed / ${counts.superseded} superseded`;
|
|
406
609
|
}
|
package/src/statefile.ts
CHANGED
|
@@ -79,15 +79,15 @@ export function appendInboxMessage(file: string, message: { id: string; from: st
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/** Publish the worker-readable roster of living teammates. */
|
|
82
|
-
export function writeRoster(file: string, teammates: Array<{ name: string; agent: string; status: string }>): void {
|
|
82
|
+
export function writeRoster(file: string, teammates: Array<{ name: string; agent: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }>): void {
|
|
83
83
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
84
84
|
writeJsonAtomic(file, { teammates });
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/** Read the roster from inside a teammate process; unknown roster = empty. */
|
|
88
|
-
export function readRoster(file: string): Array<{ name: string; agent: string; status: string }> {
|
|
88
|
+
export function readRoster(file: string): Array<{ name: string; agent: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> {
|
|
89
89
|
try {
|
|
90
|
-
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { teammates?: Array<{ name: string; agent: string; status: string }> };
|
|
90
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { teammates?: Array<{ name: string; agent: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> };
|
|
91
91
|
return Array.isArray(parsed.teammates) ? parsed.teammates : [];
|
|
92
92
|
} catch {
|
|
93
93
|
return [];
|
|
@@ -237,11 +237,18 @@ export function takeTaskIntent(dir: string): { intent?: TaskIntent; diagnostic?:
|
|
|
237
237
|
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<TaskIntent>;
|
|
238
238
|
fs.rmSync(file, { force: true });
|
|
239
239
|
if (
|
|
240
|
-
typeof parsed.taskId !== "string"
|
|
241
|
-
|| typeof parsed.worker !== "string"
|
|
242
|
-
|| typeof parsed.spawnId !== "string"
|
|
240
|
+
typeof parsed.taskId !== "string" || parsed.taskId.trim() === ""
|
|
241
|
+
|| typeof parsed.worker !== "string" || parsed.worker.trim() === ""
|
|
242
|
+
|| typeof parsed.spawnId !== "string" || parsed.spawnId.trim() === ""
|
|
243
|
+
|| typeof parsed.timestamp !== "number" || !Number.isFinite(parsed.timestamp)
|
|
243
244
|
) {
|
|
244
|
-
return { diagnostic: `malformed task intent "${name}" was consumed (
|
|
245
|
+
return { diagnostic: `malformed task intent "${name}" was consumed (requires non-empty taskId/worker/spawnId and finite timestamp)` };
|
|
246
|
+
}
|
|
247
|
+
if (parsed.status !== undefined && parsed.status !== "completed" && parsed.status !== "failed") {
|
|
248
|
+
return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission status)` };
|
|
249
|
+
}
|
|
250
|
+
if (typeof parsed.result !== "undefined" && typeof parsed.result !== "string") {
|
|
251
|
+
return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission result)` };
|
|
245
252
|
}
|
|
246
253
|
return { intent: parsed as TaskIntent };
|
|
247
254
|
} catch {
|