@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/.memory/MEMORY.md +3 -1
- package/.memory/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +18 -0
- package/.memory/team-centric-design.md +21 -0
- package/README.md +99 -62
- package/agents/observer.md +1 -1
- package/agents/reviewer.md +4 -2
- package/agents/specialist.md +1 -1
- package/agents/worker.md +3 -3
- package/package.json +3 -3
- package/src/activity.ts +86 -0
- package/src/agents.ts +58 -21
- package/src/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +237 -0
- package/src/guidance.ts +81 -57
- package/src/index.ts +119 -38
- package/src/spawner.ts +217 -393
- package/src/state.ts +253 -568
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +946 -0
- package/src/tools.ts +181 -118
- package/src/types.ts +157 -155
- package/src/ui.ts +327 -268
- package/src/worker.ts +172 -31
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/src/run-machine.ts +0 -573
- 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,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
|
-
// ──
|
|
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
|
-
|
|
68
|
+
const NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
|
|
227
69
|
|
|
228
|
-
export function
|
|
229
|
-
return
|
|
70
|
+
export function isValidTeammateName(name: string): boolean {
|
|
71
|
+
return NAME_PATTERN.test(name);
|
|
230
72
|
}
|
|
231
73
|
|
|
232
|
-
export function
|
|
233
|
-
return state.
|
|
74
|
+
export function getTeammate(name: string): Teammate | undefined {
|
|
75
|
+
return state.teammates[name];
|
|
234
76
|
}
|
|
235
77
|
|
|
236
|
-
export function
|
|
237
|
-
|
|
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
|
|
245
|
-
return
|
|
82
|
+
export function livingTeammates(): Teammate[] {
|
|
83
|
+
return listTeammates().filter((t) => t.status !== "stopped");
|
|
246
84
|
}
|
|
247
85
|
|
|
248
|
-
export function
|
|
249
|
-
return
|
|
86
|
+
export function idleTeammates(): Teammate[] {
|
|
87
|
+
return livingTeammates().filter((t) => t.status === "idle");
|
|
250
88
|
}
|
|
251
89
|
|
|
252
|
-
export function
|
|
253
|
-
|
|
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
|
|
259
|
-
return
|
|
94
|
+
export function findTeammateBySpawn(spawnId: string): Teammate | undefined {
|
|
95
|
+
return listTeammates().find((t) => t.spawnId === spawnId && t.status !== "stopped");
|
|
260
96
|
}
|
|
261
97
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
137
|
+
return true;
|
|
286
138
|
}
|
|
287
139
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
159
|
+
return full;
|
|
334
160
|
}
|
|
335
161
|
|
|
336
|
-
/**
|
|
337
|
-
export function
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
179
|
+
return true;
|
|
357
180
|
}
|
|
358
181
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
/**
|
|
367
|
-
export function
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
205
|
+
// ── Board: creation and queries ───────────────────────────────────
|
|
453
206
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
|
270
|
+
return task;
|
|
629
271
|
}
|
|
630
272
|
|
|
631
|
-
/**
|
|
632
|
-
export function
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
294
|
+
return task;
|
|
653
295
|
}
|
|
654
296
|
|
|
655
|
-
/**
|
|
656
|
-
export function
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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():
|
|
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
|
|
680
|
-
if (
|
|
681
|
-
const
|
|
682
|
-
const
|
|
683
|
-
|
|
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
|
}
|