@fradser/pi-agent-teams 0.10.0 → 1.0.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
@@ -1,23 +1,46 @@
1
+ import { isTaskReplaced } from "@fradser/pi-tasks";
1
2
  /**
2
3
  * Team state management for the current session only: the teammate roster,
3
- * the in-memory task board, and the single leader inbox. The leader process
4
- * is the sole writer of this snapshot and of board.json.
4
+ * the in-memory task storage, and the single leader inbox. The leader process
5
+ * is the sole writer of this snapshot and of tasks.json.
5
6
  */
6
7
 
7
- import { randomUUID } from "node:crypto";
8
+ import { nonEmpty } from "@fradser/pi-kit";
8
9
  import {
9
10
  messageTitle,
10
11
  TEAM_RUNTIME_VERSION,
11
- type BoardTask,
12
- type WorkerAssignment,
13
12
  type MailboxMessage,
14
- type TaskIntent,
15
- type Teammate,
16
13
  type TeamState,
17
14
  type WorkerReportEvent,
18
- type WorkerUsage,
19
15
  } from "./types.ts";
20
- import { nonEmpty } from "@fradser/pi-kit";
16
+ // The roster — one child process per entry, its lifecycle, and the revisions that
17
+ // say when it changed — is an execution fact, so it lives in @fradser/pi-subagents
18
+ // with the spawner that creates those processes. This module keeps the snapshot,
19
+ // the leader inbox, and the tasks callback, and points `agents` at the roster's
20
+ // own map by identity so no snapshot schema changes and no version bump is needed.
21
+ import {
22
+ assignAgent,
23
+ getAgent,
24
+ livingAgents,
25
+ agents as roster,
26
+ progressRevision as rosterProgressRevision,
27
+ resetRoster,
28
+ rosterRevision as currentRosterRevision,
29
+ type Agent,
30
+ } from "@fradser/pi-subagents";
31
+ // The Work Item graph and resource rules are pure and live in @fradser/pi-tasks, so the
32
+ // leader, the worker, and any tasks-only consumer share one implementation.
33
+ // `activeAssignmentConflict` below is the roster-backed policy on top of
34
+ // `resourcesConflict`, and stays here because it reads the roster.
35
+ import {
36
+ bindTask,
37
+ configureTaskStore,
38
+ createTask,
39
+ discardTask,
40
+ resourcesConflict,
41
+ tasks as storedTasks,
42
+ type WorkAssignment as WorkerAssignment,
43
+ } from "@fradser/pi-tasks";
21
44
 
22
45
  export const MAX_LEADER_MAILBOX_MESSAGES = 4096;
23
46
  /** Total mailbox body bytes retained for forensics. A count cap alone lets a few
@@ -27,13 +50,12 @@ export const MAX_LEADER_MAILBOX_BYTES = 8 * 1024 * 1024;
27
50
  export const MAX_PEER_DELIVERED_IDS = 512;
28
51
  /** Bounded forensic routing history; mailbox files retain the full peer transcript. */
29
52
  export const MAX_PEER_DELIVERY_STATES = 4096;
30
- export const MAX_TASK_DEPENDENCIES = 32;
31
53
 
32
54
  function emptyState(): TeamState {
33
55
  return {
34
56
  runtimeVersion: TEAM_RUNTIME_VERSION,
35
- teammates: {},
36
- tasks: emptyTaskMap(),
57
+ agents: roster,
58
+ tasks: storedTasks,
37
59
  leaderMailbox: [],
38
60
  messageCounter: 0,
39
61
  workerEventOffsets: {},
@@ -50,198 +72,98 @@ function nextMessageId(): string {
50
72
  return `msg_${++state.messageCounter}`;
51
73
  }
52
74
 
53
- /** Task maps must not inherit Object.prototype: slug ids like "constructor"
54
- * are legal task ids and must never alias inherited properties. */
55
- function emptyTaskMap(): Record<string, BoardTask> {
56
- return Object.create(null);
57
- }
58
-
59
75
  export function resetState(): void {
76
+ for (const id of Object.keys(storedTasks)) delete storedTasks[id];
77
+ resetRoster();
60
78
  state = emptyState();
61
- rosterRevisionCounter++;
62
- boardRevisionCounter++;
79
+ tasksRevisionCounter++;
63
80
  stateRevisionCounter++;
64
81
  }
65
82
 
66
83
  // ── Persistence revisions ────────────────────────────────────────
67
84
  // state.ts owns every in-memory mutation, so it is the one place that can say
68
85
  // exactly which artifact changed. Stream progress is counted separately: a
69
- // running teammate must not force the roster, the board, or the debug snapshot
86
+ // running teammate must not force the roster, the tasks, or the debug snapshot
70
87
  // to be rewritten on every token. Every persisted mutation in this module bumps
71
88
  // one of these counters, so a mutation that forgets to bump would not be written
72
89
  // until something else changed — add the bump with any new mutator.
73
90
  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. */
91
+ let tasksRevisionCounter = 0;
92
+
93
+ /** Revision of the whole snapshot: roster, tasks, mailbox, and offsets.
94
+ *
95
+ * Composed rather than single-sourced, because the roster now has its own counter
96
+ * in another package. Every input is monotonic, so the sum changes whenever any
97
+ * of them does, which is the only property the snapshot writer relies on: it
98
+ * compares against the last written value and rewrites on inequality. */
79
99
  export function stateRevision(): number {
80
- return stateRevisionCounter;
100
+ return stateRevisionCounter + currentRosterRevision() + tasksRevisionCounter;
81
101
  }
82
102
 
83
- /** Revision of the worker-readable roster. */
84
- export function rosterRevision(): number {
85
- return rosterRevisionCounter;
86
- }
103
+ /** Revision of the worker-readable roster, owned by @fradser/pi-subagents. */
104
+ export { currentRosterRevision as rosterRevision };
87
105
 
88
- /** Revision of the persisted board. */
89
- export function boardRevision(): number {
90
- return boardRevisionCounter;
106
+ /** Revision of the persisted tasks. */
107
+ export function tasksRevision(): number {
108
+ return tasksRevisionCounter;
91
109
  }
92
110
 
93
111
  /** Revision of streamed progress only; never persisted on its own. */
94
- export function progressRevision(): number {
95
- return progressRevisionCounter;
96
- }
112
+ export { rosterProgressRevision as progressRevision };
97
113
 
98
- /** Record a board change made outside this module's task mutators. */
99
- export function markBoardChanged(): void {
100
- boardRevisionCounter++;
114
+ /** Record a task set change made outside this module's task mutators. */
115
+ export function markTasksChanged(): void {
116
+ tasksRevisionCounter++;
101
117
  stateRevisionCounter++;
102
118
  }
103
119
 
104
- // ── Team default model ──────────────────────────────────────
120
+ /** Drop per-spawn replay metadata once its final snapshot was persisted.
121
+ * Snapshot bookkeeping rather than roster state, so it stays here even though
122
+ * the roster itself moved. */
123
+ export function clearWorkerRunEvents(workerName: string, spawnId: string): void {
124
+ const outboxKey = `${workerName}:${spawnId}`;
125
+ delete state.workerEventOffsets[outboxKey];
126
+ for (const id of Object.keys(state.workerEventIds)) {
127
+ if (id.startsWith(`${spawnId}:`)) delete state.workerEventIds[id];
128
+ }
129
+ }
130
+
131
+ // ── Session settings ─────────────────────────────────────────────
105
132
 
106
- /** The unified teammate model for this session, or undefined when Pi picks. */
133
+ /** The unified model for later spawns, or undefined when Pi picks.
134
+ *
135
+ * Deliberately session state rather than roster state: it is a setting the
136
+ * snapshot persists for forensics and resume, so moving it with the roster would
137
+ * have given the roster a second source of truth that no snapshot records. The
138
+ * spawner reads it through the re-export below. */
107
139
  export function getTeamDefaultModel(): string | undefined {
108
140
  return state.defaultModel;
109
141
  }
110
142
 
111
- /** Set (or clear with undefined) the unified teammate model for later spawns. */
143
+ /** Set (or clear with undefined) the unified model for later spawns. */
112
144
  export function setTeamDefaultModel(ref: string | undefined): void {
113
145
  state.defaultModel = nonEmpty(ref);
114
146
  stateRevisionCounter++;
115
147
  }
116
148
 
117
- // ── Roster queries ────────────────────────────────────────────────
118
-
119
- const NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
120
-
121
- export function isValidTeammateName(name: string): boolean {
122
- return NAME_PATTERN.test(name);
123
- }
124
-
125
- export function getTeammate(name: string): Teammate | undefined {
126
- return state.teammates[name];
127
- }
128
-
129
- export function listTeammates(): Teammate[] {
130
- return Object.values(state.teammates).sort((a, b) => a.createdAt - b.createdAt);
131
- }
132
-
133
- export function livingTeammates(): Teammate[] {
134
- return listTeammates().filter((t) => t.status !== "stopped");
135
- }
136
-
137
- export function idleTeammates(): Teammate[] {
138
- return livingTeammates().filter((t) => t.status === "idle");
139
- }
140
-
141
- export function registerTeammate(teammate: Teammate): { ok: true } | { ok: false; error: string } {
142
- if (!isValidTeammateName(teammate.name)) {
143
- return { ok: false, error: `Invalid teammate name "${teammate.name}". Use letters, digits, dots, dashes, underscores.` };
144
- }
145
- if (livingTeammates().some((t) => t.name === teammate.name)) {
146
- return { ok: false, error: `A living teammate named "${teammate.name}" already exists.` };
147
- }
148
- state.teammates[teammate.name] = teammate;
149
- rosterRevisionCounter++;
150
- stateRevisionCounter++;
151
- return { ok: true };
152
- }
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
-
170
- export function updateTeammate(name: string, patch: Partial<Teammate>): Teammate | undefined {
171
- const teammate = state.teammates[name];
172
- if (!teammate) return undefined;
173
- const changed = (Object.keys(patch) as Array<keyof Teammate>)
174
- .filter((field) => !Object.is(teammate[field], patch[field]));
175
- Object.assign(teammate, patch, { updatedAt: Date.now() });
176
- if (patch.status === "stopped") teammate.stoppedAt = Date.now();
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
- }
184
- return teammate;
185
- }
186
-
187
- /** Merge streaming child-process progress into a living teammate. */
188
- export function assignTeammate(
189
- name: string,
190
- assignment: WorkerAssignment | undefined,
191
- currentTaskId?: string,
192
- ): Teammate | undefined {
193
- const teammate = getTeammate(name);
194
- if (!teammate) return undefined;
195
- const lastAssignment = assignment ?? teammate.assignment ?? teammate.lastAssignment;
196
- const lastTaskId = currentTaskId ?? teammate.currentTaskId ?? teammate.lastTaskId;
197
- return updateTeammate(name, { assignment, currentTaskId, lastAssignment, lastTaskId,
198
- ...(assignment && assignment.id !== teammate.assignment?.id ? { reportSequenceEnded: false } : {}),
199
- });
200
- }
201
-
202
- export function updateTeammateProgress(
203
- name: string,
204
- spawnId: string,
205
- progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & {
206
- sequenceEnded?: boolean;
207
- modelOutputSeen?: boolean;
208
- usage?: WorkerUsage;
209
- },
210
- ): boolean {
211
- const teammate = state.teammates[name];
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);
219
- teammate.liveText = progress.liveText;
220
- teammate.activeTool = progress.activeTool;
221
- teammate.liveThinking = progress.liveThinking;
222
- teammate.turns = progress.turns;
223
- if (progress.sequenceEnded !== undefined) teammate.sequenceEnded = progress.sequenceEnded;
224
- if (progress.modelOutputSeen) teammate.modelOutputSeen = true;
225
- if (progress.usage) teammate.usage = progress.usage;
226
- if (teammate.status === "starting") {
227
- teammate.status = progress.sequenceEnded ? "idle" : "working";
228
- // A status transition is roster state, not stream noise.
229
- rosterRevisionCounter++;
230
- stateRevisionCounter++;
231
- }
232
- teammate.updatedAt = Date.now();
233
- if (changed) progressRevisionCounter++;
234
- return true;
235
- }
236
-
237
- /** Drop per-spawn replay metadata once its final snapshot was persisted. */
238
- export function clearWorkerRunEvents(workerName: string, spawnId: string): void {
239
- const outboxKey = `${workerName}:${spawnId}`;
240
- delete state.workerEventOffsets[outboxKey];
241
- for (const id of Object.keys(state.workerEventIds)) {
242
- if (id.startsWith(`${spawnId}:`)) delete state.workerEventIds[id];
243
- }
244
- }
149
+ // ── Roster (owned by @fradser/pi-subagents) ───────────────────────
150
+ //
151
+ // A child process is an execution fact, so the roster lives with the spawner.
152
+ // Re-exported here because the coordination vocabulary reads this module as its
153
+ // entry point, and every existing consumer imports it from here.
154
+
155
+ export {
156
+ assignAgent,
157
+ getAgent,
158
+ idleAgents,
159
+ isValidAgentName,
160
+ listAgents,
161
+ livingAgents,
162
+ registerAgent,
163
+ updateAgent,
164
+ updateAgentProgress,
165
+ agents,
166
+ } from "@fradser/pi-subagents";
245
167
 
246
168
  /** Drop the oldest mailbox entries until both the count and byte caps hold. */
247
169
  function trimLeaderMailbox(): void {
@@ -268,7 +190,7 @@ export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">):
268
190
 
269
191
  /** Apply a validated report event exactly once by event id. */
270
192
  export function receiveWorkerMessage(event: WorkerReportEvent, options?: { archived?: boolean }): boolean {
271
- const sender = getTeammate(event.worker);
193
+ const sender = getAgent(event.worker);
272
194
  if (!sender || sender.spawnId !== event.spawnId) return false;
273
195
  if (state.leaderMailbox.some((message) => message.id === event.id)) return false;
274
196
  state.leaderMailbox.push({
@@ -328,446 +250,75 @@ export function getPeerDeliveryState(messageId: string): "queued" | "routed" | "
328
250
  return state.peerDeliveryStates?.[messageId];
329
251
  }
330
252
 
331
- // ── Board: creation and queries ───────────────────────────────────
253
+ // ── Tasks: creation and queries ───────────────────────────────────
332
254
 
333
255
  /** Maximum characters in a generated slug task id (suffix included). */
334
- const MAX_TASK_ID_LENGTH = 48;
335
-
336
- /** A readable, filesystem-safe id derived from the subject:
337
- * "Polish login flow" -> "polish-login-flow"; duplicates get -2, -3, ... */
338
- export function taskIdFromSubject(subject: string, taken: ReadonlySet<string>): string {
339
- const base = subject
340
- .toLowerCase()
341
- .replace(/[^\p{L}\p{N}]+/gu, "-")
342
- .replace(/^-+|-+$/g, "")
343
- .slice(0, MAX_TASK_ID_LENGTH)
344
- .replace(/-+$/g, "") || "task";
345
- let id = base;
346
- for (let n = 2; taken.has(id); n++) {
347
- const suffix = `-${n}`;
348
- id = base.slice(0, Math.max(1, MAX_TASK_ID_LENGTH - suffix.length)).replace(/-+$/g, "") + suffix;
349
- }
350
- return id;
351
- }
352
-
353
- export function normalizeResources(resources: readonly string[] | undefined): string[] {
354
- return [...new Set((resources ?? [])
355
- .map((resource) => resource.trim().replace(/^\/+|\/+$/g, ""))
356
- .filter(Boolean))]
357
- .sort();
358
- }
359
-
360
- /** Follow an obsolete dependency to its latest replacement. An invalid persisted
361
- * chain is rejected rather than silently making a future task unclaimable. */
362
- export function canonicalDependency(taskId: string, tasks: Record<string, BoardTask>): string | undefined {
363
- const seen = new Set<string>();
364
- let current = taskId;
365
- while (true) {
366
- if (seen.has(current)) return undefined;
367
- seen.add(current);
368
- const task = tasks[current];
369
- if (!task) return undefined;
370
- if (task.status !== "superseded") return current;
371
- if (!task.supersededBy) return undefined;
372
- current = task.supersededBy;
373
- }
374
- }
375
-
376
- function canonicalDependencies(
377
- dependencies: readonly string[],
378
- tasks: Record<string, BoardTask>,
379
- ): string[] | undefined {
380
- const canonical: string[] = [];
381
- for (const dependency of dependencies) {
382
- const resolved = canonicalDependency(dependency, tasks);
383
- if (!resolved) return undefined;
384
- if (!canonical.includes(resolved)) canonical.push(resolved);
385
- }
386
- return canonical;
387
- }
388
-
389
- function hasDependencyCycle(graph: Map<string, readonly string[]>): boolean {
390
- const visiting = new Set<string>();
391
- const visited = new Set<string>();
392
- const visit = (taskId: string): boolean => {
393
- if (visiting.has(taskId)) return true;
394
- if (visited.has(taskId)) return false;
395
- visiting.add(taskId);
396
- for (const dependency of graph.get(taskId) ?? []) {
397
- if (graph.has(dependency) && visit(dependency)) return true;
398
- }
399
- visiting.delete(taskId);
400
- visited.add(taskId);
401
- return false;
402
- };
403
- return [...graph.keys()].some(visit);
404
- }
405
-
406
- /** `firmware/sub-node` conflicts with itself and descendants such as
407
- * `firmware/sub-node/app`; unrelated siblings stay concurrently claimable. */
408
- export function resourcesConflict(left: readonly string[], right: readonly string[]): boolean {
409
- return left.some((a) => right.some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
410
- }
411
-
412
256
  export function activeAssignmentConflict(
413
257
  resources: readonly string[],
414
258
  exceptWorker?: string,
415
- ): Teammate | undefined {
259
+ ): Agent | undefined {
416
260
  if (resources.length === 0) return undefined;
417
- return livingTeammates().find((teammate) => {
261
+ return livingAgents().find((teammate) => {
418
262
  if (teammate.name === exceptWorker) return false;
419
263
  const assignment = teammate.assignment;
420
264
  const retainsWorkAuthority = teammate.currentTaskId !== undefined
421
- && ["claimed", "superseded"].includes(state.tasks[teammate.currentTaskId]?.status ?? "");
265
+ && (state.tasks[teammate.currentTaskId]?.status === "in_progress" || isTaskReplaced(state.tasks[teammate.currentTaskId]));
422
266
  return assignment !== undefined
423
267
  && (!assignment.closed || retainsWorkAuthority)
424
268
  && resourcesConflict(resources, assignment.resources);
425
269
  });
426
270
  }
427
271
 
428
- export function createTask(input: {
429
- id?: string;
430
- subject: string;
431
- description?: string;
432
- dependsOn?: string[];
433
- verify?: string;
434
- resources?: string[];
435
- supersedes?: string[];
436
- }): { ok: true; task: BoardTask; superseded: BoardTask[] } | { ok: false; error: string } {
437
- const subject = input.subject.trim();
438
- if (!subject) return { ok: false, error: "Task subject must not be empty." };
439
- const requestedDependencies = [...new Set(input.dependsOn ?? [])];
440
- const requestedSupersedes = [...new Set(input.supersedes ?? [])];
441
- if (requestedDependencies.length > MAX_TASK_DEPENDENCIES) {
442
- return { ok: false, error: `Task depends on more than ${MAX_TASK_DEPENDENCIES} tasks.` };
443
- }
444
- for (const taskId of [...requestedDependencies, ...requestedSupersedes]) {
445
- if (!state.tasks[taskId]) return { ok: false, error: `Task references unknown task "${taskId}".` };
446
- }
447
- const completedTarget = requestedSupersedes.find((taskId) => state.tasks[taskId]?.status === "completed");
448
- if (completedTarget) {
449
- return { ok: false, error: `Task cannot supersede completed task "${completedTarget}".` };
450
- }
451
- const dependsOn = canonicalDependencies(requestedDependencies, state.tasks);
452
- const supersedes = canonicalDependencies(requestedSupersedes, state.tasks);
453
- if (!dependsOn || !supersedes) return { ok: false, error: "Task dependency or supersession chain is invalid or cyclic." };
454
- const canonicalCompletedTarget = supersedes.find((taskId) => state.tasks[taskId]?.status === "completed");
455
- if (canonicalCompletedTarget) {
456
- return { ok: false, error: `Task cannot supersede completed task "${canonicalCompletedTarget}".` };
457
- }
458
- const selfReplacement = supersedes.find((taskId) => dependsOn.includes(taskId));
459
- if (selfReplacement) {
460
- return { ok: false, error: `Task cannot both depend on and supersede "${selfReplacement}".` };
461
- }
462
-
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.` };
465
- const migrations = new Map<string, string[]>();
466
- for (const dependent of Object.values(state.tasks)) {
467
- if (dependent.status === "completed" || dependent.status === "superseded") continue;
468
- const migrated = dependent.dependsOn.map((dependency) => supersedes.includes(dependency) ? id : dependency);
469
- if (migrated.some((dependency, index) => dependency !== dependent.dependsOn[index])) {
470
- migrations.set(dependent.id, [...new Set(migrated)]);
471
- }
472
- }
473
- const prospectiveGraph = new Map<string, readonly string[]>();
474
- for (const existing of Object.values(state.tasks)) {
475
- prospectiveGraph.set(existing.id, migrations.get(existing.id) ?? existing.dependsOn);
476
- }
477
- prospectiveGraph.set(id, dependsOn);
478
- if (hasDependencyCycle(prospectiveGraph)) {
479
- return { ok: false, error: "Task supersession would create a dependency cycle." };
480
- }
481
-
482
- const task: BoardTask = {
483
- id,
484
- subject,
485
- description: input.description?.trim() || undefined,
486
- dependsOn,
487
- verify: input.verify?.trim() || undefined,
488
- resources: normalizeResources(input.resources),
489
- status: "pending",
490
- createdAt: Date.now(),
491
- updatedAt: Date.now(),
492
- };
493
- state.tasks[id] = task;
494
- boardRevisionCounter++;
495
- stateRevisionCounter++;
496
- const superseded: BoardTask[] = [];
497
- for (const oldId of supersedes) {
498
- const old = state.tasks[oldId];
499
- if (!old || old.status === "completed" || old.status === "superseded") continue;
500
- old.status = "superseded";
501
- old.supersededBy = id;
502
- // A living holder keeps the claim and assignment until it acknowledges
503
- // cancellation or stops. Releasing its resource before it receives the
504
- // stop instruction would let replacement work race active file writes.
505
- old.updatedAt = Date.now();
506
- superseded.push(old);
507
- }
508
- for (const [dependentId, migratedDependencies] of migrations) {
509
- const dependent = state.tasks[dependentId];
510
- if (!dependent) continue;
511
- dependent.dependsOn = migratedDependencies;
512
- dependent.updatedAt = Date.now();
513
- }
514
- return { ok: true, task, superseded };
515
- }
516
-
517
- export function getTask(taskId: string): BoardTask | undefined {
518
- return state.tasks[taskId];
519
- }
520
-
521
- export function listTasks(): BoardTask[] {
522
- return Object.values(state.tasks).sort((a, b) => a.id.localeCompare(b.id));
523
- }
524
-
525
- export function pendingTasks(): BoardTask[] {
526
- return listTasks().filter((task) => task.status === "pending");
527
- }
528
-
529
- export function claimableTasks(): BoardTask[] {
530
- return pendingTasks().filter((task) => !task.recoveryRequired && taskDependenciesMet(task));
531
- }
532
-
533
- export function taskDependenciesMet(task: BoardTask): boolean {
534
- return task.status === "pending" && task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
535
- }
536
-
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.recoveryRequired = undefined;
606
- task.completedAt = undefined;
607
- task.updatedAt = Date.now();
608
- boardRevisionCounter++;
609
- stateRevisionCounter++;
610
- assignTeammate(workerName, assignment, task.id);
611
- return { ok: true };
612
- }
613
-
614
- export function setTaskClaimed(taskId: string, workerName: string): BoardTask | undefined {
615
- const task = state.tasks[taskId];
616
- const teammate = getTeammate(workerName);
617
- if (!task || !teammate || task.status !== "pending" || task.recoveryRequired || !taskDependenciesMet(task)) return undefined;
618
- if (teammate.assignment) return undefined;
619
- if (activeAssignmentConflict(task.resources, workerName)) return undefined;
620
- task.status = "claimed";
621
- task.claimedBy = workerName;
622
- task.updatedAt = Date.now();
623
- boardRevisionCounter++;
624
- stateRevisionCounter++;
625
- assignTeammate(workerName, { id: `board:${randomUUID()}`, kind: "board", resources: task.resources }, task.id);
626
- return task;
627
- }
628
-
629
- /** Release a claimed task back to pending. A superseded holder instead
630
- * acknowledges cancellation: the task stays superseded but frees resources.
631
- * `recoveryRequired` marks an attempt that ran and did not succeed (failure,
632
- * crash, or stop), so it waits for a deliberate Leader assignment instead of
633
- * being re-offered. A harness start failure that happened before the attempt
634
- * reached the worker stays ordinarily claimable; a deliberate Leader release
635
- * passes `false` for the same reason. */
636
- export function claimedDependents(workId: string): BoardTask[] {
637
- const reverse = new Map<string, string[]>();
638
- for (const task of Object.values(state.tasks)) {
639
- for (const dependency of task.dependsOn) {
640
- const dependents = reverse.get(dependency) ?? [];
641
- dependents.push(task.id);
642
- reverse.set(dependency, dependents);
643
- }
644
- }
645
- const visited = new Set<string>();
646
- const pending = [...(reverse.get(workId) ?? [])];
647
- const claimed: BoardTask[] = [];
648
- while (pending.length > 0) {
649
- const id = pending.shift()!;
650
- if (visited.has(id)) continue;
651
- visited.add(id);
652
- const task = state.tasks[id];
653
- if (!task) continue;
654
- if (task.status === "claimed" || (task.status === "superseded" && task.claimedBy !== undefined)) claimed.push(task);
655
- pending.push(...(reverse.get(id) ?? []));
656
- }
657
- return claimed.sort((left, right) => left.id.localeCompare(right.id));
658
- }
659
-
660
- export function reopenCompletedWork(workId: string): { ok: true; task: BoardTask } | { ok: false; error: string } {
661
- const task = state.tasks[workId];
662
- if (!task || task.status !== "completed") return { ok: false, error: `Work Item "${workId}" is not completed.` };
663
- const blockers = claimedDependents(workId);
664
- if (blockers.length > 0) return { ok: false, error: `Work Item "${workId}" has active dependent Work: ${blockers.map((blocker) => blocker.id).join(", ")}.` };
665
- task.status = "pending";
666
- task.claimedBy = undefined;
667
- task.result = undefined;
668
- task.deferredMessages = undefined;
669
- task.errorMessage = undefined;
670
- task.recoveryRequired = undefined;
671
- task.completedAt = undefined;
672
- task.updatedAt = Date.now();
673
- boardRevisionCounter++;
674
- stateRevisionCounter++;
675
- return { ok: true, task };
676
- }
272
+ // ── Tasks (delegated to pi-tasks) ────────────────────────────────
273
+ //
274
+ // There is deliberately no `release` here. A lease is given back on completion,
275
+ // on process exit, and on stop; the runtime's own half-start reverts go through
276
+ // `revertUndeliveredTake`, and a holder that must free a lease it is not giving
277
+ // up uses `acknowledgeSupersession`. Those name what happened instead of offering
278
+ // one verb that can take any task from anyone.
279
+ export {
280
+ createTask, getTask, listTasks, pendingTasks, claimableTasks, taskDependenciesMet,
281
+ takeTask, claimedDependents, reopenTask, failTasksHeldBy,
282
+ applyClaimIntent, revertUndeliveredTake, bindTask, discardTask,
283
+ acknowledgeSupersession, completeTaskWithOutcome,
284
+ applySubmissionIntent, loadTasks, setTaskContext,
285
+ } from "@fradser/pi-tasks";
286
+ export type { WorkAssignment as WorkerAssignment } from "@fradser/pi-tasks";
677
287
 
678
- export function releaseTask(taskId: string, errorMessage: string | undefined, recoveryRequired: boolean): BoardTask | undefined {
679
- const task = state.tasks[taskId];
680
- if (!task || (task.status !== "claimed" && task.status !== "superseded")) return undefined;
681
- const holder = task.claimedBy;
682
- if (task.status === "claimed") task.status = "pending";
683
- task.claimedBy = undefined;
684
- task.recoveryRequired = task.status === "pending" && recoveryRequired ? true : undefined;
685
- if (errorMessage !== undefined) task.errorMessage = errorMessage;
686
- task.updatedAt = Date.now();
687
- boardRevisionCounter++;
688
- stateRevisionCounter++;
689
- if (holder) assignTeammate(holder, undefined, undefined);
690
- return task;
691
- }
692
-
693
- export function completeTask(taskId: string, result?: string): BoardTask | undefined {
694
- const task = state.tasks[taskId];
695
- if (!task || task.status !== "claimed") return undefined;
696
- const holder = task.claimedBy;
697
- task.status = "completed";
698
- task.claimedBy = undefined;
699
- task.result = result;
700
- task.errorMessage = undefined;
701
- task.recoveryRequired = undefined;
702
- task.completedAt = Date.now();
703
- task.updatedAt = Date.now();
704
- boardRevisionCounter++;
705
- stateRevisionCounter++;
706
- if (holder) assignTeammate(holder, undefined, undefined);
707
- return task;
708
- }
709
-
710
- /** Release every task held by a named teammate (crash or shutdown). */
711
- export function releaseTasksOf(workerName: string, reason: string): BoardTask[] {
712
- const released: BoardTask[] = [];
713
- for (const task of listTasks()) {
714
- if ((task.status === "claimed" || task.status === "superseded") && task.claimedBy === workerName) {
715
- releaseTask(task.id, reason, true);
716
- released.push(task);
288
+ /**
289
+ * Create a Task and bind it to a named holder in one step, rolling the creation
290
+ * back if the bind is refused.
291
+ *
292
+ * Composed here rather than offered by the tasks, because it is a coordinator's
293
+ * move: the task store states what must be done and takes only what a holder chose to
294
+ * take, while this names a holder and mints its lease. The rollback matters —
295
+ * without it a refusal would leave a Task whose id resolves to nothing.
296
+ */
297
+ export function createBoundWork(input: {
298
+ id: string;
299
+ subject: string;
300
+ description?: string;
301
+ resources?: string[];
302
+ verify?: string;
303
+ holder: string;
304
+ assignment: WorkerAssignment;
305
+ }): { ok: true; task: import("@fradser/pi-tasks").Task } | { ok: false; reason: string } {
306
+ const created = createTask({
307
+ id: input.id,
308
+ recordKind: "execution",
309
+ subject: input.subject,
310
+ ...(input.description ? { description: input.description } : {}),
311
+ ...(input.resources ? { resources: input.resources } : {}),
312
+ ...(input.verify ? { verify: input.verify } : {}),
313
+ });
314
+ if (!created.ok) return { ok: false, reason: created.reason };
315
+ const bound = bindTask(input.id, input.holder, input.assignment, { from: ["pending"] });
316
+ if (!bound.ok) {
317
+ discardTask(input.id, input.holder);
318
+ return { ok: false, reason: bound.reason };
717
319
  }
320
+ return { ok: true, task: bound.task };
718
321
  }
719
- return released;
720
- }
721
-
722
- /** Apply a validated claim intent from a marker file. */
723
- export function applyClaimIntent(intent: TaskIntent): { applied: boolean; reason?: string } {
724
- const task = state.tasks[intent.taskId];
725
- const teammate = getTeammate(intent.worker);
726
- if (!task) return { applied: false, reason: `unknown task "${intent.taskId}"` };
727
- if (!teammate || teammate.spawnId !== intent.spawnId || teammate.status === "stopped") {
728
- return { applied: false, reason: `worker "${intent.worker}" is not a living current incarnation` };
729
- }
730
- if (teammate.assignment) {
731
- const state = teammate.assignment.closed ? "closed pending leader reopen" : "active";
732
- return { applied: false, reason: `@${intent.worker} already owns ${state} ${teammate.assignment.kind} assignment "${teammate.assignment.id}"` };
733
- }
734
- if (task.status === "claimed") return { applied: false, reason: `task "${intent.taskId}" is already claimed` };
735
- if (task.status === "completed" || task.status === "superseded") return { applied: false, reason: `task "${intent.taskId}" is ${task.status}` };
736
- if (task.recoveryRequired) return { applied: false, reason: `task "${intent.taskId}" requires explicit leader Work assignment after failure` };
737
- if (!taskDependenciesMet(task)) return { applied: false, reason: `task "${intent.taskId}" has unmet dependencies` };
738
- const conflict = activeAssignmentConflict(task.resources, intent.worker);
739
- if (conflict) return { applied: false, reason: `resource conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}"` };
740
- if (!setTaskClaimed(intent.taskId, intent.worker)) return { applied: false, reason: `task "${intent.taskId}" could not be claimed` };
741
- return { applied: true };
742
- }
743
-
744
- /** Validate and apply a submission intent; verify gating happens in the caller. */
745
- export function applySubmissionIntent(intent: TaskIntent): { ok: boolean; error?: string } {
746
- if (intent.status !== "completed" && intent.status !== "failed") {
747
- return { ok: false, error: `task "${intent.taskId}" has invalid submission status` };
748
- }
749
- const teammate = getTeammate(intent.worker);
750
- if (!teammate || teammate.spawnId !== intent.spawnId || teammate.status === "stopped") {
751
- return { ok: false, error: `worker "${intent.worker}" is not a living current incarnation` };
752
- }
753
- const task = state.tasks[intent.taskId];
754
- if (!task) return { ok: false, error: `unknown task "${intent.taskId}"` };
755
- if (task.status !== "claimed" && task.status !== "superseded") {
756
- return { ok: false, error: `task "${intent.taskId}" is not claimed` };
757
- }
758
- if (task.claimedBy !== intent.worker) {
759
- return { ok: false, error: `task "${intent.taskId}" is claimed by ${task.claimedBy ?? "someone else"}` };
760
- }
761
- if (task.status === "superseded" && intent.status !== "failed") {
762
- return { ok: false, error: `task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}"; submit failed to acknowledge cancellation` };
763
- }
764
- if (intent.status === "failed") {
765
- releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.", true);
766
- return { ok: true };
767
- }
768
- completeTask(intent.taskId, intent.result?.trim() || undefined);
769
- return { ok: true };
770
- }
771
322
 
772
323
  // ── State inspection ──────────────────────────────────────────────
773
324
 
@@ -775,27 +326,21 @@ export function getState(): TeamState {
775
326
  return state;
776
327
  }
777
328
 
778
- /** Load a persisted board into memory on resume; claims die with their holders. */
779
- export function loadBoard(tasks: Record<string, BoardTask>): number {
780
- let reloaded = 0;
781
- for (const task of Object.values(tasks)) {
782
- const orphanedHolding = task.status === "claimed" || task.status === "superseded";
783
- const restored: BoardTask = {
784
- ...task,
785
- resources: normalizeResources(task.resources),
786
- status: task.status === "claimed" ? "pending" : task.status,
787
- recoveryRequired: task.status === "claimed" ? true : task.recoveryRequired,
788
- // Runtime workers and assignments die with the session. Superseded work
789
- // remains visible for audit but cannot retain a dead holder/resource lock.
790
- claimedBy: orphanedHolding ? undefined : task.claimedBy,
791
- updatedAt: Date.now(),
792
- };
793
- state.tasks[restored.id] = restored;
794
- reloaded++;
795
- }
796
- if (reloaded > 0) {
797
- boardRevisionCounter++;
798
- stateRevisionCounter++;
799
- }
800
- return reloaded;
801
- }
329
+ configureTaskStore({
330
+ get: (name) => {
331
+ const t = getAgent(name);
332
+ return t ? { name: t.name, spawnId: t.spawnId, status: t.status,
333
+ assignment: t.assignment, currentTaskId: t.currentTaskId } : undefined;
334
+ },
335
+ // A stopped agent holds nothing, and a replaced one is not the incarnation that
336
+ // published the intent. Only the roster knows either, which is why the tasks
337
+ // asks rather than comparing spawn ids itself.
338
+ current: (name, incarnation) => {
339
+ const t = getAgent(name);
340
+ if (!t || t.status === "stopped") return false;
341
+ return incarnation === undefined || t.spawnId === incarnation;
342
+ },
343
+ conflicting: (resources, except) => activeAssignmentConflict(resources, except),
344
+ sync: (name, assignment, taskId) => { assignAgent(name, assignment, taskId); },
345
+ changed: () => { tasksRevisionCounter++; stateRevisionCounter++; },
346
+ });