@fradser/pi-agent-teams 0.5.3 → 0.7.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,638 +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 ──────────────────────────────────────────────────
66
+ // ── Roster queries ────────────────────────────────────────────────
86
67
 
87
- export const MAX_STRUCTURED_OUTPUT_BYTES = 64 * 1024;
88
- export const MAX_STRUCTURED_OUTPUT_DEPTH = 8;
89
- export const MAX_FANOUT_ITEMS = 32;
68
+ const NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
90
69
 
91
- export function structuredOutputSize(value: unknown): number {
92
- return Buffer.byteLength(JSON.stringify(value), "utf8");
70
+ export function isValidTeammateName(name: string): boolean {
71
+ return NAME_PATTERN.test(name);
93
72
  }
94
73
 
95
- export function structuredOutputDepth(value: unknown): number {
96
- if (!value || typeof value !== "object") return 0;
97
- if (Array.isArray(value)) return 1 + Math.max(0, ...value.map(structuredOutputDepth));
98
- return 1 + Math.max(0, ...Object.values(value as Record<string, unknown>).map(structuredOutputDepth));
74
+ export function getTeammate(name: string): Teammate | undefined {
75
+ return state.teammates[name];
99
76
  }
100
77
 
101
- export function validateStructuredOutput(value: unknown): string | undefined {
102
- let serialized: string;
103
- try { serialized = JSON.stringify(value); } catch { return "Structured output must be JSON-serializable."; }
104
- if (Buffer.byteLength(serialized, "utf8") > MAX_STRUCTURED_OUTPUT_BYTES) return `Structured output exceeds ${MAX_STRUCTURED_OUTPUT_BYTES} bytes.`;
105
- if (structuredOutputDepth(value) > MAX_STRUCTURED_OUTPUT_DEPTH) return `Structured output exceeds depth ${MAX_STRUCTURED_OUTPUT_DEPTH}.`;
106
- return undefined;
78
+ export function listTeammates(): Teammate[] {
79
+ return Object.values(state.teammates).sort((a, b) => a.createdAt - b.createdAt);
107
80
  }
108
81
 
109
- export interface RunNodeInput {
110
- id: string;
111
- agent: string;
112
- prompt: string;
113
- paths: string[];
114
- access: Node["access"];
115
- model?: string;
116
- mode?: Node["mode"];
117
- turnBudget?: number;
118
- forkContext?: string[];
119
- inputBindings?: Record<string, string>;
120
- dependsOn: string[];
82
+ export function livingTeammates(): Teammate[] {
83
+ return listTeammates().filter((t) => t.status !== "stopped");
121
84
  }
122
85
 
123
- /** Reserved node id for the optional synthesized summary. */
124
- export const SUMMARY_NODE_ID = "__summary";
125
-
126
- /** System-generated task text for the summary node. */
127
- function summaryNodePrompt(): string {
128
- return [
129
- "You are the final summary step of this run. Use the upstream handoffs from",
130
- "every completed leaf node, then write ONE concise final summary covering:",
131
- "overall outcome, what changed or was produced,",
132
- "verification status, confirmed risks or failures, and recommended follow-ups.",
133
- "Synthesize — do not repeat node results verbatim and do not report your own",
134
- "plan or process. The leader sees this summary as the run's headline result.",
135
- ].join(" ");
86
+ export function idleTeammates(): Teammate[] {
87
+ return livingTeammates().filter((t) => t.status === "idle");
136
88
  }
137
89
 
138
- /**
139
- * Create a run from validated task inputs. Rejects duplicate node ids,
140
- * unknown dependsOn references, dependency cycles, and invalid paths before
141
- * any worker starts. When summarize=true, appends a reserved summary node that
142
- * depends on every leaf node.
143
- */
144
- export function createRun(
145
- input: {
146
- cwd: string;
147
- concurrency: number;
148
- worktree: boolean;
149
- background?: boolean;
150
- summarize?: boolean;
151
- summaryAgent?: string;
152
- nodes: RunNodeInput[];
153
- },
154
- ): { ok: true; run: Run } | { ok: false; error: string } {
155
- if (input.nodes.length === 0) return { ok: false, error: "Provide at least one task." };
156
- const summarize = input.summarize ?? input.nodes.length > 1;
157
- if (summarize && input.nodes.some((node) => node.id === SUMMARY_NODE_ID)) {
158
- return { ok: false, error: `Node id "${SUMMARY_NODE_ID}" is reserved for the run summary.` };
159
- }
160
-
161
- const ids = new Set<string>();
162
- const normalized: Node[] = [];
163
- for (const node of input.nodes) {
164
- if (ids.has(node.id)) return { ok: false, error: `Duplicate node id: "${node.id}".` };
165
- ids.add(node.id);
166
- const normalizedPaths = normalizeNodePaths(node.paths);
167
- if (!normalizedPaths.ok) return normalizedPaths;
168
- for (const dep of node.dependsOn) {
169
- if (!ids.has(dep) && !input.nodes.some((candidate) => candidate.id === dep)) {
170
- return { ok: false, error: `Node "${node.id}" depends on unknown node "${dep}".` };
171
- }
172
- }
173
- for (const source of Object.values(node.inputBindings ?? {})) {
174
- const dependencyId = source.split("#", 1)[0];
175
- if (!node.dependsOn.includes(dependencyId)) {
176
- return { ok: false, error: `Node "${node.id}" input binding must reference a dependency: "${source}".` };
177
- }
178
- }
179
- if (node.forkContext?.some((dependencyId) => !node.dependsOn.includes(dependencyId))) {
180
- return { ok: false, error: `Node "${node.id}" forkContext must reference dependencies only.` };
181
- }
182
- normalized.push({
183
- id: node.id,
184
- workerKey: "", // filled after the run id is assigned
185
- agent: node.agent,
186
- prompt: node.prompt,
187
- paths: normalizedPaths.paths,
188
- access: node.access,
189
- model: node.model,
190
- mode: node.mode,
191
- turnBudget: node.turnBudget,
192
- forkContext: node.forkContext ? [...new Set(node.forkContext)] : undefined,
193
- inputBindings: node.inputBindings ? { ...node.inputBindings } : undefined,
194
- dependsOn: [...new Set(node.dependsOn)],
195
- status: "pending",
196
- createdAt: Date.now(),
197
- updatedAt: Date.now(),
198
- });
199
- }
90
+ export function workingTeammates(): Teammate[] {
91
+ return livingTeammates().filter((t) => t.status === "working" || t.status === "starting");
92
+ }
200
93
 
201
- // Cycle detection (DFS over the dependency edges).
202
- const visiting = new Set<string>();
203
- const visited = new Set<string>();
204
- const visit = (id: string): boolean => {
205
- if (visiting.has(id)) return true;
206
- if (visited.has(id)) return false;
207
- visiting.add(id);
208
- const node = normalized.find((candidate) => candidate.id === id)!;
209
- for (const dep of node.dependsOn) {
210
- if (visit(dep)) return true;
211
- }
212
- visiting.delete(id);
213
- visited.add(id);
214
- return false;
215
- };
216
- for (const node of normalized) {
217
- if (visit(node.id)) return { ok: false, error: "The task graph contains a dependency cycle." };
218
- }
94
+ export function findTeammateBySpawn(spawnId: string): Teammate | undefined {
95
+ return listTeammates().find((t) => t.spawnId === spawnId && t.status !== "stopped");
96
+ }
219
97
 
220
- // Synthesized summary: default on for multi-node runs. Depends on every leaf.
221
- if (summarize) {
222
- const leaves = normalized.filter((node) => !normalized.some((other) => other.dependsOn.includes(node.id)));
223
- if (leaves.length > 0) {
224
- normalized.push({
225
- id: SUMMARY_NODE_ID,
226
- workerKey: "",
227
- agent: input.summaryAgent ?? "observer",
228
- prompt: summaryNodePrompt(),
229
- paths: ["."],
230
- access: "read",
231
- dependsOn: leaves.map((node) => node.id),
232
- status: "pending",
233
- createdAt: Date.now(),
234
- updatedAt: Date.now(),
235
- });
236
- }
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.` };
237
101
  }
238
-
239
- const runId = nextRunId();
240
- const run: Run = {
241
- id: runId,
242
- cwd: input.cwd,
243
- status: "running",
244
- concurrency: input.concurrency,
245
- worktree: input.worktree,
246
- background: input.background ?? true,
247
- nodes: {},
248
- createdAt: Date.now(),
249
- updatedAt: Date.now(),
250
- };
251
- for (const node of normalized) {
252
- node.workerKey = `${runId}:${node.id}`;
253
- run.nodes[node.id] = node;
102
+ if (livingTeammates().some((t) => t.name === teammate.name)) {
103
+ return { ok: false, error: `A living teammate named "${teammate.name}" already exists.` };
254
104
  }
255
- state.runs[runId] = run;
105
+ state.teammates[teammate.name] = teammate;
256
106
  markStateDirty();
257
- return { ok: true, run };
107
+ return { ok: true };
258
108
  }
259
109
 
260
- // ── Run / node queries ────────────────────────────────────────────
261
-
262
- export function getRun(runId: string): Run | undefined {
263
- return state.runs[runId];
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;
264
117
  }
265
118
 
266
- export function getNode(runId: string, nodeId: string): Node | undefined {
267
- return state.runs[runId]?.nodes[nodeId];
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();
136
+ markStateDirty();
137
+ return true;
268
138
  }
269
139
 
270
- export function getNodeByWorkerKey(workerKey: string): { run: Run; node: Node } | undefined {
271
- for (const run of Object.values(state.runs)) {
272
- const node = Object.values(run.nodes).find((candidate) => candidate.workerKey === workerKey);
273
- if (node) return { run, node };
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];
274
146
  }
275
- return undefined;
147
+ markStateDirty();
276
148
  }
277
149
 
278
- export function listRuns(): Run[] {
279
- return Object.values(state.runs).sort((a, b) => b.updatedAt - a.updatedAt);
280
- }
150
+ // ── Leader inbox ──────────────────────────────────────────────────
281
151
 
282
- export function listActiveRuns(): Run[] {
283
- return listRuns().filter((run) => run.status === "running");
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);
157
+ }
158
+ markStateDirty();
159
+ return full;
284
160
  }
285
161
 
286
- export function runningNodeCount(runId: string): number {
287
- const run = state.runs[runId];
288
- if (!run) return 0;
289
- return Object.values(run.nodes).filter((node) => node.status === "running").length;
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);
177
+ }
178
+ markStateDirty();
179
+ return true;
290
180
  }
291
181
 
292
- export function isRunTerminal(run: Run): boolean {
293
- return run.status === "completed" || run.status === "failed" || run.status === "cancelled";
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);
294
185
  }
295
186
 
296
- /** Claim a run's completion delivery so the follow-up is suppressed (foreground gather). */
297
- export function markRunCompletionDelivered(runId: string): void {
298
- const run = state.runs[runId];
299
- if (run) {
300
- run.completionNotified = true;
301
- markStateDirty();
302
- }
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();
303
194
  }
304
195
 
305
- /** Every node across every run (for console rows and liveness polls). */
306
- export function listNodes(): Node[] {
307
- return Object.values(state.runs).flatMap((run) => Object.values(run.nodes));
196
+ export function getPeerInboxOffset(inboxName: string): number {
197
+ return state.peerInboxOffsets[inboxName] ?? 0;
308
198
  }
309
199
 
310
- // ── Node lifecycle ────────────────────────────────────────────────
311
-
312
- export function markNodeRunning(runId: string, nodeId: string): { ok: boolean; error?: string } {
313
- const node = getNode(runId, nodeId);
314
- if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
315
- node.status = "running";
316
- node.updatedAt = Date.now();
317
- state.runs[runId].updatedAt = Date.now();
200
+ export function setPeerInboxOffset(inboxName: string, offset: number): void {
201
+ state.peerInboxOffsets[inboxName] = offset;
318
202
  markStateDirty();
319
- return { ok: true };
320
203
  }
321
204
 
322
- export function updateNodeStatus(
323
- runId: string,
324
- nodeId: string,
325
- status: NodeStatus,
326
- result?: string,
327
- errorMessage?: string,
328
- ): { ok: boolean; node?: Node; error?: string } {
329
- const node = getNode(runId, nodeId);
330
- if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
331
- node.status = status;
332
- node.updatedAt = Date.now();
333
- if (result !== undefined) node.result = result;
334
- if (errorMessage !== undefined) node.errorMessage = errorMessage;
335
- if (status === "completed" || status === "failed" || status === "cancelled") {
336
- node.completedAt = Date.now();
337
- }
338
- state.runs[runId].updatedAt = Date.now();
339
- markStateDirty();
340
- return { ok: true, node };
341
- }
205
+ // ── Board: creation and queries ───────────────────────────────────
342
206
 
343
- /**
344
- * Attach child-process execution info to a node and derive its status from
345
- * the spawn outcome (unless the node was cancelled).
346
- */
347
- export function setNodeSpawnInfo(runId: string, nodeId: string, info: SpawnInfo): { ok: boolean; node?: Node; error?: string } {
348
- const node = getNode(runId, nodeId);
349
- if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
350
- node.spawn = info;
351
- node.updatedAt = Date.now();
352
- state.runs[runId].updatedAt = Date.now();
353
-
354
- if (info.status === "running") {
355
- // A live spawn implies the node is running (idempotent with markNodeRunning).
356
- if (node.status === "pending") node.status = "running";
357
- } else if (info.status === "completed" && node.status !== "cancelled") {
358
- node.status = "completed";
359
- node.completedAt = Date.now();
360
- if (info.stdout && node.result === undefined) node.result = info.stdout;
361
- } else if (info.status === "failed" && node.status !== "cancelled") {
362
- node.status = "failed";
363
- node.completedAt = Date.now();
364
- node.errorMessage = info.error ?? info.stderr ?? `Child process exited with code ${info.exitCode ?? "unknown"}.`;
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.` };
218
+ }
219
+ for (const dep of dependsOn) {
220
+ if (!state.tasks[dep]) return { ok: false, error: `Task depends on unknown task "${dep}".` };
365
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;
366
234
  markStateDirty();
367
- return { ok: true, node };
235
+ return { ok: true, task };
368
236
  }
369
237
 
370
- /** Merge a streaming child-process update into its matching running node. */
371
- export function updateNodeSpawnProgress(
372
- runId: string,
373
- nodeId: string,
374
- spawnId: string,
375
- progress: Pick<SpawnInfo, "liveText" | "activeTool" | "liveThinking" | "turns" | "finalResponse">,
376
- ): { ok: boolean; node?: Node; error?: string } {
377
- const node = getNode(runId, nodeId);
378
- if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
379
- if (node.spawn?.spawnId !== spawnId || node.spawn.status !== "running") {
380
- return { ok: false, error: `Node "${nodeId}" is not running the expected worker.` };
381
- }
382
- node.spawn.liveText = progress.liveText;
383
- node.spawn.activeTool = progress.activeTool;
384
- node.spawn.liveThinking = progress.liveThinking;
385
- node.spawn.turns = progress.turns;
386
- node.spawn.finalResponse = progress.finalResponse;
387
- node.updatedAt = Date.now();
388
- state.runs[runId].updatedAt = Date.now();
389
- markStateDirty();
390
- return { ok: true, node };
238
+ export function getTask(taskId: string): BoardTask | undefined {
239
+ return state.tasks[taskId];
391
240
  }
392
241
 
393
- // ── Dependency readiness ──────────────────────────────────────────
242
+ export function listTasks(): BoardTask[] {
243
+ return Object.values(state.tasks).sort((a, b) => a.id.localeCompare(b.id));
244
+ }
394
245
 
395
- /** A node is ready when every dependency has completed. */
396
- export function nodeIsReady(run: Run, node: Node): boolean {
397
- return node.dependsOn.every((dep) => run.nodes[dep]?.status === "completed");
246
+ export function pendingTasks(): BoardTask[] {
247
+ return listTasks().filter((task) => task.status === "pending");
398
248
  }
399
249
 
400
- /** Nodes that are pending, have no unmet dependencies, and are not yet spawned. */
401
- export function readyPendingNodes(run: Run): Node[] {
402
- return Object.values(run.nodes)
403
- .filter((node) => node.status === "pending" && nodeIsReady(run, node))
404
- .sort((a, b) => a.id.localeCompare(b.id));
250
+ export function claimableTasks(): BoardTask[] {
251
+ return pendingTasks().filter(taskDependenciesMet);
405
252
  }
406
253
 
407
- /**
408
- * The running shared-workspace write node whose paths overlap this node's,
409
- * if any. Searched across EVERY run in the session: a write node deferred in
410
- * one run must also wait for an overlapping writer dispatched by another run.
411
- * Worktree-isolated nodes never conflict. This is advisory scheduling
412
- * coordination — it defers node starts; it does not isolate file access.
413
- */
414
- export function findSharedWorkspaceWriteConflict(runId: string, nodeId: string): Node | undefined {
415
- const run = state.runs[runId];
416
- const node = run?.nodes[nodeId];
417
- if (!run || !node || node.access !== "write") return undefined;
418
- for (const candidateRun of Object.values(state.runs)) {
419
- for (const other of Object.values(candidateRun.nodes)) {
420
- if (candidateRun.id === runId && other.id === node.id) continue;
421
- if (
422
- other.status === "running"
423
- && other.spawn?.status === "running"
424
- && other.spawn.isolation !== "worktree"
425
- && other.access === "write"
426
- && node.paths.some((path) => other.paths.some((otherPath) => pathsOverlap(path, otherPath)))
427
- ) {
428
- return other;
429
- }
430
- }
431
- }
432
- return undefined;
433
- }
434
-
435
- /** Cancel pending nodes that transitively depend on the given node. */
436
- export function cancelBlockedDependents(runId: string, nodeId: string): number {
437
- const run = state.runs[runId];
438
- if (!run) return 0;
439
- const cancelled = new Set<string>([nodeId]);
440
- let changed = true;
441
- while (changed) {
442
- changed = false;
443
- for (const node of Object.values(run.nodes)) {
444
- if (node.status !== "pending" || cancelled.has(node.id)) continue;
445
- if (node.dependsOn.some((dep) => cancelled.has(dep))) {
446
- node.status = "cancelled";
447
- node.completedAt = Date.now();
448
- node.updatedAt = Date.now();
449
- cancelled.add(node.id);
450
- changed = true;
451
- }
452
- }
453
- }
454
- run.updatedAt = Date.now();
455
- if (cancelled.size > 1) markStateDirty();
456
- return cancelled.size - 1;
254
+ export function taskDependenciesMet(task: BoardTask): boolean {
255
+ return task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
457
256
  }
458
257
 
459
- // ── Run settlement ────────────────────────────────────────────────
258
+ /** First claimable task, or undefined. */
259
+ export function firstClaimableTask(): BoardTask | undefined {
260
+ return claimableTasks()[0];
261
+ }
460
262
 
461
- /**
462
- * Recompute a running run's status from its nodes. Settles only when no node
463
- * is running or pending: any failed node fails the run, an all-cancelled run
464
- * is cancelled, otherwise the run completed.
465
- */
466
- export function settleRun(runId: string): RunStatus | undefined {
467
- const run = state.runs[runId];
468
- if (!run) return undefined;
469
- if (run.status !== "running") return run.status;
470
- const nodes = Object.values(run.nodes);
471
- const anyActive = nodes.some((node) => node.status === "running" || node.status === "pending");
472
- if (anyActive) return "running";
473
- if (nodes.some((node) => node.status === "failed")) {
474
- run.status = "failed";
475
- } else if (nodes.every((node) => node.status === "cancelled")) {
476
- run.status = "cancelled";
477
- } else {
478
- run.status = "completed";
479
- }
480
- run.finishedAt = Date.now();
481
- 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();
482
269
  markStateDirty();
483
- return run.status;
270
+ return task;
484
271
  }
485
272
 
486
- // ── Cancel / cleanup ──────────────────────────────────────────────
487
-
488
- /**
489
- * Mark a running run's pending nodes cancelled. Returns the ids of nodes with
490
- * live workers that the caller must terminate.
491
- */
492
- export function cancelRun(runId: string): { ok: boolean; runningNodeIds: string[]; error?: string } {
493
- const run = state.runs[runId];
494
- if (!run) return { ok: false, runningNodeIds: [], error: `Run "${runId}" not found.` };
495
- if (isRunTerminal(run)) return { ok: false, runningNodeIds: [], error: `Run "${runId}" is already ${run.status}.` };
496
- const runningNodeIds: string[] = [];
497
- for (const node of Object.values(run.nodes)) {
498
- if (node.status === "running") {
499
- runningNodeIds.push(node.id);
500
- } else if (node.status === "pending") {
501
- node.status = "cancelled";
502
- node.completedAt = Date.now();
503
- node.updatedAt = Date.now();
504
- }
505
- }
506
- run.updatedAt = Date.now();
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();
507
281
  markStateDirty();
508
- return { ok: true, runningNodeIds };
509
- }
510
-
511
- /** Cancel a single node (and its not-yet-started transitive dependents).
512
- * Returns the ids of nodes with live workers the caller must terminate,
513
- * including running dependents whose cancelled prerequisites invalidate them. */
514
- export function cancelNode(runId: string, nodeId: string): { ok: boolean; runningNodeIds: string[]; error?: string } {
515
- const run = state.runs[runId];
516
- const node = run?.nodes[nodeId];
517
- if (!run || !node) return { ok: false, runningNodeIds: [], error: `Node "${nodeId}" not found in run "${runId}".` };
518
- if (node.status === "completed" || node.status === "failed" || node.status === "cancelled") {
519
- return { ok: false, runningNodeIds: [], error: `Node "${nodeId}" is already ${node.status}.` };
520
- }
521
-
522
- // Cancel pending transitive dependents of the target.
523
- cancelBlockedDependents(runId, nodeId);
524
-
525
- // Propagate to RUNNING dependents whose (now cancelled) prerequisites make
526
- // their remaining work meaningless; the caller terminates their workers and
527
- // finalizeNode records them cancelled.
528
- const runningDependents: string[] = [];
529
- const invalidated = new Set<string>([nodeId]);
530
- let changed = true;
531
- while (changed) {
532
- changed = false;
533
- for (const candidate of Object.values(run.nodes)) {
534
- if (candidate.status === "cancelled") {
535
- invalidated.add(candidate.id);
536
- continue;
537
- }
538
- if (candidate.status === "running" && !invalidated.has(candidate.id)
539
- && candidate.dependsOn.some((dep) => invalidated.has(dep))) {
540
- invalidated.add(candidate.id);
541
- runningDependents.push(candidate.id);
542
- changed = true;
543
- }
544
- }
545
- }
282
+ return task;
283
+ }
546
284
 
547
- const runningNodeIds: string[] = [];
548
- if (node.status === "running") {
549
- runningNodeIds.push(node.id);
550
- } else if (node.status === "pending") {
551
- node.status = "cancelled";
552
- node.completedAt = Date.now();
553
- node.updatedAt = Date.now();
554
- }
555
- for (const id of runningDependents) {
556
- if (!runningNodeIds.includes(id)) runningNodeIds.push(id);
557
- }
558
- run.updatedAt = Date.now();
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();
559
293
  markStateDirty();
560
- return { ok: true, runningNodeIds };
294
+ return task;
561
295
  }
562
296
 
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
- }
576
-
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
- }
589
-
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
- }
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);
602
304
  }
603
305
  }
604
-
605
- if (resetSet.size === 0) {
606
- return { ok: false, reset: [], error: "No failed or cancelled nodes to retry." };
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"}` };
607
327
  }
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.nodeFollowUpSent = undefined;
616
- node.updatedAt = Date.now();
328
+ if (intent.status === "failed") {
329
+ releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
330
+ return { ok: true };
617
331
  }
618
- const reset = [...resetSet];
619
- run.status = "running";
620
- run.errorMessage = undefined;
621
- run.finishedAt = undefined;
622
- run.completionNotified = false;
623
- run.settledMessageSent = false;
624
- run.updatedAt = Date.now();
625
- markStateDirty();
626
- return { ok: true, reset };
627
- }
628
-
629
- /** Drop per-spawn replay metadata after its final state snapshot was persisted. */
630
- export function clearWorkerRunEvents(workerKey: string, spawnId: string): void {
631
- const outboxKey = `${workerKey}:${spawnId}`;
632
- delete state.workerEventOffsets[outboxKey];
633
- for (const id of Object.keys(state.workerEventIds)) {
634
- if (id.startsWith(`${spawnId}:`)) delete state.workerEventIds[id];
635
- }
636
- markStateDirty();
637
- }
638
-
639
- // ── Message storage ───────────────────────────────────────────────
640
-
641
- /** Deliver a message to the single leader inbox. */
642
- export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">): MailboxMessage {
643
- const full: MailboxMessage = {
644
- ...msg,
645
- id: nextMessageId(),
646
- timestamp: Date.now(),
647
- };
648
- state.leaderMailbox.push(full);
649
- markStateDirty();
650
- return full;
651
- }
652
-
653
- /** Apply a validated worker event exactly once by event id. */
654
- export function receiveWorkerMessage(event: WorkerMessageEvent): boolean {
655
- const sender = getNodeByWorkerKey(event.worker);
656
- if (!sender || sender.node.spawn?.spawnId !== event.spawnId) return false;
657
- if (state.leaderMailbox.some((message) => message.id === event.id)) return false;
658
- state.leaderMailbox.push({
659
- id: event.id,
660
- from: event.worker,
661
- subject: event.subject,
662
- body: event.body,
663
- runId: sender.run.id,
664
- timestamp: Date.now(),
665
- });
666
- markStateDirty();
667
- return true;
332
+ completeTask(intent.taskId, intent.result?.trim() || undefined);
333
+ return { ok: true };
668
334
  }
669
335
 
670
336
  // ── State inspection ──────────────────────────────────────────────
671
337
 
672
- export function getState(): TeammateState {
338
+ export function getState(): TeamState {
673
339
  return state;
674
340
  }
675
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
+
676
361
  export function getSummary(): string | undefined {
677
- const runCount = Object.keys(state.runs).length;
678
- if (runCount === 0) return undefined;
679
- const messageCount = state.leaderMailbox.length;
680
- const activeNodes = listNodes().filter((node) => node.status === "running" || node.status === "pending").length;
681
- 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`;
682
369
  }