@fradser/pi-agent-teams 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- markStateDirty();
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
- markStateDirty();
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
- markStateDirty();
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
- markStateDirty();
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
- markStateDirty();
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
- markStateDirty();
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
- if (state.leaderMailbox.length > MAX_LEADER_MAILBOX_MESSAGES) {
198
- state.leaderMailbox.splice(0, state.leaderMailbox.length - MAX_LEADER_MAILBOX_MESSAGES);
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
- if (state.leaderMailbox.length > MAX_LEADER_MAILBOX_MESSAGES) {
221
- state.leaderMailbox.splice(0, state.leaderMailbox.length - MAX_LEADER_MAILBOX_MESSAGES);
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
- markStateDirty();
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
- markStateDirty();
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
- markStateDirty();
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
- return assignment !== undefined && !assignment.closed && resourcesConflict(resources, assignment.resources);
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
- /** First claimable task, or undefined. */
466
- export function firstClaimableTask(): BoardTask | undefined {
467
- return claimableTasks()[0];
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
- assignTeammate(workerName, { id: task.id, kind: "board", resources: task.resources }, task.id);
480
- markStateDirty();
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() || "Teammate reported failure.");
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) markStateDirty();
598
- return reloaded;
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 `${alive.length} teammate(s) alive | board: ${counts.pending} pending / ${counts.claimed} claimed / ${counts.completed} completed / ${counts.superseded} superseded`;
789
+ return reloaded;
609
790
  }