@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/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"> & { sequenceEnded?: boolean },
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
- 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(),
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
- }): { 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 } {
252
367
  const subject = input.subject.trim();
253
368
  if (!subject) return { ok: false, error: "Task subject must not be empty." };
254
- const dependsOn = [...new Set(input.dependsOn ?? [])];
255
- 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) {
256
372
  return { ok: false, error: `Task depends on more than ${MAX_TASK_DEPENDENCIES} tasks.` };
257
373
  }
258
- for (const dep of dependsOn) {
259
- 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}".` };
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
- 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;
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 (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. */
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
- 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";
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 completed` };
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
- 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` };
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") 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
+ }
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
- 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,
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 (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 {