@fradser/pi-agent-teams 0.7.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/src/spawner.ts CHANGED
@@ -44,6 +44,12 @@ export interface WorkerProgressUpdate {
44
44
  turns: number;
45
45
  /** True after the current sequence's final response. */
46
46
  finalResponse?: boolean;
47
+ /** True once the stream delivered recognized model activity (text, thinking,
48
+ * tool-call, or tool execution events). Independent of usage totals so
49
+ * providers that omit usage are not misclassified as silent. */
50
+ modelOutputSeen?: boolean;
51
+ /** Lifetime accumulated usage parsed from message_end events. */
52
+ usage?: WorkerUsage;
47
53
  }
48
54
 
49
55
  /** A crashed teammate is one that closed without a normal zero exit. */
@@ -51,6 +57,47 @@ export function isCleanExit(result: Pick<WorkerProcessResult, "exitCode" | "sign
51
57
  return result.exitCode === 0 && result.signal === null;
52
58
  }
53
59
 
60
+ /** Tools every teammate receives regardless of its role definition. */
61
+ export const WORKER_CAPABILITY_TOOLS: readonly string[] = [
62
+ "send_message",
63
+ "task_list",
64
+ "task_claim",
65
+ "task_submit",
66
+ ];
67
+
68
+ /** Effective tool allowlist for one teammate: the role's requested tools plus
69
+ * the capability set, deduplicated in request order. Roles without a tools
70
+ * field get exactly the capability set — leaders should see that narrow
71
+ * grant before the first wake so missing read/bash is obvious at spawn time. */
72
+ export function resolveWorkerTools(requested?: string[]): string[] {
73
+ const requestedOnly = (requested ?? []).filter((tool) => !WORKER_CAPABILITY_TOOLS.includes(tool));
74
+ return [...new Set([...requestedOnly, ...WORKER_CAPABILITY_TOOLS])];
75
+ }
76
+
77
+ /** Pi built-in tool ids a bare teammate process registers. The child runs
78
+ * --no-extensions, so this list (plus the capability set) is the complete
79
+ * grantable universe; anything else is silently dropped by the child's
80
+ * --tools allowlist filter. */
81
+ export const WORKER_BUILTIN_TOOLS: readonly string[] = [
82
+ "read",
83
+ "bash",
84
+ "edit",
85
+ "write",
86
+ "grep",
87
+ "find",
88
+ "ls",
89
+ "powershell",
90
+ ];
91
+
92
+ /** Every tool id a teammate can actually receive: pi built-ins plus the capability set. */
93
+ export const WORKER_TOOL_UNIVERSE: readonly string[] = [...WORKER_BUILTIN_TOOLS, ...WORKER_CAPABILITY_TOOLS];
94
+
95
+ /** Requested tool ids outside the teammate universe — exactly the ones the
96
+ * child's --tools allowlist would silently drop. */
97
+ export function unknownWorkerTools(requested?: string[]): string[] {
98
+ return [...new Set((requested ?? []).filter((tool) => !WORKER_TOOL_UNIVERSE.includes(tool)))];
99
+ }
100
+
54
101
  // ── Process registry ──────────────────────────────────────────────
55
102
 
56
103
  /** Live children by teammate name — powers steering, prompting, and shutdown. */
@@ -164,6 +211,9 @@ interface StreamState {
164
211
  /** Lifetime count of completed assistant messages. */
165
212
  turns: number;
166
213
  finalResponse?: boolean;
214
+ /** Set by any recognized model/stream activity; never by a bare empty
215
+ * message_end artifact. The stall classifier uses this, not usage. */
216
+ modelOutputSeen?: boolean;
167
217
  usage?: WorkerUsage;
168
218
  }
169
219
 
@@ -217,11 +267,13 @@ function applyStreamLine(state: StreamState, line: string): boolean {
217
267
  return false;
218
268
  }
219
269
  if (event.type === "tool_execution_start") {
270
+ state.modelOutputSeen = true;
220
271
  state.activeTools.set(event.toolCallId ?? `tool-${state.activeTools.size}`, toolExecutionLabel(event.toolName, event.args));
221
272
  state.activeTool = [...state.activeTools.values()].at(-1);
222
273
  return true;
223
274
  }
224
275
  if (event.type === "tool_execution_end") {
276
+ state.modelOutputSeen = true;
225
277
  if (event.toolCallId) state.activeTools.delete(event.toolCallId);
226
278
  else state.activeTools.clear();
227
279
  state.activeTool = [...state.activeTools.values()].at(-1);
@@ -235,7 +287,10 @@ function applyStreamLine(state: StreamState, line: string): boolean {
235
287
  if (event.message.stopReason === "stop") state.finalResponse = true;
236
288
  const parts = extractTextContent(event.message.content, "");
237
289
  if (parts.trim()) state.text = parts;
290
+ // Usage stays diagnostics: only streamed content counts as model output,
291
+ // so input-only or failed responses cannot bypass the zero-output tier.
238
292
  const u = event.message.usage;
293
+ if (parts.trim()) state.modelOutputSeen = true;
239
294
  if (u) {
240
295
  state.usage = {
241
296
  input: (state.usage?.input ?? 0) + (u.input ?? 0),
@@ -252,23 +307,28 @@ function applyStreamLine(state: StreamState, line: string): boolean {
252
307
  if (!sub) return false;
253
308
  switch (sub.type) {
254
309
  case "text_delta":
310
+ state.modelOutputSeen = true;
255
311
  state.activeTool = undefined;
256
312
  state.text += sub.delta ?? "";
257
313
  return true;
258
314
  case "thinking_delta":
315
+ state.modelOutputSeen = true;
259
316
  state.activeTool = undefined;
260
317
  state.thinking += sub.delta ?? "";
261
318
  return true;
262
319
  case "toolcall_start":
320
+ state.modelOutputSeen = true;
263
321
  clearActiveTools(state);
264
322
  return true;
265
323
  case "toolcall_delta": {
324
+ state.modelOutputSeen = true;
266
325
  state.toolcallArgs += sub.delta ?? "";
267
326
  const label = toolcallLabel(state.toolcallArgs);
268
327
  if (label) state.activeTool = label;
269
328
  return true;
270
329
  }
271
330
  case "toolcall_end":
331
+ state.modelOutputSeen = true;
272
332
  // Execution events own the activity label until the result arrives.
273
333
  clearActiveTools(state);
274
334
  return true;
@@ -331,9 +391,7 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
331
391
  "--extension", WORKER_EXTENSION,
332
392
  ];
333
393
  if (options.model) args.push("--model", options.model);
334
- const capabilityTools = ["send_message", "task_list", "task_claim", "task_submit"];
335
- const requestedTools = (options.tools ?? []).filter((tool) => !capabilityTools.includes(tool));
336
- args.push("--tools", [...new Set([...requestedTools, ...capabilityTools])].join(","));
394
+ args.push("--tools", resolveWorkerTools(options.tools).join(","));
337
395
 
338
396
  let tempDir: string | undefined;
339
397
  const cleanupTempDir = () => {
@@ -378,6 +436,8 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
378
436
  liveThinking: truncate(streamState.thinking, OUTPUT_CAP),
379
437
  turns: Math.max(0, streamState.turns - (baselines.get(options.workerName) ?? 0)),
380
438
  finalResponse: streamState.finalResponse,
439
+ modelOutputSeen: streamState.modelOutputSeen,
440
+ usage: streamState.usage,
381
441
  });
382
442
 
383
443
  child.stdout?.on("data", (chunk: Buffer) => {
package/src/state.ts CHANGED
@@ -7,29 +7,34 @@
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";
18
+ import { nonEmpty } from "@fradser/pi-kit";
16
19
 
17
20
  export const MAX_LEADER_MAILBOX_MESSAGES = 4096;
18
21
  /** FIFO cap of remembered peer message ids per inbox (dedup guard). */
19
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;
20
25
  export const MAX_TASK_DEPENDENCIES = 32;
21
26
 
22
27
  function emptyState(): TeamState {
23
28
  return {
24
29
  teammates: {},
25
- tasks: {},
30
+ tasks: emptyTaskMap(),
26
31
  leaderMailbox: [],
27
32
  messageCounter: 0,
28
- taskCounter: 0,
29
33
  workerEventOffsets: {},
30
34
  workerEventIds: {},
31
35
  peerInboxOffsets: {},
32
36
  peerDeliveredIds: {},
37
+ peerDeliveryStates: {},
33
38
  };
34
39
  }
35
40
 
@@ -58,11 +63,30 @@ function nextMessageId(): string {
58
63
  return `msg_${++state.messageCounter}`;
59
64
  }
60
65
 
66
+ /** Task maps must not inherit Object.prototype: slug ids like "constructor"
67
+ * are legal task ids and must never alias inherited properties. */
68
+ function emptyTaskMap(): Record<string, BoardTask> {
69
+ return Object.create(null);
70
+ }
71
+
61
72
  export function resetState(): void {
62
73
  state = emptyState();
63
74
  markStateDirty();
64
75
  }
65
76
 
77
+ // ── Team default model ──────────────────────────────────────
78
+
79
+ /** The unified teammate model for this session, or undefined when Pi picks. */
80
+ export function getTeamDefaultModel(): string | undefined {
81
+ return state.defaultModel;
82
+ }
83
+
84
+ /** Set (or clear with undefined) the unified teammate model for later spawns. */
85
+ export function setTeamDefaultModel(ref: string | undefined): void {
86
+ state.defaultModel = nonEmpty(ref);
87
+ markStateDirty();
88
+ }
89
+
66
90
  // ── Roster queries ────────────────────────────────────────────────
67
91
 
68
92
  const NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
@@ -117,10 +141,26 @@ export function updateTeammate(name: string, patch: Partial<Teammate>): Teammate
117
141
  }
118
142
 
119
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
+
120
156
  export function updateTeammateProgress(
121
157
  name: string,
122
158
  spawnId: string,
123
- progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & { sequenceEnded?: boolean },
159
+ progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & {
160
+ sequenceEnded?: boolean;
161
+ modelOutputSeen?: boolean;
162
+ usage?: WorkerUsage;
163
+ },
124
164
  ): boolean {
125
165
  const teammate = state.teammates[name];
126
166
  if (!teammate || teammate.spawnId !== spawnId) return false;
@@ -129,6 +169,8 @@ export function updateTeammateProgress(
129
169
  teammate.liveThinking = progress.liveThinking;
130
170
  teammate.turns = progress.turns;
131
171
  if (progress.sequenceEnded !== undefined) teammate.sequenceEnded = progress.sequenceEnded;
172
+ if (progress.modelOutputSeen) teammate.modelOutputSeen = true;
173
+ if (progress.usage) teammate.usage = progress.usage;
132
174
  if (teammate.status === "starting") {
133
175
  teammate.status = progress.sequenceEnded ? "idle" : "working";
134
176
  }
@@ -160,7 +202,7 @@ export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">):
160
202
  }
161
203
 
162
204
  /** Apply a validated report event exactly once by event id. */
163
- export function receiveWorkerMessage(event: WorkerReportEvent): boolean {
205
+ export function receiveWorkerMessage(event: WorkerReportEvent, options?: { archived?: boolean }): boolean {
164
206
  const sender = getTeammate(event.worker);
165
207
  if (!sender || sender.spawnId !== event.spawnId) return false;
166
208
  if (state.leaderMailbox.some((message) => message.id === event.id)) return false;
@@ -170,7 +212,10 @@ export function receiveWorkerMessage(event: WorkerReportEvent): boolean {
170
212
  subject: messageTitle(event.body),
171
213
  body: event.body,
172
214
  status: event.status,
173
- timestamp: Date.now(),
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(),
174
219
  });
175
220
  if (state.leaderMailbox.length > MAX_LEADER_MAILBOX_MESSAGES) {
176
221
  state.leaderMailbox.splice(0, state.leaderMailbox.length - MAX_LEADER_MAILBOX_MESSAGES);
@@ -202,37 +247,199 @@ export function setPeerInboxOffset(inboxName: string, offset: number): void {
202
247
  markStateDirty();
203
248
  }
204
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
+
205
266
  // ── Board: creation and queries ───────────────────────────────────
206
267
 
268
+ /** Maximum characters in a generated slug task id (suffix included). */
269
+ const MAX_TASK_ID_LENGTH = 48;
270
+
271
+ /** A readable, filesystem-safe id derived from the subject:
272
+ * "Polish login flow" -> "polish-login-flow"; duplicates get -2, -3, ... */
273
+ export function taskIdFromSubject(subject: string, taken: ReadonlySet<string>): string {
274
+ const base = subject
275
+ .toLowerCase()
276
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
277
+ .replace(/^-+|-+$/g, "")
278
+ .slice(0, MAX_TASK_ID_LENGTH)
279
+ .replace(/-+$/g, "") || "task";
280
+ let id = base;
281
+ for (let n = 2; taken.has(id); n++) {
282
+ const suffix = `-${n}`;
283
+ id = base.slice(0, Math.max(1, MAX_TASK_ID_LENGTH - suffix.length)).replace(/-+$/g, "") + suffix;
284
+ }
285
+ return id;
286
+ }
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
+
207
359
  export function createTask(input: {
208
360
  subject: string;
209
361
  description?: string;
210
362
  dependsOn?: string[];
211
363
  verify?: string;
212
- }): { ok: true; task: BoardTask } | { ok: false; error: string } {
364
+ resources?: string[];
365
+ supersedes?: string[];
366
+ }): { ok: true; task: BoardTask; superseded: BoardTask[] } | { ok: false; error: string } {
213
367
  const subject = input.subject.trim();
214
368
  if (!subject) return { ok: false, error: "Task subject must not be empty." };
215
- const dependsOn = [...new Set(input.dependsOn ?? [])];
216
- if (dependsOn.length > MAX_TASK_DEPENDENCIES) {
369
+ const requestedDependencies = [...new Set(input.dependsOn ?? [])];
370
+ const requestedSupersedes = [...new Set(input.supersedes ?? [])];
371
+ if (requestedDependencies.length > MAX_TASK_DEPENDENCIES) {
217
372
  return { ok: false, error: `Task depends on more than ${MAX_TASK_DEPENDENCIES} tasks.` };
218
373
  }
219
- for (const dep of dependsOn) {
220
- if (!state.tasks[dep]) return { ok: false, error: `Task depends on unknown task "${dep}".` };
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}".` };
391
+ }
392
+
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
+ }
221
401
  }
222
- const id = `t_${++state.taskCounter}`;
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
+
223
411
  const task: BoardTask = {
224
412
  id,
225
413
  subject,
226
414
  description: input.description?.trim() || undefined,
227
415
  dependsOn,
228
416
  verify: input.verify?.trim() || undefined,
417
+ resources: normalizeResources(input.resources),
229
418
  status: "pending",
230
419
  createdAt: Date.now(),
231
420
  updatedAt: Date.now(),
232
421
  };
233
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
+ }
234
441
  markStateDirty();
235
- return { ok: true, task };
442
+ return { ok: true, task, superseded };
236
443
  }
237
444
 
238
445
  export function getTask(taskId: string): BoardTask | undefined {
@@ -252,7 +459,7 @@ export function claimableTasks(): BoardTask[] {
252
459
  }
253
460
 
254
461
  export function taskDependenciesMet(task: BoardTask): boolean {
255
- return task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
462
+ return task.status === "pending" && task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
256
463
  }
257
464
 
258
465
  /** First claimable task, or undefined. */
@@ -262,22 +469,29 @@ export function firstClaimableTask(): BoardTask | undefined {
262
469
 
263
470
  export function setTaskClaimed(taskId: string, workerName: string): BoardTask | undefined {
264
471
  const task = state.tasks[taskId];
265
- if (!task || task.status !== "pending" || !taskDependenciesMet(task)) return undefined;
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;
266
476
  task.status = "claimed";
267
477
  task.claimedBy = workerName;
268
478
  task.updatedAt = Date.now();
479
+ assignTeammate(workerName, { id: task.id, kind: "board", resources: task.resources }, task.id);
269
480
  markStateDirty();
270
481
  return task;
271
482
  }
272
483
 
273
- /** Release a claimed task back to pending (holder stopped or failed). */
484
+ /** Release a claimed task back to pending. A superseded holder instead
485
+ * acknowledges cancellation: the task stays superseded but frees resources. */
274
486
  export function releaseTask(taskId: string, errorMessage?: string): BoardTask | undefined {
275
487
  const task = state.tasks[taskId];
276
- if (!task || task.status !== "claimed") return undefined;
277
- task.status = "pending";
488
+ if (!task || (task.status !== "claimed" && task.status !== "superseded")) return undefined;
489
+ const holder = task.claimedBy;
490
+ if (task.status === "claimed") task.status = "pending";
278
491
  task.claimedBy = undefined;
279
492
  if (errorMessage !== undefined) task.errorMessage = errorMessage;
280
493
  task.updatedAt = Date.now();
494
+ if (holder) assignTeammate(holder, undefined, undefined);
281
495
  markStateDirty();
282
496
  return task;
283
497
  }
@@ -285,11 +499,13 @@ export function releaseTask(taskId: string, errorMessage?: string): BoardTask |
285
499
  export function completeTask(taskId: string, result?: string): BoardTask | undefined {
286
500
  const task = state.tasks[taskId];
287
501
  if (!task || task.status !== "claimed") return undefined;
502
+ const holder = task.claimedBy;
288
503
  task.status = "completed";
289
504
  task.result = result;
290
505
  task.errorMessage = undefined;
291
506
  task.completedAt = Date.now();
292
507
  task.updatedAt = Date.now();
508
+ if (holder) assignTeammate(holder, undefined, undefined);
293
509
  markStateDirty();
294
510
  return task;
295
511
  }
@@ -298,7 +514,7 @@ export function completeTask(taskId: string, result?: string): BoardTask | undef
298
514
  export function releaseTasksOf(workerName: string, reason: string): BoardTask[] {
299
515
  const released: BoardTask[] = [];
300
516
  for (const task of listTasks()) {
301
- if (task.status === "claimed" && task.claimedBy === workerName) {
517
+ if ((task.status === "claimed" || task.status === "superseded") && task.claimedBy === workerName) {
302
518
  releaseTask(task.id, reason);
303
519
  released.push(task);
304
520
  }
@@ -309,22 +525,44 @@ export function releaseTasksOf(workerName: string, reason: string): BoardTask[]
309
525
  /** Apply a validated claim intent from a marker file. */
310
526
  export function applyClaimIntent(intent: TaskIntent): { applied: boolean; reason?: string } {
311
527
  const task = state.tasks[intent.taskId];
528
+ const teammate = getTeammate(intent.worker);
312
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
+ }
313
537
  if (task.status === "claimed") return { applied: false, reason: `task "${intent.taskId}" is already claimed` };
314
- if (task.status === "completed") return { applied: false, reason: `task "${intent.taskId}" is completed` };
538
+ if (task.status === "completed" || task.status === "superseded") return { applied: false, reason: `task "${intent.taskId}" is ${task.status}` };
315
539
  if (!taskDependenciesMet(task)) return { applied: false, reason: `task "${intent.taskId}" has unmet dependencies` };
316
- setTaskClaimed(intent.taskId, intent.worker);
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` };
317
543
  return { applied: true };
318
544
  }
319
545
 
320
546
  /** Validate and apply a submission intent; verify gating happens in the caller. */
321
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
+ }
322
555
  const task = state.tasks[intent.taskId];
323
556
  if (!task) return { ok: false, error: `unknown task "${intent.taskId}"` };
324
- if (task.status !== "claimed") return { ok: false, error: `task "${intent.taskId}" is not claimed` };
557
+ if (task.status !== "claimed" && task.status !== "superseded") {
558
+ return { ok: false, error: `task "${intent.taskId}" is not claimed` };
559
+ }
325
560
  if (task.claimedBy !== intent.worker) {
326
561
  return { ok: false, error: `task "${intent.taskId}" is claimed by ${task.claimedBy ?? "someone else"}` };
327
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
+ }
328
566
  if (intent.status === "failed") {
329
567
  releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
330
568
  return { ok: true };
@@ -343,15 +581,17 @@ export function getState(): TeamState {
343
581
  export function loadBoard(tasks: Record<string, BoardTask>): number {
344
582
  let reloaded = 0;
345
583
  for (const task of Object.values(tasks)) {
584
+ const orphanedHolding = task.status === "claimed" || task.status === "superseded";
346
585
  const restored: BoardTask = {
347
586
  ...task,
587
+ resources: normalizeResources(task.resources),
348
588
  status: task.status === "claimed" ? "pending" : task.status,
349
- claimedBy: task.status === "claimed" ? undefined : task.claimedBy,
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,
350
592
  updatedAt: Date.now(),
351
593
  };
352
594
  state.tasks[restored.id] = restored;
353
- const numeric = Number(restored.id.slice(2));
354
- if (Number.isFinite(numeric)) state.taskCounter = Math.max(state.taskCounter, numeric);
355
595
  reloaded++;
356
596
  }
357
597
  if (reloaded > 0) markStateDirty();
@@ -361,9 +601,9 @@ export function loadBoard(tasks: Record<string, BoardTask>): number {
361
601
  export function getSummary(): string | undefined {
362
602
  const alive = livingTeammates();
363
603
  if (alive.length === 0 && listTasks().length === 0) return undefined;
364
- const counts = { pending: 0, claimed: 0, completed: 0 };
604
+ const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
365
605
  for (const task of listTasks()) {
366
606
  if (task.status in counts) counts[task.status as keyof typeof counts]++;
367
607
  }
368
- 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`;
369
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 (missing taskId/worker/spawnId)` };
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 {