@shanesaravia/hive 0.2.1 → 0.4.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 (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +18 -1
  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/status.js +9 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +191 -1
  11. package/node_modules/@hive/shared/dist/types.js +27 -0
  12. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  13. package/node_modules/@hive/shared/dist/workers.js +22 -0
  14. package/package.json +1 -1
  15. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  16. package/packages/server/dist/api/rest.js +538 -23
  17. package/packages/server/dist/api/ws.js +90 -9
  18. package/packages/server/dist/control/launcher.js +50 -11
  19. package/packages/server/dist/control/messaging.js +3 -2
  20. package/packages/server/dist/control/missionQuiesce.js +66 -0
  21. package/packages/server/dist/health/deriveAlerts.js +9 -2
  22. package/packages/server/dist/hooks/hookIngest.js +69 -10
  23. package/packages/server/dist/index.js +44 -4
  24. package/packages/server/dist/loops/loopCommand.js +56 -0
  25. package/packages/server/dist/loops/loopNoop.js +38 -0
  26. package/packages/server/dist/loops/loopScheduler.js +58 -0
  27. package/packages/server/dist/loops/loopStore.js +118 -0
  28. package/packages/server/dist/loops/monitors.js +38 -0
  29. package/packages/server/dist/messages/attachmentStore.js +92 -0
  30. package/packages/server/dist/messages/messagesStore.js +110 -33
  31. package/packages/server/dist/missions/missionsStore.js +9 -0
  32. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  33. package/packages/server/dist/plans/planReconcile.js +114 -0
  34. package/packages/server/dist/plans/plansStore.js +46 -3
  35. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  36. package/packages/server/dist/roster/missionReplay.js +82 -0
  37. package/packages/server/dist/roster/replyAsk.js +62 -0
  38. package/packages/server/dist/roster/rosterBuilder.js +78 -147
  39. package/packages/server/dist/roster/workerIdentity.js +923 -0
  40. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  41. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  42. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  43. package/packages/server/dist/terminals/providerDetection.js +27 -0
  44. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  45. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  46. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  47. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  48. package/packages/server/dist/terminals/terminalStream.js +30 -0
  49. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  50. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  51. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  52. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  53. package/packages/web/dist/index.html +2 -2
  54. package/templates/agents/hive-orchestrator.md +1 -0
  55. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  56. package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
@@ -0,0 +1,923 @@
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. Version 5 stopped a launch under a still-live
292
+ * sibling job being read as a replacement, which merged the workers of
293
+ * concurrent managers into one person — and clears the records those bindings
294
+ * already wrote.
295
+ */
296
+ export const WORKER_IDENTITY_RESOLVER_VERSION = "5";
297
+ /** Persists resolved identities so they survive the bounded event ring. */
298
+ export class WorkerIdentityStore {
299
+ db;
300
+ constructor(filePath = config.databasePath) {
301
+ this.db = new Database(filePath);
302
+ this.db.pragma("journal_mode = WAL");
303
+ this.db.exec(`
304
+ 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));
305
+ CREATE INDEX IF NOT EXISTS idx_mission_workers ON mission_workers (mission_id);
306
+ CREATE TABLE IF NOT EXISTS mission_worker_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
307
+ 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));
308
+ `);
309
+ // Older databases predate the parent column; the child→parent link is what
310
+ // lets a nested agent's activity be credited to the worker that launched it.
311
+ try {
312
+ this.db.exec("ALTER TABLE mission_nested_agents ADD COLUMN parent_native_id TEXT");
313
+ }
314
+ catch { /* already present */ }
315
+ // Bindings are deliberately never re-decided, so a change to how identity
316
+ // is resolved has to invalidate what the old rules already wrote. Missions
317
+ // simply re-resolve from their event history on the next snapshot.
318
+ const stored = this.db.prepare("SELECT value FROM mission_worker_meta WHERE key = 'resolver_version'").get()?.value;
319
+ if (stored !== WORKER_IDENTITY_RESOLVER_VERSION) {
320
+ this.db.exec("DELETE FROM mission_workers");
321
+ this.db.exec("DELETE FROM mission_nested_agents");
322
+ 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);
323
+ }
324
+ }
325
+ list(missionId) {
326
+ return this.db.prepare("SELECT record_json FROM mission_workers WHERE mission_id = ?").all(missionId)
327
+ .map((row) => JSON.parse(row.record_json));
328
+ }
329
+ save(missionId, records) {
330
+ const now = Date.now();
331
+ this.db.transaction(() => {
332
+ const keep = new Set(records.map((record) => record.canonicalId));
333
+ for (const existing of this.db.prepare("SELECT canonical_id FROM mission_workers WHERE mission_id = ?").all(missionId)) {
334
+ const id = existing.canonical_id;
335
+ if (!keep.has(id))
336
+ this.db.prepare("DELETE FROM mission_workers WHERE mission_id = ? AND canonical_id = ?").run(missionId, id);
337
+ }
338
+ for (const record of records) {
339
+ 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")
340
+ .run(missionId, record.canonicalId, now, JSON.stringify(record));
341
+ }
342
+ })();
343
+ }
344
+ /** Agents a worker launched, which never take a desk of their own. */
345
+ listNested(missionId) {
346
+ return this.db.prepare("SELECT native_id, parent_native_id FROM mission_nested_agents WHERE mission_id = ?").all(missionId)
347
+ .map((row) => ({ nativeId: row.native_id, parentId: row.parent_native_id ?? undefined }));
348
+ }
349
+ saveNested(missionId, entries) {
350
+ 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)");
351
+ this.db.transaction(() => { for (const entry of entries)
352
+ insert.run(missionId, entry.nativeId, entry.parentId ?? null); })();
353
+ }
354
+ /** Remembers a plan task binding discovered after the identity was resolved. */
355
+ addTaskBinding(missionId, canonicalId, taskId) {
356
+ const record = this.list(missionId).find((item) => item.canonicalId === canonicalId);
357
+ if (!record || record.planTaskIds.some((id) => id.trim().toLowerCase() === taskId.trim().toLowerCase()))
358
+ return;
359
+ record.planTaskIds.push(taskId);
360
+ this.db.prepare("UPDATE mission_workers SET record_json = ?, updated_at = ? WHERE mission_id = ? AND canonical_id = ?")
361
+ .run(JSON.stringify(record), Date.now(), missionId, canonicalId);
362
+ }
363
+ /** Canonical worker id for any alias, label, or plan task id. */
364
+ canonicalFor(missionId, value) {
365
+ const wanted = value?.trim().toLowerCase();
366
+ if (!wanted)
367
+ return undefined;
368
+ return this.list(missionId).find((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds]
369
+ .some((item) => item.trim().toLowerCase() === wanted))?.canonicalId;
370
+ }
371
+ removeMission(missionId) {
372
+ this.db.prepare("DELETE FROM mission_workers WHERE mission_id = ?").run(missionId);
373
+ this.db.prepare("DELETE FROM mission_nested_agents WHERE mission_id = ?").run(missionId);
374
+ }
375
+ }
376
+ /** Native agent id for an event — only a real agent id may name a person. */
377
+ function nativeIdOf(event) {
378
+ if (event.workerRef)
379
+ return event.workerRef.agentId?.trim() || undefined;
380
+ // Legacy events (persisted before workerRef existed) kept the id in
381
+ // targetWorker. An agent *type* was also written there, so type-shaped
382
+ // values are rejected below by requiring a start/stop lifecycle event.
383
+ return event.targetWorker?.trim() || undefined;
384
+ }
385
+ function labelOf(event) {
386
+ const raw = event.rawPayload && typeof event.rawPayload === "object" ? event.rawPayload : {};
387
+ const response = raw.tool_response && typeof raw.tool_response === "object" ? raw.tool_response : {};
388
+ const input = raw.tool_input && typeof raw.tool_input === "object" ? raw.tool_input : {};
389
+ const fromPayload = typeof response.description === "string" ? response.description : typeof input.description === "string" ? input.description : undefined;
390
+ return event.workerRef?.label?.trim() || fromPayload?.trim() || undefined;
391
+ }
392
+ /**
393
+ * Builds the mission's canonical worker roster from every available source.
394
+ *
395
+ * Sources, in order of authority: hook lifecycle evidence (`SubagentStart` /
396
+ * `SubagentStop` / `PostToolUse` Agent launches), `job.fan` metadata, then the
397
+ * orchestrator's own `delegating` events. Delegation labels are paired with
398
+ * launches in emission order rather than by nearest timestamp, so two parallel
399
+ * delegations reported after both launches cannot cross-wire.
400
+ */
401
+ export function resolveMissionWorkers(input) {
402
+ const { missionId, fanWorkers, eventJobId, now, missionActive } = input;
403
+ const events = expandMultiTargetEvents(input.events);
404
+ const correlationGraceMs = input.correlationGraceMs ?? 8_000;
405
+ const pairWindowMs = input.pairWindowMs ?? 30_000;
406
+ const liveJobIds = new Set(input.liveJobIds ?? []);
407
+ const planTasksInput = input.planTasks ?? [];
408
+ const table = new WorkerIdentityTable(missionId, input.seeded);
409
+ // 1. Hook lifecycle evidence, keyed by the provider's own agent id.
410
+ const nativeEvidence = new Map();
411
+ // An agent launched by a worker is that worker's activity, not a new person
412
+ // on the floor. Nesting is remembered for the mission because the proof of
413
+ // it — one `Agent` tool response naming the parent — is a single event, and
414
+ // on a long mission it scrolls out of the window the roster reads. When it
415
+ // did, the child took a desk of its own partway through, which is exactly
416
+ // what an avatar appearing out of nowhere looks like.
417
+ const nestedParents = new Map((input.seededNested ?? []).map((entry) => [entry.nativeId, entry.parentId]));
418
+ const nestedAgentIds = { has: (id) => nestedParents.has(id), add: (id, parent) => { if (!nestedParents.has(id) || (parent && !nestedParents.get(id)))
419
+ nestedParents.set(id, parent ?? nestedParents.get(id)); } };
420
+ // Nesting is decided when the launch is *called*, not when it returns. A
421
+ // worker's `PreToolUse(Agent)` carries the worker's own id as the caller —
422
+ // the manager's carries none — and the `SubagentStart` that follows in the
423
+ // same session is the agent it launched. Waiting for the launcher's
424
+ // `PostToolUse(Agent)` instead meant the nested agent held a desk for its
425
+ // whole life, because that event fires when the child *finishes*. Launches
426
+ // are matched to starts in order, the way the provider itself sequences them.
427
+ const pendingLaunches = [];
428
+ const anyHookActivity = new Map();
429
+ for (const event of [...events].sort((a, b) => a.ts - b.ts)) {
430
+ if (event.workerRef?.agentId && event.workerRef.agentId !== ORCHESTRATOR_AGENT_NAME && event.hookEventName !== "SubagentStart" && event.hookEventName !== "SubagentStop") {
431
+ anyHookActivity.set(event.workerRef.agentId, Math.max(anyHookActivity.get(event.workerRef.agentId) ?? 0, event.ts));
432
+ }
433
+ if (event.hookEventName === "PreToolUse" && event.toolName === "Agent") {
434
+ const caller = event.workerRef?.agentId?.trim();
435
+ pendingLaunches.push({ sessionId: event.sessionId, ts: event.ts, parentId: caller && caller !== ORCHESTRATOR_AGENT_NAME ? caller : undefined });
436
+ continue;
437
+ }
438
+ if (event.hookEventName !== "SubagentStart")
439
+ continue;
440
+ const nativeId = nativeIdOf(event);
441
+ if (!nativeId || event.workerRef?.parentAgentId)
442
+ continue;
443
+ const index = pendingLaunches.findIndex((launch) => launch.sessionId === event.sessionId && launch.ts <= event.ts && event.ts - launch.ts <= 15_000);
444
+ if (index < 0)
445
+ continue;
446
+ const [launch] = pendingLaunches.splice(index, 1);
447
+ if (launch.parentId)
448
+ nestedAgentIds.add(nativeId, launch.parentId);
449
+ }
450
+ for (const event of events) {
451
+ const jobId = eventJobId(event);
452
+ const nativeId = nativeIdOf(event);
453
+ if (!jobId || !nativeId || nativeId === ORCHESTRATOR_AGENT_NAME)
454
+ continue;
455
+ const isLaunchResponse = (event.hookEventName === "PostToolUse" || event.hookEventName === "PostToolUseFailure") && event.toolName === "Agent" && Boolean(event.workerRef?.agentId ?? event.targetWorker);
456
+ const isStart = event.hookEventName === "SubagentStart" || isLaunchResponse;
457
+ const isStop = event.hookEventName === "SubagentStop";
458
+ if (!isStart && !isStop)
459
+ continue;
460
+ // A parent that is not the orchestrator is proof on its own: hookIngest
461
+ // only fills `parentAgentId` from the launching agent, and a worker the
462
+ // manager launched has the manager there.
463
+ const parent = event.workerRef?.parentAgentId?.trim();
464
+ if (parent && parent !== ORCHESTRATOR_AGENT_NAME) {
465
+ nestedAgentIds.add(nativeId, parent);
466
+ // The child's own `SubagentStart` carries no parent and may have been
467
+ // seen first; forget whatever it established.
468
+ nativeEvidence.delete(nativeId);
469
+ continue;
470
+ }
471
+ if (nestedAgentIds.has(nativeId))
472
+ continue;
473
+ const prior = nativeEvidence.get(nativeId);
474
+ // A stop with no observed launch is usually a nested or background task
475
+ // notification: evidence, but not enough to invent a person — unless a
476
+ // persisted record already claims this id. Then the person exists and the
477
+ // launch has merely scrolled out of the event ring, which on a busy
478
+ // mission takes only a couple of minutes: 216 events separated one
479
+ // observed worker's start from its stop, and the ring holds 200. Dropping
480
+ // the stop there left `lastStoppedAt` unset for good, and a worker that
481
+ // had finished a quarter of an hour earlier read "Working" indefinitely.
482
+ const seededRecord = isStop && !prior ? table.find({ nativeId }) : undefined;
483
+ if (isStop && !prior && !seededRecord)
484
+ continue;
485
+ const label = labelOf(event) ?? prior?.label ?? nativeId;
486
+ nativeEvidence.set(nativeId, {
487
+ nativeId,
488
+ jobId,
489
+ label: prior && prior.label !== prior.nativeId ? prior.label : label,
490
+ startedAt: Math.min(prior?.startedAt ?? seededRecord?.startedAt ?? event.ts, event.ts),
491
+ // A stop must never be read as a start. Where the launch is out of view,
492
+ // the record's own last start is the honest answer; inventing one at the
493
+ // stop's timestamp would make the worker look as though it began at the
494
+ // moment it ended.
495
+ restartedAt: isStart ? event.ts : prior?.restartedAt ?? seededRecord?.lastStartedAt ?? event.ts,
496
+ doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
497
+ updatedAt: event.ts,
498
+ running: isStart,
499
+ sessionId: event.sessionId,
500
+ });
501
+ }
502
+ // 2. Fan metadata. The fan file is a current view only and can drop an
503
+ // earlier worker, so it enriches rather than defines the roster.
504
+ const fanByNative = new Map();
505
+ for (const worker of fanWorkers) {
506
+ if (nestedAgentIds.has(worker.id))
507
+ continue;
508
+ fanByNative.set(worker.id, worker);
509
+ }
510
+ // Which identities the orchestrator has already reported as finished. A
511
+ // completed delegation must never adopt a later launch — that launch belongs
512
+ // to whichever delegation comes next.
513
+ const reportedRefs = new Set();
514
+ for (const event of events) {
515
+ if (event.source !== "custom" || event.phase !== "worker_reported")
516
+ continue;
517
+ for (const ref of [event.targetWorker, event.targetTask]) {
518
+ const key = norm(ref);
519
+ if (key)
520
+ reportedRefs.add(key);
521
+ }
522
+ }
523
+ // 3. Delegations claim their launches first, in emission order per job.
524
+ const claimed = new Set();
525
+ for (const record of table.records())
526
+ if (record.delegated)
527
+ for (const id of record.nativeIds)
528
+ claimed.add(id);
529
+ const launchOrder = [...nativeEvidence.values(), ...[...fanByNative.values()].filter((worker) => !nativeEvidence.has(worker.id)).map((worker) => ({ nativeId: worker.id, jobId: worker.jobId, startedAt: worker.startedAt }))]
530
+ .sort((a, b) => a.startedAt - b.startedAt);
531
+ const delegations = events.filter((candidate) => candidate.source === "custom" && candidate.phase === "delegating" && (candidate.targetWorker || candidate.targetTask))
532
+ .sort((a, b) => a.ts - b.ts);
533
+ // A launch the provider has not named yet is very likely about to be: the
534
+ // `SubagentStart` arrives a fraction of a second before the `Agent` tool
535
+ // response that carries the description. Deciding by timing inside that
536
+ // window is how mission testing12 bound both delegations to the wrong
537
+ // workers — and a binding is never re-decided, so it stayed wrong for the
538
+ // rest of the mission. Wait for the name; fall back to timing only when it
539
+ // is clear no name is coming.
540
+ const launchIsNamed = (nativeId) => {
541
+ const label = nativeEvidence.get(nativeId)?.label ?? fanByNative.get(nativeId)?.label;
542
+ return Boolean(label && label !== nativeId);
543
+ };
544
+ // Only ambiguity is worth waiting for. With one delegation looking for one
545
+ // worker there is nothing a name could change, so it binds immediately as it
546
+ // always has; it is two delegations reaching for the same anonymous launch
547
+ // that need the description to arrive first.
548
+ const contenders = delegations.filter((event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length).length;
549
+ // Decide nothing while a name may still be arriving. Deferring the whole
550
+ // pass rather than the individual launch matters: pairing sees every launch
551
+ // at once either way, so waiting cannot change which delegation wins a
552
+ // contest it was always going to win — it only lets a name settle it first.
553
+ const waitingForNames = contenders > 1
554
+ && launchOrder.some((launch) => !launchIsNamed(launch.nativeId) && now - launch.startedAt < LAUNCH_NAME_GRACE_MS);
555
+ const namedLaunches = waitingForNames ? [] : launchOrder;
556
+ const needsLaunch = (event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length;
557
+ const pairing = pairDelegationsByName({
558
+ delegations: delegations.filter(needsLaunch),
559
+ launches: namedLaunches.filter((launch) => !claimed.has(launch.nativeId)),
560
+ eventJobId,
561
+ launchLabel: (nativeId) => nativeEvidence.get(nativeId)?.label ?? fanByNative.get(nativeId)?.label,
562
+ taskTitle: (taskId) => planTasksInput.find((task) => task.id === taskId)?.title,
563
+ });
564
+ const spokenFor = new Set([...pairing.values()].map((launch) => launch.nativeId));
565
+ for (const event of delegations) {
566
+ const jobId = eventJobId(event);
567
+ if (!jobId)
568
+ continue;
569
+ const label = event.targetWorker?.trim() || undefined;
570
+ const taskId = event.targetTask?.trim() || undefined;
571
+ // Identity never goes by a task several workers share (see identityTask);
572
+ // the task itself is still recorded on whoever the label binds to.
573
+ const ref = { label, taskId: identityTask(event), jobId, sessionId: event.sessionId, at: event.ts };
574
+ // Where no name settles it, a launch belongs to whichever delegation named
575
+ // it closest in time. Any later delegation still waiting for its own
576
+ // launch, and nearer to this one, has the better claim — otherwise an
577
+ // earlier delegation still inside the window swallows the next worker's.
578
+ // Millisecond margins decide this, and real runs disagree about whether
579
+ // the nth delegation means the nth launch, so it stays a heuristic: names
580
+ // are the only thing that overrules it.
581
+ const contested = (startedAt) => delegations.some((other) => {
582
+ if (other === event || other.ts <= event.ts || eventJobId(other) !== jobId)
583
+ return false;
584
+ if (pairing.has(other))
585
+ return false;
586
+ const otherNamed = table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) });
587
+ if (otherNamed?.nativeIds.length)
588
+ return false;
589
+ return Math.abs(startedAt - other.ts) < Math.abs(startedAt - event.ts);
590
+ });
591
+ // With exactly one delegation still waiting and exactly one launch nobody
592
+ // has claimed in the same job, the gap between them proves nothing: an
593
+ // orchestrator that announces its delegations early and launches minutes
594
+ // later is common, and refusing the pair left the label and the native id
595
+ // on the floor as two people.
596
+ const unpairedHere = delegations.filter((other) => eventJobId(other) === jobId && needsLaunch(other) && !pairing.has(other));
597
+ const unclaimedHere = namedLaunches.filter((launch) => launch.jobId === jobId && !claimed.has(launch.nativeId) && !spokenFor.has(launch.nativeId));
598
+ const sole = unpairedHere.length === 1 && unpairedHere[0] === event && unclaimedHere.length === 1 && !waitingForNames ? unclaimedHere[0] : undefined;
599
+ const pairable = pairing.get(event) ?? namedLaunches.find((launch) => launch.jobId === jobId && !claimed.has(launch.nativeId) && !spokenFor.has(launch.nativeId)
600
+ && Math.abs(launch.startedAt - event.ts) <= pairWindowMs && !contested(launch.startedAt)) ?? sole;
601
+ // An already-known label or plan task wins over the launch: a retried or
602
+ // replaced task keeps its person, and the fresh provider id joins it as
603
+ // another alias instead of taking a second desk.
604
+ const named = table.find({ label, taskId: identityTask(event) });
605
+ const known = named ?? (pairable ? table.find({ nativeId: pairable.nativeId }) : undefined);
606
+ // A delegation that already has its worker must not swallow a later,
607
+ // unrelated launch just because it is still inside the pairing window —
608
+ // that is the next delegation's worker. Adoption is only for a label whose
609
+ // own launch has yet to arrive, or for a genuine replacement: the previous
610
+ // attempt has stopped, nothing has been reported, and the task is open.
611
+ const alreadyLaunched = Boolean(named?.nativeIds.length);
612
+ const sameLaunch = Boolean(pairable && named?.nativeIds.some((id) => norm(id) === norm(pairable.nativeId)));
613
+ const identities = named ? [named.canonicalId, ...named.nativeIds, ...named.labels, ...named.planTaskIds] : [];
614
+ const reported = identities.some((value) => reportedRefs.has(norm(value) ?? ""));
615
+ const taskStillOpen = !named?.planTaskIds.length || !planTasksInput.length
616
+ || named.planTaskIds.some((id) => planTasksInput.some((task) => task.id === id && task.status !== "completed" && task.status !== "cancelled"));
617
+ // A replacement is a fresh id for work whose previous attempt has actually
618
+ // stopped. A launch that arrives while the record's worker is still running
619
+ // is a sibling — the next delegation's worker, or one the orchestrator
620
+ // named in the same breath — never a retry.
621
+ // ...and a delegation can only be the retry of an attempt that had stopped
622
+ // by the time it was emitted: one announced before the stop was announcing
623
+ // someone else.
624
+ const previousStoppedAt = named?.nativeIds.length ? Math.max(...named.nativeIds.map((id) => {
625
+ const evidence = nativeEvidence.get(id);
626
+ if (evidence)
627
+ return evidence.doneAt ?? Infinity;
628
+ return named.lastStoppedAt !== undefined && (named.lastStartedAt ?? 0) <= named.lastStoppedAt ? named.lastStoppedAt : Infinity;
629
+ })) : Infinity;
630
+ const previousStopped = previousStoppedAt !== Infinity && event.ts >= previousStoppedAt;
631
+ // A retry on a later turn arrives under a new job while the old attempt's
632
+ // job has simply ended; that is a replacement too, stop or no stop.
633
+ //
634
+ // "Under a new job" only means the old attempt ended if the old job did.
635
+ // Two managers live on one mission at the same time — a resume that forked
636
+ // rather than continued — delegate the same plan task within seconds of
637
+ // each other under two different job ids, and reading the second as a
638
+ // retry of the first merged four separate agents across four sessions into
639
+ // one person: a board refresher's work was credited to the reviewer, and
640
+ // the mission reported a worker had done something that worker never did.
641
+ // A job still able to act has not ended, and its worker is a sibling.
642
+ const laterJob = Boolean(pairable && named?.lastJobId && pairable.jobId !== named.lastJobId && !liveJobIds.has(named.lastJobId));
643
+ const replacing = alreadyLaunched && !sameLaunch && !reported && taskStillOpen && (previousStopped || laterJob);
644
+ const adopt = pairable && (!alreadyLaunched || sameLaunch || replacing) ? pairable : undefined;
645
+ if (adopt)
646
+ claimed.add(adopt.nativeId);
647
+ if (!known && !adopt && !label && !taskId)
648
+ continue;
649
+ const bound = table.bind({
650
+ ...ref,
651
+ nativeId: adopt?.nativeId,
652
+ label: label ?? (adopt ? undefined : taskId),
653
+ at: adopt ? Math.min(adopt.startedAt, event.ts) : event.ts,
654
+ }, known);
655
+ bound.delegated = true;
656
+ if (taskId && !bound.planTaskIds.includes(taskId)) {
657
+ bound.planTaskIds.push(taskId);
658
+ table.markDirty();
659
+ }
660
+ }
661
+ // 4. Every launch no delegation claimed is a worker in its own right.
662
+ for (const native of nativeEvidence.values()) {
663
+ if (claimed.has(native.nativeId))
664
+ continue;
665
+ const label = native.label === native.nativeId ? undefined : native.label;
666
+ // Two launches with the same description are two people when the first is
667
+ // still running. Merging by description exists for a retry — a fresh id for
668
+ // work whose previous attempt has stopped or been reported — and
669
+ // orchestrators reuse descriptions for parallel workers.
670
+ const sameName = label ? table.find({ label }) : undefined;
671
+ const sameNameLive = Boolean(sameName?.nativeIds.length) && sameName.nativeIds.some((id) => { const evidence = nativeEvidence.get(id); return evidence ? !evidence.doneAt : false; })
672
+ && ![sameName.canonicalId, ...sameName.nativeIds, ...sameName.labels, ...sameName.planTaskIds].some((value) => reportedRefs.has(norm(value) ?? ""));
673
+ const ref = { nativeId: native.nativeId, label, jobId: native.jobId, sessionId: native.sessionId, at: native.startedAt };
674
+ if (sameNameLive)
675
+ table.create(ref);
676
+ else
677
+ table.bind(ref);
678
+ }
679
+ for (const worker of fanByNative.values()) {
680
+ table.bind({ nativeId: worker.id, label: worker.label && worker.label !== worker.id ? worker.label : undefined, jobId: worker.jobId, at: worker.startedAt });
681
+ }
682
+ // 4b. Work a worker left running past the end of its turn. The provider ends
683
+ // the turn and wakes the agent when the command finishes — 27s later in
684
+ // mission test6 — so such a worker is waiting, not finished.
685
+ const backgroundedAt = new Map();
686
+ for (const event of events) {
687
+ if (!event.backgrounded)
688
+ continue;
689
+ // An async Agent launch leaves the *launcher* waiting; the event's worker
690
+ // reference points at the child. Credit the parent when a worker launched
691
+ // it, and nobody when the manager did.
692
+ const canonical = event.toolName === "Agent"
693
+ ? (event.workerRef?.parentAgentId ? table.canonicalFor(event.workerRef.parentAgentId) : undefined)
694
+ : table.canonicalFor(event.workerRef?.agentId) ?? table.canonicalFor(event.targetWorker);
695
+ if (canonical)
696
+ backgroundedAt.set(canonical, Math.max(backgroundedAt.get(canonical) ?? 0, event.ts));
697
+ }
698
+ // 5. Terminal evidence from the orchestrator's own reports, and the
699
+ // delegations that reopen a worker the orchestrator had already closed.
700
+ const reportedDone = new Map();
701
+ const delegatedAt = new Map();
702
+ const records = table.records();
703
+ for (const event of events) {
704
+ if (event.source !== "custom")
705
+ continue;
706
+ let canonical = table.canonicalFor(event.targetWorker) ?? table.canonicalFor(event.targetTask);
707
+ if (!canonical)
708
+ continue;
709
+ // A delegation whose launch was never paired sits as an unpublished label
710
+ // record. Its report is still about a real worker: when exactly one
711
+ // published, provider-named worker has not been reported, that is the one.
712
+ if (event.phase === "worker_reported") {
713
+ const record = records.find((item) => item.canonicalId === canonical);
714
+ if (record && !record.published && !record.nativeIds.length) {
715
+ const unreported = records.filter((item) => item.published && item.nativeIds.length && !reportedDone.has(item.canonicalId)
716
+ && ![item.canonicalId, ...item.nativeIds, ...item.labels, ...item.planTaskIds].some((value) => reportedRefs.has(norm(value) ?? "") && norm(value) !== norm(event.targetWorker) && norm(value) !== norm(event.targetTask)));
717
+ if (unreported.length === 1)
718
+ canonical = unreported[0].canonicalId;
719
+ }
720
+ reportedDone.set(canonical, event.ts);
721
+ }
722
+ if (event.phase === "delegating")
723
+ delegatedAt.set(canonical, event.ts);
724
+ }
725
+ // 6. Project each identity into one WorkerActivity.
726
+ for (const record of table.records()) {
727
+ if (record.nativeIds.length && record.nativeIds.every((id) => nestedAgentIds.has(id)))
728
+ table.forget(record.canonicalId);
729
+ }
730
+ const planTasks = planTasksInput;
731
+ const workers = [];
732
+ for (const record of table.records()) {
733
+ const natives = record.nativeIds.map((id) => nativeEvidence.get(id)).filter((value) => Boolean(value));
734
+ const fans = record.nativeIds.map((id) => fanByNative.get(id)).filter((value) => Boolean(value));
735
+ const latestNative = natives.sort((a, b) => a.updatedAt - b.updatedAt).at(-1);
736
+ const latestFan = fans.sort((a, b) => (a.updatedAt ?? a.startedAt) - (b.updatedAt ?? b.startedAt)).at(-1);
737
+ const jobId = latestNative?.jobId ?? latestFan?.jobId ?? record.lastJobId;
738
+ if (!jobId)
739
+ continue;
740
+ const startedAt = Math.min(record.startedAt, ...natives.map((item) => item.startedAt), ...fans.map((item) => item.startedAt));
741
+ // The orchestrator's word about this worker, remembered past the window.
742
+ const reportedAt = Math.max(record.reportedAt ?? 0, reportedDone.get(record.canonicalId) ?? 0) || undefined;
743
+ const lastDelegatedAt = Math.max(record.lastDelegatedAt ?? 0, delegatedAt.get(record.canonicalId) ?? 0) || undefined;
744
+ if (record.reportedAt !== reportedAt || record.lastDelegatedAt !== lastDelegatedAt) {
745
+ record.reportedAt = reportedAt;
746
+ record.lastDelegatedAt = lastDelegatedAt;
747
+ table.markDirty();
748
+ }
749
+ const reported = reportedAt;
750
+ const updatedAt = Math.max(latestNative?.updatedAt ?? 0, latestFan?.updatedAt ?? 0, reported ?? 0) || undefined;
751
+ const taskIds = [...record.planTaskIds];
752
+ const openTask = planTasks.some((task) => taskIds.includes(task.id) && task.status !== "completed" && task.status !== "cancelled");
753
+ // A worker ends on evidence about that worker, and once ended it stays
754
+ // ended until the provider genuinely starts it again.
755
+ //
756
+ // Neither a `SubagentStop` nor the manager's turn ending is that evidence
757
+ // on its own. The provider fires `SubagentStop` at the end of each of a
758
+ // worker's turns and the manager can send the same agent back an arbitrary
759
+ // time later; the manager's own job goes `done` and resumes on every
760
+ // prompt while it waits — five times in one observed run. Deriving a
761
+ // worker's end from either made its finished state flap, which the office
762
+ // drew as one worker leaving and a stranger arriving, over and over.
763
+ //
764
+ // In order of authority: the orchestrator's report, the provider's fan
765
+ // record, the mission leaving `active` (it is proposed complete, so
766
+ // nothing is still running), and finally a stop that has stood unanswered
767
+ // long enough to mean it. The last is what retires a worker in a mission
768
+ // with no plan and a silent orchestrator, without retiring one that is
769
+ // merely between turns.
770
+ // The worker's own lifecycle, persisted so it outlives the event ring.
771
+ const lastStartedAt = Math.max(record.lastStartedAt ?? 0, latestNative?.restartedAt ?? 0, ...natives.map((item) => item.startedAt)) || undefined;
772
+ const lastStoppedAt = Math.max(record.lastStoppedAt ?? 0, ...natives.map((item) => item.doneAt ?? 0)) || undefined;
773
+ if (record.lastStartedAt !== lastStartedAt || record.lastStoppedAt !== lastStoppedAt) {
774
+ record.lastStartedAt = lastStartedAt;
775
+ record.lastStoppedAt = lastStoppedAt;
776
+ table.markDirty();
777
+ }
778
+ const stoppedAt = lastStoppedAt !== undefined && lastStartedAt !== undefined && lastStartedAt > lastStoppedAt ? undefined : lastStoppedAt;
779
+ // Any tool the worker ran after its last stop means it is running again,
780
+ // whether or not the start that resumed it is still in view. Work its own
781
+ // sub-agents do is its work too: a worker that has fanned out and is
782
+ // waiting on its children is working, not finished — retiring it there is
783
+ // what made a lead reappear as a stranger when its children reported back.
784
+ const ownIds = new Set(record.nativeIds);
785
+ const childIds = [...nestedParents.keys()].filter((child) => { let current = child; for (let hops = 0; hops < 8 && current; hops += 1) {
786
+ const parent = nestedParents.get(current);
787
+ if (!parent)
788
+ return false;
789
+ if (ownIds.has(parent))
790
+ return true;
791
+ current = parent;
792
+ } return false; });
793
+ 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)));
794
+ // Work it left running after its last start is a promise that it will be
795
+ // back; nothing retires it until that work is answered by a restart.
796
+ const awaitingBackgroundWork = (backgroundedAt.get(record.canonicalId) ?? 0) > (lastStartedAt ?? 0);
797
+ // Its plan task closing is the orchestrator saying this work is finished.
798
+ const closedTasks = taskIds.length && planTasks.length ? planTasks.filter((task) => taskIds.includes(task.id) && (task.status === "completed" || task.status === "cancelled")) : [];
799
+ const taskClosed = closedTasks.length && !openTask ? Math.max(...closedTasks.map((task) => task.updatedAt ?? 0)) || stoppedAt || now : undefined;
800
+ // The stop-settle applies only where nothing else can ever speak for the
801
+ // worker. That is a question about *this* worker, not about the mission:
802
+ // the reason a plan suppresses the settle is that the orchestrator is
803
+ // speaking for its workers through it, and it speaks for a worker only via
804
+ // a task that worker owns. A worker with no task of its own, in a mission
805
+ // that has a plan, had nothing that could ever retire it — no task to
806
+ // close, no settle allowed — so a `SubagentStop` left it reading as merely
807
+ // paused for the rest of the mission. One observed worker sat that way for
808
+ // 27 minutes beside a manager that had been idle for 26 of them.
809
+ // Either direction of the binding counts: a task id the record has claimed,
810
+ // or a task that names this worker itself. Reading only the first missed
811
+ // every worker the plan had been reconciled onto rather than delegated to.
812
+ const ownNames = new Set([record.canonicalId, ...record.nativeIds, ...record.labels].map((name) => name.toLowerCase()));
813
+ const spokenForByPlan = planTasks.some((task) => (taskIds.length > 0 && taskIds.includes(task.id))
814
+ || (task.workerId ? ownNames.has(task.workerId.toLowerCase()) : false));
815
+ const settledStop = stoppedAt !== undefined && !spokenForByPlan && !awaitingBackgroundWork && now - stoppedAt >= WORKER_STOP_SETTLE_MS ? stoppedAt : undefined;
816
+ // The orchestrator's report closes a worker for good. The provider may
817
+ // still start that agent again — a manager checking on a delayed reply
818
+ // does exactly this, twice in one observed run — but those are follow-ups
819
+ // to work already accounted for, not a new life. Only a fresh delegation
820
+ // reopens a closed worker.
821
+ const reopened = lastDelegatedAt;
822
+ const closed = reported !== undefined && (reopened === undefined || reopened <= reported) ? reported : undefined;
823
+ const running = closed === undefined && ((lastStartedAt !== undefined && lastStartedAt > (lastStoppedAt ?? 0))
824
+ || (activityAt > (lastStoppedAt ?? 0)));
825
+ // Whether it ended, and when it ended, are two questions. Authority
826
+ // decides the first; the second prefers the moment the worker actually
827
+ // stopped over the moment someone got around to saying so.
828
+ const ends = running ? false : Boolean(closed ?? taskClosed ?? settledStop
829
+ ?? (!missionActive ? stoppedAt ?? latestNative?.updatedAt ?? startedAt : undefined)
830
+ // Sticky: an end already published never un-publishes itself. Only a
831
+ // restart clears it, and a restart clears `running` above.
832
+ ?? record.doneAt);
833
+ const doneAt = ends ? record.doneAt ?? stoppedAt ?? closed ?? taskClosed ?? latestNative?.updatedAt ?? startedAt : undefined;
834
+ if (record.doneAt !== doneAt) {
835
+ record.doneAt = doneAt;
836
+ table.markDirty();
837
+ }
838
+ const finished = Boolean(doneAt);
839
+ const resuming = !finished && stoppedAt !== undefined;
840
+ // Publishing commits an identity to the floor, so it waits until the name
841
+ // has settled. Provider-named evidence is authoritative and appears at
842
+ // once; a delegation label on its own is held through the correlation
843
+ // window to give the launch hook time to arrive and merge under its real
844
+ // id. Without that hold the worker is drawn under the label and then
845
+ // renamed, which the office can only render as two different people.
846
+ const corroborated = Boolean(latestNative) || Boolean(reported) || Boolean(record.published);
847
+ // A delegation with no provider id of its own must not take a desk while a
848
+ // launch it could still turn out to be is sitting unclaimed — nor while
849
+ // pairing is deliberately waiting for that launch to be named. Publishing
850
+ // early is how one delegation became a person of its own beside the worker
851
+ // it was describing.
852
+ const mayYetPair = !record.published && !record.nativeIds.length
853
+ && (waitingForNames || launchOrder.some((launch) => launch.jobId === jobId && !claimed.has(launch.nativeId)));
854
+ // The mirror image: a launch nobody has claimed, while a delegation that is
855
+ // *already on screen* is still waiting for one. That delegation's launch
856
+ // came so late that its label was published on its own; this native is
857
+ // almost certainly it, and its description follows within a fraction of a
858
+ // second and pairs it by name. Publishing the native first put a second
859
+ // person on the floor for one poll and then took them away again. A
860
+ // delegation not yet published needs no such care — when it pairs, the
861
+ // native id is the one that survives.
862
+ const delegationWaiting = !record.published && !record.delegated && record.nativeIds.length > 0
863
+ && delegations.some((other) => eventJobId(other) === jobId && needsLaunch(other)
864
+ && table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) })?.published)
865
+ && now - startedAt < correlationGraceMs;
866
+ if (mayYetPair || delegationWaiting || (!corroborated && now - startedAt < correlationGraceMs))
867
+ continue;
868
+ if (!record.published) {
869
+ record.published = true;
870
+ table.markDirty();
871
+ }
872
+ // Prefer a name a human wrote: the Agent description, then the
873
+ // orchestrator's delegation label. Fall back to the id only when the
874
+ // provider never gave the worker a name at all.
875
+ const named = (value) => (value && value !== record.canonicalId && !record.nativeIds.includes(value) ? value : undefined);
876
+ const label = named(latestFan?.label) ?? named(latestNative?.label) ?? record.labels.map(named).find(Boolean) ?? record.canonicalId;
877
+ const aliases = [...record.nativeIds, ...record.labels].filter((alias) => alias !== record.canonicalId && alias !== label);
878
+ workers.push({
879
+ id: record.canonicalId,
880
+ kind: latestFan?.kind ?? "agent",
881
+ label,
882
+ startedAt,
883
+ doneAt,
884
+ updatedAt,
885
+ tokens: latestFan?.tokens,
886
+ terminationReason: latestFan?.terminationReason,
887
+ jobId,
888
+ // A worker's state is its own. It never reads the manager's job, whose
889
+ // `working → done → working` at every turn boundary is what made workers
890
+ // appear to finish and restart. A worker waiting on work it backgrounded
891
+ // is working; one with no provider evidence at all (a delegation the
892
+ // provider never named) is presumed working until it is reported.
893
+ jobState: finished ? "done" : running || awaitingBackgroundWork ? "working" : resuming ? "idle" : record.nativeIds.length ? "idle" : "working",
894
+ jobUpdatedAt: latestFan?.jobUpdatedAt,
895
+ aliases: aliases.length ? aliases : undefined,
896
+ taskIds: taskIds.length ? taskIds : undefined,
897
+ });
898
+ }
899
+ // A nested agent's activity is its parent worker's: follow the chain to the
900
+ // first-level worker so the parent keeps working while its agents do.
901
+ const rootOf = (id) => {
902
+ let current = id;
903
+ for (let hops = 0; hops < 8 && nestedParents.has(current); hops += 1) {
904
+ const parent = nestedParents.get(current);
905
+ if (!parent)
906
+ break;
907
+ current = parent;
908
+ }
909
+ return current;
910
+ };
911
+ const rewrite = (event) => {
912
+ const ref = event.workerRef?.agentId ?? event.targetWorker;
913
+ if (!ref)
914
+ return event;
915
+ const root = nestedParents.has(ref) ? rootOf(ref) : ref;
916
+ const canonical = table.canonicalFor(root) ?? (root !== ref ? root : undefined);
917
+ return canonical && canonical !== event.targetWorker ? { ...event, targetWorker: canonical } : event;
918
+ };
919
+ const nested = [...nestedParents].map(([nativeId, parentId]) => ({ nativeId, parentId })).sort((a, b) => a.nativeId.localeCompare(b.nativeId));
920
+ const seededNestedKey = JSON.stringify((input.seededNested ?? []).map((entry) => [entry.nativeId, entry.parentId ?? null]).sort());
921
+ const nestedKey = JSON.stringify(nested.map((entry) => [entry.nativeId, entry.parentId ?? null]));
922
+ return { workers: workers.sort((a, b) => a.startedAt - b.startedAt), records: table.records(), nested, dirty: table.dirty || nestedKey !== seededNestedKey, rewrite };
923
+ }