@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/.memory/MEMORY.md +3 -1
- package/.memory/node-terminal-followups.md +3 -2
- package/.memory/team-centric-design.md +21 -0
- package/README.md +103 -63
- package/package.json +4 -4
- package/references/agent-roles.md +77 -0
- package/src/activity.ts +86 -0
- package/src/agents.ts +93 -24
- package/src/follow-up-queue.ts +94 -26
- package/src/guidance.ts +96 -61
- package/src/index.ts +114 -28
- package/src/spawner.ts +194 -350
- package/src/state.ts +252 -565
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +968 -0
- package/src/tools.ts +160 -175
- package/src/types.ts +153 -189
- package/src/ui.ts +459 -226
- package/src/worker.ts +174 -34
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/agents/observer.md +0 -10
- package/agents/reviewer.md +0 -11
- package/agents/specialist.md +0 -10
- package/agents/worker.md +0 -11
- package/src/run-machine.ts +0 -606
- package/src/terminal.ts +0 -34
package/src/state.ts
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
24
|
+
teammates: {},
|
|
25
|
+
tasks: {},
|
|
13
26
|
leaderMailbox: [],
|
|
14
27
|
messageCounter: 0,
|
|
15
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
|
92
|
-
return
|
|
70
|
+
export function isValidTeammateName(name: string): boolean {
|
|
71
|
+
return NAME_PATTERN.test(name);
|
|
93
72
|
}
|
|
94
73
|
|
|
95
|
-
export function
|
|
96
|
-
|
|
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
|
|
102
|
-
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
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
|
-
|
|
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.
|
|
105
|
+
state.teammates[teammate.name] = teammate;
|
|
256
106
|
markStateDirty();
|
|
257
|
-
return { ok: true
|
|
107
|
+
return { ok: true };
|
|
258
108
|
}
|
|
259
109
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
147
|
+
markStateDirty();
|
|
276
148
|
}
|
|
277
149
|
|
|
278
|
-
|
|
279
|
-
return Object.values(state.runs).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
280
|
-
}
|
|
150
|
+
// ── Leader inbox ──────────────────────────────────────────────────
|
|
281
151
|
|
|
282
|
-
export function
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
/**
|
|
297
|
-
export function
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (
|
|
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,
|
|
235
|
+
return { ok: true, task };
|
|
368
236
|
}
|
|
369
237
|
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
242
|
+
export function listTasks(): BoardTask[] {
|
|
243
|
+
return Object.values(state.tasks).sort((a, b) => a.id.localeCompare(b.id));
|
|
244
|
+
}
|
|
394
245
|
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
+
/** First claimable task, or undefined. */
|
|
259
|
+
export function firstClaimableTask(): BoardTask | undefined {
|
|
260
|
+
return claimableTasks()[0];
|
|
261
|
+
}
|
|
460
262
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
270
|
+
return task;
|
|
484
271
|
}
|
|
485
272
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
294
|
+
return task;
|
|
561
295
|
}
|
|
562
296
|
|
|
563
|
-
/**
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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():
|
|
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
|
|
678
|
-
if (
|
|
679
|
-
const
|
|
680
|
-
const
|
|
681
|
-
|
|
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
|
}
|