@shanesaravia/hive 0.2.0 → 0.3.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/CHANGELOG.md +53 -0
- package/README.md +16 -0
- package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
- package/node_modules/@hive/shared/dist/directStudio.js +12 -0
- package/node_modules/@hive/shared/dist/index.d.ts +2 -0
- package/node_modules/@hive/shared/dist/index.js +2 -0
- package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
- package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
- package/node_modules/@hive/shared/dist/types.d.ts +29 -0
- package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
- package/node_modules/@hive/shared/dist/workers.js +22 -0
- package/package.json +1 -1
- package/packages/server/dist/api/rest.js +130 -11
- package/packages/server/dist/api/ws.js +25 -8
- package/packages/server/dist/control/launcher.js +50 -11
- package/packages/server/dist/control/messaging.js +3 -2
- package/packages/server/dist/health/deriveAlerts.js +1 -2
- package/packages/server/dist/hooks/hookIngest.js +69 -10
- package/packages/server/dist/index.js +20 -4
- package/packages/server/dist/messages/messagesStore.js +25 -12
- package/packages/server/dist/missions/missionsStore.js +19 -0
- package/packages/server/dist/missions/reopenOnWork.js +20 -0
- package/packages/server/dist/plans/planReconcile.js +114 -0
- package/packages/server/dist/plans/plansStore.js +46 -3
- package/packages/server/dist/roster/missionReplay.js +82 -0
- package/packages/server/dist/roster/rosterBuilder.js +93 -189
- package/packages/server/dist/roster/workerIdentity.js +886 -0
- package/packages/server/dist/watch/jobsWatcher.js +55 -24
- package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
- package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
- package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +1 -0
- package/packages/web/dist/assets/index-BrkIk6ny.js +0 -11
- package/packages/web/dist/assets/index-DJFn_ZsI.css +0 -2
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { ORCHESTRATOR_AGENT_NAME } from "@hive/shared";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
/**
|
|
5
|
+
* How long a worker's end-of-turn marker must stand before it counts as the
|
|
6
|
+
* end of the worker — and only in a mission with no plan.
|
|
7
|
+
*
|
|
8
|
+
* A worker ends on evidence about that worker: the orchestrator's report, its
|
|
9
|
+
* plan task closing, or the mission leaving `active`. A bare `SubagentStop` is
|
|
10
|
+
* not that evidence; the provider fires one at the end of every turn and the
|
|
11
|
+
* manager may send the same agent back 27–29s later. Where there is a plan the
|
|
12
|
+
* orchestrator is speaking for its workers and no settle applies at all. This
|
|
13
|
+
* is the fallback for a plan-less mission with a silent orchestrator, set well
|
|
14
|
+
* past every resume observed so far.
|
|
15
|
+
*/
|
|
16
|
+
export const WORKER_STOP_SETTLE_MS = 60_000;
|
|
17
|
+
const norm = (value) => value?.trim().toLowerCase() || undefined;
|
|
18
|
+
const add = (list, value) => {
|
|
19
|
+
const trimmed = value?.trim();
|
|
20
|
+
if (trimmed && !list.some((item) => item.toLowerCase() === trimmed.toLowerCase()))
|
|
21
|
+
list.push(trimmed);
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Decides which launch each delegation is talking about.
|
|
25
|
+
*
|
|
26
|
+
* The orchestrator announces a delegation and the provider reports a launch,
|
|
27
|
+
* and nothing in either message names the other. Two kinds of evidence exist,
|
|
28
|
+
* and they are weighed together across the whole set rather than one
|
|
29
|
+
* delegation at a time:
|
|
30
|
+
*
|
|
31
|
+
* - **Names.** A delegation carries a plan task whose title the orchestrator
|
|
32
|
+
* wrote; a launch carries the Agent description the orchestrator wrote. When
|
|
33
|
+
* those describe the same work it is not a guess, and it outranks timing.
|
|
34
|
+
* - **Timing.** Otherwise, how close the two were in time.
|
|
35
|
+
*
|
|
36
|
+
* Assigning globally is what stops a delegation being starved. The previous
|
|
37
|
+
* rule walked delegations in order and skipped any launch a later delegation
|
|
38
|
+
* sat nearer to — so when two delegations were emitted a fifth of a second
|
|
39
|
+
* apart before either launch, the first was refused both launches, and later
|
|
40
|
+
* adopted whatever was left over. Two real missions crossed their workers'
|
|
41
|
+
* tasks that way.
|
|
42
|
+
*/
|
|
43
|
+
export function pairDelegationsByName(input) {
|
|
44
|
+
// A word shared by every launch says nothing about which one is meant. What
|
|
45
|
+
// identifies a worker is the word only it has: "ok" against "done", "home"
|
|
46
|
+
// against "superplus".
|
|
47
|
+
const launchWords = new Map(input.launches.map((launch) => [launch.nativeId, words(input.launchLabel(launch.nativeId))]));
|
|
48
|
+
const seen = new Map();
|
|
49
|
+
for (const set of launchWords.values())
|
|
50
|
+
for (const word of set)
|
|
51
|
+
seen.set(word, (seen.get(word) ?? 0) + 1);
|
|
52
|
+
const candidates = [];
|
|
53
|
+
for (const event of input.delegations) {
|
|
54
|
+
const jobId = input.eventJobId(event);
|
|
55
|
+
if (!jobId)
|
|
56
|
+
continue;
|
|
57
|
+
// Everything the orchestrator called this piece of work.
|
|
58
|
+
const asked = new Set([
|
|
59
|
+
...words(input.taskTitle(event.targetTask?.trim() || undefined)),
|
|
60
|
+
...words(event.targetWorker),
|
|
61
|
+
...words(event.targetTask),
|
|
62
|
+
]);
|
|
63
|
+
for (const launch of input.launches) {
|
|
64
|
+
if (launch.jobId !== jobId)
|
|
65
|
+
continue;
|
|
66
|
+
const shared = [...(launchWords.get(launch.nativeId) ?? [])].filter((word) => asked.has(word) && seen.get(word) === 1);
|
|
67
|
+
if (shared.length)
|
|
68
|
+
candidates.push({ event, launch, score: shared.length });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Best evidence first; ties resolve by emission order so the result is the
|
|
72
|
+
// same whatever order the events arrived in.
|
|
73
|
+
candidates.sort((a, b) => b.score - a.score || a.event.ts - b.event.ts || a.launch.startedAt - b.launch.startedAt);
|
|
74
|
+
const pairing = new Map();
|
|
75
|
+
const taken = new Set();
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (pairing.has(candidate.event) || taken.has(candidate.launch.nativeId))
|
|
78
|
+
continue;
|
|
79
|
+
pairing.set(candidate.event, candidate.launch);
|
|
80
|
+
taken.add(candidate.launch.nativeId);
|
|
81
|
+
}
|
|
82
|
+
return pairing;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Meaningful words in a human-written name, ignoring ids and punctuation. A
|
|
86
|
+
* lone digit counts: "worker-1" against "Worker 1 reply yes" is decided by it,
|
|
87
|
+
* and the uniqueness check keeps a digit shared by every launch from mattering.
|
|
88
|
+
*/
|
|
89
|
+
function words(value) {
|
|
90
|
+
return new Set((value ?? "").toLowerCase().split(/[^a-z0-9]+/i).filter((word) => (word.length > 1 || /^\d$/.test(word)) && !/^[0-9a-f]{12,}$/.test(word)));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* An orchestrator that spawns several workers in one breath may emit one event
|
|
94
|
+
* naming them all — `--target worker-1,worker-2,worker-3`. Read as one label it
|
|
95
|
+
* became one person who kept adopting the next launch as a "replacement"; in
|
|
96
|
+
* mission test33 three finished workers were shown as one. Each name is its
|
|
97
|
+
* own delegation.
|
|
98
|
+
*/
|
|
99
|
+
export function expandMultiTargetEvents(events) {
|
|
100
|
+
return events.flatMap((event) => {
|
|
101
|
+
if (event.source !== "custom" || !event.targetWorker || !/[,;]/.test(event.targetWorker))
|
|
102
|
+
return [event];
|
|
103
|
+
const targets = [...new Set(event.targetWorker.split(/[,;]/).map((value) => value.trim()).filter(Boolean))];
|
|
104
|
+
if (targets.length < 2)
|
|
105
|
+
return [event];
|
|
106
|
+
const copies = targets.map((targetWorker) => ({ ...event, targetWorker }));
|
|
107
|
+
for (const copy of copies)
|
|
108
|
+
sharedTaskEvents.add(copy);
|
|
109
|
+
return copies;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Delegations split from one multi-target emit share its `--task`. A plan task
|
|
114
|
+
* normally names one person — a retry keeps its worker by task id — but a task
|
|
115
|
+
* several workers were given at once cannot, or the second and third names
|
|
116
|
+
* land on the first worker's record. Identity for these goes by label alone;
|
|
117
|
+
* the task still reaches each worker's plan links.
|
|
118
|
+
*/
|
|
119
|
+
const sharedTaskEvents = new WeakSet();
|
|
120
|
+
const identityTask = (event) => sharedTaskEvents.has(event) ? undefined : (event.targetTask?.trim() || undefined);
|
|
121
|
+
/**
|
|
122
|
+
* How long to let a launch stay anonymous before pairing it on timing alone.
|
|
123
|
+
* The Agent description follows its `SubagentStart` within a fraction of a
|
|
124
|
+
* second; anything that has not been named by now never will be.
|
|
125
|
+
*/
|
|
126
|
+
const LAUNCH_NAME_GRACE_MS = 3_000;
|
|
127
|
+
/**
|
|
128
|
+
* Mutable per-mission identity table. Seeded from persistence so a binding
|
|
129
|
+
* decided on an earlier poll can never be re-decided differently: the
|
|
130
|
+
* heuristics only ever run for evidence that is genuinely new.
|
|
131
|
+
*/
|
|
132
|
+
export class WorkerIdentityTable {
|
|
133
|
+
missionId;
|
|
134
|
+
byCanonical = new Map();
|
|
135
|
+
dirty = false;
|
|
136
|
+
constructor(missionId, seeded = []) {
|
|
137
|
+
this.missionId = missionId;
|
|
138
|
+
for (const record of seeded)
|
|
139
|
+
this.byCanonical.set(record.canonicalId, { ...record, nativeIds: [...record.nativeIds], labels: [...record.labels], planTaskIds: [...record.planTaskIds], sessionIds: [...record.sessionIds] });
|
|
140
|
+
}
|
|
141
|
+
/** Flags a change made by a later pass, so the record set is persisted. */
|
|
142
|
+
markDirty() { this.dirty = true; }
|
|
143
|
+
/**
|
|
144
|
+
* Drops an identity that turned out not to be a person of its own. A bare
|
|
145
|
+
* `SubagentStart` names no parent, so a worker's sub-agent can be recorded
|
|
146
|
+
* before the launch event that discloses whose it is arrives.
|
|
147
|
+
*/
|
|
148
|
+
forget(canonicalId) {
|
|
149
|
+
if (this.byCanonical.delete(canonicalId))
|
|
150
|
+
this.dirty = true;
|
|
151
|
+
}
|
|
152
|
+
records() {
|
|
153
|
+
return [...this.byCanonical.values()].sort((a, b) => a.startedAt - b.startedAt || a.canonicalId.localeCompare(b.canonicalId));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Resolution order, strongest evidence first: a native id we have already
|
|
157
|
+
* seen, then the plan task (so a retry keeps the same person), then an exact
|
|
158
|
+
* label. Never a prefix or substring — fuzzy matching is what let two
|
|
159
|
+
* workers merge or one worker split.
|
|
160
|
+
*/
|
|
161
|
+
find(ref) {
|
|
162
|
+
const native = norm(ref.nativeId);
|
|
163
|
+
if (native) {
|
|
164
|
+
const match = this.records().find((record) => record.nativeIds.some((id) => id.toLowerCase() === native));
|
|
165
|
+
if (match)
|
|
166
|
+
return match;
|
|
167
|
+
}
|
|
168
|
+
const task = norm(ref.taskId);
|
|
169
|
+
if (task) {
|
|
170
|
+
const match = this.records().find((record) => record.planTaskIds.some((id) => id.toLowerCase() === task));
|
|
171
|
+
if (match)
|
|
172
|
+
return match;
|
|
173
|
+
}
|
|
174
|
+
const label = norm(ref.label);
|
|
175
|
+
if (label) {
|
|
176
|
+
const match = this.records().find((record) => record.labels.some((item) => item.toLowerCase() === label));
|
|
177
|
+
if (match)
|
|
178
|
+
return match;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
/** A brand-new person, deliberately not merged into anyone by label. */
|
|
183
|
+
create(ref) {
|
|
184
|
+
return this.bind(ref, undefined, { allowLabelMerge: false });
|
|
185
|
+
}
|
|
186
|
+
/** Merge this evidence into an existing person, or create one. */
|
|
187
|
+
bind(ref, into, options = {}) {
|
|
188
|
+
const existing = into ?? (options.allowLabelMerge === false ? this.find({ nativeId: ref.nativeId, taskId: ref.taskId }) : this.find(ref));
|
|
189
|
+
if (!existing) {
|
|
190
|
+
// A native id is the stable, provider-owned name and is preferred as the
|
|
191
|
+
// canonical id. A label-only worker (Codex, or a delegation whose launch
|
|
192
|
+
// hook never arrived) is keyed by its label, scoped to this mission
|
|
193
|
+
// rather than to a single parent job so a resumed turn reuses it.
|
|
194
|
+
const canonicalId = ref.nativeId?.trim() || ref.label?.trim();
|
|
195
|
+
if (!canonicalId)
|
|
196
|
+
throw new Error("worker identity requires a native id or a label");
|
|
197
|
+
const record = { canonicalId, missionId: this.missionId, nativeIds: [], labels: [], planTaskIds: [], sessionIds: [], startedAt: ref.at, lastJobId: ref.jobId };
|
|
198
|
+
add(record.nativeIds, ref.nativeId);
|
|
199
|
+
add(record.labels, ref.label);
|
|
200
|
+
add(record.planTaskIds, ref.taskId);
|
|
201
|
+
add(record.sessionIds, ref.sessionId);
|
|
202
|
+
this.byCanonical.set(canonicalId, record);
|
|
203
|
+
this.dirty = true;
|
|
204
|
+
return record;
|
|
205
|
+
}
|
|
206
|
+
const before = JSON.stringify(existing);
|
|
207
|
+
// Evidence can arrive for one person under a name another record already
|
|
208
|
+
// claimed — a retried task whose fresh provider id was first seen on its
|
|
209
|
+
// own. Absorb that record rather than leaving a duplicate on the floor.
|
|
210
|
+
for (const other of this.records()) {
|
|
211
|
+
if (other === existing)
|
|
212
|
+
continue;
|
|
213
|
+
const shares = (ref.nativeId && other.nativeIds.some((id) => norm(id) === norm(ref.nativeId)))
|
|
214
|
+
|| (ref.taskId && other.planTaskIds.some((id) => norm(id) === norm(ref.taskId)));
|
|
215
|
+
if (!shares)
|
|
216
|
+
continue;
|
|
217
|
+
for (const id of other.nativeIds)
|
|
218
|
+
add(existing.nativeIds, id);
|
|
219
|
+
for (const label of [...other.labels, other.canonicalId])
|
|
220
|
+
add(existing.labels, label);
|
|
221
|
+
for (const task of other.planTaskIds)
|
|
222
|
+
add(existing.planTaskIds, task);
|
|
223
|
+
for (const session of other.sessionIds)
|
|
224
|
+
add(existing.sessionIds, session);
|
|
225
|
+
existing.startedAt = Math.min(existing.startedAt, other.startedAt);
|
|
226
|
+
existing.delegated = existing.delegated || other.delegated;
|
|
227
|
+
// A merge must not forget what either half already knew: an end already
|
|
228
|
+
// published, an id already on screen, a lifecycle already observed.
|
|
229
|
+
//
|
|
230
|
+
// The half that is already on screen keeps its id. When a delegation
|
|
231
|
+
// label finally pairs with a launch the floor has been drawing for
|
|
232
|
+
// seconds, the record that survives must be keyed by that native id —
|
|
233
|
+
// re-keying it to the label is the rename that makes an avatar vanish
|
|
234
|
+
// mid-walk and a stranger appear.
|
|
235
|
+
const rekeyed = Boolean(other.published && !existing.published);
|
|
236
|
+
if (rekeyed) {
|
|
237
|
+
this.byCanonical.delete(existing.canonicalId);
|
|
238
|
+
add(existing.labels, existing.canonicalId);
|
|
239
|
+
existing.canonicalId = other.canonicalId;
|
|
240
|
+
// Overwrites `other` under its own key, so no separate delete follows.
|
|
241
|
+
this.byCanonical.set(existing.canonicalId, existing);
|
|
242
|
+
}
|
|
243
|
+
existing.published = existing.published || other.published;
|
|
244
|
+
existing.doneAt = existing.doneAt ?? other.doneAt;
|
|
245
|
+
existing.lastJobId = existing.lastJobId ?? other.lastJobId;
|
|
246
|
+
existing.lastStartedAt = Math.max(existing.lastStartedAt ?? 0, other.lastStartedAt ?? 0) || undefined;
|
|
247
|
+
existing.lastStoppedAt = Math.max(existing.lastStoppedAt ?? 0, other.lastStoppedAt ?? 0) || undefined;
|
|
248
|
+
existing.reportedAt = Math.max(existing.reportedAt ?? 0, other.reportedAt ?? 0) || undefined;
|
|
249
|
+
existing.lastDelegatedAt = Math.max(existing.lastDelegatedAt ?? 0, other.lastDelegatedAt ?? 0) || undefined;
|
|
250
|
+
if (!rekeyed)
|
|
251
|
+
this.byCanonical.delete(other.canonicalId);
|
|
252
|
+
this.dirty = true;
|
|
253
|
+
}
|
|
254
|
+
add(existing.nativeIds, ref.nativeId);
|
|
255
|
+
add(existing.labels, ref.label);
|
|
256
|
+
add(existing.planTaskIds, ref.taskId);
|
|
257
|
+
add(existing.sessionIds, ref.sessionId);
|
|
258
|
+
existing.startedAt = Math.min(existing.startedAt, ref.at);
|
|
259
|
+
if (ref.jobId)
|
|
260
|
+
existing.lastJobId = ref.jobId;
|
|
261
|
+
// Promote a label-keyed identity once the provider names it — but only
|
|
262
|
+
// while the worker has never been rendered. An unpublished label is held
|
|
263
|
+
// through the correlation grace precisely so this can happen off-screen.
|
|
264
|
+
existing.labels = existing.labels.filter((label) => !existing.nativeIds.some((id) => norm(id) === norm(label)));
|
|
265
|
+
const native = existing.nativeIds[0];
|
|
266
|
+
if (!existing.published && native && existing.canonicalId !== native && !existing.nativeIds.some((id) => id === existing.canonicalId)) {
|
|
267
|
+
add(existing.labels, existing.canonicalId);
|
|
268
|
+
this.byCanonical.delete(existing.canonicalId);
|
|
269
|
+
existing.canonicalId = native;
|
|
270
|
+
this.byCanonical.set(native, existing);
|
|
271
|
+
}
|
|
272
|
+
if (JSON.stringify(existing) !== before)
|
|
273
|
+
this.dirty = true;
|
|
274
|
+
return existing;
|
|
275
|
+
}
|
|
276
|
+
/** Canonical id for any alias, or undefined when nothing has claimed it. */
|
|
277
|
+
canonicalFor(value) {
|
|
278
|
+
if (!value)
|
|
279
|
+
return undefined;
|
|
280
|
+
const wanted = norm(value);
|
|
281
|
+
if (!wanted)
|
|
282
|
+
return undefined;
|
|
283
|
+
return this.records().find((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds].some((item) => item.toLowerCase() === wanted))?.canonicalId;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Bumped whenever identity resolution changes in a way that could have
|
|
288
|
+
* produced a different binding. Version 2 fixed an earlier delegation
|
|
289
|
+
* adopting a later, unrelated launch that was still inside the pairing window,
|
|
290
|
+
* which merged two workers into one actor and left the second delegation's
|
|
291
|
+
* label stranded as a third.
|
|
292
|
+
*/
|
|
293
|
+
export const WORKER_IDENTITY_RESOLVER_VERSION = "4";
|
|
294
|
+
/** Persists resolved identities so they survive the bounded event ring. */
|
|
295
|
+
export class WorkerIdentityStore {
|
|
296
|
+
db;
|
|
297
|
+
constructor(filePath = config.databasePath) {
|
|
298
|
+
this.db = new Database(filePath);
|
|
299
|
+
this.db.pragma("journal_mode = WAL");
|
|
300
|
+
this.db.exec(`
|
|
301
|
+
CREATE TABLE IF NOT EXISTS mission_workers (mission_id TEXT NOT NULL, canonical_id TEXT NOT NULL, updated_at INTEGER NOT NULL, record_json TEXT NOT NULL, PRIMARY KEY (mission_id, canonical_id));
|
|
302
|
+
CREATE INDEX IF NOT EXISTS idx_mission_workers ON mission_workers (mission_id);
|
|
303
|
+
CREATE TABLE IF NOT EXISTS mission_worker_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
304
|
+
CREATE TABLE IF NOT EXISTS mission_nested_agents (mission_id TEXT NOT NULL, native_id TEXT NOT NULL, parent_native_id TEXT, PRIMARY KEY (mission_id, native_id));
|
|
305
|
+
`);
|
|
306
|
+
// Older databases predate the parent column; the child→parent link is what
|
|
307
|
+
// lets a nested agent's activity be credited to the worker that launched it.
|
|
308
|
+
try {
|
|
309
|
+
this.db.exec("ALTER TABLE mission_nested_agents ADD COLUMN parent_native_id TEXT");
|
|
310
|
+
}
|
|
311
|
+
catch { /* already present */ }
|
|
312
|
+
// Bindings are deliberately never re-decided, so a change to how identity
|
|
313
|
+
// is resolved has to invalidate what the old rules already wrote. Missions
|
|
314
|
+
// simply re-resolve from their event history on the next snapshot.
|
|
315
|
+
const stored = this.db.prepare("SELECT value FROM mission_worker_meta WHERE key = 'resolver_version'").get()?.value;
|
|
316
|
+
if (stored !== WORKER_IDENTITY_RESOLVER_VERSION) {
|
|
317
|
+
this.db.exec("DELETE FROM mission_workers");
|
|
318
|
+
this.db.exec("DELETE FROM mission_nested_agents");
|
|
319
|
+
this.db.prepare("INSERT INTO mission_worker_meta (key, value) VALUES ('resolver_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(WORKER_IDENTITY_RESOLVER_VERSION);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
list(missionId) {
|
|
323
|
+
return this.db.prepare("SELECT record_json FROM mission_workers WHERE mission_id = ?").all(missionId)
|
|
324
|
+
.map((row) => JSON.parse(row.record_json));
|
|
325
|
+
}
|
|
326
|
+
save(missionId, records) {
|
|
327
|
+
const now = Date.now();
|
|
328
|
+
this.db.transaction(() => {
|
|
329
|
+
const keep = new Set(records.map((record) => record.canonicalId));
|
|
330
|
+
for (const existing of this.db.prepare("SELECT canonical_id FROM mission_workers WHERE mission_id = ?").all(missionId)) {
|
|
331
|
+
const id = existing.canonical_id;
|
|
332
|
+
if (!keep.has(id))
|
|
333
|
+
this.db.prepare("DELETE FROM mission_workers WHERE mission_id = ? AND canonical_id = ?").run(missionId, id);
|
|
334
|
+
}
|
|
335
|
+
for (const record of records) {
|
|
336
|
+
this.db.prepare("INSERT INTO mission_workers (mission_id, canonical_id, updated_at, record_json) VALUES (?, ?, ?, ?) ON CONFLICT(mission_id, canonical_id) DO UPDATE SET updated_at=excluded.updated_at, record_json=excluded.record_json")
|
|
337
|
+
.run(missionId, record.canonicalId, now, JSON.stringify(record));
|
|
338
|
+
}
|
|
339
|
+
})();
|
|
340
|
+
}
|
|
341
|
+
/** Agents a worker launched, which never take a desk of their own. */
|
|
342
|
+
listNested(missionId) {
|
|
343
|
+
return this.db.prepare("SELECT native_id, parent_native_id FROM mission_nested_agents WHERE mission_id = ?").all(missionId)
|
|
344
|
+
.map((row) => ({ nativeId: row.native_id, parentId: row.parent_native_id ?? undefined }));
|
|
345
|
+
}
|
|
346
|
+
saveNested(missionId, entries) {
|
|
347
|
+
const insert = this.db.prepare("INSERT INTO mission_nested_agents (mission_id, native_id, parent_native_id) VALUES (?, ?, ?) ON CONFLICT(mission_id, native_id) DO UPDATE SET parent_native_id = COALESCE(excluded.parent_native_id, mission_nested_agents.parent_native_id)");
|
|
348
|
+
this.db.transaction(() => { for (const entry of entries)
|
|
349
|
+
insert.run(missionId, entry.nativeId, entry.parentId ?? null); })();
|
|
350
|
+
}
|
|
351
|
+
/** Remembers a plan task binding discovered after the identity was resolved. */
|
|
352
|
+
addTaskBinding(missionId, canonicalId, taskId) {
|
|
353
|
+
const record = this.list(missionId).find((item) => item.canonicalId === canonicalId);
|
|
354
|
+
if (!record || record.planTaskIds.some((id) => id.trim().toLowerCase() === taskId.trim().toLowerCase()))
|
|
355
|
+
return;
|
|
356
|
+
record.planTaskIds.push(taskId);
|
|
357
|
+
this.db.prepare("UPDATE mission_workers SET record_json = ?, updated_at = ? WHERE mission_id = ? AND canonical_id = ?")
|
|
358
|
+
.run(JSON.stringify(record), Date.now(), missionId, canonicalId);
|
|
359
|
+
}
|
|
360
|
+
/** Canonical worker id for any alias, label, or plan task id. */
|
|
361
|
+
canonicalFor(missionId, value) {
|
|
362
|
+
const wanted = value?.trim().toLowerCase();
|
|
363
|
+
if (!wanted)
|
|
364
|
+
return undefined;
|
|
365
|
+
return this.list(missionId).find((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds]
|
|
366
|
+
.some((item) => item.trim().toLowerCase() === wanted))?.canonicalId;
|
|
367
|
+
}
|
|
368
|
+
removeMission(missionId) {
|
|
369
|
+
this.db.prepare("DELETE FROM mission_workers WHERE mission_id = ?").run(missionId);
|
|
370
|
+
this.db.prepare("DELETE FROM mission_nested_agents WHERE mission_id = ?").run(missionId);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Native agent id for an event — only a real agent id may name a person. */
|
|
374
|
+
function nativeIdOf(event) {
|
|
375
|
+
if (event.workerRef)
|
|
376
|
+
return event.workerRef.agentId?.trim() || undefined;
|
|
377
|
+
// Legacy events (persisted before workerRef existed) kept the id in
|
|
378
|
+
// targetWorker. An agent *type* was also written there, so type-shaped
|
|
379
|
+
// values are rejected below by requiring a start/stop lifecycle event.
|
|
380
|
+
return event.targetWorker?.trim() || undefined;
|
|
381
|
+
}
|
|
382
|
+
function labelOf(event) {
|
|
383
|
+
const raw = event.rawPayload && typeof event.rawPayload === "object" ? event.rawPayload : {};
|
|
384
|
+
const response = raw.tool_response && typeof raw.tool_response === "object" ? raw.tool_response : {};
|
|
385
|
+
const input = raw.tool_input && typeof raw.tool_input === "object" ? raw.tool_input : {};
|
|
386
|
+
const fromPayload = typeof response.description === "string" ? response.description : typeof input.description === "string" ? input.description : undefined;
|
|
387
|
+
return event.workerRef?.label?.trim() || fromPayload?.trim() || undefined;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Builds the mission's canonical worker roster from every available source.
|
|
391
|
+
*
|
|
392
|
+
* Sources, in order of authority: hook lifecycle evidence (`SubagentStart` /
|
|
393
|
+
* `SubagentStop` / `PostToolUse` Agent launches), `job.fan` metadata, then the
|
|
394
|
+
* orchestrator's own `delegating` events. Delegation labels are paired with
|
|
395
|
+
* launches in emission order rather than by nearest timestamp, so two parallel
|
|
396
|
+
* delegations reported after both launches cannot cross-wire.
|
|
397
|
+
*/
|
|
398
|
+
export function resolveMissionWorkers(input) {
|
|
399
|
+
const { missionId, fanWorkers, eventJobId, now, missionActive } = input;
|
|
400
|
+
const events = expandMultiTargetEvents(input.events);
|
|
401
|
+
const correlationGraceMs = input.correlationGraceMs ?? 8_000;
|
|
402
|
+
const pairWindowMs = input.pairWindowMs ?? 30_000;
|
|
403
|
+
const planTasksInput = input.planTasks ?? [];
|
|
404
|
+
const table = new WorkerIdentityTable(missionId, input.seeded);
|
|
405
|
+
// 1. Hook lifecycle evidence, keyed by the provider's own agent id.
|
|
406
|
+
const nativeEvidence = new Map();
|
|
407
|
+
// An agent launched by a worker is that worker's activity, not a new person
|
|
408
|
+
// on the floor. Nesting is remembered for the mission because the proof of
|
|
409
|
+
// it — one `Agent` tool response naming the parent — is a single event, and
|
|
410
|
+
// on a long mission it scrolls out of the window the roster reads. When it
|
|
411
|
+
// did, the child took a desk of its own partway through, which is exactly
|
|
412
|
+
// what an avatar appearing out of nowhere looks like.
|
|
413
|
+
const nestedParents = new Map((input.seededNested ?? []).map((entry) => [entry.nativeId, entry.parentId]));
|
|
414
|
+
const nestedAgentIds = { has: (id) => nestedParents.has(id), add: (id, parent) => { if (!nestedParents.has(id) || (parent && !nestedParents.get(id)))
|
|
415
|
+
nestedParents.set(id, parent ?? nestedParents.get(id)); } };
|
|
416
|
+
// Nesting is decided when the launch is *called*, not when it returns. A
|
|
417
|
+
// worker's `PreToolUse(Agent)` carries the worker's own id as the caller —
|
|
418
|
+
// the manager's carries none — and the `SubagentStart` that follows in the
|
|
419
|
+
// same session is the agent it launched. Waiting for the launcher's
|
|
420
|
+
// `PostToolUse(Agent)` instead meant the nested agent held a desk for its
|
|
421
|
+
// whole life, because that event fires when the child *finishes*. Launches
|
|
422
|
+
// are matched to starts in order, the way the provider itself sequences them.
|
|
423
|
+
const pendingLaunches = [];
|
|
424
|
+
const anyHookActivity = new Map();
|
|
425
|
+
for (const event of [...events].sort((a, b) => a.ts - b.ts)) {
|
|
426
|
+
if (event.workerRef?.agentId && event.workerRef.agentId !== ORCHESTRATOR_AGENT_NAME && event.hookEventName !== "SubagentStart" && event.hookEventName !== "SubagentStop") {
|
|
427
|
+
anyHookActivity.set(event.workerRef.agentId, Math.max(anyHookActivity.get(event.workerRef.agentId) ?? 0, event.ts));
|
|
428
|
+
}
|
|
429
|
+
if (event.hookEventName === "PreToolUse" && event.toolName === "Agent") {
|
|
430
|
+
const caller = event.workerRef?.agentId?.trim();
|
|
431
|
+
pendingLaunches.push({ sessionId: event.sessionId, ts: event.ts, parentId: caller && caller !== ORCHESTRATOR_AGENT_NAME ? caller : undefined });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (event.hookEventName !== "SubagentStart")
|
|
435
|
+
continue;
|
|
436
|
+
const nativeId = nativeIdOf(event);
|
|
437
|
+
if (!nativeId || event.workerRef?.parentAgentId)
|
|
438
|
+
continue;
|
|
439
|
+
const index = pendingLaunches.findIndex((launch) => launch.sessionId === event.sessionId && launch.ts <= event.ts && event.ts - launch.ts <= 15_000);
|
|
440
|
+
if (index < 0)
|
|
441
|
+
continue;
|
|
442
|
+
const [launch] = pendingLaunches.splice(index, 1);
|
|
443
|
+
if (launch.parentId)
|
|
444
|
+
nestedAgentIds.add(nativeId, launch.parentId);
|
|
445
|
+
}
|
|
446
|
+
for (const event of events) {
|
|
447
|
+
const jobId = eventJobId(event);
|
|
448
|
+
const nativeId = nativeIdOf(event);
|
|
449
|
+
if (!jobId || !nativeId || nativeId === ORCHESTRATOR_AGENT_NAME)
|
|
450
|
+
continue;
|
|
451
|
+
const isLaunchResponse = (event.hookEventName === "PostToolUse" || event.hookEventName === "PostToolUseFailure") && event.toolName === "Agent" && Boolean(event.workerRef?.agentId ?? event.targetWorker);
|
|
452
|
+
const isStart = event.hookEventName === "SubagentStart" || isLaunchResponse;
|
|
453
|
+
const isStop = event.hookEventName === "SubagentStop";
|
|
454
|
+
if (!isStart && !isStop)
|
|
455
|
+
continue;
|
|
456
|
+
// A parent that is not the orchestrator is proof on its own: hookIngest
|
|
457
|
+
// only fills `parentAgentId` from the launching agent, and a worker the
|
|
458
|
+
// manager launched has the manager there.
|
|
459
|
+
const parent = event.workerRef?.parentAgentId?.trim();
|
|
460
|
+
if (parent && parent !== ORCHESTRATOR_AGENT_NAME) {
|
|
461
|
+
nestedAgentIds.add(nativeId, parent);
|
|
462
|
+
// The child's own `SubagentStart` carries no parent and may have been
|
|
463
|
+
// seen first; forget whatever it established.
|
|
464
|
+
nativeEvidence.delete(nativeId);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (nestedAgentIds.has(nativeId))
|
|
468
|
+
continue;
|
|
469
|
+
const prior = nativeEvidence.get(nativeId);
|
|
470
|
+
// A stop with no observed launch is usually a nested or background task
|
|
471
|
+
// notification: evidence, but not enough to invent a person.
|
|
472
|
+
if (isStop && !prior)
|
|
473
|
+
continue;
|
|
474
|
+
const label = labelOf(event) ?? prior?.label ?? nativeId;
|
|
475
|
+
nativeEvidence.set(nativeId, {
|
|
476
|
+
nativeId,
|
|
477
|
+
jobId,
|
|
478
|
+
label: prior && prior.label !== prior.nativeId ? prior.label : label,
|
|
479
|
+
startedAt: Math.min(prior?.startedAt ?? event.ts, event.ts),
|
|
480
|
+
restartedAt: isStart ? event.ts : prior?.restartedAt ?? event.ts,
|
|
481
|
+
doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
|
|
482
|
+
updatedAt: event.ts,
|
|
483
|
+
running: isStart,
|
|
484
|
+
sessionId: event.sessionId,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// 2. Fan metadata. The fan file is a current view only and can drop an
|
|
488
|
+
// earlier worker, so it enriches rather than defines the roster.
|
|
489
|
+
const fanByNative = new Map();
|
|
490
|
+
for (const worker of fanWorkers) {
|
|
491
|
+
if (nestedAgentIds.has(worker.id))
|
|
492
|
+
continue;
|
|
493
|
+
fanByNative.set(worker.id, worker);
|
|
494
|
+
}
|
|
495
|
+
// Which identities the orchestrator has already reported as finished. A
|
|
496
|
+
// completed delegation must never adopt a later launch — that launch belongs
|
|
497
|
+
// to whichever delegation comes next.
|
|
498
|
+
const reportedRefs = new Set();
|
|
499
|
+
for (const event of events) {
|
|
500
|
+
if (event.source !== "custom" || event.phase !== "worker_reported")
|
|
501
|
+
continue;
|
|
502
|
+
for (const ref of [event.targetWorker, event.targetTask]) {
|
|
503
|
+
const key = norm(ref);
|
|
504
|
+
if (key)
|
|
505
|
+
reportedRefs.add(key);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// 3. Delegations claim their launches first, in emission order per job.
|
|
509
|
+
const claimed = new Set();
|
|
510
|
+
for (const record of table.records())
|
|
511
|
+
if (record.delegated)
|
|
512
|
+
for (const id of record.nativeIds)
|
|
513
|
+
claimed.add(id);
|
|
514
|
+
const launchOrder = [...nativeEvidence.values(), ...[...fanByNative.values()].filter((worker) => !nativeEvidence.has(worker.id)).map((worker) => ({ nativeId: worker.id, jobId: worker.jobId, startedAt: worker.startedAt }))]
|
|
515
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
516
|
+
const delegations = events.filter((candidate) => candidate.source === "custom" && candidate.phase === "delegating" && (candidate.targetWorker || candidate.targetTask))
|
|
517
|
+
.sort((a, b) => a.ts - b.ts);
|
|
518
|
+
// A launch the provider has not named yet is very likely about to be: the
|
|
519
|
+
// `SubagentStart` arrives a fraction of a second before the `Agent` tool
|
|
520
|
+
// response that carries the description. Deciding by timing inside that
|
|
521
|
+
// window is how mission testing12 bound both delegations to the wrong
|
|
522
|
+
// workers — and a binding is never re-decided, so it stayed wrong for the
|
|
523
|
+
// rest of the mission. Wait for the name; fall back to timing only when it
|
|
524
|
+
// is clear no name is coming.
|
|
525
|
+
const launchIsNamed = (nativeId) => {
|
|
526
|
+
const label = nativeEvidence.get(nativeId)?.label ?? fanByNative.get(nativeId)?.label;
|
|
527
|
+
return Boolean(label && label !== nativeId);
|
|
528
|
+
};
|
|
529
|
+
// Only ambiguity is worth waiting for. With one delegation looking for one
|
|
530
|
+
// worker there is nothing a name could change, so it binds immediately as it
|
|
531
|
+
// always has; it is two delegations reaching for the same anonymous launch
|
|
532
|
+
// that need the description to arrive first.
|
|
533
|
+
const contenders = delegations.filter((event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length).length;
|
|
534
|
+
// Decide nothing while a name may still be arriving. Deferring the whole
|
|
535
|
+
// pass rather than the individual launch matters: pairing sees every launch
|
|
536
|
+
// at once either way, so waiting cannot change which delegation wins a
|
|
537
|
+
// contest it was always going to win — it only lets a name settle it first.
|
|
538
|
+
const waitingForNames = contenders > 1
|
|
539
|
+
&& launchOrder.some((launch) => !launchIsNamed(launch.nativeId) && now - launch.startedAt < LAUNCH_NAME_GRACE_MS);
|
|
540
|
+
const namedLaunches = waitingForNames ? [] : launchOrder;
|
|
541
|
+
const needsLaunch = (event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length;
|
|
542
|
+
const pairing = pairDelegationsByName({
|
|
543
|
+
delegations: delegations.filter(needsLaunch),
|
|
544
|
+
launches: namedLaunches.filter((launch) => !claimed.has(launch.nativeId)),
|
|
545
|
+
eventJobId,
|
|
546
|
+
launchLabel: (nativeId) => nativeEvidence.get(nativeId)?.label ?? fanByNative.get(nativeId)?.label,
|
|
547
|
+
taskTitle: (taskId) => planTasksInput.find((task) => task.id === taskId)?.title,
|
|
548
|
+
});
|
|
549
|
+
const spokenFor = new Set([...pairing.values()].map((launch) => launch.nativeId));
|
|
550
|
+
for (const event of delegations) {
|
|
551
|
+
const jobId = eventJobId(event);
|
|
552
|
+
if (!jobId)
|
|
553
|
+
continue;
|
|
554
|
+
const label = event.targetWorker?.trim() || undefined;
|
|
555
|
+
const taskId = event.targetTask?.trim() || undefined;
|
|
556
|
+
// Identity never goes by a task several workers share (see identityTask);
|
|
557
|
+
// the task itself is still recorded on whoever the label binds to.
|
|
558
|
+
const ref = { label, taskId: identityTask(event), jobId, sessionId: event.sessionId, at: event.ts };
|
|
559
|
+
// Where no name settles it, a launch belongs to whichever delegation named
|
|
560
|
+
// it closest in time. Any later delegation still waiting for its own
|
|
561
|
+
// launch, and nearer to this one, has the better claim — otherwise an
|
|
562
|
+
// earlier delegation still inside the window swallows the next worker's.
|
|
563
|
+
// Millisecond margins decide this, and real runs disagree about whether
|
|
564
|
+
// the nth delegation means the nth launch, so it stays a heuristic: names
|
|
565
|
+
// are the only thing that overrules it.
|
|
566
|
+
const contested = (startedAt) => delegations.some((other) => {
|
|
567
|
+
if (other === event || other.ts <= event.ts || eventJobId(other) !== jobId)
|
|
568
|
+
return false;
|
|
569
|
+
if (pairing.has(other))
|
|
570
|
+
return false;
|
|
571
|
+
const otherNamed = table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) });
|
|
572
|
+
if (otherNamed?.nativeIds.length)
|
|
573
|
+
return false;
|
|
574
|
+
return Math.abs(startedAt - other.ts) < Math.abs(startedAt - event.ts);
|
|
575
|
+
});
|
|
576
|
+
// With exactly one delegation still waiting and exactly one launch nobody
|
|
577
|
+
// has claimed in the same job, the gap between them proves nothing: an
|
|
578
|
+
// orchestrator that announces its delegations early and launches minutes
|
|
579
|
+
// later is common, and refusing the pair left the label and the native id
|
|
580
|
+
// on the floor as two people.
|
|
581
|
+
const unpairedHere = delegations.filter((other) => eventJobId(other) === jobId && needsLaunch(other) && !pairing.has(other));
|
|
582
|
+
const unclaimedHere = namedLaunches.filter((launch) => launch.jobId === jobId && !claimed.has(launch.nativeId) && !spokenFor.has(launch.nativeId));
|
|
583
|
+
const sole = unpairedHere.length === 1 && unpairedHere[0] === event && unclaimedHere.length === 1 && !waitingForNames ? unclaimedHere[0] : undefined;
|
|
584
|
+
const pairable = pairing.get(event) ?? namedLaunches.find((launch) => launch.jobId === jobId && !claimed.has(launch.nativeId) && !spokenFor.has(launch.nativeId)
|
|
585
|
+
&& Math.abs(launch.startedAt - event.ts) <= pairWindowMs && !contested(launch.startedAt)) ?? sole;
|
|
586
|
+
// An already-known label or plan task wins over the launch: a retried or
|
|
587
|
+
// replaced task keeps its person, and the fresh provider id joins it as
|
|
588
|
+
// another alias instead of taking a second desk.
|
|
589
|
+
const named = table.find({ label, taskId: identityTask(event) });
|
|
590
|
+
const known = named ?? (pairable ? table.find({ nativeId: pairable.nativeId }) : undefined);
|
|
591
|
+
// A delegation that already has its worker must not swallow a later,
|
|
592
|
+
// unrelated launch just because it is still inside the pairing window —
|
|
593
|
+
// that is the next delegation's worker. Adoption is only for a label whose
|
|
594
|
+
// own launch has yet to arrive, or for a genuine replacement: the previous
|
|
595
|
+
// attempt has stopped, nothing has been reported, and the task is open.
|
|
596
|
+
const alreadyLaunched = Boolean(named?.nativeIds.length);
|
|
597
|
+
const sameLaunch = Boolean(pairable && named?.nativeIds.some((id) => norm(id) === norm(pairable.nativeId)));
|
|
598
|
+
const identities = named ? [named.canonicalId, ...named.nativeIds, ...named.labels, ...named.planTaskIds] : [];
|
|
599
|
+
const reported = identities.some((value) => reportedRefs.has(norm(value) ?? ""));
|
|
600
|
+
const taskStillOpen = !named?.planTaskIds.length || !planTasksInput.length
|
|
601
|
+
|| named.planTaskIds.some((id) => planTasksInput.some((task) => task.id === id && task.status !== "completed" && task.status !== "cancelled"));
|
|
602
|
+
// A replacement is a fresh id for work whose previous attempt has actually
|
|
603
|
+
// stopped. A launch that arrives while the record's worker is still running
|
|
604
|
+
// is a sibling — the next delegation's worker, or one the orchestrator
|
|
605
|
+
// named in the same breath — never a retry.
|
|
606
|
+
// ...and a delegation can only be the retry of an attempt that had stopped
|
|
607
|
+
// by the time it was emitted: one announced before the stop was announcing
|
|
608
|
+
// someone else.
|
|
609
|
+
const previousStoppedAt = named?.nativeIds.length ? Math.max(...named.nativeIds.map((id) => {
|
|
610
|
+
const evidence = nativeEvidence.get(id);
|
|
611
|
+
if (evidence)
|
|
612
|
+
return evidence.doneAt ?? Infinity;
|
|
613
|
+
return named.lastStoppedAt !== undefined && (named.lastStartedAt ?? 0) <= named.lastStoppedAt ? named.lastStoppedAt : Infinity;
|
|
614
|
+
})) : Infinity;
|
|
615
|
+
const previousStopped = previousStoppedAt !== Infinity && event.ts >= previousStoppedAt;
|
|
616
|
+
// A retry on a later turn arrives under a new job while the old attempt's
|
|
617
|
+
// job has simply ended; that is a replacement too, stop or no stop.
|
|
618
|
+
const laterJob = Boolean(pairable && named?.lastJobId && pairable.jobId !== named.lastJobId);
|
|
619
|
+
const replacing = alreadyLaunched && !sameLaunch && !reported && taskStillOpen && (previousStopped || laterJob);
|
|
620
|
+
const adopt = pairable && (!alreadyLaunched || sameLaunch || replacing) ? pairable : undefined;
|
|
621
|
+
if (adopt)
|
|
622
|
+
claimed.add(adopt.nativeId);
|
|
623
|
+
if (!known && !adopt && !label && !taskId)
|
|
624
|
+
continue;
|
|
625
|
+
const bound = table.bind({
|
|
626
|
+
...ref,
|
|
627
|
+
nativeId: adopt?.nativeId,
|
|
628
|
+
label: label ?? (adopt ? undefined : taskId),
|
|
629
|
+
at: adopt ? Math.min(adopt.startedAt, event.ts) : event.ts,
|
|
630
|
+
}, known);
|
|
631
|
+
bound.delegated = true;
|
|
632
|
+
if (taskId && !bound.planTaskIds.includes(taskId)) {
|
|
633
|
+
bound.planTaskIds.push(taskId);
|
|
634
|
+
table.markDirty();
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// 4. Every launch no delegation claimed is a worker in its own right.
|
|
638
|
+
for (const native of nativeEvidence.values()) {
|
|
639
|
+
if (claimed.has(native.nativeId))
|
|
640
|
+
continue;
|
|
641
|
+
const label = native.label === native.nativeId ? undefined : native.label;
|
|
642
|
+
// Two launches with the same description are two people when the first is
|
|
643
|
+
// still running. Merging by description exists for a retry — a fresh id for
|
|
644
|
+
// work whose previous attempt has stopped or been reported — and
|
|
645
|
+
// orchestrators reuse descriptions for parallel workers.
|
|
646
|
+
const sameName = label ? table.find({ label }) : undefined;
|
|
647
|
+
const sameNameLive = Boolean(sameName?.nativeIds.length) && sameName.nativeIds.some((id) => { const evidence = nativeEvidence.get(id); return evidence ? !evidence.doneAt : false; })
|
|
648
|
+
&& ![sameName.canonicalId, ...sameName.nativeIds, ...sameName.labels, ...sameName.planTaskIds].some((value) => reportedRefs.has(norm(value) ?? ""));
|
|
649
|
+
const ref = { nativeId: native.nativeId, label, jobId: native.jobId, sessionId: native.sessionId, at: native.startedAt };
|
|
650
|
+
if (sameNameLive)
|
|
651
|
+
table.create(ref);
|
|
652
|
+
else
|
|
653
|
+
table.bind(ref);
|
|
654
|
+
}
|
|
655
|
+
for (const worker of fanByNative.values()) {
|
|
656
|
+
table.bind({ nativeId: worker.id, label: worker.label && worker.label !== worker.id ? worker.label : undefined, jobId: worker.jobId, at: worker.startedAt });
|
|
657
|
+
}
|
|
658
|
+
// 4b. Work a worker left running past the end of its turn. The provider ends
|
|
659
|
+
// the turn and wakes the agent when the command finishes — 27s later in
|
|
660
|
+
// mission test6 — so such a worker is waiting, not finished.
|
|
661
|
+
const backgroundedAt = new Map();
|
|
662
|
+
for (const event of events) {
|
|
663
|
+
if (!event.backgrounded)
|
|
664
|
+
continue;
|
|
665
|
+
// An async Agent launch leaves the *launcher* waiting; the event's worker
|
|
666
|
+
// reference points at the child. Credit the parent when a worker launched
|
|
667
|
+
// it, and nobody when the manager did.
|
|
668
|
+
const canonical = event.toolName === "Agent"
|
|
669
|
+
? (event.workerRef?.parentAgentId ? table.canonicalFor(event.workerRef.parentAgentId) : undefined)
|
|
670
|
+
: table.canonicalFor(event.workerRef?.agentId) ?? table.canonicalFor(event.targetWorker);
|
|
671
|
+
if (canonical)
|
|
672
|
+
backgroundedAt.set(canonical, Math.max(backgroundedAt.get(canonical) ?? 0, event.ts));
|
|
673
|
+
}
|
|
674
|
+
// 5. Terminal evidence from the orchestrator's own reports, and the
|
|
675
|
+
// delegations that reopen a worker the orchestrator had already closed.
|
|
676
|
+
const reportedDone = new Map();
|
|
677
|
+
const delegatedAt = new Map();
|
|
678
|
+
const records = table.records();
|
|
679
|
+
for (const event of events) {
|
|
680
|
+
if (event.source !== "custom")
|
|
681
|
+
continue;
|
|
682
|
+
let canonical = table.canonicalFor(event.targetWorker) ?? table.canonicalFor(event.targetTask);
|
|
683
|
+
if (!canonical)
|
|
684
|
+
continue;
|
|
685
|
+
// A delegation whose launch was never paired sits as an unpublished label
|
|
686
|
+
// record. Its report is still about a real worker: when exactly one
|
|
687
|
+
// published, provider-named worker has not been reported, that is the one.
|
|
688
|
+
if (event.phase === "worker_reported") {
|
|
689
|
+
const record = records.find((item) => item.canonicalId === canonical);
|
|
690
|
+
if (record && !record.published && !record.nativeIds.length) {
|
|
691
|
+
const unreported = records.filter((item) => item.published && item.nativeIds.length && !reportedDone.has(item.canonicalId)
|
|
692
|
+
&& ![item.canonicalId, ...item.nativeIds, ...item.labels, ...item.planTaskIds].some((value) => reportedRefs.has(norm(value) ?? "") && norm(value) !== norm(event.targetWorker) && norm(value) !== norm(event.targetTask)));
|
|
693
|
+
if (unreported.length === 1)
|
|
694
|
+
canonical = unreported[0].canonicalId;
|
|
695
|
+
}
|
|
696
|
+
reportedDone.set(canonical, event.ts);
|
|
697
|
+
}
|
|
698
|
+
if (event.phase === "delegating")
|
|
699
|
+
delegatedAt.set(canonical, event.ts);
|
|
700
|
+
}
|
|
701
|
+
// 6. Project each identity into one WorkerActivity.
|
|
702
|
+
for (const record of table.records()) {
|
|
703
|
+
if (record.nativeIds.length && record.nativeIds.every((id) => nestedAgentIds.has(id)))
|
|
704
|
+
table.forget(record.canonicalId);
|
|
705
|
+
}
|
|
706
|
+
const planTasks = planTasksInput;
|
|
707
|
+
const workers = [];
|
|
708
|
+
for (const record of table.records()) {
|
|
709
|
+
const natives = record.nativeIds.map((id) => nativeEvidence.get(id)).filter((value) => Boolean(value));
|
|
710
|
+
const fans = record.nativeIds.map((id) => fanByNative.get(id)).filter((value) => Boolean(value));
|
|
711
|
+
const latestNative = natives.sort((a, b) => a.updatedAt - b.updatedAt).at(-1);
|
|
712
|
+
const latestFan = fans.sort((a, b) => (a.updatedAt ?? a.startedAt) - (b.updatedAt ?? b.startedAt)).at(-1);
|
|
713
|
+
const jobId = latestNative?.jobId ?? latestFan?.jobId ?? record.lastJobId;
|
|
714
|
+
if (!jobId)
|
|
715
|
+
continue;
|
|
716
|
+
const startedAt = Math.min(record.startedAt, ...natives.map((item) => item.startedAt), ...fans.map((item) => item.startedAt));
|
|
717
|
+
// The orchestrator's word about this worker, remembered past the window.
|
|
718
|
+
const reportedAt = Math.max(record.reportedAt ?? 0, reportedDone.get(record.canonicalId) ?? 0) || undefined;
|
|
719
|
+
const lastDelegatedAt = Math.max(record.lastDelegatedAt ?? 0, delegatedAt.get(record.canonicalId) ?? 0) || undefined;
|
|
720
|
+
if (record.reportedAt !== reportedAt || record.lastDelegatedAt !== lastDelegatedAt) {
|
|
721
|
+
record.reportedAt = reportedAt;
|
|
722
|
+
record.lastDelegatedAt = lastDelegatedAt;
|
|
723
|
+
table.markDirty();
|
|
724
|
+
}
|
|
725
|
+
const reported = reportedAt;
|
|
726
|
+
const updatedAt = Math.max(latestNative?.updatedAt ?? 0, latestFan?.updatedAt ?? 0, reported ?? 0) || undefined;
|
|
727
|
+
const taskIds = [...record.planTaskIds];
|
|
728
|
+
const openTask = planTasks.some((task) => taskIds.includes(task.id) && task.status !== "completed" && task.status !== "cancelled");
|
|
729
|
+
// A worker ends on evidence about that worker, and once ended it stays
|
|
730
|
+
// ended until the provider genuinely starts it again.
|
|
731
|
+
//
|
|
732
|
+
// Neither a `SubagentStop` nor the manager's turn ending is that evidence
|
|
733
|
+
// on its own. The provider fires `SubagentStop` at the end of each of a
|
|
734
|
+
// worker's turns and the manager can send the same agent back an arbitrary
|
|
735
|
+
// time later; the manager's own job goes `done` and resumes on every
|
|
736
|
+
// prompt while it waits — five times in one observed run. Deriving a
|
|
737
|
+
// worker's end from either made its finished state flap, which the office
|
|
738
|
+
// drew as one worker leaving and a stranger arriving, over and over.
|
|
739
|
+
//
|
|
740
|
+
// In order of authority: the orchestrator's report, the provider's fan
|
|
741
|
+
// record, the mission leaving `active` (it is proposed complete, so
|
|
742
|
+
// nothing is still running), and finally a stop that has stood unanswered
|
|
743
|
+
// long enough to mean it. The last is what retires a worker in a mission
|
|
744
|
+
// with no plan and a silent orchestrator, without retiring one that is
|
|
745
|
+
// merely between turns.
|
|
746
|
+
// The worker's own lifecycle, persisted so it outlives the event ring.
|
|
747
|
+
const lastStartedAt = Math.max(record.lastStartedAt ?? 0, latestNative?.restartedAt ?? 0, ...natives.map((item) => item.startedAt)) || undefined;
|
|
748
|
+
const lastStoppedAt = Math.max(record.lastStoppedAt ?? 0, ...natives.map((item) => item.doneAt ?? 0)) || undefined;
|
|
749
|
+
if (record.lastStartedAt !== lastStartedAt || record.lastStoppedAt !== lastStoppedAt) {
|
|
750
|
+
record.lastStartedAt = lastStartedAt;
|
|
751
|
+
record.lastStoppedAt = lastStoppedAt;
|
|
752
|
+
table.markDirty();
|
|
753
|
+
}
|
|
754
|
+
const stoppedAt = lastStoppedAt !== undefined && lastStartedAt !== undefined && lastStartedAt > lastStoppedAt ? undefined : lastStoppedAt;
|
|
755
|
+
// Any tool the worker ran after its last stop means it is running again,
|
|
756
|
+
// whether or not the start that resumed it is still in view. Work its own
|
|
757
|
+
// sub-agents do is its work too: a worker that has fanned out and is
|
|
758
|
+
// waiting on its children is working, not finished — retiring it there is
|
|
759
|
+
// what made a lead reappear as a stranger when its children reported back.
|
|
760
|
+
const ownIds = new Set(record.nativeIds);
|
|
761
|
+
const childIds = [...nestedParents.keys()].filter((child) => { let current = child; for (let hops = 0; hops < 8 && current; hops += 1) {
|
|
762
|
+
const parent = nestedParents.get(current);
|
|
763
|
+
if (!parent)
|
|
764
|
+
return false;
|
|
765
|
+
if (ownIds.has(parent))
|
|
766
|
+
return true;
|
|
767
|
+
current = parent;
|
|
768
|
+
} return false; });
|
|
769
|
+
const activityAt = Math.max(0, ...record.nativeIds.map((id) => anyHookActivity.get(id) ?? 0), ...childIds.map((id) => Math.max(anyHookActivity.get(id) ?? 0, nativeEvidence.get(id)?.updatedAt ?? 0)));
|
|
770
|
+
// Work it left running after its last start is a promise that it will be
|
|
771
|
+
// back; nothing retires it until that work is answered by a restart.
|
|
772
|
+
const awaitingBackgroundWork = (backgroundedAt.get(record.canonicalId) ?? 0) > (lastStartedAt ?? 0);
|
|
773
|
+
// Its plan task closing is the orchestrator saying this work is finished.
|
|
774
|
+
const closedTasks = taskIds.length && planTasks.length ? planTasks.filter((task) => taskIds.includes(task.id) && (task.status === "completed" || task.status === "cancelled")) : [];
|
|
775
|
+
const taskClosed = closedTasks.length && !openTask ? Math.max(...closedTasks.map((task) => task.updatedAt ?? 0)) || stoppedAt || now : undefined;
|
|
776
|
+
// The stop-settle applies only where nothing else can ever speak for the
|
|
777
|
+
// worker: a mission with no plan.
|
|
778
|
+
const settledStop = stoppedAt !== undefined && !planTasks.length && !awaitingBackgroundWork && now - stoppedAt >= WORKER_STOP_SETTLE_MS ? stoppedAt : undefined;
|
|
779
|
+
// The orchestrator's report closes a worker for good. The provider may
|
|
780
|
+
// still start that agent again — a manager checking on a delayed reply
|
|
781
|
+
// does exactly this, twice in one observed run — but those are follow-ups
|
|
782
|
+
// to work already accounted for, not a new life. Only a fresh delegation
|
|
783
|
+
// reopens a closed worker.
|
|
784
|
+
const reopened = lastDelegatedAt;
|
|
785
|
+
const closed = reported !== undefined && (reopened === undefined || reopened <= reported) ? reported : undefined;
|
|
786
|
+
const running = closed === undefined && ((lastStartedAt !== undefined && lastStartedAt > (lastStoppedAt ?? 0))
|
|
787
|
+
|| (activityAt > (lastStoppedAt ?? 0)));
|
|
788
|
+
// Whether it ended, and when it ended, are two questions. Authority
|
|
789
|
+
// decides the first; the second prefers the moment the worker actually
|
|
790
|
+
// stopped over the moment someone got around to saying so.
|
|
791
|
+
const ends = running ? false : Boolean(closed ?? taskClosed ?? settledStop
|
|
792
|
+
?? (!missionActive ? stoppedAt ?? latestNative?.updatedAt ?? startedAt : undefined)
|
|
793
|
+
// Sticky: an end already published never un-publishes itself. Only a
|
|
794
|
+
// restart clears it, and a restart clears `running` above.
|
|
795
|
+
?? record.doneAt);
|
|
796
|
+
const doneAt = ends ? record.doneAt ?? stoppedAt ?? closed ?? taskClosed ?? latestNative?.updatedAt ?? startedAt : undefined;
|
|
797
|
+
if (record.doneAt !== doneAt) {
|
|
798
|
+
record.doneAt = doneAt;
|
|
799
|
+
table.markDirty();
|
|
800
|
+
}
|
|
801
|
+
const finished = Boolean(doneAt);
|
|
802
|
+
const resuming = !finished && stoppedAt !== undefined;
|
|
803
|
+
// Publishing commits an identity to the floor, so it waits until the name
|
|
804
|
+
// has settled. Provider-named evidence is authoritative and appears at
|
|
805
|
+
// once; a delegation label on its own is held through the correlation
|
|
806
|
+
// window to give the launch hook time to arrive and merge under its real
|
|
807
|
+
// id. Without that hold the worker is drawn under the label and then
|
|
808
|
+
// renamed, which the office can only render as two different people.
|
|
809
|
+
const corroborated = Boolean(latestNative) || Boolean(reported) || Boolean(record.published);
|
|
810
|
+
// A delegation with no provider id of its own must not take a desk while a
|
|
811
|
+
// launch it could still turn out to be is sitting unclaimed — nor while
|
|
812
|
+
// pairing is deliberately waiting for that launch to be named. Publishing
|
|
813
|
+
// early is how one delegation became a person of its own beside the worker
|
|
814
|
+
// it was describing.
|
|
815
|
+
const mayYetPair = !record.published && !record.nativeIds.length
|
|
816
|
+
&& (waitingForNames || launchOrder.some((launch) => launch.jobId === jobId && !claimed.has(launch.nativeId)));
|
|
817
|
+
// The mirror image: a launch nobody has claimed, while a delegation that is
|
|
818
|
+
// *already on screen* is still waiting for one. That delegation's launch
|
|
819
|
+
// came so late that its label was published on its own; this native is
|
|
820
|
+
// almost certainly it, and its description follows within a fraction of a
|
|
821
|
+
// second and pairs it by name. Publishing the native first put a second
|
|
822
|
+
// person on the floor for one poll and then took them away again. A
|
|
823
|
+
// delegation not yet published needs no such care — when it pairs, the
|
|
824
|
+
// native id is the one that survives.
|
|
825
|
+
const delegationWaiting = !record.published && !record.delegated && record.nativeIds.length > 0
|
|
826
|
+
&& delegations.some((other) => eventJobId(other) === jobId && needsLaunch(other)
|
|
827
|
+
&& table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) })?.published)
|
|
828
|
+
&& now - startedAt < correlationGraceMs;
|
|
829
|
+
if (mayYetPair || delegationWaiting || (!corroborated && now - startedAt < correlationGraceMs))
|
|
830
|
+
continue;
|
|
831
|
+
if (!record.published) {
|
|
832
|
+
record.published = true;
|
|
833
|
+
table.markDirty();
|
|
834
|
+
}
|
|
835
|
+
// Prefer a name a human wrote: the Agent description, then the
|
|
836
|
+
// orchestrator's delegation label. Fall back to the id only when the
|
|
837
|
+
// provider never gave the worker a name at all.
|
|
838
|
+
const named = (value) => (value && value !== record.canonicalId && !record.nativeIds.includes(value) ? value : undefined);
|
|
839
|
+
const label = named(latestFan?.label) ?? named(latestNative?.label) ?? record.labels.map(named).find(Boolean) ?? record.canonicalId;
|
|
840
|
+
const aliases = [...record.nativeIds, ...record.labels].filter((alias) => alias !== record.canonicalId && alias !== label);
|
|
841
|
+
workers.push({
|
|
842
|
+
id: record.canonicalId,
|
|
843
|
+
kind: latestFan?.kind ?? "agent",
|
|
844
|
+
label,
|
|
845
|
+
startedAt,
|
|
846
|
+
doneAt,
|
|
847
|
+
updatedAt,
|
|
848
|
+
tokens: latestFan?.tokens,
|
|
849
|
+
terminationReason: latestFan?.terminationReason,
|
|
850
|
+
jobId,
|
|
851
|
+
// A worker's state is its own. It never reads the manager's job, whose
|
|
852
|
+
// `working → done → working` at every turn boundary is what made workers
|
|
853
|
+
// appear to finish and restart. A worker waiting on work it backgrounded
|
|
854
|
+
// is working; one with no provider evidence at all (a delegation the
|
|
855
|
+
// provider never named) is presumed working until it is reported.
|
|
856
|
+
jobState: finished ? "done" : running || awaitingBackgroundWork ? "working" : resuming ? "idle" : record.nativeIds.length ? "idle" : "working",
|
|
857
|
+
jobUpdatedAt: latestFan?.jobUpdatedAt,
|
|
858
|
+
aliases: aliases.length ? aliases : undefined,
|
|
859
|
+
taskIds: taskIds.length ? taskIds : undefined,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
// A nested agent's activity is its parent worker's: follow the chain to the
|
|
863
|
+
// first-level worker so the parent keeps working while its agents do.
|
|
864
|
+
const rootOf = (id) => {
|
|
865
|
+
let current = id;
|
|
866
|
+
for (let hops = 0; hops < 8 && nestedParents.has(current); hops += 1) {
|
|
867
|
+
const parent = nestedParents.get(current);
|
|
868
|
+
if (!parent)
|
|
869
|
+
break;
|
|
870
|
+
current = parent;
|
|
871
|
+
}
|
|
872
|
+
return current;
|
|
873
|
+
};
|
|
874
|
+
const rewrite = (event) => {
|
|
875
|
+
const ref = event.workerRef?.agentId ?? event.targetWorker;
|
|
876
|
+
if (!ref)
|
|
877
|
+
return event;
|
|
878
|
+
const root = nestedParents.has(ref) ? rootOf(ref) : ref;
|
|
879
|
+
const canonical = table.canonicalFor(root) ?? (root !== ref ? root : undefined);
|
|
880
|
+
return canonical && canonical !== event.targetWorker ? { ...event, targetWorker: canonical } : event;
|
|
881
|
+
};
|
|
882
|
+
const nested = [...nestedParents].map(([nativeId, parentId]) => ({ nativeId, parentId })).sort((a, b) => a.nativeId.localeCompare(b.nativeId));
|
|
883
|
+
const seededNestedKey = JSON.stringify((input.seededNested ?? []).map((entry) => [entry.nativeId, entry.parentId ?? null]).sort());
|
|
884
|
+
const nestedKey = JSON.stringify(nested.map((entry) => [entry.nativeId, entry.parentId ?? null]));
|
|
885
|
+
return { workers: workers.sort((a, b) => a.startedAt - b.startedAt), records: table.records(), nested, dirty: table.dirty || nestedKey !== seededNestedKey, rewrite };
|
|
886
|
+
}
|