@shanesaravia/hive 0.4.0 → 0.5.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.
@@ -72,6 +72,33 @@ function normalizeGates(gates) {
72
72
  return { ...gate, id, label: gate.label.trim(), required: gate.required !== false, evidence: gate.evidence ?? [], waiver: gate.waiver ? { ...gate.waiver, reason: gate.waiver.reason.trim() } : undefined };
73
73
  });
74
74
  }
75
+ function gateKey(gate) {
76
+ return `${gate.type}:${gate.label.trim().toLocaleLowerCase().replace(/\s+/g, " ")}`;
77
+ }
78
+ /**
79
+ * User-created gates are durable requirements, but the orchestrator is the
80
+ * actor that supplies evidence and reports when the requirement is satisfied.
81
+ * Reconcile an equivalent reported gate onto the protected user gate instead
82
+ * of retaining both IDs. The orchestrator may satisfy or fail the requirement,
83
+ * but only the user may waive it or weaken its definition.
84
+ */
85
+ function reconcileUserGates(gates) {
86
+ const userGates = gates.filter((gate) => gate.source === "user");
87
+ if (!userGates.length)
88
+ return gates;
89
+ const reported = gates.filter((gate) => gate.source !== "user");
90
+ const matches = (userGate) => reported.filter((gate) => gate.id === userGate.id || gateKey(gate) === gateKey(userGate));
91
+ const matchedIds = new Set(userGates.flatMap((gate) => matches(gate).map((item) => item.id)));
92
+ const reconciled = userGates.map((gate) => {
93
+ const equivalents = matches(gate);
94
+ const satisfied = equivalents.find((item) => item.status === "satisfied");
95
+ const failed = equivalents.find((item) => item.status === "failed");
96
+ const status = gate.status === "satisfied" || gate.status === "waived" ? gate.status : satisfied ? "satisfied" : failed ? "failed" : gate.status;
97
+ const evidence = [...new Set([...(gate.evidence ?? []), ...equivalents.flatMap((item) => item.evidence ?? [])])];
98
+ return { ...gate, status, evidence };
99
+ });
100
+ return [...reported.filter((gate) => !matchedIds.has(gate.id)), ...reconciled];
101
+ }
75
102
  /** Durable current plan plus immutable JSON snapshots for revision history. */
76
103
  export class PlansStore {
77
104
  db;
@@ -91,7 +118,7 @@ export class PlansStore {
91
118
  const incoming = input.gates === undefined ? current?.gates ?? [] : normalizeGates(input.gates);
92
119
  const protectedUserGates = preserveUserGates ? (current?.gates ?? []).filter((gate) => gate.source === "user") : [];
93
120
  const protectedIds = new Set(protectedUserGates.map((gate) => gate.id));
94
- const gates = [...incoming.filter((gate) => !protectedIds.has(gate.id)), ...protectedUserGates];
121
+ const gates = reconcileUserGates([...incoming.filter((gate) => !protectedIds.has(gate.id)), ...protectedUserGates]);
95
122
  const plan = { missionId, revision: (current?.revision ?? 0) + 1, updatedAt: Date.now(), phases, gates, layout: input.tasks ? "flat" : "phased", approvalStatus: input.approvalStatus ?? "proposed", approvalReason: input.approvalReason?.trim() || undefined, progress: calculateProgress(phases) };
96
123
  const json = JSON.stringify(plan);
97
124
  this.db.transaction(() => {
@@ -209,7 +236,10 @@ export class PlansStore {
209
236
  this.db.prepare("INSERT OR REPLACE INTO mission_plan_revisions (mission_id, revision, updated_at, plan_json) VALUES (?, ?, ?, ?)").run(newId, revision.revision, revision.updatedAt, JSON.stringify({ ...revision, missionId: newId })); this.db.prepare("DELETE FROM mission_plans WHERE mission_id = ?").run(oldId); this.db.prepare("DELETE FROM mission_plan_revisions WHERE mission_id = ?").run(oldId); })(); }
210
237
  get(missionId) {
211
238
  const row = this.db.prepare("SELECT plan_json FROM mission_plans WHERE mission_id = ?").get(missionId);
212
- return row ? JSON.parse(row.plan_json) : undefined;
239
+ if (!row)
240
+ return undefined;
241
+ const plan = JSON.parse(row.plan_json);
242
+ return { ...plan, gates: reconcileUserGates(plan.gates ?? []) };
213
243
  }
214
244
  revisions(missionId) {
215
245
  return this.db.prepare("SELECT plan_json FROM mission_plan_revisions WHERE mission_id = ? ORDER BY revision DESC").all(missionId).map((row) => JSON.parse(row.plan_json));
@@ -131,13 +131,62 @@ const LAUNCH_NAME_GRACE_MS = 3_000;
131
131
  */
132
132
  export class WorkerIdentityTable {
133
133
  missionId;
134
+ liveJobIds;
134
135
  byCanonical = new Map();
135
136
  dirty = false;
136
- constructor(missionId, seeded = []) {
137
+ constructor(missionId, seeded = [], liveJobIds = new Set()) {
137
138
  this.missionId = missionId;
139
+ this.liveJobIds = liveJobIds;
138
140
  for (const record of seeded)
139
141
  this.byCanonical.set(record.canonicalId, { ...record, nativeIds: [...record.nativeIds], labels: [...record.labels], planTaskIds: [...record.planTaskIds], sessionIds: [...record.sessionIds] });
140
142
  }
143
+ /**
144
+ * Whether this record belongs to a manager other than the one asking, and
145
+ * so must not be found by name.
146
+ *
147
+ * Only a name can be ambiguous. A provider-native agent id is unique by
148
+ * construction and means the same worker wherever it turns up. A plan task
149
+ * id and a delegation label are words an orchestrator chose, and two
150
+ * managers live on one mission choose the same words for different people:
151
+ * on mission f7ae71c7 two of them delegated plan task `t12` to a worker they
152
+ * both called `mr-babysitter`, three minutes apart. Matched across the two,
153
+ * one manager's report retired the other manager's worker.
154
+ *
155
+ * Two conditions keep this from splitting workers that ought to merge. The
156
+ * other job must still be able to act — a job that is over is not competing
157
+ * for anything, so a task picked up on a later turn still finds the worker
158
+ * whose task it is. And the worker must still be running: one that has
159
+ * finished is nobody's to guard, and adopting its task is exactly the retry
160
+ * this table exists to recognise.
161
+ */
162
+ ownedElsewhere(record, jobId) {
163
+ if (!jobId || !record.lastJobId || record.lastJobId === jobId)
164
+ return false;
165
+ return this.liveJobIds.has(record.lastJobId) && !record.doneAt;
166
+ }
167
+ /**
168
+ * A canonical id no other record has taken.
169
+ *
170
+ * A native id is unique already. A label is not, and two live managers can
171
+ * hand the same one to two different workers — where the table used to merge
172
+ * them it now keeps them apart, and keying both on the label would silently
173
+ * drop the first from the floor. The label stays the worker's display name
174
+ * either way; only the key it is filed under gains the owning job.
175
+ */
176
+ freeCanonicalId(label, jobId) {
177
+ if (!this.byCanonical.has(label))
178
+ return label;
179
+ const scoped = `${label}#${(jobId ?? "").slice(0, 8)}`;
180
+ if (jobId && !this.byCanonical.has(scoped))
181
+ return scoped;
182
+ for (let suffix = 2; suffix <= this.byCanonical.size + 2; suffix += 1) {
183
+ const candidate = `${jobId ? scoped : label}#${suffix}`;
184
+ if (!this.byCanonical.has(candidate))
185
+ return candidate;
186
+ }
187
+ // Unreachable: more candidates are tried above than there are records.
188
+ return `${label}#${this.byCanonical.size + 3}`;
189
+ }
141
190
  /** Flags a change made by a later pass, so the record set is persisted. */
142
191
  markDirty() { this.dirty = true; }
143
192
  /**
@@ -165,15 +214,17 @@ export class WorkerIdentityTable {
165
214
  if (match)
166
215
  return match;
167
216
  }
217
+ // Names, unlike native ids, can belong to a live sibling manager.
218
+ const mine = (record) => !this.ownedElsewhere(record, ref.jobId);
168
219
  const task = norm(ref.taskId);
169
220
  if (task) {
170
- const match = this.records().find((record) => record.planTaskIds.some((id) => id.toLowerCase() === task));
221
+ const match = this.records().find((record) => mine(record) && record.planTaskIds.some((id) => id.toLowerCase() === task));
171
222
  if (match)
172
223
  return match;
173
224
  }
174
225
  const label = norm(ref.label);
175
226
  if (label) {
176
- const match = this.records().find((record) => record.labels.some((item) => item.toLowerCase() === label));
227
+ const match = this.records().find((record) => mine(record) && record.labels.some((item) => item.toLowerCase() === label));
177
228
  if (match)
178
229
  return match;
179
230
  }
@@ -185,13 +236,16 @@ export class WorkerIdentityTable {
185
236
  }
186
237
  /** Merge this evidence into an existing person, or create one. */
187
238
  bind(ref, into, options = {}) {
188
- const existing = into ?? (options.allowLabelMerge === false ? this.find({ nativeId: ref.nativeId, taskId: ref.taskId }) : this.find(ref));
239
+ const existing = into ?? (options.allowLabelMerge === false ? this.find({ nativeId: ref.nativeId, taskId: ref.taskId, jobId: ref.jobId }) : this.find(ref));
189
240
  if (!existing) {
190
241
  // A native id is the stable, provider-owned name and is preferred as the
191
242
  // canonical id. A label-only worker (Codex, or a delegation whose launch
192
243
  // 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();
244
+ // rather than to a single parent job so a resumed turn reuses it — and
245
+ // disambiguated by job only where a live sibling already holds the name.
246
+ const native = ref.nativeId?.trim();
247
+ const label = ref.label?.trim();
248
+ const canonicalId = native || (label ? this.freeCanonicalId(label, ref.jobId) : undefined);
195
249
  if (!canonicalId)
196
250
  throw new Error("worker identity requires a native id or a label");
197
251
  const record = { canonicalId, missionId: this.missionId, nativeIds: [], labels: [], planTaskIds: [], sessionIds: [], startedAt: ref.at, lastJobId: ref.jobId };
@@ -273,14 +327,26 @@ export class WorkerIdentityTable {
273
327
  this.dirty = true;
274
328
  return existing;
275
329
  }
276
- /** Canonical id for any alias, or undefined when nothing has claimed it. */
277
- canonicalFor(value) {
330
+ /**
331
+ * Canonical id for any alias, or undefined when nothing has claimed it.
332
+ *
333
+ * `jobId` is the manager speaking. Where two of them are live and have used
334
+ * the same word for two different workers, the speaker's own worker is the
335
+ * one it means — without that, a manager reporting its worker finished
336
+ * retired whichever record happened to be found first, and the wrong avatar
337
+ * left the floor.
338
+ */
339
+ canonicalFor(value, jobId) {
278
340
  if (!value)
279
341
  return undefined;
280
342
  const wanted = norm(value);
281
343
  if (!wanted)
282
344
  return undefined;
283
- return this.records().find((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds].some((item) => item.toLowerCase() === wanted))?.canonicalId;
345
+ const matches = this.records().filter((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds].some((item) => item.toLowerCase() === wanted));
346
+ if (!matches.length)
347
+ return undefined;
348
+ const own = jobId ? matches.find((record) => record.lastJobId === jobId) : undefined;
349
+ return (own ?? matches.find((record) => !this.ownedElsewhere(record, jobId)) ?? matches[0]).canonicalId;
284
350
  }
285
351
  }
286
352
  /**
@@ -291,9 +357,10 @@ export class WorkerIdentityTable {
291
357
  * label stranded as a third. Version 5 stopped a launch under a still-live
292
358
  * sibling job being read as a replacement, which merged the workers of
293
359
  * concurrent managers into one person — and clears the records those bindings
294
- * already wrote.
360
+ * already wrote. Version 6 stopped a plan task or delegation label finding a
361
+ * live sibling manager's worker at all.
295
362
  */
296
- export const WORKER_IDENTITY_RESOLVER_VERSION = "5";
363
+ export const WORKER_IDENTITY_RESOLVER_VERSION = "6";
297
364
  /** Persists resolved identities so they survive the bounded event ring. */
298
365
  export class WorkerIdentityStore {
299
366
  db;
@@ -361,12 +428,13 @@ export class WorkerIdentityStore {
361
428
  .run(JSON.stringify(record), Date.now(), missionId, canonicalId);
362
429
  }
363
430
  /** Canonical worker id for any alias, label, or plan task id. */
364
- canonicalFor(missionId, value) {
431
+ canonicalFor(missionId, value, jobId) {
365
432
  const wanted = value?.trim().toLowerCase();
366
433
  if (!wanted)
367
434
  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;
435
+ const matches = this.list(missionId).filter((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds]
436
+ .some((item) => item.trim().toLowerCase() === wanted));
437
+ return (jobId ? matches.find((record) => record.lastJobId === jobId) : undefined)?.canonicalId ?? matches[0]?.canonicalId;
370
438
  }
371
439
  removeMission(missionId) {
372
440
  this.db.prepare("DELETE FROM mission_workers WHERE mission_id = ?").run(missionId);
@@ -405,7 +473,7 @@ export function resolveMissionWorkers(input) {
405
473
  const pairWindowMs = input.pairWindowMs ?? 30_000;
406
474
  const liveJobIds = new Set(input.liveJobIds ?? []);
407
475
  const planTasksInput = input.planTasks ?? [];
408
- const table = new WorkerIdentityTable(missionId, input.seeded);
476
+ const table = new WorkerIdentityTable(missionId, input.seeded, liveJobIds);
409
477
  // 1. Hook lifecycle evidence, keyed by the provider's own agent id.
410
478
  const nativeEvidence = new Map();
411
479
  // An agent launched by a worker is that worker's activity, not a new person
@@ -545,7 +613,7 @@ export function resolveMissionWorkers(input) {
545
613
  // worker there is nothing a name could change, so it binds immediately as it
546
614
  // always has; it is two delegations reaching for the same anonymous launch
547
615
  // 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;
616
+ const contenders = delegations.filter((event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event), jobId: eventJobId(event) })?.nativeIds.length).length;
549
617
  // Decide nothing while a name may still be arriving. Deferring the whole
550
618
  // pass rather than the individual launch matters: pairing sees every launch
551
619
  // at once either way, so waiting cannot change which delegation wins a
@@ -553,7 +621,7 @@ export function resolveMissionWorkers(input) {
553
621
  const waitingForNames = contenders > 1
554
622
  && launchOrder.some((launch) => !launchIsNamed(launch.nativeId) && now - launch.startedAt < LAUNCH_NAME_GRACE_MS);
555
623
  const namedLaunches = waitingForNames ? [] : launchOrder;
556
- const needsLaunch = (event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length;
624
+ const needsLaunch = (event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event), jobId: eventJobId(event) })?.nativeIds.length;
557
625
  const pairing = pairDelegationsByName({
558
626
  delegations: delegations.filter(needsLaunch),
559
627
  launches: namedLaunches.filter((launch) => !claimed.has(launch.nativeId)),
@@ -583,7 +651,7 @@ export function resolveMissionWorkers(input) {
583
651
  return false;
584
652
  if (pairing.has(other))
585
653
  return false;
586
- const otherNamed = table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) });
654
+ const otherNamed = table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other), jobId });
587
655
  if (otherNamed?.nativeIds.length)
588
656
  return false;
589
657
  return Math.abs(startedAt - other.ts) < Math.abs(startedAt - event.ts);
@@ -601,7 +669,7 @@ export function resolveMissionWorkers(input) {
601
669
  // An already-known label or plan task wins over the launch: a retried or
602
670
  // replaced task keeps its person, and the fresh provider id joins it as
603
671
  // another alias instead of taking a second desk.
604
- const named = table.find({ label, taskId: identityTask(event) });
672
+ const named = table.find({ label, taskId: identityTask(event), jobId });
605
673
  const known = named ?? (pairable ? table.find({ nativeId: pairable.nativeId }) : undefined);
606
674
  // A delegation that already has its worker must not swallow a later,
607
675
  // unrelated launch just because it is still inside the pairing window —
@@ -667,7 +735,7 @@ export function resolveMissionWorkers(input) {
667
735
  // still running. Merging by description exists for a retry — a fresh id for
668
736
  // work whose previous attempt has stopped or been reported — and
669
737
  // orchestrators reuse descriptions for parallel workers.
670
- const sameName = label ? table.find({ label }) : undefined;
738
+ const sameName = label ? table.find({ label, jobId: native.jobId }) : undefined;
671
739
  const sameNameLive = Boolean(sameName?.nativeIds.length) && sameName.nativeIds.some((id) => { const evidence = nativeEvidence.get(id); return evidence ? !evidence.doneAt : false; })
672
740
  && ![sameName.canonicalId, ...sameName.nativeIds, ...sameName.labels, ...sameName.planTaskIds].some((value) => reportedRefs.has(norm(value) ?? ""));
673
741
  const ref = { nativeId: native.nativeId, label, jobId: native.jobId, sessionId: native.sessionId, at: native.startedAt };
@@ -691,7 +759,7 @@ export function resolveMissionWorkers(input) {
691
759
  // it, and nobody when the manager did.
692
760
  const canonical = event.toolName === "Agent"
693
761
  ? (event.workerRef?.parentAgentId ? table.canonicalFor(event.workerRef.parentAgentId) : undefined)
694
- : table.canonicalFor(event.workerRef?.agentId) ?? table.canonicalFor(event.targetWorker);
762
+ : table.canonicalFor(event.workerRef?.agentId) ?? table.canonicalFor(event.targetWorker, eventJobId(event));
695
763
  if (canonical)
696
764
  backgroundedAt.set(canonical, Math.max(backgroundedAt.get(canonical) ?? 0, event.ts));
697
765
  }
@@ -703,7 +771,7 @@ export function resolveMissionWorkers(input) {
703
771
  for (const event of events) {
704
772
  if (event.source !== "custom")
705
773
  continue;
706
- let canonical = table.canonicalFor(event.targetWorker) ?? table.canonicalFor(event.targetTask);
774
+ let canonical = table.canonicalFor(event.targetWorker, eventJobId(event)) ?? table.canonicalFor(event.targetTask, eventJobId(event));
707
775
  if (!canonical)
708
776
  continue;
709
777
  // A delegation whose launch was never paired sits as an unpublished label
@@ -861,7 +929,7 @@ export function resolveMissionWorkers(input) {
861
929
  // native id is the one that survives.
862
930
  const delegationWaiting = !record.published && !record.delegated && record.nativeIds.length > 0
863
931
  && delegations.some((other) => eventJobId(other) === jobId && needsLaunch(other)
864
- && table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) })?.published)
932
+ && table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other), jobId: eventJobId(other) })?.published)
865
933
  && now - startedAt < correlationGraceMs;
866
934
  if (mayYetPair || delegationWaiting || (!corroborated && now - startedAt < correlationGraceMs))
867
935
  continue;