@cotal-ai/manager 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/manager.js CHANGED
@@ -372,6 +372,16 @@ export class Manager {
372
372
  * hear `not-found`, which a seat reads as "drop it": failure reported for work that committed.
373
373
  * The settled answer lives here for a bounded window instead, and the sweep drops it after,
374
374
  * which is also what keeps this map from being one entry per turn for the process lifetime.
375
+ *
376
+ * `ref` is the goal coordinates the durable terminal is published under. A late yield that
377
+ * arrives after pendingTurns is gone and before `settled` is remembered (or after a concurrent
378
+ * sweep dropped the settled field) still has to name that terminal, never `not-found`. The
379
+ * index entry is not that answer: it is cleared at commit, and the yield path never consults it.
380
+ *
381
+ * `leftoverSince` is when the pending latch dropped. An unsettled leftover ages off that stamp,
382
+ * never off `acceptance.deadlineAt`: a turn that expired after a long outage would otherwise be
383
+ * pruned on the first sweep tick, taking the GoalRef with it, and a late yield in the retry
384
+ * window would hear `not-found` again.
375
385
  */
376
386
  turnAcceptances = new Map();
377
387
  /** Every relayed turn awaiting its seat's yield, by goal id. The seat's `turn-pending` pull
@@ -1353,13 +1363,25 @@ export class Manager {
1353
1363
  */
1354
1364
  async deregisterServiceOnStop() {
1355
1365
  const iid = this.managerInstanceId;
1356
- const dereg = ({ recordsKv }) => deregisterServiceInstance(recordsKv, { endpoint: MANAGER_ENDPOINT, instanceId: iid });
1366
+ const dereg = ({ recordsKv, authKv }) => deregisterServiceInstance(recordsKv, {
1367
+ endpoint: MANAGER_ENDPOINT,
1368
+ instanceId: iid,
1369
+ observeGeneration: async () => {
1370
+ const key = epgateKey(MANAGER_ENDPOINT, iid);
1371
+ const entry = await authKv.get(key);
1372
+ if (!entry || entry.operation !== "PUT")
1373
+ throw new Error(`no issuance gate at ${key}`);
1374
+ return parseEndpointGate(entry.value, key).generation;
1375
+ },
1376
+ });
1357
1377
  try {
1358
1378
  const outcome = await (this.auth ? this.withEndpointServeExecutor(dereg) : this.withOpenServeConnection(dereg));
1359
1379
  if (outcome.removed)
1360
1380
  console.error(`✓ deregistered manager instance ${iid} from the ${MANAGER_ENDPOINT} service registry (spec revision ${outcome.specRevision})`);
1361
1381
  else if (outcome.reason === "superseded")
1362
1382
  console.error(`! manager instance ${iid} was not deregistered: its registration moved while this stop ran, so another incarnation owns it now - leaving it alone`);
1383
+ else if (outcome.reason === "registration-in-flight")
1384
+ console.error(`! manager instance ${iid} was not deregistered: a registration is still in flight (governance slot held at the live gate generation) - leaving it alone`);
1363
1385
  // `absent` is silent: there was nothing registered to remove, which is not news at shutdown.
1364
1386
  }
1365
1387
  catch (e) {
@@ -4431,8 +4453,9 @@ export class Manager {
4431
4453
  * `gone` into `unestablishable`). Live / unknown / unestablishable / wrong-op-kind stay loud
4432
4454
  * refusals. No TTL: request-ingress has no process-epoch fence, so a clock would either let a
4433
4455
  * superseded serve credential keep consuming ingress or just rename the freeze. An open gate
4434
- * is a no-op (the successor's own freeze comes next). */
4435
- async healFrozenRegistrationGate(authKv, instanceId, auth) {
4456
+ * is a no-op (the successor's own freeze comes next). A committed spec under that freeze is
4457
+ * finished at the committed registrationRevision; only a definite no-commit abort-reopens. */
4458
+ async healFrozenRegistrationGate(authKv, instanceId, auth, recordsKv) {
4436
4459
  const key = epgateKey(MANAGER_ENDPOINT, instanceId);
4437
4460
  const entry = await authKv.get(key);
4438
4461
  if (!entry || entry.operation !== "PUT")
@@ -4449,6 +4472,7 @@ export class Manager {
4449
4472
  probeHolder: makeManagerHolderLivenessProbe({ space: this.space, servers, auth, log }),
4450
4473
  evict: makeManagerEndpointEvictor({ space: this.space, servers, auth, log }),
4451
4474
  log,
4475
+ recordsKv,
4452
4476
  });
4453
4477
  }
4454
4478
  catch (e) {
@@ -4464,7 +4488,7 @@ export class Manager {
4464
4488
  }
4465
4489
  throw e;
4466
4490
  }
4467
- console.error(`✓ boot self-heal: ${MANAGER_ENDPOINT}/${instanceId} registration gate reopened at generation ${report.reopenedAtGeneration} (processEpoch unchanged at ${report.before.processEpoch}; freeze-holder gone, sweepComplete=true). Continuing the normal takeover.`);
4491
+ console.error(`✓ boot self-heal: ${MANAGER_ENDPOINT}/${instanceId} registration gate reopened at generation ${report.reopenedAtGeneration} (processEpoch ${report.after.processEpoch}, registrationRevision ${report.after.registrationRevision}; freeze-holder gone, sweepComplete=true). Continuing the normal takeover.`);
4468
4492
  }
4469
4493
  /** P2 item 1: register the manager as an ordinary v0.4 `service` endpoint and serve its typed
4470
4494
  * command surface on the ep rails - since 1d the manager's ONLY control door. On an AUTH mesh
@@ -4596,8 +4620,9 @@ export class Manager {
4596
4620
  // when the freeze-holder is gone. Complete that SAME op (abort-reopen) on independent
4597
4621
  // holder-gone evidence BEFORE this incarnation freezes a new one. Auth only: the
4598
4622
  // CONNZ oracle rides delivery-admin, which an open mesh does not have.
4599
- if (auth)
4600
- await this.healFrozenRegistrationGate(authKv, iid, auth);
4623
+ if (auth) {
4624
+ await this.healFrozenRegistrationGate(authKv, iid, auth, recordsKv);
4625
+ }
4601
4626
  // P2 item 3 (slice 3a): on an AUTH mesh a RE-registration (restart of the persisted instanceId)
4602
4627
  // must VERIFY-EVICT the superseded serve family BEFORE the epoch advances (§13.1 "old authority
4603
4628
  // dies before new authority is visible"). Inject the SCOPED delivery-admin evictor; the OPEN
@@ -5608,7 +5633,7 @@ export class Manager {
5608
5633
  this.pendingTurns.set(goalId, pending);
5609
5634
  this.ensureTurnSweep();
5610
5635
  const acceptance = { name: a.name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid, goalId, fingerprint, deadlineAt, executor };
5611
- this.turnAcceptances.set(goalId, { acceptance });
5636
+ this.turnAcceptances.set(goalId, { acceptance, ref });
5612
5637
  this.emitGoalProgress(ref, executor.epoch, { phase: "relayed" });
5613
5638
  return acceptance;
5614
5639
  }
@@ -5662,11 +5687,22 @@ export class Manager {
5662
5687
  // reporting failure for work the run already has. The answer the first reply carried is
5663
5688
  // served again instead, to the addressee it was addressed to and nobody else.
5664
5689
  const settled = this.turnAcceptances.get(goalId);
5665
- if (settled?.settled !== undefined) {
5690
+ if (settled !== undefined) {
5666
5691
  const a = settled.acceptance;
5667
5692
  if (a.owner !== c.owner || a.actor !== c.actor || a.uid !== c.uid)
5668
5693
  throw new EpEnvelopeError("permission-denied", `turn "${goalId}" was addressed to ${a.owner}.${a.actor}/${a.uid}; a yield is the addressee's own (SPEC 13.6)`);
5669
- return { goalId, state: settled.settled.state };
5694
+ if (settled.settled !== undefined)
5695
+ return { goalId, state: settled.settled.state };
5696
+ // The in-memory answer is not yet remembered (pending deleted before the terminal CAS,
5697
+ // or a concurrent sweep dropped `settled` while the commit was still in flight). The
5698
+ // durable terminal is the one the run already has; name it, never `not-found`.
5699
+ if (settled.ref !== undefined) {
5700
+ const ended = await readGoalResult(gw.ctx, settled.ref);
5701
+ if (ended !== undefined) {
5702
+ this.rememberSettledTurn(goalId, ended.state, ended.ts);
5703
+ return { goalId, state: ended.state };
5704
+ }
5705
+ }
5670
5706
  }
5671
5707
  throw new EpEnvelopeError("not-found", `no pending turn "${goalId}" on this manager`);
5672
5708
  }
@@ -5704,6 +5740,7 @@ export class Manager {
5704
5740
  this.emitGoalProgress(p.ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
5705
5741
  await clearGoalIndex(gw.ctx, p.ref);
5706
5742
  this.pendingTurns.delete(goalId);
5743
+ this.markTurnLatchDropped(goalId, at);
5707
5744
  this.rememberSettledTurn(goalId, fact.state, at);
5708
5745
  this.maybeStopTurnSweep();
5709
5746
  return { goalId, state: fact.state };
@@ -5718,17 +5755,18 @@ export class Manager {
5718
5755
  return;
5719
5756
  if (!this.pendingTurns.delete(p.goalId))
5720
5757
  return;
5758
+ this.markTurnLatchDropped(p.goalId);
5721
5759
  const epoch = this.serviceServe?.grant.epoch ?? 0;
5722
5760
  try {
5723
5761
  await this.assertGoalWriterEpochCurrent(epoch);
5724
5762
  const { fact } = await commitGoalResult(gw.ctx, { ref: p.ref, now: Date.now(), cause: "deny", denial: { kind: "hold-expired", token: p.holdToken }, data: { reason: "turn-deadline", ...(p.seatDiedAt !== undefined ? { agentDownAt: p.seatDiedAt } : {}), ...(p.handoffFrom !== undefined ? { handoffFrom: p.handoffFrom } : {}) }, committer: { instanceId: this.managerInstanceId, epoch } });
5725
5763
  this.emitGoalProgress(p.ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
5726
- await clearGoalIndex(gw.ctx, p.ref);
5727
- // The SAME order the yield path keeps: remember, then ask whether the sweep may stop. Asked
5728
- // first, the stop saw no pending turn and no settled answer, cleared the acceptance map, and
5729
- // the remember below found nothing to write to; a yield whose reply was lost then heard
5730
- // `not-found` for the last expired turn, which the seat reads as "drop it".
5764
+ // Remember BEFORE the index clear: a late yield that arrives after the pending latch
5765
+ // (the delete at the top of this function) and after this CAS has to name this terminal.
5766
+ // Clearing first, then remembering, left a window where maybeStopTurnSweep saw no pending
5767
+ // turn and no settled answer, wiped the acceptance, and rememberSettledTurn no-op'd.
5731
5768
  this.rememberSettledTurn(p.goalId, fact.state, Date.now());
5769
+ await clearGoalIndex(gw.ctx, p.ref);
5732
5770
  }
5733
5771
  catch (e) {
5734
5772
  console.error(`! turn deadline terminal for ${p.goalId}: ${e.message}`);
@@ -5775,16 +5813,27 @@ export class Manager {
5775
5813
  return;
5776
5814
  entry.settled = { state, at };
5777
5815
  }
5816
+ /** Stamp when the pending latch dropped, so an unsettled leftover ages off that instant rather
5817
+ * than the original deadline. Idempotent: the first drop is the one the retry window starts at. */
5818
+ markTurnLatchDropped(goalId, at = Date.now()) {
5819
+ const entry = this.turnAcceptances.get(goalId);
5820
+ if (entry === undefined || entry.leftoverSince !== undefined)
5821
+ return;
5822
+ entry.leftoverSince = at;
5823
+ }
5778
5824
  /** Stop the sweep only when it has nothing left to do. It drives elapsed turns to their deadline
5779
5825
  * terminals AND drops settled answers once their retry window has passed, so a stop while the
5780
5826
  * second is still owed would leave one entry per turn for the process lifetime. */
5781
5827
  maybeStopTurnSweep() {
5782
5828
  if (this.pendingTurns.size > 0)
5783
5829
  return;
5784
- for (const e of this.turnAcceptances.values())
5785
- if (e.settled !== undefined)
5786
- return;
5787
- this.turnAcceptances.clear();
5830
+ // An acceptance with no `settled` yet is the in-flight deadline commit: pending was the
5831
+ // latch and is already gone, the terminal CAS has not returned, and a concurrent sweep
5832
+ // tick used to clear the map here, so rememberSettledTurn no-op'd and a late yield heard
5833
+ // `not-found` for a deny the run already has. Prune is the only deleter; stop only when
5834
+ // it has left nothing.
5835
+ if (this.turnAcceptances.size > 0)
5836
+ return;
5788
5837
  this.stopTurnSweep();
5789
5838
  }
5790
5839
  stopTurnSweep() {
@@ -5798,9 +5847,17 @@ export class Manager {
5798
5847
  async sweepTurnDeadlines() {
5799
5848
  const now = Date.now();
5800
5849
  // A settled answer is kept only as long as a lost reply could still be retried under it.
5801
- for (const [goalId, e] of [...this.turnAcceptances.entries()])
5802
- if (e.settled !== undefined && now - e.settled.at >= TURN_ANSWER_RETENTION_MS)
5850
+ // An UNSETTLED acceptance whose pending latch is already gone is the in-flight (or failed)
5851
+ // deadline commit: keep it for the same window so a late yield can still read the durable
5852
+ // terminal, then drop it so a commit that never remembered cannot pin the sweep forever.
5853
+ for (const [goalId, e] of [...this.turnAcceptances.entries()]) {
5854
+ if (this.pendingTurns.has(goalId))
5855
+ continue;
5856
+ // Unsettled leftovers age off the latch-drop stamp, never `deadlineAt`.
5857
+ const at = e.settled?.at ?? e.leftoverSince;
5858
+ if (at !== undefined && now - at >= TURN_ANSWER_RETENTION_MS)
5803
5859
  this.turnAcceptances.delete(goalId);
5860
+ }
5804
5861
  // The prune above is the one event after which the sweep may have nothing left to do, and
5805
5862
  // the settle-time callers cannot see it: they run when an answer has just been remembered.
5806
5863
  this.maybeStopTurnSweep();
@@ -5881,6 +5938,7 @@ export class Manager {
5881
5938
  fingerprint: spec.value.fingerprint, deadlineAt: p.deadlineAt,
5882
5939
  executor: { lifecycleUid: this.managerInstanceId, epoch: p.holdEpoch },
5883
5940
  },
5941
+ ref: p.ref,
5884
5942
  });
5885
5943
  // NO LIVENESS VERDICT AT BOOT. The agents map is empty here: seats are re-registered later,
5886
5944
  // by the resume path, so `not in the map` at this moment means "not yet re-registered", never