@fradser/pi-agent-teams 0.10.0 → 1.0.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/README.md +132 -64
- package/package.json +13 -5
- package/references/agent-roles.md +23 -13
- package/src/activity.ts +20 -11
- package/src/agent-actions.ts +61 -36
- package/src/agent-host.ts +64 -0
- package/src/capability-tools.ts +34 -0
- package/src/guidance.ts +44 -38
- package/src/index.ts +70 -36
- package/src/recipient.ts +4 -4
- package/src/state.ts +163 -618
- package/src/statefile.ts +28 -244
- package/src/team-machine.ts +825 -534
- package/src/tool-copy.ts +26 -34
- package/src/tools.ts +57 -210
- package/src/types.ts +51 -159
- package/src/ui.ts +85 -59
- package/src/worker.ts +88 -66
- package/src/agents.ts +0 -296
- package/src/leader-reports.ts +0 -92
- package/src/spawner.ts +0 -751
- package/src/work-context.ts +0 -88
- package/src/worker-tools.ts +0 -4
- package/src/worktree.ts +0 -164
package/src/state.ts
CHANGED
|
@@ -1,23 +1,46 @@
|
|
|
1
|
+
import { isTaskReplaced } from "@fradser/pi-tasks";
|
|
1
2
|
/**
|
|
2
3
|
* Team state management for the current session only: the teammate roster,
|
|
3
|
-
* the in-memory task
|
|
4
|
-
* is the sole writer of this snapshot and of
|
|
4
|
+
* the in-memory task storage, and the single leader inbox. The leader process
|
|
5
|
+
* is the sole writer of this snapshot and of tasks.json.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
+
import { nonEmpty } from "@fradser/pi-kit";
|
|
8
9
|
import {
|
|
9
10
|
messageTitle,
|
|
10
11
|
TEAM_RUNTIME_VERSION,
|
|
11
|
-
type BoardTask,
|
|
12
|
-
type WorkerAssignment,
|
|
13
12
|
type MailboxMessage,
|
|
14
|
-
type TaskIntent,
|
|
15
|
-
type Teammate,
|
|
16
13
|
type TeamState,
|
|
17
14
|
type WorkerReportEvent,
|
|
18
|
-
type WorkerUsage,
|
|
19
15
|
} from "./types.ts";
|
|
20
|
-
|
|
16
|
+
// The roster — one child process per entry, its lifecycle, and the revisions that
|
|
17
|
+
// say when it changed — is an execution fact, so it lives in @fradser/pi-subagents
|
|
18
|
+
// with the spawner that creates those processes. This module keeps the snapshot,
|
|
19
|
+
// the leader inbox, and the tasks callback, and points `agents` at the roster's
|
|
20
|
+
// own map by identity so no snapshot schema changes and no version bump is needed.
|
|
21
|
+
import {
|
|
22
|
+
assignAgent,
|
|
23
|
+
getAgent,
|
|
24
|
+
livingAgents,
|
|
25
|
+
agents as roster,
|
|
26
|
+
progressRevision as rosterProgressRevision,
|
|
27
|
+
resetRoster,
|
|
28
|
+
rosterRevision as currentRosterRevision,
|
|
29
|
+
type Agent,
|
|
30
|
+
} from "@fradser/pi-subagents";
|
|
31
|
+
// The Work Item graph and resource rules are pure and live in @fradser/pi-tasks, so the
|
|
32
|
+
// leader, the worker, and any tasks-only consumer share one implementation.
|
|
33
|
+
// `activeAssignmentConflict` below is the roster-backed policy on top of
|
|
34
|
+
// `resourcesConflict`, and stays here because it reads the roster.
|
|
35
|
+
import {
|
|
36
|
+
bindTask,
|
|
37
|
+
configureTaskStore,
|
|
38
|
+
createTask,
|
|
39
|
+
discardTask,
|
|
40
|
+
resourcesConflict,
|
|
41
|
+
tasks as storedTasks,
|
|
42
|
+
type WorkAssignment as WorkerAssignment,
|
|
43
|
+
} from "@fradser/pi-tasks";
|
|
21
44
|
|
|
22
45
|
export const MAX_LEADER_MAILBOX_MESSAGES = 4096;
|
|
23
46
|
/** Total mailbox body bytes retained for forensics. A count cap alone lets a few
|
|
@@ -27,13 +50,12 @@ export const MAX_LEADER_MAILBOX_BYTES = 8 * 1024 * 1024;
|
|
|
27
50
|
export const MAX_PEER_DELIVERED_IDS = 512;
|
|
28
51
|
/** Bounded forensic routing history; mailbox files retain the full peer transcript. */
|
|
29
52
|
export const MAX_PEER_DELIVERY_STATES = 4096;
|
|
30
|
-
export const MAX_TASK_DEPENDENCIES = 32;
|
|
31
53
|
|
|
32
54
|
function emptyState(): TeamState {
|
|
33
55
|
return {
|
|
34
56
|
runtimeVersion: TEAM_RUNTIME_VERSION,
|
|
35
|
-
|
|
36
|
-
tasks:
|
|
57
|
+
agents: roster,
|
|
58
|
+
tasks: storedTasks,
|
|
37
59
|
leaderMailbox: [],
|
|
38
60
|
messageCounter: 0,
|
|
39
61
|
workerEventOffsets: {},
|
|
@@ -50,198 +72,98 @@ function nextMessageId(): string {
|
|
|
50
72
|
return `msg_${++state.messageCounter}`;
|
|
51
73
|
}
|
|
52
74
|
|
|
53
|
-
/** Task maps must not inherit Object.prototype: slug ids like "constructor"
|
|
54
|
-
* are legal task ids and must never alias inherited properties. */
|
|
55
|
-
function emptyTaskMap(): Record<string, BoardTask> {
|
|
56
|
-
return Object.create(null);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
75
|
export function resetState(): void {
|
|
76
|
+
for (const id of Object.keys(storedTasks)) delete storedTasks[id];
|
|
77
|
+
resetRoster();
|
|
60
78
|
state = emptyState();
|
|
61
|
-
|
|
62
|
-
boardRevisionCounter++;
|
|
79
|
+
tasksRevisionCounter++;
|
|
63
80
|
stateRevisionCounter++;
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
// ── Persistence revisions ────────────────────────────────────────
|
|
67
84
|
// state.ts owns every in-memory mutation, so it is the one place that can say
|
|
68
85
|
// exactly which artifact changed. Stream progress is counted separately: a
|
|
69
|
-
// running teammate must not force the roster, the
|
|
86
|
+
// running teammate must not force the roster, the tasks, or the debug snapshot
|
|
70
87
|
// to be rewritten on every token. Every persisted mutation in this module bumps
|
|
71
88
|
// one of these counters, so a mutation that forgets to bump would not be written
|
|
72
89
|
// until something else changed — add the bump with any new mutator.
|
|
73
90
|
let stateRevisionCounter = 0;
|
|
74
|
-
let
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
let tasksRevisionCounter = 0;
|
|
92
|
+
|
|
93
|
+
/** Revision of the whole snapshot: roster, tasks, mailbox, and offsets.
|
|
94
|
+
*
|
|
95
|
+
* Composed rather than single-sourced, because the roster now has its own counter
|
|
96
|
+
* in another package. Every input is monotonic, so the sum changes whenever any
|
|
97
|
+
* of them does, which is the only property the snapshot writer relies on: it
|
|
98
|
+
* compares against the last written value and rewrites on inequality. */
|
|
79
99
|
export function stateRevision(): number {
|
|
80
|
-
return stateRevisionCounter;
|
|
100
|
+
return stateRevisionCounter + currentRosterRevision() + tasksRevisionCounter;
|
|
81
101
|
}
|
|
82
102
|
|
|
83
|
-
/** Revision of the worker-readable roster. */
|
|
84
|
-
export
|
|
85
|
-
return rosterRevisionCounter;
|
|
86
|
-
}
|
|
103
|
+
/** Revision of the worker-readable roster, owned by @fradser/pi-subagents. */
|
|
104
|
+
export { currentRosterRevision as rosterRevision };
|
|
87
105
|
|
|
88
|
-
/** Revision of the persisted
|
|
89
|
-
export function
|
|
90
|
-
return
|
|
106
|
+
/** Revision of the persisted tasks. */
|
|
107
|
+
export function tasksRevision(): number {
|
|
108
|
+
return tasksRevisionCounter;
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
/** Revision of streamed progress only; never persisted on its own. */
|
|
94
|
-
export
|
|
95
|
-
return progressRevisionCounter;
|
|
96
|
-
}
|
|
112
|
+
export { rosterProgressRevision as progressRevision };
|
|
97
113
|
|
|
98
|
-
/** Record a
|
|
99
|
-
export function
|
|
100
|
-
|
|
114
|
+
/** Record a task set change made outside this module's task mutators. */
|
|
115
|
+
export function markTasksChanged(): void {
|
|
116
|
+
tasksRevisionCounter++;
|
|
101
117
|
stateRevisionCounter++;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
|
|
120
|
+
/** Drop per-spawn replay metadata once its final snapshot was persisted.
|
|
121
|
+
* Snapshot bookkeeping rather than roster state, so it stays here even though
|
|
122
|
+
* the roster itself moved. */
|
|
123
|
+
export function clearWorkerRunEvents(workerName: string, spawnId: string): void {
|
|
124
|
+
const outboxKey = `${workerName}:${spawnId}`;
|
|
125
|
+
delete state.workerEventOffsets[outboxKey];
|
|
126
|
+
for (const id of Object.keys(state.workerEventIds)) {
|
|
127
|
+
if (id.startsWith(`${spawnId}:`)) delete state.workerEventIds[id];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Session settings ─────────────────────────────────────────────
|
|
105
132
|
|
|
106
|
-
/** The unified
|
|
133
|
+
/** The unified model for later spawns, or undefined when Pi picks.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately session state rather than roster state: it is a setting the
|
|
136
|
+
* snapshot persists for forensics and resume, so moving it with the roster would
|
|
137
|
+
* have given the roster a second source of truth that no snapshot records. The
|
|
138
|
+
* spawner reads it through the re-export below. */
|
|
107
139
|
export function getTeamDefaultModel(): string | undefined {
|
|
108
140
|
return state.defaultModel;
|
|
109
141
|
}
|
|
110
142
|
|
|
111
|
-
/** Set (or clear with undefined) the unified
|
|
143
|
+
/** Set (or clear with undefined) the unified model for later spawns. */
|
|
112
144
|
export function setTeamDefaultModel(ref: string | undefined): void {
|
|
113
145
|
state.defaultModel = nonEmpty(ref);
|
|
114
146
|
stateRevisionCounter++;
|
|
115
147
|
}
|
|
116
148
|
|
|
117
|
-
// ── Roster
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function idleTeammates(): Teammate[] {
|
|
138
|
-
return livingTeammates().filter((t) => t.status === "idle");
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function registerTeammate(teammate: Teammate): { ok: true } | { ok: false; error: string } {
|
|
142
|
-
if (!isValidTeammateName(teammate.name)) {
|
|
143
|
-
return { ok: false, error: `Invalid teammate name "${teammate.name}". Use letters, digits, dots, dashes, underscores.` };
|
|
144
|
-
}
|
|
145
|
-
if (livingTeammates().some((t) => t.name === teammate.name)) {
|
|
146
|
-
return { ok: false, error: `A living teammate named "${teammate.name}" already exists.` };
|
|
147
|
-
}
|
|
148
|
-
state.teammates[teammate.name] = teammate;
|
|
149
|
-
rosterRevisionCounter++;
|
|
150
|
-
stateRevisionCounter++;
|
|
151
|
-
return { ok: true };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Fields whose change is stream progress rather than persisted state. A
|
|
155
|
-
* progress tick must not republish the roster or the board. */
|
|
156
|
-
const VOLATILE_TEAMMATE_FIELDS = new Set<string>([
|
|
157
|
-
"liveText",
|
|
158
|
-
"liveThinking",
|
|
159
|
-
"activeTool",
|
|
160
|
-
"turns",
|
|
161
|
-
"sequenceEnded",
|
|
162
|
-
"modelOutputSeen",
|
|
163
|
-
"usage",
|
|
164
|
-
"lastOutputAt",
|
|
165
|
-
"updatedAt",
|
|
166
|
-
"lastNoticeAt",
|
|
167
|
-
"noticedTaskIds",
|
|
168
|
-
]);
|
|
169
|
-
|
|
170
|
-
export function updateTeammate(name: string, patch: Partial<Teammate>): Teammate | undefined {
|
|
171
|
-
const teammate = state.teammates[name];
|
|
172
|
-
if (!teammate) return undefined;
|
|
173
|
-
const changed = (Object.keys(patch) as Array<keyof Teammate>)
|
|
174
|
-
.filter((field) => !Object.is(teammate[field], patch[field]));
|
|
175
|
-
Object.assign(teammate, patch, { updatedAt: Date.now() });
|
|
176
|
-
if (patch.status === "stopped") teammate.stoppedAt = Date.now();
|
|
177
|
-
// Republishing a roster that only streamed progress is pure write churn.
|
|
178
|
-
if (changed.some((field) => !VOLATILE_TEAMMATE_FIELDS.has(field as string))) {
|
|
179
|
-
rosterRevisionCounter++;
|
|
180
|
-
stateRevisionCounter++;
|
|
181
|
-
} else if (changed.length > 0) {
|
|
182
|
-
progressRevisionCounter++;
|
|
183
|
-
}
|
|
184
|
-
return teammate;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Merge streaming child-process progress into a living teammate. */
|
|
188
|
-
export function assignTeammate(
|
|
189
|
-
name: string,
|
|
190
|
-
assignment: WorkerAssignment | undefined,
|
|
191
|
-
currentTaskId?: string,
|
|
192
|
-
): Teammate | undefined {
|
|
193
|
-
const teammate = getTeammate(name);
|
|
194
|
-
if (!teammate) return undefined;
|
|
195
|
-
const lastAssignment = assignment ?? teammate.assignment ?? teammate.lastAssignment;
|
|
196
|
-
const lastTaskId = currentTaskId ?? teammate.currentTaskId ?? teammate.lastTaskId;
|
|
197
|
-
return updateTeammate(name, { assignment, currentTaskId, lastAssignment, lastTaskId,
|
|
198
|
-
...(assignment && assignment.id !== teammate.assignment?.id ? { reportSequenceEnded: false } : {}),
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export function updateTeammateProgress(
|
|
203
|
-
name: string,
|
|
204
|
-
spawnId: string,
|
|
205
|
-
progress: Pick<Teammate, "liveText" | "activeTool" | "liveThinking" | "turns"> & {
|
|
206
|
-
sequenceEnded?: boolean;
|
|
207
|
-
modelOutputSeen?: boolean;
|
|
208
|
-
usage?: WorkerUsage;
|
|
209
|
-
},
|
|
210
|
-
): boolean {
|
|
211
|
-
const teammate = state.teammates[name];
|
|
212
|
-
if (!teammate || teammate.spawnId !== spawnId) return false;
|
|
213
|
-
const changed = teammate.liveText !== progress.liveText
|
|
214
|
-
|| teammate.activeTool !== progress.activeTool
|
|
215
|
-
|| teammate.liveThinking !== progress.liveThinking
|
|
216
|
-
|| teammate.turns !== progress.turns
|
|
217
|
-
|| (progress.sequenceEnded !== undefined && teammate.sequenceEnded !== progress.sequenceEnded)
|
|
218
|
-
|| (progress.modelOutputSeen === true && teammate.modelOutputSeen !== true);
|
|
219
|
-
teammate.liveText = progress.liveText;
|
|
220
|
-
teammate.activeTool = progress.activeTool;
|
|
221
|
-
teammate.liveThinking = progress.liveThinking;
|
|
222
|
-
teammate.turns = progress.turns;
|
|
223
|
-
if (progress.sequenceEnded !== undefined) teammate.sequenceEnded = progress.sequenceEnded;
|
|
224
|
-
if (progress.modelOutputSeen) teammate.modelOutputSeen = true;
|
|
225
|
-
if (progress.usage) teammate.usage = progress.usage;
|
|
226
|
-
if (teammate.status === "starting") {
|
|
227
|
-
teammate.status = progress.sequenceEnded ? "idle" : "working";
|
|
228
|
-
// A status transition is roster state, not stream noise.
|
|
229
|
-
rosterRevisionCounter++;
|
|
230
|
-
stateRevisionCounter++;
|
|
231
|
-
}
|
|
232
|
-
teammate.updatedAt = Date.now();
|
|
233
|
-
if (changed) progressRevisionCounter++;
|
|
234
|
-
return true;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Drop per-spawn replay metadata once its final snapshot was persisted. */
|
|
238
|
-
export function clearWorkerRunEvents(workerName: string, spawnId: string): void {
|
|
239
|
-
const outboxKey = `${workerName}:${spawnId}`;
|
|
240
|
-
delete state.workerEventOffsets[outboxKey];
|
|
241
|
-
for (const id of Object.keys(state.workerEventIds)) {
|
|
242
|
-
if (id.startsWith(`${spawnId}:`)) delete state.workerEventIds[id];
|
|
243
|
-
}
|
|
244
|
-
}
|
|
149
|
+
// ── Roster (owned by @fradser/pi-subagents) ───────────────────────
|
|
150
|
+
//
|
|
151
|
+
// A child process is an execution fact, so the roster lives with the spawner.
|
|
152
|
+
// Re-exported here because the coordination vocabulary reads this module as its
|
|
153
|
+
// entry point, and every existing consumer imports it from here.
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
assignAgent,
|
|
157
|
+
getAgent,
|
|
158
|
+
idleAgents,
|
|
159
|
+
isValidAgentName,
|
|
160
|
+
listAgents,
|
|
161
|
+
livingAgents,
|
|
162
|
+
registerAgent,
|
|
163
|
+
updateAgent,
|
|
164
|
+
updateAgentProgress,
|
|
165
|
+
agents,
|
|
166
|
+
} from "@fradser/pi-subagents";
|
|
245
167
|
|
|
246
168
|
/** Drop the oldest mailbox entries until both the count and byte caps hold. */
|
|
247
169
|
function trimLeaderMailbox(): void {
|
|
@@ -268,7 +190,7 @@ export function deliverToLeader(msg: Omit<MailboxMessage, "id" | "timestamp">):
|
|
|
268
190
|
|
|
269
191
|
/** Apply a validated report event exactly once by event id. */
|
|
270
192
|
export function receiveWorkerMessage(event: WorkerReportEvent, options?: { archived?: boolean }): boolean {
|
|
271
|
-
const sender =
|
|
193
|
+
const sender = getAgent(event.worker);
|
|
272
194
|
if (!sender || sender.spawnId !== event.spawnId) return false;
|
|
273
195
|
if (state.leaderMailbox.some((message) => message.id === event.id)) return false;
|
|
274
196
|
state.leaderMailbox.push({
|
|
@@ -328,446 +250,75 @@ export function getPeerDeliveryState(messageId: string): "queued" | "routed" | "
|
|
|
328
250
|
return state.peerDeliveryStates?.[messageId];
|
|
329
251
|
}
|
|
330
252
|
|
|
331
|
-
// ──
|
|
253
|
+
// ── Tasks: creation and queries ───────────────────────────────────
|
|
332
254
|
|
|
333
255
|
/** Maximum characters in a generated slug task id (suffix included). */
|
|
334
|
-
const MAX_TASK_ID_LENGTH = 48;
|
|
335
|
-
|
|
336
|
-
/** A readable, filesystem-safe id derived from the subject:
|
|
337
|
-
* "Polish login flow" -> "polish-login-flow"; duplicates get -2, -3, ... */
|
|
338
|
-
export function taskIdFromSubject(subject: string, taken: ReadonlySet<string>): string {
|
|
339
|
-
const base = subject
|
|
340
|
-
.toLowerCase()
|
|
341
|
-
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
342
|
-
.replace(/^-+|-+$/g, "")
|
|
343
|
-
.slice(0, MAX_TASK_ID_LENGTH)
|
|
344
|
-
.replace(/-+$/g, "") || "task";
|
|
345
|
-
let id = base;
|
|
346
|
-
for (let n = 2; taken.has(id); n++) {
|
|
347
|
-
const suffix = `-${n}`;
|
|
348
|
-
id = base.slice(0, Math.max(1, MAX_TASK_ID_LENGTH - suffix.length)).replace(/-+$/g, "") + suffix;
|
|
349
|
-
}
|
|
350
|
-
return id;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export function normalizeResources(resources: readonly string[] | undefined): string[] {
|
|
354
|
-
return [...new Set((resources ?? [])
|
|
355
|
-
.map((resource) => resource.trim().replace(/^\/+|\/+$/g, ""))
|
|
356
|
-
.filter(Boolean))]
|
|
357
|
-
.sort();
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Follow an obsolete dependency to its latest replacement. An invalid persisted
|
|
361
|
-
* chain is rejected rather than silently making a future task unclaimable. */
|
|
362
|
-
export function canonicalDependency(taskId: string, tasks: Record<string, BoardTask>): string | undefined {
|
|
363
|
-
const seen = new Set<string>();
|
|
364
|
-
let current = taskId;
|
|
365
|
-
while (true) {
|
|
366
|
-
if (seen.has(current)) return undefined;
|
|
367
|
-
seen.add(current);
|
|
368
|
-
const task = tasks[current];
|
|
369
|
-
if (!task) return undefined;
|
|
370
|
-
if (task.status !== "superseded") return current;
|
|
371
|
-
if (!task.supersededBy) return undefined;
|
|
372
|
-
current = task.supersededBy;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function canonicalDependencies(
|
|
377
|
-
dependencies: readonly string[],
|
|
378
|
-
tasks: Record<string, BoardTask>,
|
|
379
|
-
): string[] | undefined {
|
|
380
|
-
const canonical: string[] = [];
|
|
381
|
-
for (const dependency of dependencies) {
|
|
382
|
-
const resolved = canonicalDependency(dependency, tasks);
|
|
383
|
-
if (!resolved) return undefined;
|
|
384
|
-
if (!canonical.includes(resolved)) canonical.push(resolved);
|
|
385
|
-
}
|
|
386
|
-
return canonical;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function hasDependencyCycle(graph: Map<string, readonly string[]>): boolean {
|
|
390
|
-
const visiting = new Set<string>();
|
|
391
|
-
const visited = new Set<string>();
|
|
392
|
-
const visit = (taskId: string): boolean => {
|
|
393
|
-
if (visiting.has(taskId)) return true;
|
|
394
|
-
if (visited.has(taskId)) return false;
|
|
395
|
-
visiting.add(taskId);
|
|
396
|
-
for (const dependency of graph.get(taskId) ?? []) {
|
|
397
|
-
if (graph.has(dependency) && visit(dependency)) return true;
|
|
398
|
-
}
|
|
399
|
-
visiting.delete(taskId);
|
|
400
|
-
visited.add(taskId);
|
|
401
|
-
return false;
|
|
402
|
-
};
|
|
403
|
-
return [...graph.keys()].some(visit);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** `firmware/sub-node` conflicts with itself and descendants such as
|
|
407
|
-
* `firmware/sub-node/app`; unrelated siblings stay concurrently claimable. */
|
|
408
|
-
export function resourcesConflict(left: readonly string[], right: readonly string[]): boolean {
|
|
409
|
-
return left.some((a) => right.some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
|
|
410
|
-
}
|
|
411
|
-
|
|
412
256
|
export function activeAssignmentConflict(
|
|
413
257
|
resources: readonly string[],
|
|
414
258
|
exceptWorker?: string,
|
|
415
|
-
):
|
|
259
|
+
): Agent | undefined {
|
|
416
260
|
if (resources.length === 0) return undefined;
|
|
417
|
-
return
|
|
261
|
+
return livingAgents().find((teammate) => {
|
|
418
262
|
if (teammate.name === exceptWorker) return false;
|
|
419
263
|
const assignment = teammate.assignment;
|
|
420
264
|
const retainsWorkAuthority = teammate.currentTaskId !== undefined
|
|
421
|
-
&&
|
|
265
|
+
&& (state.tasks[teammate.currentTaskId]?.status === "in_progress" || isTaskReplaced(state.tasks[teammate.currentTaskId]));
|
|
422
266
|
return assignment !== undefined
|
|
423
267
|
&& (!assignment.closed || retainsWorkAuthority)
|
|
424
268
|
&& resourcesConflict(resources, assignment.resources);
|
|
425
269
|
});
|
|
426
270
|
}
|
|
427
271
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
for (const taskId of [...requestedDependencies, ...requestedSupersedes]) {
|
|
445
|
-
if (!state.tasks[taskId]) return { ok: false, error: `Task references unknown task "${taskId}".` };
|
|
446
|
-
}
|
|
447
|
-
const completedTarget = requestedSupersedes.find((taskId) => state.tasks[taskId]?.status === "completed");
|
|
448
|
-
if (completedTarget) {
|
|
449
|
-
return { ok: false, error: `Task cannot supersede completed task "${completedTarget}".` };
|
|
450
|
-
}
|
|
451
|
-
const dependsOn = canonicalDependencies(requestedDependencies, state.tasks);
|
|
452
|
-
const supersedes = canonicalDependencies(requestedSupersedes, state.tasks);
|
|
453
|
-
if (!dependsOn || !supersedes) return { ok: false, error: "Task dependency or supersession chain is invalid or cyclic." };
|
|
454
|
-
const canonicalCompletedTarget = supersedes.find((taskId) => state.tasks[taskId]?.status === "completed");
|
|
455
|
-
if (canonicalCompletedTarget) {
|
|
456
|
-
return { ok: false, error: `Task cannot supersede completed task "${canonicalCompletedTarget}".` };
|
|
457
|
-
}
|
|
458
|
-
const selfReplacement = supersedes.find((taskId) => dependsOn.includes(taskId));
|
|
459
|
-
if (selfReplacement) {
|
|
460
|
-
return { ok: false, error: `Task cannot both depend on and supersede "${selfReplacement}".` };
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const id = input.id ?? taskIdFromSubject(subject, new Set(Object.keys(state.tasks)));
|
|
464
|
-
if (state.tasks[id]) return { ok: false, error: `Task id "${id}" already exists.` };
|
|
465
|
-
const migrations = new Map<string, string[]>();
|
|
466
|
-
for (const dependent of Object.values(state.tasks)) {
|
|
467
|
-
if (dependent.status === "completed" || dependent.status === "superseded") continue;
|
|
468
|
-
const migrated = dependent.dependsOn.map((dependency) => supersedes.includes(dependency) ? id : dependency);
|
|
469
|
-
if (migrated.some((dependency, index) => dependency !== dependent.dependsOn[index])) {
|
|
470
|
-
migrations.set(dependent.id, [...new Set(migrated)]);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
const prospectiveGraph = new Map<string, readonly string[]>();
|
|
474
|
-
for (const existing of Object.values(state.tasks)) {
|
|
475
|
-
prospectiveGraph.set(existing.id, migrations.get(existing.id) ?? existing.dependsOn);
|
|
476
|
-
}
|
|
477
|
-
prospectiveGraph.set(id, dependsOn);
|
|
478
|
-
if (hasDependencyCycle(prospectiveGraph)) {
|
|
479
|
-
return { ok: false, error: "Task supersession would create a dependency cycle." };
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
const task: BoardTask = {
|
|
483
|
-
id,
|
|
484
|
-
subject,
|
|
485
|
-
description: input.description?.trim() || undefined,
|
|
486
|
-
dependsOn,
|
|
487
|
-
verify: input.verify?.trim() || undefined,
|
|
488
|
-
resources: normalizeResources(input.resources),
|
|
489
|
-
status: "pending",
|
|
490
|
-
createdAt: Date.now(),
|
|
491
|
-
updatedAt: Date.now(),
|
|
492
|
-
};
|
|
493
|
-
state.tasks[id] = task;
|
|
494
|
-
boardRevisionCounter++;
|
|
495
|
-
stateRevisionCounter++;
|
|
496
|
-
const superseded: BoardTask[] = [];
|
|
497
|
-
for (const oldId of supersedes) {
|
|
498
|
-
const old = state.tasks[oldId];
|
|
499
|
-
if (!old || old.status === "completed" || old.status === "superseded") continue;
|
|
500
|
-
old.status = "superseded";
|
|
501
|
-
old.supersededBy = id;
|
|
502
|
-
// A living holder keeps the claim and assignment until it acknowledges
|
|
503
|
-
// cancellation or stops. Releasing its resource before it receives the
|
|
504
|
-
// stop instruction would let replacement work race active file writes.
|
|
505
|
-
old.updatedAt = Date.now();
|
|
506
|
-
superseded.push(old);
|
|
507
|
-
}
|
|
508
|
-
for (const [dependentId, migratedDependencies] of migrations) {
|
|
509
|
-
const dependent = state.tasks[dependentId];
|
|
510
|
-
if (!dependent) continue;
|
|
511
|
-
dependent.dependsOn = migratedDependencies;
|
|
512
|
-
dependent.updatedAt = Date.now();
|
|
513
|
-
}
|
|
514
|
-
return { ok: true, task, superseded };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
export function getTask(taskId: string): BoardTask | undefined {
|
|
518
|
-
return state.tasks[taskId];
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
export function listTasks(): BoardTask[] {
|
|
522
|
-
return Object.values(state.tasks).sort((a, b) => a.id.localeCompare(b.id));
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
export function pendingTasks(): BoardTask[] {
|
|
526
|
-
return listTasks().filter((task) => task.status === "pending");
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
export function claimableTasks(): BoardTask[] {
|
|
530
|
-
return pendingTasks().filter((task) => !task.recoveryRequired && taskDependenciesMet(task));
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
export function taskDependenciesMet(task: BoardTask): boolean {
|
|
534
|
-
return task.status === "pending" && task.dependsOn.every((dep) => state.tasks[dep]?.status === "completed");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
export function createDirectWork(input: {
|
|
538
|
-
id: string;
|
|
539
|
-
subject: string;
|
|
540
|
-
description?: string;
|
|
541
|
-
resources: string[];
|
|
542
|
-
verify?: string;
|
|
543
|
-
workerName: string;
|
|
544
|
-
assignment: WorkerAssignment;
|
|
545
|
-
}): { ok: true; task: BoardTask } | { ok: false; error: string } {
|
|
546
|
-
const created = createTask({
|
|
547
|
-
id: input.id,
|
|
548
|
-
subject: input.subject,
|
|
549
|
-
description: input.description,
|
|
550
|
-
resources: input.resources,
|
|
551
|
-
verify: input.verify,
|
|
552
|
-
});
|
|
553
|
-
if (!created.ok) return created;
|
|
554
|
-
const task = created.task;
|
|
555
|
-
const worker = getTeammate(input.workerName);
|
|
556
|
-
if (!worker || worker.assignment || activeAssignmentConflict(task.resources, input.workerName)) {
|
|
557
|
-
// The Work is rolled back here, so its id would render as an unresolvable
|
|
558
|
-
// handle: name the work a person can recognize instead.
|
|
559
|
-
const reason = !worker
|
|
560
|
-
? `@${input.workerName} is not a registered teammate`
|
|
561
|
-
: worker.assignment
|
|
562
|
-
? `@${input.workerName} already owns ${worker.assignment.kind} assignment "${worker.assignment.id}"`
|
|
563
|
-
: `its resources conflict with active Work`;
|
|
564
|
-
delete state.tasks[task.id];
|
|
565
|
-
return { ok: false, error: `Cannot bind direct Work "${input.subject}" to @${input.workerName}: ${reason}.` };
|
|
566
|
-
}
|
|
567
|
-
task.status = "claimed";
|
|
568
|
-
task.claimedBy = input.workerName;
|
|
569
|
-
task.updatedAt = Date.now();
|
|
570
|
-
boardRevisionCounter++;
|
|
571
|
-
stateRevisionCounter++;
|
|
572
|
-
assignTeammate(input.workerName, input.assignment, task.id);
|
|
573
|
-
return { ok: true, task };
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
export function discardDirectWork(taskId: string, workerName: string): boolean {
|
|
577
|
-
const task = state.tasks[taskId];
|
|
578
|
-
if (!task || task.status !== "claimed" || task.claimedBy !== workerName) return false;
|
|
579
|
-
delete state.tasks[taskId];
|
|
580
|
-
boardRevisionCounter++;
|
|
581
|
-
stateRevisionCounter++;
|
|
582
|
-
return true;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
export function reclaimDirectWork(
|
|
586
|
-
taskId: string,
|
|
587
|
-
workerName: string,
|
|
588
|
-
assignment: WorkerAssignment,
|
|
589
|
-
expectedState: "pending" | "completed" | "claimed",
|
|
590
|
-
allowActiveAssignment = false,
|
|
591
|
-
): { ok: true } | { ok: false; error: string } {
|
|
592
|
-
const task = state.tasks[taskId];
|
|
593
|
-
const worker = getTeammate(workerName);
|
|
594
|
-
const retainsSupersededWork = worker?.currentTaskId !== undefined
|
|
595
|
-
&& state.tasks[worker.currentTaskId]?.status === "superseded";
|
|
596
|
-
if (!task || task.status !== expectedState || !worker || retainsSupersededWork || (!allowActiveAssignment && worker.assignment && !worker.assignment.closed)) {
|
|
597
|
-
return { ok: false, error: `Unable to reclaim direct Work Item "${taskId}".` };
|
|
598
|
-
}
|
|
599
|
-
const conflict = activeAssignmentConflict(task.resources, workerName);
|
|
600
|
-
if (conflict) return { ok: false, error: `Resource conflict with @${conflict.name}'s active assignment.` };
|
|
601
|
-
task.status = "claimed";
|
|
602
|
-
task.claimedBy = workerName;
|
|
603
|
-
task.result = undefined;
|
|
604
|
-
task.errorMessage = undefined;
|
|
605
|
-
task.recoveryRequired = undefined;
|
|
606
|
-
task.completedAt = undefined;
|
|
607
|
-
task.updatedAt = Date.now();
|
|
608
|
-
boardRevisionCounter++;
|
|
609
|
-
stateRevisionCounter++;
|
|
610
|
-
assignTeammate(workerName, assignment, task.id);
|
|
611
|
-
return { ok: true };
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
export function setTaskClaimed(taskId: string, workerName: string): BoardTask | undefined {
|
|
615
|
-
const task = state.tasks[taskId];
|
|
616
|
-
const teammate = getTeammate(workerName);
|
|
617
|
-
if (!task || !teammate || task.status !== "pending" || task.recoveryRequired || !taskDependenciesMet(task)) return undefined;
|
|
618
|
-
if (teammate.assignment) return undefined;
|
|
619
|
-
if (activeAssignmentConflict(task.resources, workerName)) return undefined;
|
|
620
|
-
task.status = "claimed";
|
|
621
|
-
task.claimedBy = workerName;
|
|
622
|
-
task.updatedAt = Date.now();
|
|
623
|
-
boardRevisionCounter++;
|
|
624
|
-
stateRevisionCounter++;
|
|
625
|
-
assignTeammate(workerName, { id: `board:${randomUUID()}`, kind: "board", resources: task.resources }, task.id);
|
|
626
|
-
return task;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/** Release a claimed task back to pending. A superseded holder instead
|
|
630
|
-
* acknowledges cancellation: the task stays superseded but frees resources.
|
|
631
|
-
* `recoveryRequired` marks an attempt that ran and did not succeed (failure,
|
|
632
|
-
* crash, or stop), so it waits for a deliberate Leader assignment instead of
|
|
633
|
-
* being re-offered. A harness start failure that happened before the attempt
|
|
634
|
-
* reached the worker stays ordinarily claimable; a deliberate Leader release
|
|
635
|
-
* passes `false` for the same reason. */
|
|
636
|
-
export function claimedDependents(workId: string): BoardTask[] {
|
|
637
|
-
const reverse = new Map<string, string[]>();
|
|
638
|
-
for (const task of Object.values(state.tasks)) {
|
|
639
|
-
for (const dependency of task.dependsOn) {
|
|
640
|
-
const dependents = reverse.get(dependency) ?? [];
|
|
641
|
-
dependents.push(task.id);
|
|
642
|
-
reverse.set(dependency, dependents);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
const visited = new Set<string>();
|
|
646
|
-
const pending = [...(reverse.get(workId) ?? [])];
|
|
647
|
-
const claimed: BoardTask[] = [];
|
|
648
|
-
while (pending.length > 0) {
|
|
649
|
-
const id = pending.shift()!;
|
|
650
|
-
if (visited.has(id)) continue;
|
|
651
|
-
visited.add(id);
|
|
652
|
-
const task = state.tasks[id];
|
|
653
|
-
if (!task) continue;
|
|
654
|
-
if (task.status === "claimed" || (task.status === "superseded" && task.claimedBy !== undefined)) claimed.push(task);
|
|
655
|
-
pending.push(...(reverse.get(id) ?? []));
|
|
656
|
-
}
|
|
657
|
-
return claimed.sort((left, right) => left.id.localeCompare(right.id));
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
export function reopenCompletedWork(workId: string): { ok: true; task: BoardTask } | { ok: false; error: string } {
|
|
661
|
-
const task = state.tasks[workId];
|
|
662
|
-
if (!task || task.status !== "completed") return { ok: false, error: `Work Item "${workId}" is not completed.` };
|
|
663
|
-
const blockers = claimedDependents(workId);
|
|
664
|
-
if (blockers.length > 0) return { ok: false, error: `Work Item "${workId}" has active dependent Work: ${blockers.map((blocker) => blocker.id).join(", ")}.` };
|
|
665
|
-
task.status = "pending";
|
|
666
|
-
task.claimedBy = undefined;
|
|
667
|
-
task.result = undefined;
|
|
668
|
-
task.deferredMessages = undefined;
|
|
669
|
-
task.errorMessage = undefined;
|
|
670
|
-
task.recoveryRequired = undefined;
|
|
671
|
-
task.completedAt = undefined;
|
|
672
|
-
task.updatedAt = Date.now();
|
|
673
|
-
boardRevisionCounter++;
|
|
674
|
-
stateRevisionCounter++;
|
|
675
|
-
return { ok: true, task };
|
|
676
|
-
}
|
|
272
|
+
// ── Tasks (delegated to pi-tasks) ────────────────────────────────
|
|
273
|
+
//
|
|
274
|
+
// There is deliberately no `release` here. A lease is given back on completion,
|
|
275
|
+
// on process exit, and on stop; the runtime's own half-start reverts go through
|
|
276
|
+
// `revertUndeliveredTake`, and a holder that must free a lease it is not giving
|
|
277
|
+
// up uses `acknowledgeSupersession`. Those name what happened instead of offering
|
|
278
|
+
// one verb that can take any task from anyone.
|
|
279
|
+
export {
|
|
280
|
+
createTask, getTask, listTasks, pendingTasks, claimableTasks, taskDependenciesMet,
|
|
281
|
+
takeTask, claimedDependents, reopenTask, failTasksHeldBy,
|
|
282
|
+
applyClaimIntent, revertUndeliveredTake, bindTask, discardTask,
|
|
283
|
+
acknowledgeSupersession, completeTaskWithOutcome,
|
|
284
|
+
applySubmissionIntent, loadTasks, setTaskContext,
|
|
285
|
+
} from "@fradser/pi-tasks";
|
|
286
|
+
export type { WorkAssignment as WorkerAssignment } from "@fradser/pi-tasks";
|
|
677
287
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/** Release every task held by a named teammate (crash or shutdown). */
|
|
711
|
-
export function releaseTasksOf(workerName: string, reason: string): BoardTask[] {
|
|
712
|
-
const released: BoardTask[] = [];
|
|
713
|
-
for (const task of listTasks()) {
|
|
714
|
-
if ((task.status === "claimed" || task.status === "superseded") && task.claimedBy === workerName) {
|
|
715
|
-
releaseTask(task.id, reason, true);
|
|
716
|
-
released.push(task);
|
|
288
|
+
/**
|
|
289
|
+
* Create a Task and bind it to a named holder in one step, rolling the creation
|
|
290
|
+
* back if the bind is refused.
|
|
291
|
+
*
|
|
292
|
+
* Composed here rather than offered by the tasks, because it is a coordinator's
|
|
293
|
+
* move: the task store states what must be done and takes only what a holder chose to
|
|
294
|
+
* take, while this names a holder and mints its lease. The rollback matters —
|
|
295
|
+
* without it a refusal would leave a Task whose id resolves to nothing.
|
|
296
|
+
*/
|
|
297
|
+
export function createBoundWork(input: {
|
|
298
|
+
id: string;
|
|
299
|
+
subject: string;
|
|
300
|
+
description?: string;
|
|
301
|
+
resources?: string[];
|
|
302
|
+
verify?: string;
|
|
303
|
+
holder: string;
|
|
304
|
+
assignment: WorkerAssignment;
|
|
305
|
+
}): { ok: true; task: import("@fradser/pi-tasks").Task } | { ok: false; reason: string } {
|
|
306
|
+
const created = createTask({
|
|
307
|
+
id: input.id,
|
|
308
|
+
recordKind: "execution",
|
|
309
|
+
subject: input.subject,
|
|
310
|
+
...(input.description ? { description: input.description } : {}),
|
|
311
|
+
...(input.resources ? { resources: input.resources } : {}),
|
|
312
|
+
...(input.verify ? { verify: input.verify } : {}),
|
|
313
|
+
});
|
|
314
|
+
if (!created.ok) return { ok: false, reason: created.reason };
|
|
315
|
+
const bound = bindTask(input.id, input.holder, input.assignment, { from: ["pending"] });
|
|
316
|
+
if (!bound.ok) {
|
|
317
|
+
discardTask(input.id, input.holder);
|
|
318
|
+
return { ok: false, reason: bound.reason };
|
|
717
319
|
}
|
|
320
|
+
return { ok: true, task: bound.task };
|
|
718
321
|
}
|
|
719
|
-
return released;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/** Apply a validated claim intent from a marker file. */
|
|
723
|
-
export function applyClaimIntent(intent: TaskIntent): { applied: boolean; reason?: string } {
|
|
724
|
-
const task = state.tasks[intent.taskId];
|
|
725
|
-
const teammate = getTeammate(intent.worker);
|
|
726
|
-
if (!task) return { applied: false, reason: `unknown task "${intent.taskId}"` };
|
|
727
|
-
if (!teammate || teammate.spawnId !== intent.spawnId || teammate.status === "stopped") {
|
|
728
|
-
return { applied: false, reason: `worker "${intent.worker}" is not a living current incarnation` };
|
|
729
|
-
}
|
|
730
|
-
if (teammate.assignment) {
|
|
731
|
-
const state = teammate.assignment.closed ? "closed pending leader reopen" : "active";
|
|
732
|
-
return { applied: false, reason: `@${intent.worker} already owns ${state} ${teammate.assignment.kind} assignment "${teammate.assignment.id}"` };
|
|
733
|
-
}
|
|
734
|
-
if (task.status === "claimed") return { applied: false, reason: `task "${intent.taskId}" is already claimed` };
|
|
735
|
-
if (task.status === "completed" || task.status === "superseded") return { applied: false, reason: `task "${intent.taskId}" is ${task.status}` };
|
|
736
|
-
if (task.recoveryRequired) return { applied: false, reason: `task "${intent.taskId}" requires explicit leader Work assignment after failure` };
|
|
737
|
-
if (!taskDependenciesMet(task)) return { applied: false, reason: `task "${intent.taskId}" has unmet dependencies` };
|
|
738
|
-
const conflict = activeAssignmentConflict(task.resources, intent.worker);
|
|
739
|
-
if (conflict) return { applied: false, reason: `resource conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}"` };
|
|
740
|
-
if (!setTaskClaimed(intent.taskId, intent.worker)) return { applied: false, reason: `task "${intent.taskId}" could not be claimed` };
|
|
741
|
-
return { applied: true };
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/** Validate and apply a submission intent; verify gating happens in the caller. */
|
|
745
|
-
export function applySubmissionIntent(intent: TaskIntent): { ok: boolean; error?: string } {
|
|
746
|
-
if (intent.status !== "completed" && intent.status !== "failed") {
|
|
747
|
-
return { ok: false, error: `task "${intent.taskId}" has invalid submission status` };
|
|
748
|
-
}
|
|
749
|
-
const teammate = getTeammate(intent.worker);
|
|
750
|
-
if (!teammate || teammate.spawnId !== intent.spawnId || teammate.status === "stopped") {
|
|
751
|
-
return { ok: false, error: `worker "${intent.worker}" is not a living current incarnation` };
|
|
752
|
-
}
|
|
753
|
-
const task = state.tasks[intent.taskId];
|
|
754
|
-
if (!task) return { ok: false, error: `unknown task "${intent.taskId}"` };
|
|
755
|
-
if (task.status !== "claimed" && task.status !== "superseded") {
|
|
756
|
-
return { ok: false, error: `task "${intent.taskId}" is not claimed` };
|
|
757
|
-
}
|
|
758
|
-
if (task.claimedBy !== intent.worker) {
|
|
759
|
-
return { ok: false, error: `task "${intent.taskId}" is claimed by ${task.claimedBy ?? "someone else"}` };
|
|
760
|
-
}
|
|
761
|
-
if (task.status === "superseded" && intent.status !== "failed") {
|
|
762
|
-
return { ok: false, error: `task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}"; submit failed to acknowledge cancellation` };
|
|
763
|
-
}
|
|
764
|
-
if (intent.status === "failed") {
|
|
765
|
-
releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.", true);
|
|
766
|
-
return { ok: true };
|
|
767
|
-
}
|
|
768
|
-
completeTask(intent.taskId, intent.result?.trim() || undefined);
|
|
769
|
-
return { ok: true };
|
|
770
|
-
}
|
|
771
322
|
|
|
772
323
|
// ── State inspection ──────────────────────────────────────────────
|
|
773
324
|
|
|
@@ -775,27 +326,21 @@ export function getState(): TeamState {
|
|
|
775
326
|
return state;
|
|
776
327
|
}
|
|
777
328
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
if (reloaded > 0) {
|
|
797
|
-
boardRevisionCounter++;
|
|
798
|
-
stateRevisionCounter++;
|
|
799
|
-
}
|
|
800
|
-
return reloaded;
|
|
801
|
-
}
|
|
329
|
+
configureTaskStore({
|
|
330
|
+
get: (name) => {
|
|
331
|
+
const t = getAgent(name);
|
|
332
|
+
return t ? { name: t.name, spawnId: t.spawnId, status: t.status,
|
|
333
|
+
assignment: t.assignment, currentTaskId: t.currentTaskId } : undefined;
|
|
334
|
+
},
|
|
335
|
+
// A stopped agent holds nothing, and a replaced one is not the incarnation that
|
|
336
|
+
// published the intent. Only the roster knows either, which is why the tasks
|
|
337
|
+
// asks rather than comparing spawn ids itself.
|
|
338
|
+
current: (name, incarnation) => {
|
|
339
|
+
const t = getAgent(name);
|
|
340
|
+
if (!t || t.status === "stopped") return false;
|
|
341
|
+
return incarnation === undefined || t.spawnId === incarnation;
|
|
342
|
+
},
|
|
343
|
+
conflicting: (resources, except) => activeAssignmentConflict(resources, except),
|
|
344
|
+
sync: (name, assignment, taskId) => { assignAgent(name, assignment, taskId); },
|
|
345
|
+
changed: () => { tasksRevisionCounter++; stateRevisionCounter++; },
|
|
346
|
+
});
|