@cotal-ai/manager 0.48.0 → 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-service-contract.d.ts +33 -1
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +51 -3
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +33 -0
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +425 -59
- package/dist/manager.js.map +1 -1
- package/package.json +9 -9
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
|
-
|
|
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
|
|
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} -
|
|
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
|
-
|
|
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
|
|
6298
|
-
|
|
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
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
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
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6331
|
-
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
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
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6355
|
-
|
|
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
|