@fradser/pi-agent-teams 0.7.0 → 0.8.2
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 +1 -0
- package/.memory/follow-up-queue.md +10 -6
- package/.memory/team-centric-design.md +3 -3
- package/README.md +48 -22
- package/package.json +2 -2
- package/references/agent-roles.md +8 -2
- package/src/agents.ts +100 -6
- package/src/follow-up-queue.ts +84 -21
- package/src/guidance.ts +141 -44
- package/src/index.ts +91 -56
- package/src/picker-keys.ts +30 -0
- package/src/spawner.ts +63 -3
- package/src/state.ts +266 -26
- package/src/statefile.ts +14 -7
- package/src/team-machine.ts +1012 -106
- package/src/tool-render.ts +48 -0
- package/src/tools.ts +120 -78
- package/src/types.ts +70 -7
- package/src/ui.ts +204 -52
- package/src/worker.ts +182 -19
- package/src/worktree.ts +46 -9
package/src/spawner.ts
CHANGED
|
@@ -44,6 +44,12 @@ export interface WorkerProgressUpdate {
|
|
|
44
44
|
turns: number;
|
|
45
45
|
/** True after the current sequence's final response. */
|
|
46
46
|
finalResponse?: boolean;
|
|
47
|
+
/** True once the stream delivered recognized model activity (text, thinking,
|
|
48
|
+
* tool-call, or tool execution events). Independent of usage totals so
|
|
49
|
+
* providers that omit usage are not misclassified as silent. */
|
|
50
|
+
modelOutputSeen?: boolean;
|
|
51
|
+
/** Lifetime accumulated usage parsed from message_end events. */
|
|
52
|
+
usage?: WorkerUsage;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/** A crashed teammate is one that closed without a normal zero exit. */
|
|
@@ -51,6 +57,47 @@ export function isCleanExit(result: Pick<WorkerProcessResult, "exitCode" | "sign
|
|
|
51
57
|
return result.exitCode === 0 && result.signal === null;
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/** Tools every teammate receives regardless of its role definition. */
|
|
61
|
+
export const WORKER_CAPABILITY_TOOLS: readonly string[] = [
|
|
62
|
+
"send_message",
|
|
63
|
+
"task_list",
|
|
64
|
+
"task_claim",
|
|
65
|
+
"task_submit",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Effective tool allowlist for one teammate: the role's requested tools plus
|
|
69
|
+
* the capability set, deduplicated in request order. Roles without a tools
|
|
70
|
+
* field get exactly the capability set — leaders should see that narrow
|
|
71
|
+
* grant before the first wake so missing read/bash is obvious at spawn time. */
|
|
72
|
+
export function resolveWorkerTools(requested?: string[]): string[] {
|
|
73
|
+
const requestedOnly = (requested ?? []).filter((tool) => !WORKER_CAPABILITY_TOOLS.includes(tool));
|
|
74
|
+
return [...new Set([...requestedOnly, ...WORKER_CAPABILITY_TOOLS])];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Pi built-in tool ids a bare teammate process registers. The child runs
|
|
78
|
+
* --no-extensions, so this list (plus the capability set) is the complete
|
|
79
|
+
* grantable universe; anything else is silently dropped by the child's
|
|
80
|
+
* --tools allowlist filter. */
|
|
81
|
+
export const WORKER_BUILTIN_TOOLS: readonly string[] = [
|
|
82
|
+
"read",
|
|
83
|
+
"bash",
|
|
84
|
+
"edit",
|
|
85
|
+
"write",
|
|
86
|
+
"grep",
|
|
87
|
+
"find",
|
|
88
|
+
"ls",
|
|
89
|
+
"powershell",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** Every tool id a teammate can actually receive: pi built-ins plus the capability set. */
|
|
93
|
+
export const WORKER_TOOL_UNIVERSE: readonly string[] = [...WORKER_BUILTIN_TOOLS, ...WORKER_CAPABILITY_TOOLS];
|
|
94
|
+
|
|
95
|
+
/** Requested tool ids outside the teammate universe — exactly the ones the
|
|
96
|
+
* child's --tools allowlist would silently drop. */
|
|
97
|
+
export function unknownWorkerTools(requested?: string[]): string[] {
|
|
98
|
+
return [...new Set((requested ?? []).filter((tool) => !WORKER_TOOL_UNIVERSE.includes(tool)))];
|
|
99
|
+
}
|
|
100
|
+
|
|
54
101
|
// ── Process registry ──────────────────────────────────────────────
|
|
55
102
|
|
|
56
103
|
/** Live children by teammate name — powers steering, prompting, and shutdown. */
|
|
@@ -164,6 +211,9 @@ interface StreamState {
|
|
|
164
211
|
/** Lifetime count of completed assistant messages. */
|
|
165
212
|
turns: number;
|
|
166
213
|
finalResponse?: boolean;
|
|
214
|
+
/** Set by any recognized model/stream activity; never by a bare empty
|
|
215
|
+
* message_end artifact. The stall classifier uses this, not usage. */
|
|
216
|
+
modelOutputSeen?: boolean;
|
|
167
217
|
usage?: WorkerUsage;
|
|
168
218
|
}
|
|
169
219
|
|
|
@@ -217,11 +267,13 @@ function applyStreamLine(state: StreamState, line: string): boolean {
|
|
|
217
267
|
return false;
|
|
218
268
|
}
|
|
219
269
|
if (event.type === "tool_execution_start") {
|
|
270
|
+
state.modelOutputSeen = true;
|
|
220
271
|
state.activeTools.set(event.toolCallId ?? `tool-${state.activeTools.size}`, toolExecutionLabel(event.toolName, event.args));
|
|
221
272
|
state.activeTool = [...state.activeTools.values()].at(-1);
|
|
222
273
|
return true;
|
|
223
274
|
}
|
|
224
275
|
if (event.type === "tool_execution_end") {
|
|
276
|
+
state.modelOutputSeen = true;
|
|
225
277
|
if (event.toolCallId) state.activeTools.delete(event.toolCallId);
|
|
226
278
|
else state.activeTools.clear();
|
|
227
279
|
state.activeTool = [...state.activeTools.values()].at(-1);
|
|
@@ -235,7 +287,10 @@ function applyStreamLine(state: StreamState, line: string): boolean {
|
|
|
235
287
|
if (event.message.stopReason === "stop") state.finalResponse = true;
|
|
236
288
|
const parts = extractTextContent(event.message.content, "");
|
|
237
289
|
if (parts.trim()) state.text = parts;
|
|
290
|
+
// Usage stays diagnostics: only streamed content counts as model output,
|
|
291
|
+
// so input-only or failed responses cannot bypass the zero-output tier.
|
|
238
292
|
const u = event.message.usage;
|
|
293
|
+
if (parts.trim()) state.modelOutputSeen = true;
|
|
239
294
|
if (u) {
|
|
240
295
|
state.usage = {
|
|
241
296
|
input: (state.usage?.input ?? 0) + (u.input ?? 0),
|
|
@@ -252,23 +307,28 @@ function applyStreamLine(state: StreamState, line: string): boolean {
|
|
|
252
307
|
if (!sub) return false;
|
|
253
308
|
switch (sub.type) {
|
|
254
309
|
case "text_delta":
|
|
310
|
+
state.modelOutputSeen = true;
|
|
255
311
|
state.activeTool = undefined;
|
|
256
312
|
state.text += sub.delta ?? "";
|
|
257
313
|
return true;
|
|
258
314
|
case "thinking_delta":
|
|
315
|
+
state.modelOutputSeen = true;
|
|
259
316
|
state.activeTool = undefined;
|
|
260
317
|
state.thinking += sub.delta ?? "";
|
|
261
318
|
return true;
|
|
262
319
|
case "toolcall_start":
|
|
320
|
+
state.modelOutputSeen = true;
|
|
263
321
|
clearActiveTools(state);
|
|
264
322
|
return true;
|
|
265
323
|
case "toolcall_delta": {
|
|
324
|
+
state.modelOutputSeen = true;
|
|
266
325
|
state.toolcallArgs += sub.delta ?? "";
|
|
267
326
|
const label = toolcallLabel(state.toolcallArgs);
|
|
268
327
|
if (label) state.activeTool = label;
|
|
269
328
|
return true;
|
|
270
329
|
}
|
|
271
330
|
case "toolcall_end":
|
|
331
|
+
state.modelOutputSeen = true;
|
|
272
332
|
// Execution events own the activity label until the result arrives.
|
|
273
333
|
clearActiveTools(state);
|
|
274
334
|
return true;
|
|
@@ -331,9 +391,7 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
|
|
|
331
391
|
"--extension", WORKER_EXTENSION,
|
|
332
392
|
];
|
|
333
393
|
if (options.model) args.push("--model", options.model);
|
|
334
|
-
|
|
335
|
-
const requestedTools = (options.tools ?? []).filter((tool) => !capabilityTools.includes(tool));
|
|
336
|
-
args.push("--tools", [...new Set([...requestedTools, ...capabilityTools])].join(","));
|
|
394
|
+
args.push("--tools", resolveWorkerTools(options.tools).join(","));
|
|
337
395
|
|
|
338
396
|
let tempDir: string | undefined;
|
|
339
397
|
const cleanupTempDir = () => {
|
|
@@ -378,6 +436,8 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
|
|
|
378
436
|
liveThinking: truncate(streamState.thinking, OUTPUT_CAP),
|
|
379
437
|
turns: Math.max(0, streamState.turns - (baselines.get(options.workerName) ?? 0)),
|
|
380
438
|
finalResponse: streamState.finalResponse,
|
|
439
|
+
modelOutputSeen: streamState.modelOutputSeen,
|
|
440
|
+
usage: streamState.usage,
|
|
381
441
|
});
|
|
382
442
|
|
|
383
443
|
child.stdout?.on("data", (chunk: Buffer) => {
|
package/src/state.ts
CHANGED
|
@@ -7,29 +7,34 @@
|
|
|
7
7
|
import {
|
|
8
8
|
messageTitle,
|
|
9
9
|
type BoardTask,
|
|
10
|
+
type WorkerAssignment,
|
|
10
11
|
type MailboxMessage,
|
|
11
12
|
type TaskIntent,
|
|
12
13
|
type Teammate,
|
|
13
14
|
type TeamState,
|
|
14
15
|
type WorkerReportEvent,
|
|
16
|
+
type WorkerUsage,
|
|
15
17
|
} from "./types.ts";
|
|
18
|
+
import { nonEmpty } from "@fradser/pi-kit";
|
|
16
19
|
|
|
17
20
|
export const MAX_LEADER_MAILBOX_MESSAGES = 4096;
|
|
18
21
|
/** FIFO cap of remembered peer message ids per inbox (dedup guard). */
|
|
19
22
|
export const MAX_PEER_DELIVERED_IDS = 512;
|
|
23
|
+
/** Bounded forensic routing history; mailbox files retain the full peer transcript. */
|
|
24
|
+
export const MAX_PEER_DELIVERY_STATES = 4096;
|
|
20
25
|
export const MAX_TASK_DEPENDENCIES = 32;
|
|
21
26
|
|
|
22
27
|
function emptyState(): TeamState {
|
|
23
28
|
return {
|
|
24
29
|
teammates: {},
|
|
25
|
-
tasks:
|
|
30
|
+
tasks: emptyTaskMap(),
|
|
26
31
|
leaderMailbox: [],
|
|
27
32
|
messageCounter: 0,
|
|
28
|
-
taskCounter: 0,
|
|
29
33
|
workerEventOffsets: {},
|
|
30
34
|
workerEventIds: {},
|
|
31
35
|
peerInboxOffsets: {},
|
|
32
36
|
peerDeliveredIds: {},
|
|
37
|
+
peerDeliveryStates: {},
|
|
33
38
|
};
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -58,11 +63,30 @@ function nextMessageId(): string {
|
|
|
58
63
|
return `msg_${++state.messageCounter}`;
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
/** Task maps must not inherit Object.prototype: slug ids like "constructor"
|
|
67
|
+
* are legal task ids and must never alias inherited properties. */
|
|
68
|
+
function emptyTaskMap(): Record<string, BoardTask> {
|
|
69
|
+
return Object.create(null);
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
export function resetState(): void {
|
|
62
73
|
state = emptyState();
|
|
63
74
|
markStateDirty();
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
// ── Team default model ──────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** The unified teammate model for this session, or undefined when Pi picks. */
|
|
80
|
+
export function getTeamDefaultModel(): string | undefined {
|
|
81
|
+
return state.defaultModel;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Set (or clear with undefined) the unified teammate model for later spawns. */
|
|
85
|
+
export function setTeamDefaultModel(ref: string | undefined): void {
|
|
86
|
+
state.defaultModel = nonEmpty(ref);
|
|
87
|
+
markStateDirty();
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
// ── Roster queries ────────────────────────────────────────────────
|
|
67
91
|
|
|
68
92
|
const NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
|
|
@@ -117,10 +141,26 @@ export function updateTeammate(name: string, patch: Partial<Teammate>): Teammate
|
|
|
117
141
|
}
|
|
118
142
|
|
|
119
143
|
/** Merge streaming child-process progress into a living teammate. */
|
|
144
|
+
export function assignTeammate(
|
|
145
|
+
name: string,
|
|
146
|
+
assignment: WorkerAssignment | undefined,
|
|
147
|
+
currentTaskId?: string,
|
|
148
|
+
): Teammate | undefined {
|
|
149
|
+
const teammate = getTeammate(name);
|
|
150
|
+
if (!teammate) return undefined;
|
|
151
|
+
const lastAssignment = assignment ?? teammate.assignment ?? teammate.lastAssignment;
|
|
152
|
+
const lastTaskId = currentTaskId ?? teammate.currentTaskId ?? teammate.lastTaskId;
|
|
153
|
+
return updateTeammate(name, { assignment, currentTaskId, lastAssignment, lastTaskId });
|
|
154
|
+
}
|
|
155
|
+
|
|
120
156
|
export function updateTeammateProgress(
|
|
121
157
|
name: string,
|
|
122
158
|
spawnId: string,
|
|
123
|
-
progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & {
|
|
159
|
+
progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & {
|
|
160
|
+
sequenceEnded?: boolean;
|
|
161
|
+
modelOutputSeen?: boolean;
|
|
162
|
+
usage?: WorkerUsage;
|
|
163
|
+
},
|
|
124
164
|
): boolean {
|
|
125
165
|
const teammate = state.teammates[name];
|
|
126
166
|
if (!teammate || teammate.spawnId !== spawnId) return false;
|
|
@@ -129,6 +169,8 @@ export function updateTeammateProgress(
|
|
|
129
169
|
teammate.liveThinking = progress.liveThinking;
|
|
130
170
|
teammate.turns = progress.turns;
|
|
131
171
|
if (progress.sequenceEnded !== undefined) teammate.sequenceEnded = progress.sequenceEnded;
|
|
172
|
+
if (progress.modelOutputSeen) teammate.modelOutputSeen = true;
|
|
173
|
+
if (progress.usage) teammate.usage = progress.usage;
|
|
132
174
|
if (teammate.status === "starting") {
|
|
133
175
|
teammate.status = progress.sequenceEnded ? "idle" : "working";
|
|
134
176
|
}
|
|
@@ -160,7 +202,7 @@ export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">):
|
|
|
160
202
|
}
|
|
161
203
|
|
|
162
204
|
/** Apply a validated report event exactly once by event id. */
|
|
163
|
-
export function receiveWorkerMessage(event: WorkerReportEvent): boolean {
|
|
205
|
+
export function receiveWorkerMessage(event: WorkerReportEvent, options?: { archived?: boolean }): boolean {
|
|
164
206
|
const sender = getTeammate(event.worker);
|
|
165
207
|
if (!sender || sender.spawnId !== event.spawnId) return false;
|
|
166
208
|
if (state.leaderMailbox.some((message) => message.id === event.id)) return false;
|
|
@@ -170,7 +212,10 @@ export function receiveWorkerMessage(event: WorkerReportEvent): boolean {
|
|
|
170
212
|
subject: messageTitle(event.body),
|
|
171
213
|
body: event.body,
|
|
172
214
|
status: event.status,
|
|
173
|
-
|
|
215
|
+
archived: options?.archived,
|
|
216
|
+
// Keep the authored-at moment so console ordering reflects when the
|
|
217
|
+
// teammate spoke, not when the leader happened to drain the outbox.
|
|
218
|
+
timestamp: event.timestamp ?? Date.now(),
|
|
174
219
|
});
|
|
175
220
|
if (state.leaderMailbox.length > MAX_LEADER_MAILBOX_MESSAGES) {
|
|
176
221
|
state.leaderMailbox.splice(0, state.leaderMailbox.length - MAX_LEADER_MAILBOX_MESSAGES);
|
|
@@ -202,37 +247,199 @@ export function setPeerInboxOffset(inboxName: string, offset: number): void {
|
|
|
202
247
|
markStateDirty();
|
|
203
248
|
}
|
|
204
249
|
|
|
250
|
+
/** Record only the harness-controlled routing transition, never recipient read. */
|
|
251
|
+
export function setPeerDeliveryState(messageId: string, routing: "queued" | "routed"): void {
|
|
252
|
+
state.peerDeliveryStates ??= {};
|
|
253
|
+
if (!(messageId in state.peerDeliveryStates)
|
|
254
|
+
&& Object.keys(state.peerDeliveryStates).length >= MAX_PEER_DELIVERY_STATES) {
|
|
255
|
+
const oldest = Object.keys(state.peerDeliveryStates)[0];
|
|
256
|
+
if (oldest) delete state.peerDeliveryStates[oldest];
|
|
257
|
+
}
|
|
258
|
+
state.peerDeliveryStates[messageId] = routing;
|
|
259
|
+
markStateDirty();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function getPeerDeliveryState(messageId: string): "queued" | "routed" | undefined {
|
|
263
|
+
return state.peerDeliveryStates?.[messageId];
|
|
264
|
+
}
|
|
265
|
+
|
|
205
266
|
// ── Board: creation and queries ───────────────────────────────────
|
|
206
267
|
|
|
268
|
+
/** Maximum characters in a generated slug task id (suffix included). */
|
|
269
|
+
const MAX_TASK_ID_LENGTH = 48;
|
|
270
|
+
|
|
271
|
+
/** A readable, filesystem-safe id derived from the subject:
|
|
272
|
+
* "Polish login flow" -> "polish-login-flow"; duplicates get -2, -3, ... */
|
|
273
|
+
export function taskIdFromSubject(subject: string, taken: ReadonlySet<string>): string {
|
|
274
|
+
const base = subject
|
|
275
|
+
.toLowerCase()
|
|
276
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
277
|
+
.replace(/^-+|-+$/g, "")
|
|
278
|
+
.slice(0, MAX_TASK_ID_LENGTH)
|
|
279
|
+
.replace(/-+$/g, "") || "task";
|
|
280
|
+
let id = base;
|
|
281
|
+
for (let n = 2; taken.has(id); n++) {
|
|
282
|
+
const suffix = `-${n}`;
|
|
283
|
+
id = base.slice(0, Math.max(1, MAX_TASK_ID_LENGTH - suffix.length)).replace(/-+$/g, "") + suffix;
|
|
284
|
+
}
|
|
285
|
+
return id;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function normalizeResources(resources: readonly string[] | undefined): string[] {
|
|
289
|
+
return [...new Set((resources ?? [])
|
|
290
|
+
.map((resource) => resource.trim().replace(/^\/+|\/+$/g, ""))
|
|
291
|
+
.filter(Boolean))]
|
|
292
|
+
.sort();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Follow an obsolete dependency to its latest replacement. An invalid persisted
|
|
296
|
+
* chain is rejected rather than silently making a future task unclaimable. */
|
|
297
|
+
export function canonicalDependency(taskId: string, tasks: Record<string, BoardTask>): string | undefined {
|
|
298
|
+
const seen = new Set<string>();
|
|
299
|
+
let current = taskId;
|
|
300
|
+
while (true) {
|
|
301
|
+
if (seen.has(current)) return undefined;
|
|
302
|
+
seen.add(current);
|
|
303
|
+
const task = tasks[current];
|
|
304
|
+
if (!task) return undefined;
|
|
305
|
+
if (task.status !== "superseded") return current;
|
|
306
|
+
if (!task.supersededBy) return undefined;
|
|
307
|
+
current = task.supersededBy;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function canonicalDependencies(
|
|
312
|
+
dependencies: readonly string[],
|
|
313
|
+
tasks: Record<string, BoardTask>,
|
|
314
|
+
): string[] | undefined {
|
|
315
|
+
const canonical: string[] = [];
|
|
316
|
+
for (const dependency of dependencies) {
|
|
317
|
+
const resolved = canonicalDependency(dependency, tasks);
|
|
318
|
+
if (!resolved) return undefined;
|
|
319
|
+
if (!canonical.includes(resolved)) canonical.push(resolved);
|
|
320
|
+
}
|
|
321
|
+
return canonical;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function hasDependencyCycle(graph: Map<string, readonly string[]>): boolean {
|
|
325
|
+
const visiting = new Set<string>();
|
|
326
|
+
const visited = new Set<string>();
|
|
327
|
+
const visit = (taskId: string): boolean => {
|
|
328
|
+
if (visiting.has(taskId)) return true;
|
|
329
|
+
if (visited.has(taskId)) return false;
|
|
330
|
+
visiting.add(taskId);
|
|
331
|
+
for (const dependency of graph.get(taskId) ?? []) {
|
|
332
|
+
if (graph.has(dependency) && visit(dependency)) return true;
|
|
333
|
+
}
|
|
334
|
+
visiting.delete(taskId);
|
|
335
|
+
visited.add(taskId);
|
|
336
|
+
return false;
|
|
337
|
+
};
|
|
338
|
+
return [...graph.keys()].some(visit);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `firmware/sub-node` conflicts with itself and descendants such as
|
|
342
|
+
* `firmware/sub-node/app`; unrelated siblings stay concurrently claimable. */
|
|
343
|
+
export function resourcesConflict(left: readonly string[], right: readonly string[]): boolean {
|
|
344
|
+
return left.some((a) => right.some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function activeAssignmentConflict(
|
|
348
|
+
resources: readonly string[],
|
|
349
|
+
exceptWorker?: string,
|
|
350
|
+
): Teammate | undefined {
|
|
351
|
+
if (resources.length === 0) return undefined;
|
|
352
|
+
return livingTeammates().find((teammate) => {
|
|
353
|
+
if (teammate.name === exceptWorker) return false;
|
|
354
|
+
const assignment = teammate.assignment;
|
|
355
|
+
return assignment !== undefined && !assignment.closed && resourcesConflict(resources, assignment.resources);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
207
359
|
export function createTask(input: {
|
|
208
360
|
subject: string;
|
|
209
361
|
description?: string;
|
|
210
362
|
dependsOn?: string[];
|
|
211
363
|
verify?: string;
|
|
212
|
-
|
|
364
|
+
resources?: string[];
|
|
365
|
+
supersedes?: string[];
|
|
366
|
+
}): { ok: true; task: BoardTask; superseded: BoardTask[] } | { ok: false; error: string } {
|
|
213
367
|
const subject = input.subject.trim();
|
|
214
368
|
if (!subject) return { ok: false, error: "Task subject must not be empty." };
|
|
215
|
-
const
|
|
216
|
-
|
|
369
|
+
const requestedDependencies = [...new Set(input.dependsOn ?? [])];
|
|
370
|
+
const requestedSupersedes = [...new Set(input.supersedes ?? [])];
|
|
371
|
+
if (requestedDependencies.length > MAX_TASK_DEPENDENCIES) {
|
|
217
372
|
return { ok: false, error: `Task depends on more than ${MAX_TASK_DEPENDENCIES} tasks.` };
|
|
218
373
|
}
|
|
219
|
-
for (const
|
|
220
|
-
if (!state.tasks[
|
|
374
|
+
for (const taskId of [...requestedDependencies, ...requestedSupersedes]) {
|
|
375
|
+
if (!state.tasks[taskId]) return { ok: false, error: `Task references unknown task "${taskId}".` };
|
|
376
|
+
}
|
|
377
|
+
const completedTarget = requestedSupersedes.find((taskId) => state.tasks[taskId]?.status === "completed");
|
|
378
|
+
if (completedTarget) {
|
|
379
|
+
return { ok: false, error: `Task cannot supersede completed task "${completedTarget}".` };
|
|
380
|
+
}
|
|
381
|
+
const dependsOn = canonicalDependencies(requestedDependencies, state.tasks);
|
|
382
|
+
const supersedes = canonicalDependencies(requestedSupersedes, state.tasks);
|
|
383
|
+
if (!dependsOn || !supersedes) return { ok: false, error: "Task dependency or supersession chain is invalid or cyclic." };
|
|
384
|
+
const canonicalCompletedTarget = supersedes.find((taskId) => state.tasks[taskId]?.status === "completed");
|
|
385
|
+
if (canonicalCompletedTarget) {
|
|
386
|
+
return { ok: false, error: `Task cannot supersede completed task "${canonicalCompletedTarget}".` };
|
|
387
|
+
}
|
|
388
|
+
const selfReplacement = supersedes.find((taskId) => dependsOn.includes(taskId));
|
|
389
|
+
if (selfReplacement) {
|
|
390
|
+
return { ok: false, error: `Task cannot both depend on and supersede "${selfReplacement}".` };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const id = taskIdFromSubject(subject, new Set(Object.keys(state.tasks)));
|
|
394
|
+
const migrations = new Map<string, string[]>();
|
|
395
|
+
for (const dependent of Object.values(state.tasks)) {
|
|
396
|
+
if (dependent.status === "completed" || dependent.status === "superseded") continue;
|
|
397
|
+
const migrated = dependent.dependsOn.map((dependency) => supersedes.includes(dependency) ? id : dependency);
|
|
398
|
+
if (migrated.some((dependency, index) => dependency !== dependent.dependsOn[index])) {
|
|
399
|
+
migrations.set(dependent.id, [...new Set(migrated)]);
|
|
400
|
+
}
|
|
221
401
|
}
|
|
222
|
-
const
|
|
402
|
+
const prospectiveGraph = new Map<string, readonly string[]>();
|
|
403
|
+
for (const existing of Object.values(state.tasks)) {
|
|
404
|
+
prospectiveGraph.set(existing.id, migrations.get(existing.id) ?? existing.dependsOn);
|
|
405
|
+
}
|
|
406
|
+
prospectiveGraph.set(id, dependsOn);
|
|
407
|
+
if (hasDependencyCycle(prospectiveGraph)) {
|
|
408
|
+
return { ok: false, error: "Task supersession would create a dependency cycle." };
|
|
409
|
+
}
|
|
410
|
+
|
|
223
411
|
const task: BoardTask = {
|
|
224
412
|
id,
|
|
225
413
|
subject,
|
|
226
414
|
description: input.description?.trim() || undefined,
|
|
227
415
|
dependsOn,
|
|
228
416
|
verify: input.verify?.trim() || undefined,
|
|
417
|
+
resources: normalizeResources(input.resources),
|
|
229
418
|
status: "pending",
|
|
230
419
|
createdAt: Date.now(),
|
|
231
420
|
updatedAt: Date.now(),
|
|
232
421
|
};
|
|
233
422
|
state.tasks[id] = task;
|
|
423
|
+
const superseded: BoardTask[] = [];
|
|
424
|
+
for (const oldId of supersedes) {
|
|
425
|
+
const old = state.tasks[oldId];
|
|
426
|
+
if (!old || old.status === "completed" || old.status === "superseded") continue;
|
|
427
|
+
old.status = "superseded";
|
|
428
|
+
old.supersededBy = id;
|
|
429
|
+
// A living holder keeps the claim and assignment until it acknowledges
|
|
430
|
+
// cancellation or stops. Releasing its resource before it receives the
|
|
431
|
+
// stop instruction would let replacement work race active file writes.
|
|
432
|
+
old.updatedAt = Date.now();
|
|
433
|
+
superseded.push(old);
|
|
434
|
+
}
|
|
435
|
+
for (const [dependentId, migratedDependencies] of migrations) {
|
|
436
|
+
const dependent = state.tasks[dependentId];
|
|
437
|
+
if (!dependent) continue;
|
|
438
|
+
dependent.dependsOn = migratedDependencies;
|
|
439
|
+
dependent.updatedAt = Date.now();
|
|
440
|
+
}
|
|
234
441
|
markStateDirty();
|
|
235
|
-
return { ok: true, task };
|
|
442
|
+
return { ok: true, task, superseded };
|
|
236
443
|
}
|
|
237
444
|
|
|
238
445
|
export function getTask(taskId: string): BoardTask | undefined {
|
|
@@ -252,7 +459,7 @@ export function claimableTasks(): BoardTask[] {
|
|
|
252
459
|
}
|
|
253
460
|
|
|
254
461
|
export function taskDependenciesMet(task: BoardTask): boolean {
|
|
255
|
-
return task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
|
|
462
|
+
return task.status === "pending" && task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
|
|
256
463
|
}
|
|
257
464
|
|
|
258
465
|
/** First claimable task, or undefined. */
|
|
@@ -262,22 +469,29 @@ export function firstClaimableTask(): BoardTask | undefined {
|
|
|
262
469
|
|
|
263
470
|
export function setTaskClaimed(taskId: string, workerName: string): BoardTask | undefined {
|
|
264
471
|
const task = state.tasks[taskId];
|
|
265
|
-
|
|
472
|
+
const teammate = getTeammate(workerName);
|
|
473
|
+
if (!task || !teammate || task.status !== "pending" || !taskDependenciesMet(task)) return undefined;
|
|
474
|
+
if (teammate.assignment) return undefined;
|
|
475
|
+
if (activeAssignmentConflict(task.resources, workerName)) return undefined;
|
|
266
476
|
task.status = "claimed";
|
|
267
477
|
task.claimedBy = workerName;
|
|
268
478
|
task.updatedAt = Date.now();
|
|
479
|
+
assignTeammate(workerName, { id: task.id, kind: "board", resources: task.resources }, task.id);
|
|
269
480
|
markStateDirty();
|
|
270
481
|
return task;
|
|
271
482
|
}
|
|
272
483
|
|
|
273
|
-
/** Release a claimed task back to pending
|
|
484
|
+
/** Release a claimed task back to pending. A superseded holder instead
|
|
485
|
+
* acknowledges cancellation: the task stays superseded but frees resources. */
|
|
274
486
|
export function releaseTask(taskId: string, errorMessage?: string): BoardTask | undefined {
|
|
275
487
|
const task = state.tasks[taskId];
|
|
276
|
-
if (!task || task.status !== "claimed") return undefined;
|
|
277
|
-
|
|
488
|
+
if (!task || (task.status !== "claimed" && task.status !== "superseded")) return undefined;
|
|
489
|
+
const holder = task.claimedBy;
|
|
490
|
+
if (task.status === "claimed") task.status = "pending";
|
|
278
491
|
task.claimedBy = undefined;
|
|
279
492
|
if (errorMessage !== undefined) task.errorMessage = errorMessage;
|
|
280
493
|
task.updatedAt = Date.now();
|
|
494
|
+
if (holder) assignTeammate(holder, undefined, undefined);
|
|
281
495
|
markStateDirty();
|
|
282
496
|
return task;
|
|
283
497
|
}
|
|
@@ -285,11 +499,13 @@ export function releaseTask(taskId: string, errorMessage?: string): BoardTask |
|
|
|
285
499
|
export function completeTask(taskId: string, result?: string): BoardTask | undefined {
|
|
286
500
|
const task = state.tasks[taskId];
|
|
287
501
|
if (!task || task.status !== "claimed") return undefined;
|
|
502
|
+
const holder = task.claimedBy;
|
|
288
503
|
task.status = "completed";
|
|
289
504
|
task.result = result;
|
|
290
505
|
task.errorMessage = undefined;
|
|
291
506
|
task.completedAt = Date.now();
|
|
292
507
|
task.updatedAt = Date.now();
|
|
508
|
+
if (holder) assignTeammate(holder, undefined, undefined);
|
|
293
509
|
markStateDirty();
|
|
294
510
|
return task;
|
|
295
511
|
}
|
|
@@ -298,7 +514,7 @@ export function completeTask(taskId: string, result?: string): BoardTask | undef
|
|
|
298
514
|
export function releaseTasksOf(workerName: string, reason: string): BoardTask[] {
|
|
299
515
|
const released: BoardTask[] = [];
|
|
300
516
|
for (const task of listTasks()) {
|
|
301
|
-
if (task.status === "claimed" && task.claimedBy === workerName) {
|
|
517
|
+
if ((task.status === "claimed" || task.status === "superseded") && task.claimedBy === workerName) {
|
|
302
518
|
releaseTask(task.id, reason);
|
|
303
519
|
released.push(task);
|
|
304
520
|
}
|
|
@@ -309,22 +525,44 @@ export function releaseTasksOf(workerName: string, reason: string): BoardTask[]
|
|
|
309
525
|
/** Apply a validated claim intent from a marker file. */
|
|
310
526
|
export function applyClaimIntent(intent: TaskIntent): { applied: boolean; reason?: string } {
|
|
311
527
|
const task = state.tasks[intent.taskId];
|
|
528
|
+
const teammate = getTeammate(intent.worker);
|
|
312
529
|
if (!task) return { applied: false, reason: `unknown task "${intent.taskId}"` };
|
|
530
|
+
if (!teammate || teammate.spawnId !== intent.spawnId || teammate.status === "stopped") {
|
|
531
|
+
return { applied: false, reason: `worker "${intent.worker}" is not a living current incarnation` };
|
|
532
|
+
}
|
|
533
|
+
if (teammate.assignment) {
|
|
534
|
+
const state = teammate.assignment.closed ? "closed pending leader reopen" : "active";
|
|
535
|
+
return { applied: false, reason: `@${intent.worker} already owns ${state} ${teammate.assignment.kind} assignment "${teammate.assignment.id}"` };
|
|
536
|
+
}
|
|
313
537
|
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
|
|
538
|
+
if (task.status === "completed" || task.status === "superseded") return { applied: false, reason: `task "${intent.taskId}" is ${task.status}` };
|
|
315
539
|
if (!taskDependenciesMet(task)) return { applied: false, reason: `task "${intent.taskId}" has unmet dependencies` };
|
|
316
|
-
|
|
540
|
+
const conflict = activeAssignmentConflict(task.resources, intent.worker);
|
|
541
|
+
if (conflict) return { applied: false, reason: `resource conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}"` };
|
|
542
|
+
if (!setTaskClaimed(intent.taskId, intent.worker)) return { applied: false, reason: `task "${intent.taskId}" could not be claimed` };
|
|
317
543
|
return { applied: true };
|
|
318
544
|
}
|
|
319
545
|
|
|
320
546
|
/** Validate and apply a submission intent; verify gating happens in the caller. */
|
|
321
547
|
export function applySubmissionIntent(intent: TaskIntent): { ok: boolean; error?: string } {
|
|
548
|
+
if (intent.status !== "completed" && intent.status !== "failed") {
|
|
549
|
+
return { ok: false, error: `task "${intent.taskId}" has invalid submission status` };
|
|
550
|
+
}
|
|
551
|
+
const teammate = getTeammate(intent.worker);
|
|
552
|
+
if (!teammate || teammate.spawnId !== intent.spawnId || teammate.status === "stopped") {
|
|
553
|
+
return { ok: false, error: `worker "${intent.worker}" is not a living current incarnation` };
|
|
554
|
+
}
|
|
322
555
|
const task = state.tasks[intent.taskId];
|
|
323
556
|
if (!task) return { ok: false, error: `unknown task "${intent.taskId}"` };
|
|
324
|
-
if (task.status !== "claimed"
|
|
557
|
+
if (task.status !== "claimed" && task.status !== "superseded") {
|
|
558
|
+
return { ok: false, error: `task "${intent.taskId}" is not claimed` };
|
|
559
|
+
}
|
|
325
560
|
if (task.claimedBy !== intent.worker) {
|
|
326
561
|
return { ok: false, error: `task "${intent.taskId}" is claimed by ${task.claimedBy ?? "someone else"}` };
|
|
327
562
|
}
|
|
563
|
+
if (task.status === "superseded" && intent.status !== "failed") {
|
|
564
|
+
return { ok: false, error: `task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}"; submit failed to acknowledge cancellation` };
|
|
565
|
+
}
|
|
328
566
|
if (intent.status === "failed") {
|
|
329
567
|
releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
|
|
330
568
|
return { ok: true };
|
|
@@ -343,15 +581,17 @@ export function getState(): TeamState {
|
|
|
343
581
|
export function loadBoard(tasks: Record<string, BoardTask>): number {
|
|
344
582
|
let reloaded = 0;
|
|
345
583
|
for (const task of Object.values(tasks)) {
|
|
584
|
+
const orphanedHolding = task.status === "claimed" || task.status === "superseded";
|
|
346
585
|
const restored: BoardTask = {
|
|
347
586
|
...task,
|
|
587
|
+
resources: normalizeResources(task.resources),
|
|
348
588
|
status: task.status === "claimed" ? "pending" : task.status,
|
|
349
|
-
|
|
589
|
+
// Runtime workers and assignments die with the session. Superseded work
|
|
590
|
+
// remains visible for audit but cannot retain a dead holder/resource lock.
|
|
591
|
+
claimedBy: orphanedHolding ? undefined : task.claimedBy,
|
|
350
592
|
updatedAt: Date.now(),
|
|
351
593
|
};
|
|
352
594
|
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
595
|
reloaded++;
|
|
356
596
|
}
|
|
357
597
|
if (reloaded > 0) markStateDirty();
|
|
@@ -361,9 +601,9 @@ export function loadBoard(tasks: Record<string, BoardTask>): number {
|
|
|
361
601
|
export function getSummary(): string | undefined {
|
|
362
602
|
const alive = livingTeammates();
|
|
363
603
|
if (alive.length === 0 && listTasks().length === 0) return undefined;
|
|
364
|
-
const counts = { pending: 0, claimed: 0, completed: 0 };
|
|
604
|
+
const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
|
|
365
605
|
for (const task of listTasks()) {
|
|
366
606
|
if (task.status in counts) counts[task.status as keyof typeof counts]++;
|
|
367
607
|
}
|
|
368
|
-
return `${alive.length} teammate(s) alive | board: ${counts.pending} pending / ${counts.claimed} claimed / ${counts.completed} completed`;
|
|
608
|
+
return `${alive.length} teammate(s) alive | board: ${counts.pending} pending / ${counts.claimed} claimed / ${counts.completed} completed / ${counts.superseded} superseded`;
|
|
369
609
|
}
|
package/src/statefile.ts
CHANGED
|
@@ -79,15 +79,15 @@ export function appendInboxMessage(file: string, message: { id: string; from: st
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/** Publish the worker-readable roster of living teammates. */
|
|
82
|
-
export function writeRoster(file: string, teammates: Array<{ name: string; agent: string; status: string }>): void {
|
|
82
|
+
export function writeRoster(file: string, teammates: Array<{ name: string; agent: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }>): void {
|
|
83
83
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
84
84
|
writeJsonAtomic(file, { teammates });
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/** Read the roster from inside a teammate process; unknown roster = empty. */
|
|
88
|
-
export function readRoster(file: string): Array<{ name: string; agent: string; status: string }> {
|
|
88
|
+
export function readRoster(file: string): Array<{ name: string; agent: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> {
|
|
89
89
|
try {
|
|
90
|
-
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { teammates?: Array<{ name: string; agent: string; status: string }> };
|
|
90
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { teammates?: Array<{ name: string; agent: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> };
|
|
91
91
|
return Array.isArray(parsed.teammates) ? parsed.teammates : [];
|
|
92
92
|
} catch {
|
|
93
93
|
return [];
|
|
@@ -237,11 +237,18 @@ export function takeTaskIntent(dir: string): { intent?: TaskIntent; diagnostic?:
|
|
|
237
237
|
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<TaskIntent>;
|
|
238
238
|
fs.rmSync(file, { force: true });
|
|
239
239
|
if (
|
|
240
|
-
typeof parsed.taskId !== "string"
|
|
241
|
-
|| typeof parsed.worker !== "string"
|
|
242
|
-
|| typeof parsed.spawnId !== "string"
|
|
240
|
+
typeof parsed.taskId !== "string" || parsed.taskId.trim() === ""
|
|
241
|
+
|| typeof parsed.worker !== "string" || parsed.worker.trim() === ""
|
|
242
|
+
|| typeof parsed.spawnId !== "string" || parsed.spawnId.trim() === ""
|
|
243
|
+
|| typeof parsed.timestamp !== "number" || !Number.isFinite(parsed.timestamp)
|
|
243
244
|
) {
|
|
244
|
-
return { diagnostic: `malformed task intent "${name}" was consumed (
|
|
245
|
+
return { diagnostic: `malformed task intent "${name}" was consumed (requires non-empty taskId/worker/spawnId and finite timestamp)` };
|
|
246
|
+
}
|
|
247
|
+
if (parsed.status !== undefined && parsed.status !== "completed" && parsed.status !== "failed") {
|
|
248
|
+
return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission status)` };
|
|
249
|
+
}
|
|
250
|
+
if (typeof parsed.result !== "undefined" && typeof parsed.result !== "string") {
|
|
251
|
+
return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission result)` };
|
|
245
252
|
}
|
|
246
253
|
return { intent: parsed as TaskIntent };
|
|
247
254
|
} catch {
|