@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +16 -0
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/types.d.ts +29 -0
  10. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  11. package/node_modules/@hive/shared/dist/workers.js +22 -0
  12. package/package.json +1 -1
  13. package/packages/server/dist/api/rest.js +130 -11
  14. package/packages/server/dist/api/ws.js +25 -8
  15. package/packages/server/dist/control/launcher.js +50 -11
  16. package/packages/server/dist/control/messaging.js +3 -2
  17. package/packages/server/dist/health/deriveAlerts.js +1 -2
  18. package/packages/server/dist/hooks/hookIngest.js +69 -10
  19. package/packages/server/dist/index.js +20 -4
  20. package/packages/server/dist/messages/messagesStore.js +25 -12
  21. package/packages/server/dist/missions/missionsStore.js +19 -0
  22. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  23. package/packages/server/dist/plans/planReconcile.js +114 -0
  24. package/packages/server/dist/plans/plansStore.js +46 -3
  25. package/packages/server/dist/roster/missionReplay.js +82 -0
  26. package/packages/server/dist/roster/rosterBuilder.js +93 -189
  27. package/packages/server/dist/roster/workerIdentity.js +886 -0
  28. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  29. package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
  30. package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
  31. package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
  32. package/packages/web/dist/index.html +2 -2
  33. package/templates/agents/hive-orchestrator.md +1 -0
  34. package/packages/web/dist/assets/index-BrkIk6ny.js +0 -11
  35. 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
+ }