@fradser/pi-agent-teams 0.5.2 → 0.6.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,20 +1,35 @@
1
1
  /**
2
- * Run-centric state management for the current session only.
3
- *
4
- * A run is a dispatched dependency-aware task graph; each node is one bounded
5
- * child-process worker with a stable identity of `${runId}:${nodeId}`.
2
+ * 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.
6
5
  */
7
6
 
8
- import type { MailboxMessage, Node, NodeStatus, Run, RunStatus, SpawnInfo, TeammateState, WorkerMessageEvent } from "./types";
9
-
10
- function emptyState(): TeammateState {
7
+ import {
8
+ messageTitle,
9
+ type BoardTask,
10
+ type MailboxMessage,
11
+ type TaskIntent,
12
+ type Teammate,
13
+ type TeamState,
14
+ type WorkerReportEvent,
15
+ } from "./types.ts";
16
+
17
+ export const MAX_LEADER_MAILBOX_MESSAGES = 4096;
18
+ /** FIFO cap of remembered peer message ids per inbox (dedup guard). */
19
+ export const MAX_PEER_DELIVERED_IDS = 512;
20
+ export const MAX_TASK_DEPENDENCIES = 32;
21
+
22
+ function emptyState(): TeamState {
11
23
  return {
12
- runs: {},
24
+ teammates: {},
25
+ tasks: {},
13
26
  leaderMailbox: [],
14
27
  messageCounter: 0,
15
- runCounter: 0,
28
+ taskCounter: 0,
16
29
  workerEventOffsets: {},
17
30
  workerEventIds: {},
31
+ peerInboxOffsets: {},
32
+ peerDeliveredIds: {},
18
33
  };
19
34
  }
20
35
 
@@ -25,7 +40,6 @@ export function markStateDirty(): void {
25
40
  stateDirty = true;
26
41
  }
27
42
 
28
- /** Return whether state changed since the last persisted snapshot. */
29
43
  export function isStateDirty(): boolean {
30
44
  return stateDirty;
31
45
  }
@@ -34,7 +48,6 @@ export function clearStateDirty(): void {
34
48
  stateDirty = false;
35
49
  }
36
50
 
37
- /** Return whether state changed since the last persisted snapshot, then clear it. */
38
51
  export function consumeStateDirty(): boolean {
39
52
  const dirty = stateDirty;
40
53
  clearStateDirty();
@@ -45,640 +58,312 @@ function nextMessageId(): string {
45
58
  return `msg_${++state.messageCounter}`;
46
59
  }
47
60
 
48
- function nextRunId(): string {
49
- return `run_${++state.runCounter}`;
50
- }
51
-
52
61
  export function resetState(): void {
53
62
  state = emptyState();
54
63
  markStateDirty();
55
64
  }
56
65
 
57
- // ── Path normalization ────────────────────────────────────────────
58
-
59
- export function pathsOverlap(left: string, right: string): boolean {
60
- return left === "." || right === "." || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
61
- }
62
-
63
- export function normalizeNodePaths(paths: string[]): { ok: true; paths: string[] } | { ok: false; error: string } {
64
- if (paths.length === 0) return { ok: false, error: "Provide at least one node path." };
65
- const normalized: string[] = [];
66
- for (const rawPath of paths) {
67
- const candidate = rawPath.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
68
- const valid = candidate === "." || (
69
- candidate
70
- && !/^[A-Za-z]:\//.test(candidate)
71
- && !candidate.startsWith("/")
72
- && !/[*?[\]{}]/.test(candidate)
73
- && candidate.split("/").every((part) => part !== "" && part !== "." && part !== "..")
74
- );
75
- if (!valid) return { ok: false, error: `Invalid node path: "${rawPath}".` };
76
- if (normalized.includes(candidate)) return { ok: false, error: `Duplicate node path: "${rawPath}".` };
77
- if (normalized.some((path) => pathsOverlap(path, candidate))) {
78
- return { ok: false, error: `Overlapping node paths: "${rawPath}".` };
79
- }
80
- normalized.push(candidate);
81
- }
82
- return { ok: true, paths: normalized };
83
- }
84
-
85
- // ── Run creation ──────────────────────────────────────────────────
86
-
87
- export interface RunNodeInput {
88
- id: string;
89
- agent: string;
90
- prompt: string;
91
- paths: string[];
92
- access: Node["access"];
93
- model?: string;
94
- timeoutMs?: number;
95
- dependsOn: string[];
96
- }
97
-
98
- /** Reserved node id for the optional synthesized summary. */
99
- export const SUMMARY_NODE_ID = "__summary";
100
-
101
- /** System-generated task text for the summary node. */
102
- function summaryNodePrompt(): string {
103
- return [
104
- "You are the final summary step of this run. Use the upstream handoffs from",
105
- "every completed leaf node, then write ONE concise final summary covering:",
106
- "overall outcome, what changed or was produced,",
107
- "verification status, confirmed risks or failures, and recommended follow-ups.",
108
- "Synthesize — do not repeat node results verbatim and do not report your own",
109
- "plan or process. The leader sees this summary as the run's headline result.",
110
- ].join(" ");
111
- }
112
-
113
- /**
114
- * Create a run from validated task inputs. Rejects duplicate node ids,
115
- * unknown dependsOn references, dependency cycles, and invalid paths before
116
- * any worker starts. When summarize=true, appends a reserved summary node that
117
- * depends on every leaf node.
118
- */
119
- export function createRun(
120
- input: {
121
- cwd: string;
122
- concurrency: number;
123
- worktree: boolean;
124
- background?: boolean;
125
- timeoutMs?: number;
126
- summarize?: boolean;
127
- summaryAgent?: string;
128
- nodes: RunNodeInput[];
129
- },
130
- ): { ok: true; run: Run } | { ok: false; error: string } {
131
- if (input.nodes.length === 0) return { ok: false, error: "Provide at least one task." };
132
- const summarize = input.summarize ?? input.nodes.length > 1;
133
- if (summarize && input.nodes.some((node) => node.id === SUMMARY_NODE_ID)) {
134
- return { ok: false, error: `Node id "${SUMMARY_NODE_ID}" is reserved for the run summary.` };
135
- }
136
-
137
- const ids = new Set<string>();
138
- const normalized: Node[] = [];
139
- for (const node of input.nodes) {
140
- if (ids.has(node.id)) return { ok: false, error: `Duplicate node id: "${node.id}".` };
141
- ids.add(node.id);
142
- const normalizedPaths = normalizeNodePaths(node.paths);
143
- if (!normalizedPaths.ok) return normalizedPaths;
144
- for (const dep of node.dependsOn) {
145
- if (!ids.has(dep) && !input.nodes.some((candidate) => candidate.id === dep)) {
146
- return { ok: false, error: `Node "${node.id}" depends on unknown node "${dep}".` };
147
- }
148
- }
149
- normalized.push({
150
- id: node.id,
151
- workerKey: "", // filled after the run id is assigned
152
- agent: node.agent,
153
- prompt: node.prompt,
154
- paths: normalizedPaths.paths,
155
- access: node.access,
156
- model: node.model,
157
- timeoutMs: node.timeoutMs,
158
- dependsOn: [...new Set(node.dependsOn)],
159
- status: "pending",
160
- createdAt: Date.now(),
161
- updatedAt: Date.now(),
162
- });
163
- }
164
-
165
- // Cycle detection (DFS over the dependency edges).
166
- const visiting = new Set<string>();
167
- const visited = new Set<string>();
168
- const visit = (id: string): boolean => {
169
- if (visiting.has(id)) return true;
170
- if (visited.has(id)) return false;
171
- visiting.add(id);
172
- const node = normalized.find((candidate) => candidate.id === id)!;
173
- for (const dep of node.dependsOn) {
174
- if (visit(dep)) return true;
175
- }
176
- visiting.delete(id);
177
- visited.add(id);
178
- return false;
179
- };
180
- for (const node of normalized) {
181
- if (visit(node.id)) return { ok: false, error: "The task graph contains a dependency cycle." };
182
- }
183
-
184
- // Synthesized summary: default on for multi-node runs. Depends on every leaf.
185
- if (summarize) {
186
- const leaves = normalized.filter((node) => !normalized.some((other) => other.dependsOn.includes(node.id)));
187
- if (leaves.length > 0) {
188
- normalized.push({
189
- id: SUMMARY_NODE_ID,
190
- workerKey: "",
191
- agent: input.summaryAgent ?? "observer",
192
- prompt: summaryNodePrompt(),
193
- paths: ["."],
194
- access: "read",
195
- dependsOn: leaves.map((node) => node.id),
196
- status: "pending",
197
- createdAt: Date.now(),
198
- updatedAt: Date.now(),
199
- });
200
- }
201
- }
202
-
203
- const runId = nextRunId();
204
- const run: Run = {
205
- id: runId,
206
- cwd: input.cwd,
207
- status: "running",
208
- concurrency: input.concurrency,
209
- worktree: input.worktree,
210
- background: input.background ?? true,
211
- timeoutMs: input.timeoutMs,
212
- deadlineAt: input.timeoutMs ? Date.now() + input.timeoutMs : undefined,
213
- nodes: {},
214
- createdAt: Date.now(),
215
- updatedAt: Date.now(),
216
- };
217
- for (const node of normalized) {
218
- node.workerKey = `${runId}:${node.id}`;
219
- run.nodes[node.id] = node;
220
- }
221
- state.runs[runId] = run;
222
- markStateDirty();
223
- return { ok: true, run };
224
- }
66
+ // ── Roster queries ────────────────────────────────────────────────
225
67
 
226
- // ── Run / node queries ────────────────────────────────────────────
68
+ const NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
227
69
 
228
- export function getRun(runId: string): Run | undefined {
229
- return state.runs[runId];
70
+ export function isValidTeammateName(name: string): boolean {
71
+ return NAME_PATTERN.test(name);
230
72
  }
231
73
 
232
- export function getNode(runId: string, nodeId: string): Node | undefined {
233
- return state.runs[runId]?.nodes[nodeId];
74
+ export function getTeammate(name: string): Teammate | undefined {
75
+ return state.teammates[name];
234
76
  }
235
77
 
236
- export function getNodeByWorkerKey(workerKey: string): { run: Run; node: Node } | undefined {
237
- for (const run of Object.values(state.runs)) {
238
- const node = Object.values(run.nodes).find((candidate) => candidate.workerKey === workerKey);
239
- if (node) return { run, node };
240
- }
241
- return undefined;
78
+ export function listTeammates(): Teammate[] {
79
+ return Object.values(state.teammates).sort((a, b) => a.createdAt - b.createdAt);
242
80
  }
243
81
 
244
- export function listRuns(): Run[] {
245
- return Object.values(state.runs).sort((a, b) => b.updatedAt - a.updatedAt);
82
+ export function livingTeammates(): Teammate[] {
83
+ return listTeammates().filter((t) => t.status !== "stopped");
246
84
  }
247
85
 
248
- export function listActiveRuns(): Run[] {
249
- return listRuns().filter((run) => run.status === "running");
86
+ export function idleTeammates(): Teammate[] {
87
+ return livingTeammates().filter((t) => t.status === "idle");
250
88
  }
251
89
 
252
- export function runningNodeCount(runId: string): number {
253
- const run = state.runs[runId];
254
- if (!run) return 0;
255
- return Object.values(run.nodes).filter((node) => node.status === "running").length;
90
+ export function workingTeammates(): Teammate[] {
91
+ return livingTeammates().filter((t) => t.status === "working" || t.status === "starting");
256
92
  }
257
93
 
258
- export function isRunTerminal(run: Run): boolean {
259
- return run.status === "completed" || run.status === "failed" || run.status === "cancelled";
94
+ export function findTeammateBySpawn(spawnId: string): Teammate | undefined {
95
+ return listTeammates().find((t) => t.spawnId === spawnId && t.status !== "stopped");
260
96
  }
261
97
 
262
- /** Claim a run's completion delivery so the follow-up is suppressed (foreground gather). */
263
- export function markRunCompletionDelivered(runId: string): void {
264
- const run = state.runs[runId];
265
- if (run) {
266
- run.completionNotified = true;
267
- markStateDirty();
98
+ export function registerTeammate(teammate: Teammate): { ok: true } | { ok: false; error: string } {
99
+ if (!isValidTeammateName(teammate.name)) {
100
+ return { ok: false, error: `Invalid teammate name "${teammate.name}". Use letters, digits, dots, dashes, underscores.` };
101
+ }
102
+ if (livingTeammates().some((t) => t.name === teammate.name)) {
103
+ return { ok: false, error: `A living teammate named "${teammate.name}" already exists.` };
268
104
  }
105
+ state.teammates[teammate.name] = teammate;
106
+ markStateDirty();
107
+ return { ok: true };
269
108
  }
270
109
 
271
- /** Every node across every run (for console rows and liveness polls). */
272
- export function listNodes(): Node[] {
273
- return Object.values(state.runs).flatMap((run) => Object.values(run.nodes));
110
+ export function updateTeammate(name: string, patch: Partial<Teammate>): Teammate | undefined {
111
+ const teammate = state.teammates[name];
112
+ if (!teammate) return undefined;
113
+ Object.assign(teammate, patch, { updatedAt: Date.now() });
114
+ if (patch.status === "stopped") teammate.stoppedAt = Date.now();
115
+ markStateDirty();
116
+ return teammate;
274
117
  }
275
118
 
276
- // ── Node lifecycle ────────────────────────────────────────────────
277
-
278
- export function markNodeRunning(runId: string, nodeId: string): { ok: boolean; error?: string } {
279
- const node = getNode(runId, nodeId);
280
- if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
281
- node.status = "running";
282
- node.updatedAt = Date.now();
283
- state.runs[runId].updatedAt = Date.now();
119
+ /** Merge streaming child-process progress into a living teammate. */
120
+ export function updateTeammateProgress(
121
+ name: string,
122
+ spawnId: string,
123
+ progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & { sequenceEnded?: boolean },
124
+ ): boolean {
125
+ const teammate = state.teammates[name];
126
+ if (!teammate || teammate.spawnId !== spawnId) return false;
127
+ teammate.liveText = progress.liveText;
128
+ teammate.activeTool = progress.activeTool;
129
+ teammate.liveThinking = progress.liveThinking;
130
+ teammate.turns = progress.turns;
131
+ if (progress.sequenceEnded !== undefined) teammate.sequenceEnded = progress.sequenceEnded;
132
+ if (teammate.status === "starting") {
133
+ teammate.status = progress.sequenceEnded ? "idle" : "working";
134
+ }
135
+ teammate.updatedAt = Date.now();
284
136
  markStateDirty();
285
- return { ok: true };
137
+ return true;
286
138
  }
287
139
 
288
- export function updateNodeStatus(
289
- runId: string,
290
- nodeId: string,
291
- status: NodeStatus,
292
- result?: string,
293
- errorMessage?: string,
294
- ): { ok: boolean; node?: Node; error?: string } {
295
- const node = getNode(runId, nodeId);
296
- if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
297
- node.status = status;
298
- node.updatedAt = Date.now();
299
- if (result !== undefined) node.result = result;
300
- if (errorMessage !== undefined) node.errorMessage = errorMessage;
301
- if (status === "completed" || status === "failed" || status === "cancelled") {
302
- node.completedAt = Date.now();
140
+ /** Drop per-spawn replay metadata once its final snapshot was persisted. */
141
+ export function clearWorkerRunEvents(workerName: string, spawnId: string): void {
142
+ const outboxKey = `${workerName}:${spawnId}`;
143
+ delete state.workerEventOffsets[outboxKey];
144
+ for (const id of Object.keys(state.workerEventIds)) {
145
+ if (id.startsWith(`${spawnId}:`)) delete state.workerEventIds[id];
303
146
  }
304
- state.runs[runId].updatedAt = Date.now();
305
147
  markStateDirty();
306
- return { ok: true, node };
307
148
  }
308
149
 
309
- /**
310
- * Attach child-process execution info to a node and derive its status from
311
- * the spawn outcome (unless the node was cancelled).
312
- */
313
- export function setNodeSpawnInfo(runId: string, nodeId: string, info: SpawnInfo): { ok: boolean; node?: Node; error?: string } {
314
- const node = getNode(runId, nodeId);
315
- if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
316
- node.spawn = info;
317
- node.updatedAt = Date.now();
318
- state.runs[runId].updatedAt = Date.now();
319
-
320
- if (info.status === "running") {
321
- // A live spawn implies the node is running (idempotent with markNodeRunning).
322
- if (node.status === "pending") node.status = "running";
323
- } else if (info.status === "completed" && node.status !== "cancelled") {
324
- node.status = "completed";
325
- node.completedAt = Date.now();
326
- if (info.stdout && node.result === undefined) node.result = info.stdout;
327
- } else if (info.status === "failed" && node.status !== "cancelled") {
328
- node.status = "failed";
329
- node.completedAt = Date.now();
330
- node.errorMessage = info.error ?? info.stderr ?? `Child process exited with code ${info.exitCode ?? "unknown"}.`;
150
+ // ── Leader inbox ──────────────────────────────────────────────────
151
+
152
+ export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">): MailboxMessage {
153
+ const full: MailboxMessage = { ...msg, id: nextMessageId(), timestamp: Date.now() };
154
+ state.leaderMailbox.push(full);
155
+ if (state.leaderMailbox.length > MAX_LEADER_MAILBOX_MESSAGES) {
156
+ state.leaderMailbox.splice(0, state.leaderMailbox.length - MAX_LEADER_MAILBOX_MESSAGES);
331
157
  }
332
158
  markStateDirty();
333
- return { ok: true, node };
159
+ return full;
334
160
  }
335
161
 
336
- /** Merge a streaming child-process update into its matching running node. */
337
- export function updateNodeSpawnProgress(
338
- runId: string,
339
- nodeId: string,
340
- spawnId: string,
341
- progress: Pick<SpawnInfo, "liveText" | "activeTool" | "liveThinking" | "turns" | "finalResponse">,
342
- ): { ok: boolean; node?: Node; error?: string } {
343
- const node = getNode(runId, nodeId);
344
- if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
345
- if (node.spawn?.spawnId !== spawnId || node.spawn.status !== "running") {
346
- return { ok: false, error: `Node "${nodeId}" is not running the expected worker.` };
162
+ /** Apply a validated report event exactly once by event id. */
163
+ export function receiveWorkerMessage(event: WorkerReportEvent): boolean {
164
+ const sender = getTeammate(event.worker);
165
+ if (!sender || sender.spawnId !== event.spawnId) return false;
166
+ if (state.leaderMailbox.some((message) => message.id === event.id)) return false;
167
+ state.leaderMailbox.push({
168
+ id: event.id,
169
+ from: event.worker,
170
+ subject: messageTitle(event.body),
171
+ body: event.body,
172
+ status: event.status,
173
+ timestamp: Date.now(),
174
+ });
175
+ if (state.leaderMailbox.length > MAX_LEADER_MAILBOX_MESSAGES) {
176
+ state.leaderMailbox.splice(0, state.leaderMailbox.length - MAX_LEADER_MAILBOX_MESSAGES);
347
177
  }
348
- node.spawn.liveText = progress.liveText;
349
- node.spawn.activeTool = progress.activeTool;
350
- node.spawn.liveThinking = progress.liveThinking;
351
- node.spawn.turns = progress.turns;
352
- node.spawn.finalResponse = progress.finalResponse;
353
- node.updatedAt = Date.now();
354
- state.runs[runId].updatedAt = Date.now();
355
178
  markStateDirty();
356
- return { ok: true, node };
179
+ return true;
357
180
  }
358
181
 
359
- // ── Dependency readiness ──────────────────────────────────────────
360
-
361
- /** A node is ready when every dependency has completed. */
362
- export function nodeIsReady(run: Run, node: Node): boolean {
363
- return node.dependsOn.every((dep) => run.nodes[dep]?.status === "completed");
182
+ /** True when this peer message id was already delivered to that inbox. */
183
+ export function isPeerDelivered(inboxName: string, messageId: string): boolean {
184
+ return (state.peerDeliveredIds[inboxName] ?? []).includes(messageId);
364
185
  }
365
186
 
366
- /** Nodes that are pending, have no unmet dependencies, and are not yet spawned. */
367
- export function readyPendingNodes(run: Run): Node[] {
368
- return Object.values(run.nodes)
369
- .filter((node) => node.status === "pending" && nodeIsReady(run, node))
370
- .sort((a, b) => a.id.localeCompare(b.id));
187
+ /** Remember a delivered peer message id with a bounded FIFO per inbox. */
188
+ export function markPeerDelivered(inboxName: string, messageId: string): void {
189
+ const ids = state.peerDeliveredIds[inboxName] ?? [];
190
+ ids.push(messageId);
191
+ while (ids.length > MAX_PEER_DELIVERED_IDS) ids.shift();
192
+ state.peerDeliveredIds[inboxName] = ids;
193
+ markStateDirty();
371
194
  }
372
195
 
373
- /**
374
- * The running shared-workspace write node whose paths overlap this node's,
375
- * if any. Searched across EVERY run in the session: a write node deferred in
376
- * one run must also wait for an overlapping writer dispatched by another run.
377
- * Worktree-isolated nodes never conflict. This is advisory scheduling
378
- * coordination — it defers node starts; it does not isolate file access.
379
- */
380
- export function findSharedWorkspaceWriteConflict(runId: string, nodeId: string): Node | undefined {
381
- const run = state.runs[runId];
382
- const node = run?.nodes[nodeId];
383
- if (!run || !node || node.access !== "write") return undefined;
384
- for (const candidateRun of Object.values(state.runs)) {
385
- for (const other of Object.values(candidateRun.nodes)) {
386
- if (candidateRun.id === runId && other.id === node.id) continue;
387
- if (
388
- other.status === "running"
389
- && other.spawn?.status === "running"
390
- && other.spawn.isolation !== "worktree"
391
- && other.access === "write"
392
- && node.paths.some((path) => other.paths.some((otherPath) => pathsOverlap(path, otherPath)))
393
- ) {
394
- return other;
395
- }
396
- }
397
- }
398
- return undefined;
399
- }
400
-
401
- /** Cancel pending nodes that transitively depend on the given node. */
402
- export function cancelBlockedDependents(runId: string, nodeId: string): number {
403
- const run = state.runs[runId];
404
- if (!run) return 0;
405
- const cancelled = new Set<string>([nodeId]);
406
- let changed = true;
407
- while (changed) {
408
- changed = false;
409
- for (const node of Object.values(run.nodes)) {
410
- if (node.status !== "pending" || cancelled.has(node.id)) continue;
411
- if (node.dependsOn.some((dep) => cancelled.has(dep))) {
412
- node.status = "cancelled";
413
- node.completedAt = Date.now();
414
- node.updatedAt = Date.now();
415
- cancelled.add(node.id);
416
- changed = true;
417
- }
418
- }
419
- }
420
- run.updatedAt = Date.now();
421
- if (cancelled.size > 1) markStateDirty();
422
- return cancelled.size - 1;
196
+ export function getPeerInboxOffset(inboxName: string): number {
197
+ return state.peerInboxOffsets[inboxName] ?? 0;
423
198
  }
424
199
 
425
- // ── Run settlement ────────────────────────────────────────────────
426
-
427
- /**
428
- * Recompute a running run's status from its nodes. Settles only when no node
429
- * is running or pending: any failed node fails the run, an all-cancelled run
430
- * is cancelled, otherwise the run completed.
431
- */
432
- export function settleRun(runId: string): RunStatus | undefined {
433
- const run = state.runs[runId];
434
- if (!run) return undefined;
435
- if (run.status !== "running") return run.status;
436
- const nodes = Object.values(run.nodes);
437
- const anyActive = nodes.some((node) => node.status === "running" || node.status === "pending");
438
- if (anyActive) return "running";
439
- if (nodes.some((node) => node.status === "failed")) {
440
- run.status = "failed";
441
- } else if (nodes.every((node) => node.status === "cancelled")) {
442
- run.status = "cancelled";
443
- } else {
444
- run.status = "completed";
445
- }
446
- run.finishedAt = Date.now();
447
- run.updatedAt = Date.now();
200
+ export function setPeerInboxOffset(inboxName: string, offset: number): void {
201
+ state.peerInboxOffsets[inboxName] = offset;
448
202
  markStateDirty();
449
- return run.status;
450
203
  }
451
204
 
452
- // ── Cancel / cleanup ──────────────────────────────────────────────
205
+ // ── Board: creation and queries ───────────────────────────────────
453
206
 
454
- /**
455
- * Mark a running run's pending nodes cancelled. Returns the ids of nodes with
456
- * live workers that the caller must terminate.
457
- */
458
- export function cancelRun(runId: string): { ok: boolean; runningNodeIds: string[]; error?: string } {
459
- const run = state.runs[runId];
460
- if (!run) return { ok: false, runningNodeIds: [], error: `Run "${runId}" not found.` };
461
- if (isRunTerminal(run)) return { ok: false, runningNodeIds: [], error: `Run "${runId}" is already ${run.status}.` };
462
- const runningNodeIds: string[] = [];
463
- for (const node of Object.values(run.nodes)) {
464
- if (node.status === "running") {
465
- runningNodeIds.push(node.id);
466
- } else if (node.status === "pending") {
467
- node.status = "cancelled";
468
- node.completedAt = Date.now();
469
- node.updatedAt = Date.now();
470
- }
207
+ export function createTask(input: {
208
+ subject: string;
209
+ description?: string;
210
+ dependsOn?: string[];
211
+ verify?: string;
212
+ }): { ok: true; task: BoardTask } | { ok: false; error: string } {
213
+ const subject = input.subject.trim();
214
+ 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) {
217
+ return { ok: false, error: `Task depends on more than ${MAX_TASK_DEPENDENCIES} tasks.` };
471
218
  }
472
- run.updatedAt = Date.now();
473
- markStateDirty();
474
- return { ok: true, runningNodeIds };
475
- }
476
-
477
- /** Cancel a single node (and its not-yet-started transitive dependents).
478
- * Returns the ids of nodes with live workers the caller must terminate,
479
- * including running dependents whose cancelled prerequisites invalidate them. */
480
- export function cancelNode(runId: string, nodeId: string): { ok: boolean; runningNodeIds: string[]; error?: string } {
481
- const run = state.runs[runId];
482
- const node = run?.nodes[nodeId];
483
- if (!run || !node) return { ok: false, runningNodeIds: [], error: `Node "${nodeId}" not found in run "${runId}".` };
484
- if (node.status === "completed" || node.status === "failed" || node.status === "cancelled") {
485
- return { ok: false, runningNodeIds: [], error: `Node "${nodeId}" is already ${node.status}.` };
219
+ for (const dep of dependsOn) {
220
+ if (!state.tasks[dep]) return { ok: false, error: `Task depends on unknown task "${dep}".` };
486
221
  }
222
+ const id = `t_${++state.taskCounter}`;
223
+ const task: BoardTask = {
224
+ id,
225
+ subject,
226
+ description: input.description?.trim() || undefined,
227
+ dependsOn,
228
+ verify: input.verify?.trim() || undefined,
229
+ status: "pending",
230
+ createdAt: Date.now(),
231
+ updatedAt: Date.now(),
232
+ };
233
+ state.tasks[id] = task;
234
+ markStateDirty();
235
+ return { ok: true, task };
236
+ }
487
237
 
488
- // Cancel pending transitive dependents of the target.
489
- cancelBlockedDependents(runId, nodeId);
490
-
491
- // Propagate to RUNNING dependents whose (now cancelled) prerequisites make
492
- // their remaining work meaningless; the caller terminates their workers and
493
- // finalizeNode records them cancelled.
494
- const runningDependents: string[] = [];
495
- const invalidated = new Set<string>([nodeId]);
496
- let changed = true;
497
- while (changed) {
498
- changed = false;
499
- for (const candidate of Object.values(run.nodes)) {
500
- if (candidate.status === "cancelled") {
501
- invalidated.add(candidate.id);
502
- continue;
503
- }
504
- if (candidate.status === "running" && !invalidated.has(candidate.id)
505
- && candidate.dependsOn.some((dep) => invalidated.has(dep))) {
506
- invalidated.add(candidate.id);
507
- runningDependents.push(candidate.id);
508
- changed = true;
509
- }
510
- }
511
- }
238
+ export function getTask(taskId: string): BoardTask | undefined {
239
+ return state.tasks[taskId];
240
+ }
512
241
 
513
- const runningNodeIds: string[] = [];
514
- if (node.status === "running") {
515
- runningNodeIds.push(node.id);
516
- } else if (node.status === "pending") {
517
- node.status = "cancelled";
518
- node.completedAt = Date.now();
519
- node.updatedAt = Date.now();
520
- }
521
- for (const id of runningDependents) {
522
- if (!runningNodeIds.includes(id)) runningNodeIds.push(id);
523
- }
524
- run.updatedAt = Date.now();
525
- markStateDirty();
526
- return { ok: true, runningNodeIds };
242
+ export function listTasks(): BoardTask[] {
243
+ return Object.values(state.tasks).sort((a, b) => a.id.localeCompare(b.id));
527
244
  }
528
245
 
529
- /**
530
- * Fail a run for exceeding its hard wall-clock cap: pending nodes are
531
- * cancelled and the run is marked failed; live workers must be terminated by
532
- * the caller.
533
- */
534
- export function failRunTimeout(runId: string, errorMessage: string): { ok: boolean; runningNodeIds: string[]; error?: string } {
535
- const run = state.runs[runId];
536
- if (!run) return { ok: false, runningNodeIds: [], error: `Run "${runId}" not found.` };
537
- if (run.status !== "running") return { ok: false, runningNodeIds: [], error: `Run "${runId}" is already ${run.status}.` };
538
- const runningNodeIds: string[] = [];
539
- for (const node of Object.values(run.nodes)) {
540
- if (node.status === "running") {
541
- // The node's worker is terminated by the caller; clearing the spawn means
542
- // its late close event cannot pollute a retried (reset) node.
543
- node.status = "failed";
544
- node.errorMessage = errorMessage;
545
- node.completedAt = Date.now();
546
- node.spawn = undefined;
547
- node.updatedAt = Date.now();
548
- runningNodeIds.push(node.id);
549
- } else if (node.status === "pending") {
550
- node.status = "cancelled";
551
- node.completedAt = Date.now();
552
- node.updatedAt = Date.now();
553
- }
554
- }
555
- run.status = "failed";
556
- run.errorMessage = errorMessage;
557
- run.finishedAt = Date.now();
558
- run.updatedAt = Date.now();
559
- markStateDirty();
560
- return { ok: true, runningNodeIds };
246
+ export function pendingTasks(): BoardTask[] {
247
+ return listTasks().filter((task) => task.status === "pending");
561
248
  }
562
249
 
563
- /**
564
- * Reset failed and cancelled nodes of a settled run back to pending so the
565
- * run can be re-dispatched. Completed nodes are retained. The run returns to
566
- * running; callers must scheduleRun afterwards.
567
- */
568
- export function retryRun(runId: string, nodeIds?: string[]): { ok: boolean; reset: string[]; error?: string } {
569
- const run = state.runs[runId];
570
- if (!run) return { ok: false, reset: [], error: `Run "${runId}" not found.` };
571
- if (run.status === "running") return { ok: false, reset: [], error: `Run "${runId}" is still running; wait for it to settle first.` };
572
- const targets = nodeIds && nodeIds.length > 0 ? nodeIds : [];
573
- for (const id of targets) {
574
- if (!run.nodes[id]) return { ok: false, reset: [], error: `Node "${id}" not found in run "${runId}".` };
575
- }
250
+ export function claimableTasks(): BoardTask[] {
251
+ return pendingTasks().filter(taskDependenciesMet);
252
+ }
576
253
 
577
- // Explicit targets may only reset failed or cancelled nodes.
578
- const resetSet = new Set<string>();
579
- for (const node of Object.values(run.nodes)) {
580
- if (targets.length > 0 && !targets.includes(node.id)) continue;
581
- if (node.status !== "failed" && node.status !== "cancelled") {
582
- if (targets.includes(node.id)) {
583
- return { ok: false, reset: [], error: `Node "${node.id}" is ${node.status}, not failed or cancelled.` };
584
- }
585
- continue;
586
- }
587
- resetSet.add(node.id);
588
- }
254
+ export function taskDependenciesMet(task: BoardTask): boolean {
255
+ return task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
256
+ }
589
257
 
590
- // Propagate to cancelled dependents whose (now reset) prerequisites failed:
591
- // a targeted retry of node A must also re-run B, which was cancelled because
592
- // it depended on A.
593
- let changed = true;
594
- while (changed) {
595
- changed = false;
596
- for (const node of Object.values(run.nodes)) {
597
- if (resetSet.has(node.id) || node.status !== "cancelled") continue;
598
- if (node.dependsOn.some((dep) => resetSet.has(dep))) {
599
- resetSet.add(node.id);
600
- changed = true;
601
- }
602
- }
603
- }
258
+ /** First claimable task, or undefined. */
259
+ export function firstClaimableTask(): BoardTask | undefined {
260
+ return claimableTasks()[0];
261
+ }
604
262
 
605
- if (resetSet.size === 0) {
606
- return { ok: false, reset: [], error: "No failed or cancelled nodes to retry." };
607
- }
608
- for (const node of Object.values(run.nodes)) {
609
- if (!resetSet.has(node.id)) continue;
610
- node.status = "pending";
611
- node.result = undefined;
612
- node.errorMessage = undefined;
613
- node.completedAt = undefined;
614
- node.spawn = undefined;
615
- node.updatedAt = Date.now();
616
- }
617
- const reset = [...resetSet];
618
- run.status = "running";
619
- run.errorMessage = undefined;
620
- run.finishedAt = undefined;
621
- run.completionNotified = false;
622
- run.settledMessageSent = false;
623
- // Re-arm the run-level cap: a stale deadline would re-fail the retried run
624
- // on the very next poll.
625
- run.deadlineAt = run.timeoutMs ? Date.now() + run.timeoutMs : undefined;
626
- run.updatedAt = Date.now();
263
+ export function setTaskClaimed(taskId: string, workerName: string): BoardTask | undefined {
264
+ const task = state.tasks[taskId];
265
+ if (!task || task.status !== "pending" || !taskDependenciesMet(task)) return undefined;
266
+ task.status = "claimed";
267
+ task.claimedBy = workerName;
268
+ task.updatedAt = Date.now();
627
269
  markStateDirty();
628
- return { ok: true, reset };
270
+ return task;
629
271
  }
630
272
 
631
- /** Drop per-spawn replay metadata after its final state snapshot was persisted. */
632
- export function clearWorkerRunEvents(workerKey: string, spawnId: string): void {
633
- const outboxKey = `${workerKey}:${spawnId}`;
634
- delete state.workerEventOffsets[outboxKey];
635
- for (const id of Object.keys(state.workerEventIds)) {
636
- if (id.startsWith(`${spawnId}:`)) delete state.workerEventIds[id];
637
- }
273
+ /** Release a claimed task back to pending (holder stopped or failed). */
274
+ export function releaseTask(taskId: string, errorMessage?: string): BoardTask | undefined {
275
+ const task = state.tasks[taskId];
276
+ if (!task || task.status !== "claimed") return undefined;
277
+ task.status = "pending";
278
+ task.claimedBy = undefined;
279
+ if (errorMessage !== undefined) task.errorMessage = errorMessage;
280
+ task.updatedAt = Date.now();
638
281
  markStateDirty();
282
+ return task;
639
283
  }
640
284
 
641
- // ── Message storage ───────────────────────────────────────────────
642
-
643
- /** Deliver a message to the single leader inbox. */
644
- export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">): MailboxMessage {
645
- const full: MailboxMessage = {
646
- ...msg,
647
- id: nextMessageId(),
648
- timestamp: Date.now(),
649
- };
650
- state.leaderMailbox.push(full);
285
+ export function completeTask(taskId: string, result?: string): BoardTask | undefined {
286
+ const task = state.tasks[taskId];
287
+ if (!task || task.status !== "claimed") return undefined;
288
+ task.status = "completed";
289
+ task.result = result;
290
+ task.errorMessage = undefined;
291
+ task.completedAt = Date.now();
292
+ task.updatedAt = Date.now();
651
293
  markStateDirty();
652
- return full;
294
+ return task;
653
295
  }
654
296
 
655
- /** Apply a validated worker event exactly once by event id. */
656
- export function receiveWorkerMessage(event: WorkerMessageEvent): boolean {
657
- const sender = getNodeByWorkerKey(event.worker);
658
- if (!sender || sender.node.spawn?.spawnId !== event.spawnId) return false;
659
- if (state.leaderMailbox.some((message) => message.id === event.id)) return false;
660
- state.leaderMailbox.push({
661
- id: event.id,
662
- from: event.worker,
663
- subject: event.subject,
664
- body: event.body,
665
- runId: sender.run.id,
666
- timestamp: Date.now(),
667
- });
668
- markStateDirty();
669
- return true;
297
+ /** Release every task held by a named teammate (crash or shutdown). */
298
+ export function releaseTasksOf(workerName: string, reason: string): BoardTask[] {
299
+ const released: BoardTask[] = [];
300
+ for (const task of listTasks()) {
301
+ if (task.status === "claimed" && task.claimedBy === workerName) {
302
+ releaseTask(task.id, reason);
303
+ released.push(task);
304
+ }
305
+ }
306
+ return released;
307
+ }
308
+
309
+ /** Apply a validated claim intent from a marker file. */
310
+ export function applyClaimIntent(intent: TaskIntent): { applied: boolean; reason?: string } {
311
+ const task = state.tasks[intent.taskId];
312
+ if (!task) return { applied: false, reason: `unknown task "${intent.taskId}"` };
313
+ 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` };
315
+ if (!taskDependenciesMet(task)) return { applied: false, reason: `task "${intent.taskId}" has unmet dependencies` };
316
+ setTaskClaimed(intent.taskId, intent.worker);
317
+ return { applied: true };
318
+ }
319
+
320
+ /** Validate and apply a submission intent; verify gating happens in the caller. */
321
+ export function applySubmissionIntent(intent: TaskIntent): { ok: boolean; error?: string } {
322
+ const task = state.tasks[intent.taskId];
323
+ 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` };
325
+ if (task.claimedBy !== intent.worker) {
326
+ return { ok: false, error: `task "${intent.taskId}" is claimed by ${task.claimedBy ?? "someone else"}` };
327
+ }
328
+ if (intent.status === "failed") {
329
+ releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
330
+ return { ok: true };
331
+ }
332
+ completeTask(intent.taskId, intent.result?.trim() || undefined);
333
+ return { ok: true };
670
334
  }
671
335
 
672
336
  // ── State inspection ──────────────────────────────────────────────
673
337
 
674
- export function getState(): TeammateState {
338
+ export function getState(): TeamState {
675
339
  return state;
676
340
  }
677
341
 
342
+ /** Load a persisted board into memory on resume; claims die with their holders. */
343
+ export function loadBoard(tasks: Record<string, BoardTask>): number {
344
+ let reloaded = 0;
345
+ for (const task of Object.values(tasks)) {
346
+ const restored: BoardTask = {
347
+ ...task,
348
+ status: task.status === "claimed" ? "pending" : task.status,
349
+ claimedBy: task.status === "claimed" ? undefined : task.claimedBy,
350
+ updatedAt: Date.now(),
351
+ };
352
+ 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
+ reloaded++;
356
+ }
357
+ if (reloaded > 0) markStateDirty();
358
+ return reloaded;
359
+ }
360
+
678
361
  export function getSummary(): string | undefined {
679
- const runCount = Object.keys(state.runs).length;
680
- if (runCount === 0) return undefined;
681
- const messageCount = state.leaderMailbox.length;
682
- const activeNodes = listNodes().filter((node) => node.status === "running" || node.status === "pending").length;
683
- return `${runCount} run(s) | ${messageCount} message(s) | ${activeNodes} active node(s)`;
362
+ const alive = livingTeammates();
363
+ if (alive.length === 0 && listTasks().length === 0) return undefined;
364
+ const counts = { pending: 0, claimed: 0, completed: 0 };
365
+ for (const task of listTasks()) {
366
+ if (task.status in counts) counts[task.status as keyof typeof counts]++;
367
+ }
368
+ return `${alive.length} teammate(s) alive | board: ${counts.pending} pending / ${counts.claimed} claimed / ${counts.completed} completed`;
684
369
  }