@cotal-ai/manager 0.47.1 → 0.48.1

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
@@ -29,7 +29,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
29
29
  // P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
30
30
  // endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
31
31
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
32
- import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, registerServiceInstance, deregisterServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, renderLifecycleBlocked, } from "@cotal-ai/core";
32
+ import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, registerServiceInstance, deregisterServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
33
33
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
34
34
  import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
35
35
  /** Concurrency ceiling — the manager refuses to hold more than this many live + in-flight +
@@ -95,6 +95,12 @@ export function credRenewIntervalMs(ttlSeconds) {
95
95
  * these aliases must wait until THAT alias's exact-op terminal attempt returns rather than racing
96
96
  * a reuse. */
97
97
  const STARTUP_RECONCILING = "startup static lifecycle reconciliation";
98
+ /** One initial terminal entry plus three re-drives. The short first retry absorbs a one-shot
99
+ * broker/eviction blip; the wider later spacing avoids hammering a persistently unavailable
100
+ * authority. The finite ~36s window is intentionally not a liveness inference: exhausting it
101
+ * leaves the durable alias held and requires a later manager start. */
102
+ const STATIC_RECONCILE_RETRY_DELAYS_MS = [1_000, 5_000, 30_000];
103
+ const STATIC_RECONCILE_MAX_ATTEMPTS = STATIC_RECONCILE_RETRY_DELAYS_MS.length + 1;
98
104
  /** The STABLE retirement opId for one lifecycle (#29 piece 3): deterministic from the uid, so a
99
105
  * despawn retry, a same-name-spawn nudge, and the auth service's boot resume all drive the SAME
100
106
  * operation (the rail's idempotence table needs exactly one op per retiring incarnation). 26 hex
@@ -407,6 +413,19 @@ export class Manager {
407
413
  /** Static aliases whose startup exact-op terminal is still in flight. Scoped per alias: control
408
414
  * serves during the sweep, but no caller can reuse or attach an alias the sweep still owns. */
409
415
  reconcilingAliases = new Set();
416
+ /** Current failed/recovered static reconciliation coordinates. This is an operator snapshot, not
417
+ * lifecycle authority: every re-drive re-reads broker KV and re-runs planStaticSlotResume. */
418
+ staticReconcileItems = new Map();
419
+ staticReconcileLastSweep;
420
+ staticReconcileSweepsInFlight = 0;
421
+ staticReconcileDrainWaiters = [];
422
+ staticReconcileStopping = false;
423
+ /** The complete boot task. stop() joins it after fencing static reconciliation, so a registration
424
+ * already past an earlier shutdown check cannot finish after stop() returns. */
425
+ startTask;
426
+ /** Held as an instance field so broker-owning smokes can compress the schedule without changing
427
+ * production semantics. It is never a fence expiry: the durable non-retired row stays authoritative. */
428
+ staticReconcileRetryDelaysMs = STATIC_RECONCILE_RETRY_DELAYS_MS;
410
429
  /** SINGLE-FLIGHT guard for {@link deprovision} (INT-2/C): one in-flight teardown per
411
430
  * (name, lifecycleUid). The detached freeSlot teardown and every same-name-spawn nudge that
412
431
  * re-drives it JOIN one promise instead of launching a SECOND, concurrent teardown. Without it,
@@ -559,7 +578,20 @@ export class Manager {
559
578
  `one: \`mint\` reads --allow-subscribe only for that profile, so an observer mint is ` +
560
579
  `refused outright and writes no creds file.`;
561
580
  }
562
- async start() {
581
+ start() {
582
+ if (this.startTask)
583
+ return this.startTask;
584
+ const task = this.runStart();
585
+ let wrapped;
586
+ wrapped = task.finally(() => {
587
+ if (this.startTask === wrapped)
588
+ this.startTask = undefined;
589
+ });
590
+ this.startTask = wrapped;
591
+ return wrapped;
592
+ }
593
+ async runStart() {
594
+ this.staticReconcileStopping = false;
563
595
  await this.inspectConnectorsAtBoot();
564
596
  await this.attach.start();
565
597
  // In auth mode the manager is just another user in the space's account — it mints
@@ -687,7 +719,7 @@ export class Manager {
687
719
  // attach THAT alias until its terminal attempt returns.
688
720
  const startupReconcile = this.auth && !this.userMode ? this.reconcileStaticLifecycles() : undefined;
689
721
  if (startupReconcile)
690
- void startupReconcile.catch((e) => console.error(`! ${STARTUP_RECONCILING}: ${e.message} - a later manager start retries any unfinished terminal`));
722
+ void startupReconcile.catch((e) => console.error(`! ${STARTUP_RECONCILING}: ${e.message} - no per-alias retry could be planned; a later manager start re-reads unfinished durable terminals`));
691
723
  // P2 item 1 (1d): the manager serves NO ctl tiers - its whole control surface is the v0.4
692
724
  // service endpoint registered below. The old three-tier rail (self/manager/admin) is deleted;
693
725
  // `ctl.delivery`/`ctl.delivery-admin` (the delivery daemon) and `ctl.auth-admin` (the auth
@@ -703,6 +735,11 @@ export class Manager {
703
735
  this.credRenewTimer = setInterval(() => { void this.renewDaemonCreds(); }, credRenewIntervalMs(STANDING_RENEWABLE_TTL_SEC));
704
736
  this.credRenewTimer.unref?.();
705
737
  }
738
+ // stop() fences before it waits for an accepted startup reconciliation terminal. Once that
739
+ // terminal drains, start() and stop() resume from the same await boundary; start must observe
740
+ // the fence before it can publish a fresh service registration after shutdown.
741
+ if (this.staticReconcileStopping)
742
+ return;
706
743
  // P2 item 1: register the manager as an ordinary v0.4 `service` endpoint (SPEC §13.7/§13.9)
707
744
  // and serve its typed command surface on the ep rails - since 1d the ONLY control door, in
708
745
  // EVERY mesh mode. Static + user meshes mint the scoped executor + endpoint-serve credential;
@@ -710,6 +747,8 @@ export class Manager {
710
747
  // (there is no credential system - the broker enforces nothing, matching the old open-mesh ctl
711
748
  // trust). Fail-loud: a manager that cannot register does not start half-registered.
712
749
  await this.registerManagerService();
750
+ if (this.staticReconcileStopping)
751
+ return;
713
752
  // P2 item 2: stand up the standing goal-writer connection for spawn-as-action — AFTER
714
753
  // registration (it writes this endpoint's goal facts/records), disjoint from the serve cred.
715
754
  await this.startGoalWriter();
@@ -1358,6 +1397,16 @@ export class Manager {
1358
1397
  throw new Error(`manager preservation shutdown incomplete: ${failures.join("; ")}`);
1359
1398
  }
1360
1399
  async stop() {
1400
+ this.staticReconcileStopping = true;
1401
+ const starting = this.startTask;
1402
+ for (const item of this.staticReconcileItems.values()) {
1403
+ if (item.timer)
1404
+ clearTimeout(item.timer);
1405
+ item.timer = undefined;
1406
+ item.nextRetryAt = undefined;
1407
+ }
1408
+ await this.awaitStaticReconcileDrain();
1409
+ await starting?.catch(() => { });
1361
1410
  if (this.leaseTimer)
1362
1411
  clearInterval(this.leaseTimer);
1363
1412
  if (this.credRenewTimer)
@@ -1602,7 +1651,7 @@ export class Manager {
1602
1651
  // retiredPrincipals, so a copied JWT is refused). Best-effort + loud: a sweep failure must
1603
1652
  // not fail the finalize (the next non-resume boot re-drives it), but it is never swallowed.
1604
1653
  if (this.auth && !this.userMode)
1605
- await this.reconcileStaticLifecycles(true).catch((e) => console.error(`! post-resume static reconcile: ${e.message} - a durable active orphan may still wedge its alias until the next non-resume restart`));
1654
+ await this.reconcileStaticLifecycles(true).catch((e) => console.error(`! post-resume static reconcile: ${e.message} - no per-alias retry could be planned; a later manager start re-reads unfinished durable terminals`));
1606
1655
  this.resumeFinalized = true;
1607
1656
  this.resumeRequired = false;
1608
1657
  return { ok: true, data: { attemptId: args.attemptId, state: "active" } };
@@ -4647,6 +4696,24 @@ export class Manager {
4647
4696
  agentCount: this.agents.size,
4648
4697
  uptimeMs: Date.now() - this.startedAtMs,
4649
4698
  connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
4699
+ staticReconciliation: this.staticReconciliationStatus(),
4700
+ };
4701
+ }
4702
+ /** Render the current operator snapshot. Recovered rows live until the next sweep starts, then
4703
+ * clear, so recovery is visible without becoming stale process-lifetime history. */
4704
+ staticReconciliationStatus() {
4705
+ const failures = [...this.staticReconcileItems.values()]
4706
+ .map(({ owner: _owner, timer: _timer, flight: _flight, retryFlight: _retryFlight, ...item }) => ({ ...item }))
4707
+ .sort((a, b) => a.alias.localeCompare(b.alias));
4708
+ const state = failures.some((item) => item.disposition === "retrying") ? "retrying" :
4709
+ failures.some((item) => item.disposition === "retry-scheduled") ? "retry-wait" :
4710
+ failures.some((item) => item.disposition === "refused" || item.disposition === "refused-foreign" || item.disposition === "retry-exhausted") ? "failed" :
4711
+ this.staticReconcileSweepsInFlight > 0 ? "running" :
4712
+ failures.some((item) => item.disposition === "recovered") ? "recovered" : "idle";
4713
+ return {
4714
+ state,
4715
+ ...(this.staticReconcileLastSweep ? { lastSweep: { ...this.staticReconcileLastSweep } } : {}),
4716
+ failures,
4650
4717
  };
4651
4718
  }
4652
4719
  /** Complete a crashed predecessor's FROZEN registration gate before this incarnation starts a
@@ -6181,7 +6248,7 @@ export class Manager {
6181
6248
  * clears (ABA-guarded by uid). A PRE-UNIT-B lifecycle (no slot row — spawned before the
6182
6249
  * durable registry existed) has nothing to terminalize: its footprint teardown runs directly
6183
6250
  * and the hold clears, the honest upgrade path. */
6184
- async driveStaticRetirement(a) {
6251
+ async driveStaticRetirement(a, surfaceFailure = false) {
6185
6252
  // A renewal that published its flight before the synchronous terminal latch is accepted work.
6186
6253
  // Drain it before the durable terminal enumerates credential ids and before cleanup deletes its
6187
6254
  // material; a failed renewal must not block retirement because it may still have staged an id.
@@ -6228,6 +6295,8 @@ export class Manager {
6228
6295
  if (h && h.lifecycleUid === a.lifecycleUid)
6229
6296
  h.lastError = `the static retirement did not complete (${e.message}); the name stays held - a same-name spawn retries the same terminal (op ${opId})`;
6230
6297
  console.error(`static retirement ${a.name} (${a.id}): ${e.message}`);
6298
+ if (surfaceFailure)
6299
+ throw e;
6231
6300
  }
6232
6301
  }
6233
6302
  /** F5(b) push renewal of ONE live managed-static credential (Unit B): re-mint the SAME nkey
@@ -6286,77 +6355,374 @@ export class Manager {
6286
6355
  * slots while a resume is still pending (adoption runs after it); the POST-ADOPTION sweep
6287
6356
  * (`postAdoption=true`, inside finalizeResume while `resumeRequired` still fences ordinary
6288
6357
  * spawns) terminalizes any active slot the resume did not claim. */
6289
- async reconcileStaticLifecycles(postAdoption = false) {
6358
+ staticReconcileKey(row) {
6359
+ return JSON.stringify([row.owner, row.alias, row.lifecycleUid]);
6360
+ }
6361
+ /** Read one retry coordinate from the durable slot store. The status snapshot never authorizes a
6362
+ * re-drive: every timer comes through this fresh read before the planner and exact terminal. */
6363
+ async readStaticReconcileSlot(alias) {
6290
6364
  if (!this.auth)
6291
- return;
6365
+ return undefined;
6292
6366
  const identity = newIdentity();
6293
6367
  const creds = await mintCreds(this.auth, identity, "provisioner");
6294
6368
  const nc = await this.dial({ ...standaloneConnectOpts({ creds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 });
6295
- const slotRows = [];
6296
6369
  try {
6297
- const jsm = await jetstreamManager(nc);
6298
- const kvm = new Kvm(nc);
6299
- await ensureAuthorityStores(jsm, kvm, this.space);
6300
- const recordsKv = await kvm.open(recordsBucket(this.space));
6301
- const t = staticLifecycleTransport(recordsKv, recordsKv /* auth reads unused in the sweep */);
6302
- const keys = await recordsKv.keys(`${STATIC_SLOT_PREFIX}.${DEV_OWNER}.>`);
6303
- const aliases = [];
6304
- for await (const k of keys)
6305
- aliases.push(k.split(".").slice(2).join("."));
6306
- for (const alias of aliases) {
6307
- const slot = await readStaticSlot(t, DEV_OWNER, alias);
6308
- if (slot !== undefined)
6309
- slotRows.push(slot.row);
6310
- }
6370
+ const recordsKv = await new Kvm(nc).open(recordsBucket(this.space));
6371
+ return (await readStaticSlot(staticLifecycleTransport(recordsKv, recordsKv), DEV_OWNER, alias))?.row;
6311
6372
  }
6312
6373
  finally {
6313
6374
  await nc.drain().catch(() => nc.close());
6314
6375
  }
6315
- const terminalRows = [];
6316
- for (const row of slotRows) {
6317
- if (row.phase === "retired") {
6318
- // A retirement is a GLOBAL refusal fact — seed the F5 index for EVERY retired incarnation
6319
- // regardless of which instance owned it, so a sibling-retired incarnation's copied credential
6320
- // is refused at this control surface too. Ownership gates only the DESTRUCTIVE sweep below.
6321
- this.retiredPrincipals.add(principalKey(row.owner, row.actor).key);
6322
- continue;
6376
+ }
6377
+ scheduleStaticReconcileRetry(key, item) {
6378
+ if (this.staticReconcileStopping || item.timer || item.attempts >= item.maxAttempts)
6379
+ return;
6380
+ const delay = this.staticReconcileRetryDelaysMs[item.attempts - 1];
6381
+ if (delay === undefined)
6382
+ return;
6383
+ const nextRetryAtMs = Date.now() + delay;
6384
+ item.disposition = "retry-scheduled";
6385
+ item.nextRetryAt = new Date(nextRetryAtMs).toISOString();
6386
+ item.timer = setTimeout(() => {
6387
+ item.timer = undefined;
6388
+ item.nextRetryAt = undefined;
6389
+ const run = async () => {
6390
+ // The timer may fire while the failed attempt is still unwinding, especially in compressed
6391
+ // smoke schedules. Join that flight before consuming another budget entry; never overlap it.
6392
+ if (item.flight)
6393
+ await item.flight;
6394
+ await this.retryStaticReconcile(key);
6395
+ };
6396
+ void run().catch((error) => {
6397
+ item.lastError = `retry driver failed before the exact terminal: ${error.message}`;
6398
+ item.disposition = "retry-exhausted";
6399
+ item.remedy = "restart this manager for a fresh per-process retry budget";
6400
+ console.error(`! static reconcile retry-exhausted alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid}: ${item.lastError}; NEXT: restart this manager for a fresh per-process retry budget`);
6401
+ });
6402
+ }, delay);
6403
+ item.timer.unref?.();
6404
+ console.error(`! static reconcile retry-scheduled alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid} attempt=${item.attempts + 1}/${item.maxAttempts} nextRetryAt=${item.nextRetryAt}`);
6405
+ }
6406
+ retryStaticReconcile(key) {
6407
+ const item = this.staticReconcileItems.get(key);
6408
+ if (!item || this.staticReconcileStopping || item.disposition === "recovered")
6409
+ return Promise.resolve();
6410
+ if (item.retryFlight)
6411
+ return item.retryFlight;
6412
+ const flight = this.driveStaticReconcileRetry(key, item);
6413
+ let wrapped;
6414
+ wrapped = flight.finally(() => {
6415
+ if (item.retryFlight === wrapped)
6416
+ item.retryFlight = undefined;
6417
+ });
6418
+ item.retryFlight = wrapped;
6419
+ return wrapped;
6420
+ }
6421
+ async driveStaticReconcileRetry(key, item) {
6422
+ if (item.flight)
6423
+ await item.flight;
6424
+ if (this.staticReconcileStopping || item.disposition === "recovered")
6425
+ return;
6426
+ // A retry trigger consumes one finite budget entry even if the authoritative re-read itself
6427
+ // fails. Otherwise a broker outage before the planner could reschedule forever without ever
6428
+ // incrementing `attempts`, violating the bounded-retry guarantee.
6429
+ item.attempts++;
6430
+ item.disposition = "retrying";
6431
+ item.remedy = undefined;
6432
+ console.error(`static reconcile retrying alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid} attempt=${item.attempts}/${item.maxAttempts}`);
6433
+ let row;
6434
+ try {
6435
+ row = await this.readStaticReconcileSlot(item.alias);
6436
+ }
6437
+ catch (error) {
6438
+ item.lastError = `durable slot re-read failed: ${error.message}`;
6439
+ if (item.attempts >= item.maxAttempts) {
6440
+ item.disposition = "retry-exhausted";
6441
+ item.remedy = "restart this manager for a fresh per-process retry budget";
6442
+ console.error(`! static reconcile retry-exhausted alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid}: ${item.lastError}; NEXT: restart this manager for a fresh per-process retry budget`);
6323
6443
  }
6324
- // 3b-2 RECONCILE OWNERSHIP (multi-manager-per-space): a manager adjudicates ONLY the non-retired
6325
- // rows THIS logical instance owns. A SIBLING manager's active/provisioning row is LEFT UNTOUCHED —
6326
- // sweep-terminalizing it would destroy the sibling's live agent (the historical all-agents-kill
6327
- // hazard, now cross-instance). A legacy row (pre-3b-2, no owner recorded) predates multi-manager,
6328
- // so this manager is its legitimate single-manager-past successor and reconciles it. An orphaned
6329
- // sibling row is reclaimed only by an explicit operator CAS takeover (ruling 1), never here.
6330
- if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId)
6331
- continue;
6332
- // ADOPTION is genuine membership: a slot backed by a live managed agent THIS process owns
6333
- // at the SAME uid is never an orphan (empty at boot; exactly the adopted set at the
6334
- // post-adoption sweep the fix for the F3 resume hole).
6335
- const live = this.agents.get(row.alias);
6336
- const adopted = live !== undefined && live.lifecycleUid === row.lifecycleUid;
6337
- // Boot sweep with a resume PENDING: an active slot may yet be adopted (the resume path runs
6338
- // AFTER this boot sweep), so DEFER it — the post-adoption sweep terminalizes any the resume
6339
- // did not claim. provisioning/terminalizing NEVER defer (they are crashed operations, never
6340
- // an agent to adopt). At `postAdoption` (or a non-resume boot) nothing defers.
6341
- if (!postAdoption && row.phase === "active" && !adopted && this.resumeRequired)
6342
- continue;
6343
- if (planStaticSlotResume(row, adopted) !== "none")
6344
- terminalRows.push(row);
6444
+ else {
6445
+ this.scheduleStaticReconcileRetry(key, item);
6446
+ }
6447
+ return;
6448
+ }
6449
+ if (row === undefined || row.owner !== item.owner || row.actor !== item.actor || row.lifecycleUid !== item.lifecycleUid) {
6450
+ item.disposition = "refused";
6451
+ item.remedy = "inspect the durable slot; reconciliation will not cross incarnations";
6452
+ item.lastError = row === undefined
6453
+ ? "the durable slot vanished; reconciliation never acts from the in-memory snapshot"
6454
+ : `the durable slot moved to actor ${row.actor} uid ${row.lifecycleUid}; reconciliation never crosses incarnations`;
6455
+ console.error(`! static reconcile refused alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid}: ${item.lastError}`);
6456
+ return;
6457
+ }
6458
+ item.phase = row.phase;
6459
+ if (row.phase === "retired") {
6460
+ item.disposition = "recovered";
6461
+ item.recoveredAt = new Date().toISOString();
6462
+ item.lastError = undefined;
6463
+ console.error(`✓ static reconcile recovered alias=${item.alias} phase=retired uid=${item.lifecycleUid} attempts=${item.attempts}/${item.maxAttempts}`);
6464
+ return;
6465
+ }
6466
+ if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId) {
6467
+ item.disposition = "refused";
6468
+ item.remedy = "the owning manager must reconcile or an operator must perform an explicit CAS takeover";
6469
+ item.lastError = `the durable slot belongs to manager instance ${row.ownerInstanceId}, not ${this.managerInstanceId}`;
6470
+ console.error(`! static reconcile refused alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid}: ${item.lastError}`);
6471
+ return;
6472
+ }
6473
+ const live = this.agents.get(row.alias);
6474
+ const adopted = live !== undefined && live.lifecycleUid === row.lifecycleUid;
6475
+ if (planStaticSlotResume(row, adopted) !== "drive-terminal") {
6476
+ item.disposition = "refused";
6477
+ item.remedy = adopted ? "leave the adopted live lifecycle running" : "inspect the fresh durable slot state";
6478
+ item.lastError = adopted
6479
+ ? "the durable lifecycle is now backed by an adopted live managed agent"
6480
+ : `the fresh durable phase ${row.phase} is not terminal-plannable`;
6481
+ console.error(`! static reconcile refused alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid}: ${item.lastError}`);
6482
+ return;
6345
6483
  }
6346
- // Mark the complete planned set before the first terminal awaits. The manager may already be
6347
- // serving by now; this synchronous handoff prevents a spawn from slipping between an alias's
6348
- // discovery and its later serial exact-op terminal.
6349
- for (const row of terminalRows)
6484
+ await this.attemptStaticReconcile(key, item, row, false);
6485
+ }
6486
+ /** Enter one exact terminal at most once per coordinate. Sweep and timer triggers join the same
6487
+ * promise. A settled failed item keeps its original per-process budget; a restart builds a fresh
6488
+ * map from the still-authoritative durable row. */
6489
+ attemptStaticReconcile(key, item, row, countAttempt = true) {
6490
+ if (item.flight)
6491
+ return item.flight;
6492
+ if (item.timer)
6493
+ return Promise.resolve(false);
6494
+ // A timer/retry driver increments before its mandatory durable re-read, then calls with
6495
+ // countAttempt=false. Let that already-consumed budget entry enter the terminal. Only a fresh
6496
+ // sweep entry (countAttempt=true) is refused once the per-process budget is exhausted.
6497
+ if (countAttempt && item.attempts >= item.maxAttempts)
6498
+ return Promise.resolve(false);
6499
+ const flight = (async () => {
6500
+ if (countAttempt)
6501
+ item.attempts++;
6502
+ item.phase = row.phase;
6503
+ item.disposition = "retrying";
6504
+ item.nextRetryAt = undefined;
6350
6505
  this.reconcilingAliases.add(row.alias);
6351
- for (let index = 0; index < terminalRows.length; index++) {
6352
- const row = terminalRows[index];
6506
+ console.error(`static reconcile terminal alias=${row.alias} phase=${row.phase} uid=${row.lifecycleUid} attempt=${item.attempts}/${item.maxAttempts}`);
6353
6507
  try {
6354
- console.error(`static reconcile ${index + 1}/${terminalRows.length} via ${this.servers ?? DEFAULT_SERVER}: ${row.alias} is ${row.phase} with no live managed owner${postAdoption ? " after resume adoption" : ""} - driving its exact-op terminal (uid ${row.lifecycleUid})`);
6355
- await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid });
6508
+ await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid }, true);
6509
+ if (item.attempts === 1) {
6510
+ this.staticReconcileItems.delete(key);
6511
+ }
6512
+ else {
6513
+ item.phase = "retired";
6514
+ item.disposition = "recovered";
6515
+ item.recoveredAt = new Date().toISOString();
6516
+ item.lastError = undefined;
6517
+ console.error(`✓ static reconcile recovered alias=${item.alias} phase=retired uid=${item.lifecycleUid} attempts=${item.attempts}/${item.maxAttempts}`);
6518
+ }
6519
+ return true;
6520
+ }
6521
+ catch (error) {
6522
+ item.lastError = error.message;
6523
+ try {
6524
+ const fresh = await this.readStaticReconcileSlot(item.alias);
6525
+ if (fresh?.lifecycleUid === item.lifecycleUid && fresh.actor === item.actor)
6526
+ item.phase = fresh.phase;
6527
+ }
6528
+ catch { /* the terminal error remains primary; the next retry re-reads authoritatively */ }
6529
+ const blocked = lifecycleBlockedFrom(error);
6530
+ const ownOp = retireOpId(item.lifecycleUid);
6531
+ const foreign = blocked !== undefined && (blocked.blockedOp !== "retirement" || blocked.opId !== ownOp);
6532
+ if (foreign) {
6533
+ item.disposition = "refused-foreign";
6534
+ item.remedy = blocked?.remedy ?? "resolve the foreign durable operation; it is never force-cleared";
6535
+ console.error(`! static reconcile refused-foreign alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid}: ${item.lastError}`);
6536
+ }
6537
+ else if (item.attempts >= item.maxAttempts) {
6538
+ item.disposition = "retry-exhausted";
6539
+ item.remedy = "restart this manager for a fresh per-process retry budget";
6540
+ console.error(`! static reconcile retry-exhausted alias=${item.alias} phase=${item.phase} uid=${item.lifecycleUid}: ${item.lastError}; NEXT: restart this manager for a fresh per-process retry budget`);
6541
+ }
6542
+ else {
6543
+ // The initial serial sweep must publish its complete failed aggregate before any timer can
6544
+ // re-enter a middle row. Mark it now; the sweep arms the timer after its last planned row.
6545
+ if (this.staticReconcileSweepsInFlight > 0)
6546
+ item.disposition = "retry-scheduled";
6547
+ else
6548
+ this.scheduleStaticReconcileRetry(key, item);
6549
+ }
6550
+ return false;
6356
6551
  }
6357
6552
  finally {
6358
6553
  this.reconcilingAliases.delete(row.alias);
6359
6554
  }
6555
+ })();
6556
+ item.flight = flight.finally(() => {
6557
+ if (item.flight === wrapped)
6558
+ item.flight = undefined;
6559
+ });
6560
+ const wrapped = item.flight;
6561
+ return wrapped;
6562
+ }
6563
+ releaseStaticReconcileSweep() {
6564
+ this.staticReconcileSweepsInFlight--;
6565
+ if (this.staticReconcileSweepsInFlight !== 0)
6566
+ return;
6567
+ const waiters = this.staticReconcileDrainWaiters;
6568
+ this.staticReconcileDrainWaiters = [];
6569
+ for (const wake of waiters)
6570
+ wake();
6571
+ }
6572
+ /** Shutdown accepts no new static reconciliation work, then drains every sweep and exact-terminal
6573
+ * flight that crossed the fence before stop(). A sweep can discover several rows before its first
6574
+ * await, so the serial loop also consults the fence and never starts a later row after shutdown. */
6575
+ async awaitStaticReconcileDrain() {
6576
+ while (true) {
6577
+ if (this.staticReconcileSweepsInFlight > 0)
6578
+ await new Promise((resolve) => this.staticReconcileDrainWaiters.push(resolve));
6579
+ const flights = [];
6580
+ for (const item of this.staticReconcileItems.values()) {
6581
+ if (item.flight)
6582
+ flights.push(item.flight);
6583
+ if (item.retryFlight)
6584
+ flights.push(item.retryFlight);
6585
+ }
6586
+ if (flights.length === 0 && this.staticReconcileSweepsInFlight === 0)
6587
+ return;
6588
+ await Promise.allSettled(flights);
6589
+ }
6590
+ }
6591
+ async reconcileStaticLifecycles(postAdoption = false) {
6592
+ if (!this.auth || this.staticReconcileStopping)
6593
+ return;
6594
+ // Admission is synchronous with the shutdown fence. Once this increments, stop() must drain the
6595
+ // whole accepted sweep even if it is still minting its short-lived provisioner credential.
6596
+ this.staticReconcileSweepsInFlight++;
6597
+ try {
6598
+ // Recovered is a transition report, not permanent history. The next sweep clears it before
6599
+ // deriving a fresh plan; failed coordinates keep their per-process budget and are joined.
6600
+ for (const [key, item] of this.staticReconcileItems) {
6601
+ if (item.disposition === "recovered")
6602
+ this.staticReconcileItems.delete(key);
6603
+ }
6604
+ const sweep = {
6605
+ kind: postAdoption ? "post-adoption" : "startup",
6606
+ startedAt: new Date().toISOString(),
6607
+ attempted: 0,
6608
+ succeeded: 0,
6609
+ failed: 0,
6610
+ };
6611
+ this.staticReconcileLastSweep = sweep;
6612
+ const identity = newIdentity();
6613
+ const creds = await mintCreds(this.auth, identity, "provisioner");
6614
+ const nc = await this.dial({ ...standaloneConnectOpts({ creds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 });
6615
+ const slotRows = [];
6616
+ try {
6617
+ const jsm = await jetstreamManager(nc);
6618
+ const kvm = new Kvm(nc);
6619
+ await ensureAuthorityStores(jsm, kvm, this.space);
6620
+ const recordsKv = await kvm.open(recordsBucket(this.space));
6621
+ const t = staticLifecycleTransport(recordsKv, recordsKv /* auth reads unused in the sweep */);
6622
+ const keys = await recordsKv.keys(`${STATIC_SLOT_PREFIX}.${DEV_OWNER}.>`);
6623
+ const aliases = [];
6624
+ for await (const k of keys)
6625
+ aliases.push(k.split(".").slice(2).join("."));
6626
+ for (const alias of aliases) {
6627
+ const slot = await readStaticSlot(t, DEV_OWNER, alias);
6628
+ if (slot !== undefined)
6629
+ slotRows.push(slot.row);
6630
+ }
6631
+ }
6632
+ catch (error) {
6633
+ sweep.completedAt = new Date().toISOString();
6634
+ throw error;
6635
+ }
6636
+ finally {
6637
+ await nc.drain().catch(() => nc.close());
6638
+ }
6639
+ const terminalRows = [];
6640
+ for (const row of slotRows) {
6641
+ if (row.phase === "retired") {
6642
+ // A retirement is a GLOBAL refusal fact — seed the F5 index for EVERY retired incarnation
6643
+ // regardless of which instance owned it, so a sibling-retired incarnation's copied credential
6644
+ // is refused at this control surface too. Ownership gates only the DESTRUCTIVE sweep below.
6645
+ this.retiredPrincipals.add(principalKey(row.owner, row.actor).key);
6646
+ continue;
6647
+ }
6648
+ // 3b-2 RECONCILE OWNERSHIP (multi-manager-per-space): a manager adjudicates ONLY the non-retired
6649
+ // rows THIS logical instance owns. A SIBLING manager's active/provisioning row is LEFT UNTOUCHED —
6650
+ // sweep-terminalizing it would destroy the sibling's live agent (the historical all-agents-kill
6651
+ // hazard, now cross-instance). A legacy row (pre-3b-2, no owner recorded) predates multi-manager,
6652
+ // so this manager is its legitimate single-manager-past successor and reconciles it. An orphaned
6653
+ // sibling row is reclaimed only by an explicit operator CAS takeover (ruling 1), never here.
6654
+ if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId)
6655
+ continue;
6656
+ // ADOPTION is genuine membership: a slot backed by a live managed agent THIS process owns
6657
+ // at the SAME uid is never an orphan (empty at boot; exactly the adopted set at the
6658
+ // post-adoption sweep — the fix for the F3 resume hole).
6659
+ const live = this.agents.get(row.alias);
6660
+ const adopted = live !== undefined && live.lifecycleUid === row.lifecycleUid;
6661
+ // Boot sweep with a resume PENDING: an active slot may yet be adopted (the resume path runs
6662
+ // AFTER this boot sweep), so DEFER it — the post-adoption sweep terminalizes any the resume
6663
+ // did not claim. provisioning/terminalizing NEVER defer (they are crashed operations, never
6664
+ // an agent to adopt). At `postAdoption` (or a non-resume boot) nothing defers.
6665
+ if (!postAdoption && row.phase === "active" && !adopted && this.resumeRequired)
6666
+ continue;
6667
+ if (planStaticSlotResume(row, adopted) !== "none")
6668
+ terminalRows.push(row);
6669
+ }
6670
+ try {
6671
+ // Mark the complete planned set before the first terminal awaits. A timer owns only its own
6672
+ // alias, but the initial serial sweep must close every discovery-to-await gap up front.
6673
+ for (const row of terminalRows)
6674
+ this.reconcilingAliases.add(row.alias);
6675
+ for (const row of terminalRows) {
6676
+ if (this.staticReconcileStopping)
6677
+ break;
6678
+ sweep.attempted++;
6679
+ const key = this.staticReconcileKey(row);
6680
+ let item = this.staticReconcileItems.get(key);
6681
+ if (!item) {
6682
+ item = {
6683
+ owner: row.owner,
6684
+ alias: row.alias,
6685
+ actor: row.actor,
6686
+ lifecycleUid: row.lifecycleUid,
6687
+ phase: row.phase,
6688
+ attempts: 0,
6689
+ maxAttempts: STATIC_RECONCILE_MAX_ATTEMPTS,
6690
+ disposition: "retrying",
6691
+ };
6692
+ this.staticReconcileItems.set(key, item);
6693
+ }
6694
+ const succeeded = await this.attemptStaticReconcile(key, item, row);
6695
+ if (succeeded)
6696
+ sweep.succeeded++;
6697
+ else
6698
+ sweep.failed++;
6699
+ this.reconcilingAliases.delete(row.alias);
6700
+ }
6701
+ sweep.completedAt = new Date().toISOString();
6702
+ for (const row of terminalRows) {
6703
+ const key = this.staticReconcileKey(row);
6704
+ const item = this.staticReconcileItems.get(key);
6705
+ if (item?.disposition === "retry-scheduled" && !item.timer)
6706
+ this.scheduleStaticReconcileRetry(key, item);
6707
+ }
6708
+ const failed = terminalRows.flatMap((row) => {
6709
+ const item = this.staticReconcileItems.get(this.staticReconcileKey(row));
6710
+ return item && item.disposition !== "recovered" ? [`${item.alias}:${item.phase}:${item.disposition}`] : [];
6711
+ });
6712
+ if (sweep.failed === 0) {
6713
+ console.error(`✓ static reconcile completed: ${sweep.attempted} attempted, ${sweep.succeeded} succeeded, 0 failed`);
6714
+ }
6715
+ else {
6716
+ console.error(`! static reconcile completed: ${sweep.attempted} attempted, ${sweep.succeeded} succeeded, ${sweep.failed} failed; failed=${failed.join(",")}`);
6717
+ }
6718
+ }
6719
+ finally {
6720
+ for (const row of terminalRows)
6721
+ this.reconcilingAliases.delete(row.alias);
6722
+ }
6723
+ }
6724
+ finally {
6725
+ this.releaseStaticReconcileSweep();
6360
6726
  }
6361
6727
  }
6362
6728
  /** The F5(a) membership gate (Unit B, the F5-bind design): decide a control caller by its