@cotal-ai/manager 0.57.0 → 0.58.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
@@ -4,7 +4,7 @@ import { hostname } from "node:os";
4
4
  import { credsAuthenticator } from "@nats-io/transport-node";
5
5
  import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
6
  import { isAbsolute, join, dirname, resolve } from "node:path";
7
- import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
7
+ import { CotalEndpoint, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
8
8
  import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
9
9
  import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
10
10
  import { AttachEndpoint } from "./attach-endpoint.js";
@@ -197,55 +197,6 @@ function parseTurnNote(raw) {
197
197
  function turnHoldToken(goalId) {
198
198
  return createHash("sha256").update(`${goalId}:turn-deadline`, "utf8").digest("base64url").slice(0, 43);
199
199
  }
200
- /** One ep request/reply round-trip on the caller's OWN reply-plane filter (§13.2). The responder
201
- * derives the reply subject from the authenticated request, so there is no caller-selected reply
202
- * target to honour; the caller binds the answer off the reply SUBJECT — endpoint and nonce, both
203
- * broker-pinned by the responder's serve publish grant. A reply on another nonce belongs to a
204
- * different request (the rail is shared) and is ignored rather than allowed to fail this one. */
205
- export async function epAwaitReply(nc, space, caller, nonce, requestId, requestSubject, body, timeoutMs) {
206
- let sub;
207
- let timer;
208
- try {
209
- const got = new Promise((resolve, reject) => {
210
- sub = nc.subscribe(epCallerReplyFilter(space, caller), {
211
- callback: (err, msg) => {
212
- if (err) {
213
- reject(new Error(`the retirement reply subscription failed: ${err.message}`));
214
- return;
215
- }
216
- const parsed = parseEpSubject(msg.subject);
217
- if (!parsed || parsed.plane !== "reply" || parsed.endpoint !== AUTH_ENDPOINT || parsed.nonce !== nonce)
218
- return;
219
- let body;
220
- try {
221
- body = JSON.parse(new TextDecoder().decode(msg.data));
222
- }
223
- catch {
224
- return;
225
- } // a malformed body on our nonce is not an answer; keep waiting
226
- // REQUIRE THE ID ECHO. Binding on (endpoint, nonce) alone would accept a malformed or
227
- // WRONG-ID `{ok:true}` and clear a retirement hold on it. Ignore rather than fail: the
228
- // rail is shared across concurrent requests and a responder may publish at any nonce, so
229
- // an unmatched reply is someone else's answer, never grounds to fail the honest one.
230
- if (body.id !== requestId)
231
- return;
232
- resolve(body);
233
- },
234
- });
235
- timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
236
- nc.publish(requestSubject, new TextEncoder().encode(body));
237
- });
238
- return await got;
239
- }
240
- finally {
241
- if (timer)
242
- clearTimeout(timer);
243
- try {
244
- sub?.unsubscribe();
245
- }
246
- catch { /* connection already down */ }
247
- }
248
- }
249
200
  /**
250
201
  * The agent supervisor: a long-lived mesh node that owns agent process lifecycle.
251
202
  * It serves control requests on the "manager" service and spawns/kills agents
@@ -467,6 +418,8 @@ export class Manager {
467
418
  * lifecycle authority: every re-drive re-reads broker KV and re-runs planStaticSlotResume. */
468
419
  staticReconcileItems = new Map();
469
420
  staticReconcileLastSweep;
421
+ staticReconcileLastSweepResources;
422
+ lastDeprovisionResources;
470
423
  staticReconcileSweepsInFlight = 0;
471
424
  staticReconcileDrainWaiters = [];
472
425
  staticReconcileStopping = false;
@@ -549,6 +502,11 @@ export class Manager {
549
502
  resumedAgentNames = new Set();
550
503
  remoteAuthority;
551
504
  remoteExecutorCreds;
505
+ remoteSupervisorCreds;
506
+ remoteBundleRenewal;
507
+ /** A refused or failed standing candidate is cleanup debt, never a silent loss: the last-good
508
+ * family stays live and this records what the host may have signed but the manager never adopted. */
509
+ remoteRenewalDebt;
552
510
  /** Every broker dial this manager makes, through ONE transport decision.
553
511
  *
554
512
  * `this.servers` is whatever the mesh record held (`cotal supervise` reads it from there), so it
@@ -561,6 +519,10 @@ export class Manager {
561
519
  return dialerFor(servers)({ ...opts, servers });
562
520
  }
563
521
  constructor(opts) {
522
+ if (opts.pooled && (!opts.remoteAuthority || opts.runtime === undefined || opts.runtime === "auto" || opts.runtime === "pty"))
523
+ throw new Error("pooled control requires signerless remote authority and an explicit non-PTY runtime; local custodial execution is forbidden");
524
+ if (opts.pooled && !opts.remoteAuthority?.renewStandingBundle)
525
+ throw new Error("pooled control requires a closed host-issued all-duty renewal callback before construction");
564
526
  this.space = opts.space;
565
527
  this.servers = opts.servers;
566
528
  this.name = opts.name ?? "manager";
@@ -569,6 +531,10 @@ export class Manager {
569
531
  this.maxSessions = opts.maxSessions;
570
532
  this.remoteAuthority = opts.remoteAuthority;
571
533
  this.remoteExecutorCreds = opts.remoteAuthority?.executorCreds;
534
+ this.remoteSupervisorCreds = opts.remoteAuthority?.supervisorCreds;
535
+ if (opts.pooled && (!opts.remoteAuthority?.accountPublicKey ||
536
+ accountFromCreds(opts.remoteAuthority.supervisorCreds) !== opts.remoteAuthority.accountPublicKey))
537
+ throw new Error("pooled control requires a proved assigned account for its initial supervisor credential");
572
538
  if (opts.remoteAuthority)
573
539
  this.managerLifecycleUid = opts.remoteAuthority.lifecycleUid;
574
540
  this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
@@ -577,6 +543,8 @@ export class Manager {
577
543
  : { kind: "fs", root: resolve(this.workspaceRoot) };
578
544
  this.installedExtensions = opts.installedExtensions ?? false;
579
545
  this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
546
+ if (opts.pooled && isCustodialRuntime(this.runtime))
547
+ throw new Error(`pooled control refuses custodial runtime "${this.runtime.kind}" before starting agents`);
580
548
  this.preserveStopTimeoutMs = opts.preserveStopTimeoutMs ?? PRESERVE_STOP_TIMEOUT_MS;
581
549
  this.endpointServeExecutorExpiresInSeconds = opts.endpointServeExecutorExpiresInSeconds;
582
550
  if (opts.resumeAttemptId && !/^[A-Za-z0-9_-]{1,128}$/.test(opts.resumeAttemptId))
@@ -792,7 +760,7 @@ export class Manager {
792
760
  if (this.remoteAuthority) {
793
761
  const remote = this.remoteAuthority;
794
762
  id = remote.identities.supervisor.id;
795
- creds = async () => remote.supervisorCreds;
763
+ creds = async () => this.remoteSupervisorCreds;
796
764
  }
797
765
  else if (this.auth) {
798
766
  const identity = newIdentity();
@@ -895,8 +863,38 @@ export class Manager {
895
863
  this.credRenewTimer.unref?.();
896
864
  }
897
865
  else if (this.remoteAuthority) {
898
- await this.renewRemoteExecutor();
899
- this.credRenewTimer = setInterval(() => { void this.renewRemoteExecutor(); }, credRenewIntervalMs(5 * 60));
866
+ if (this.remoteAuthority.renewStandingBundle)
867
+ await this.renewRemoteStandingBundle();
868
+ else
869
+ await this.renewRemoteExecutor();
870
+ let standingTtl = STANDING_RENEWABLE_TTL_SEC;
871
+ if (this.remoteSupervisorCreds) {
872
+ try {
873
+ const claims = credsClaims(this.remoteSupervisorCreds);
874
+ if (typeof claims.exp === "number" && typeof claims.iat === "number" && claims.exp > claims.iat) {
875
+ standingTtl = claims.exp - claims.iat;
876
+ }
877
+ }
878
+ catch { }
879
+ }
880
+ const intervalMs = Math.min(credRenewIntervalMs(5 * 60), credRenewIntervalMs(standingTtl));
881
+ this.credRenewTimer = setInterval(async () => {
882
+ try {
883
+ if (this.remoteAuthority?.renewStandingBundle)
884
+ await this.renewRemoteStandingBundle();
885
+ else
886
+ await this.renewRemoteExecutor();
887
+ }
888
+ catch (e) {
889
+ console.error(`! remote manager renewal pass failed: ${e.message}`);
890
+ }
891
+ try {
892
+ await this.runHosting?.renew();
893
+ }
894
+ catch (e) {
895
+ console.error(`! remote run-hosting renewal pass failed: ${e.message}`);
896
+ }
897
+ }, intervalMs);
900
898
  this.credRenewTimer.unref?.();
901
899
  }
902
900
  // stop() fences before it waits for an accepted startup reconciliation terminal. Once that
@@ -929,9 +927,26 @@ export class Manager {
929
927
  // incarnation takes back every run a dead predecessor was driving before it accepts new ones.
930
928
  // The serve surface is already live by here, so the family itself holds the gate: `runHost()`
931
929
  // refuses `run-start`/`run-resume` as `unavailable` until the host exists and `RunHosting`
932
- // refuses them until its reconcile has returned. A user-auth mesh stands no host up at all
933
- // (`runHost()` names why); a remote-authority manager holds no signer to mint with.
934
- if (!this.remoteAuthority && !this.userMode) {
930
+ // refuses them until its reconcile has returned. A remote user-auth manager uses its four
931
+ // registered issuer callbacks instead of holding a signer; a local user mesh stands none up.
932
+ const remoteRuns = this.remoteAuthority?.runHosting;
933
+ if (this.remoteAuthority && remoteRuns) {
934
+ this.runHosting = new RunHosting({
935
+ space: this.space,
936
+ servers: this.servers,
937
+ endpoint: MANAGER_ENDPOINT,
938
+ instanceId: this.managerInstanceId,
939
+ holder: { id: this.remoteAuthority.identities.supervisor.id, lifecycleUid: this.managerLifecycleUid },
940
+ auth: undefined,
941
+ log: (line) => console.error(line),
942
+ admitRun: remoteRuns.admitRun,
943
+ issueAttempt: remoteRuns.issueAttempt,
944
+ issueOperator: remoteRuns.issueOperator,
945
+ renewRun: remoteRuns.renewRun,
946
+ });
947
+ await this.runHosting.reconcile();
948
+ }
949
+ else if (!this.remoteAuthority && !this.userMode) {
935
950
  this.runHosting = new RunHosting({
936
951
  space: this.space,
937
952
  servers: this.servers,
@@ -1339,6 +1354,94 @@ export class Manager {
1339
1354
  throw e;
1340
1355
  }
1341
1356
  }
1357
+ /** An active signerless manager renews its standing duties together. The issuer stages the closed
1358
+ * credential family first; broker preflight proves every JWT and existing caller-held nkey
1359
+ * before any mutable holder changes. A failed candidate leaves all current creds intact. */
1360
+ async renewRemoteStandingBundle(force = false) {
1361
+ if (this.remoteBundleRenewal)
1362
+ return this.remoteBundleRenewal;
1363
+ const renewal = this.renewRemoteStandingBundleOnce(force);
1364
+ this.remoteBundleRenewal = renewal;
1365
+ try {
1366
+ await renewal;
1367
+ }
1368
+ finally {
1369
+ if (this.remoteBundleRenewal === renewal)
1370
+ this.remoteBundleRenewal = undefined;
1371
+ }
1372
+ }
1373
+ /** A signerless manager's closed standing connection re-dials through the all-duty family only.
1374
+ * A healthy holder re-dials as is. An unhealthy one waits for the closed renewal and adoption,
1375
+ * and a refusal fails this attempt with last-good and debt kept. Nothing is minted locally. */
1376
+ async renewRemoteBeforeRedial(label, read) {
1377
+ if (inspectCredHealth(read() ?? "").state === "healthy")
1378
+ return;
1379
+ await this.renewRemoteStandingBundle(true);
1380
+ if (inspectCredHealth(read() ?? "").state !== "healthy")
1381
+ throw new Error(`the ${label} credential is not healthy after all-duty renewal; last-good is kept and nothing is minted locally`);
1382
+ }
1383
+ async renewRemoteStandingBundleOnce(force) {
1384
+ const remote = this.remoteAuthority;
1385
+ if (!remote?.renewStandingBundle || !this.serviceServe)
1386
+ return;
1387
+ const current = [this.remoteSupervisorCreds, this.remoteExecutorCreds, this.serviceServe.creds,
1388
+ this.goalWriterCreds, this.sessionLedgerCreds];
1389
+ if (!force && current.every((cred) => cred && inspectCredHealth(cred).state === "healthy"))
1390
+ return;
1391
+ const processEpoch = this.serviceServe.grant.epoch;
1392
+ let candidate;
1393
+ try {
1394
+ candidate = await remote.renewStandingBundle(processEpoch);
1395
+ const names = ["supervisor", "executor", "serve", "goalWriter", "sessionLedger"];
1396
+ if (Object.keys(candidate).sort().join(",") !== [...names].sort().join(","))
1397
+ throw new Error("host returned an incomplete or widened standing credential family");
1398
+ for (const name of names) {
1399
+ if (typeof candidate[name] !== "string" || inspectCredHealth(candidate[name]).state === "unreadable")
1400
+ throw new Error(`host returned an unreadable ${name} credential`);
1401
+ if (!remote.accountPublicKey || accountFromCreds(candidate[name]) !== remote.accountPublicKey)
1402
+ throw new Error(`host returned a ${name} credential for another account`);
1403
+ if (credsClaims(candidate[name]).sub !== remote.identities[name].id)
1404
+ throw new Error(`host returned a ${name} credential for a different nkey than the held identity`);
1405
+ const staged = candidate[name];
1406
+ const nc = await this.dial({ authenticator: (nonce) => credsAuthenticator(new TextEncoder().encode(staged))(nonce), maxReconnectAttempts: 0 });
1407
+ await nc.close();
1408
+ }
1409
+ if (this.leaseStopping || !this.serviceServe || this.serviceServe.grant.epoch !== processEpoch)
1410
+ throw new Error("manager changed its active serve epoch while standing renewal was in flight");
1411
+ this.remoteSupervisorCreds = candidate.supervisor;
1412
+ this.remoteExecutorCreds = candidate.executor;
1413
+ this.serviceServe.creds = candidate.serve;
1414
+ this.goalWriterCreds = candidate.goalWriter;
1415
+ this.sessionLedgerCreds = candidate.sessionLedger;
1416
+ if (this.goalWriter)
1417
+ this.goalWriter.creds = candidate.goalWriter;
1418
+ if (this.sessionLedgerConn)
1419
+ this.sessionLedgerConn.creds = candidate.sessionLedger;
1420
+ // Adoption is committed above; a connection that cannot come back yet is a push failure the
1421
+ // endpoint's own reconnect loop retries with the adopted credential, not unadopted debt.
1422
+ this.remoteRenewalDebt = undefined;
1423
+ await Promise.all([
1424
+ this.ep.reconnect().catch((e) => console.error(`! remote manager supervisor reconnect after renewal: ${e.message}`)),
1425
+ this.pushRenewedCreds(this.serviceServe.nc),
1426
+ ...(this.goalWriter ? [this.pushRenewedCreds(this.goalWriter.nc)] : []),
1427
+ ...(this.sessionLedgerConn ? [this.pushRenewedCreds(this.sessionLedgerConn.nc)] : []),
1428
+ ]);
1429
+ }
1430
+ catch (e) {
1431
+ const unadoptedSubjects = candidate && typeof candidate === "object"
1432
+ ? Object.values(candidate).flatMap((cred) => { try {
1433
+ return typeof cred === "string" && credsClaims(cred).sub ? [credsClaims(cred).sub] : [];
1434
+ }
1435
+ catch {
1436
+ return [];
1437
+ } })
1438
+ : [];
1439
+ this.remoteRenewalDebt = { at: Date.now(), processEpoch, reason: e.message, unadoptedSubjects };
1440
+ console.error(`! remote manager all-duty renewal: ${e.message} - no unsigned or local replacement is available`);
1441
+ if (force)
1442
+ throw e;
1443
+ }
1444
+ }
1342
1445
  /** Warn, on every renewal pass, when a $SYS credential is at or past its renewal point.
1343
1446
  *
1344
1447
  * The manager is the renewal owner for every credential it CAN re-sign, and these two are the ones
@@ -2357,9 +2460,8 @@ export class Manager {
2357
2460
  * admin tier allowed operator cross-owner redefine; (2) launch is owner-equality-only, above.
2358
2461
  * Both are least-privilege reductions, never widenings. */
2359
2462
  /** The run host, or one of three refusals. A remote-authority manager holds no space signer, so
2360
- * it cannot mint the per-run driver credential SPEC 14.6 requires, and hosting on any other
2361
- * identity would be the fallback this tree does not take: `unimplemented`, for good. A
2362
- * user-auth mesh is `unimplemented` too, for a different reason it names: a hosted run's seats
2463
+ * it cannot mint the per-run driver credential SPEC 14.6 requires without all four closed
2464
+ * host callbacks. A local user-auth mesh is `unimplemented`: a hosted run's seats
2363
2465
  * are spawned, turned and despawned by a caller derived from the run id under the static
2364
2466
  * owner, which the user-mode spawn door refuses (no `u_` owner), so a program would fail at
2365
2467
  * its first seat; no path to `--local` is offered there since a user bearer holds no run rows
@@ -2369,9 +2471,9 @@ export class Manager {
2369
2471
  runHost() {
2370
2472
  if (this.runHosting)
2371
2473
  return this.runHosting;
2372
- if (this.remoteAuthority)
2373
- throw new EpEnvelopeError("unimplemented", "this manager does not host workflow runs: a remote-authority manager mints no run-driver credentials (SPEC 14.6); drive the run from a terminal with `cotal run start --local --file <program>`");
2374
- if (this.userMode)
2474
+ if (this.remoteAuthority && !this.remoteAuthority.runHosting)
2475
+ throw new EpEnvelopeError("unimplemented", "this manager does not host workflow runs: its issuing host supplied no closed run callbacks, and a remote-authority manager mints no run-driver credentials (SPEC 14.6); drive the run from a terminal with `cotal run start --local --file <program>`");
2476
+ if (this.userMode && !this.remoteAuthority?.runHosting)
2375
2477
  throw new EpEnvelopeError("unimplemented", `user-auth space "${this.space}" hosts no workflow runs yet: a hosted run's seats would be spawned under the static owner, which a user mesh refuses; run programs on a static-auth mesh`);
2376
2478
  throw new EpEnvelopeError("unavailable", "the manager is still booting its workflow-run host; retry shortly (SPEC 14.3)");
2377
2479
  }
@@ -3101,14 +3203,14 @@ export class Manager {
3101
3203
  if (this.userMode) {
3102
3204
  const p = parsePrincipalKey(a.id);
3103
3205
  if (p)
3104
- this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: p.owner, actor: p.actor, agentId: a.id, userOwner: a.userOwner, secretPaths: a.secretPaths, startedAt: Date.now() });
3206
+ this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: p.owner, actor: p.actor, agentId: a.id, userOwner: a.userOwner, secretPaths: a.secretPaths, launch: { allowSubscribe: a.launch.allowSubscribe }, startedAt: Date.now() });
3105
3207
  }
3106
3208
  else if (this.auth) {
3107
3209
  // Unit B: a STATIC lifecycle now also holds its name pending its own terminal (the F1
3108
3210
  // static retirement the detached deprovision below drives) — the alias frees only when the
3109
3211
  // gate+head terminal completes, exactly the user-mode discipline. The wire principal is the
3110
3212
  // incarnation-unique nkey (F5-bind); owner is the dev owner.
3111
- this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: DEV_OWNER, actor: a.id, agentId: a.id, secretPaths: a.secretPaths, runtime: a.handle?.reference, startedAt: Date.now() });
3213
+ this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: DEV_OWNER, actor: a.id, agentId: a.id, secretPaths: a.secretPaths, launch: { allowSubscribe: a.launch.allowSubscribe }, runtime: a.handle?.reference, startedAt: Date.now() });
3112
3214
  }
3113
3215
  // Auth mode: tear down the departed agent's minted broker footprint + creds file (#159 B2). The
3114
3216
  // process is already gone, so this must never block the slot free or throw into the caller — it runs
@@ -3232,7 +3334,21 @@ export class Manager {
3232
3334
  console.error(`revoke agent grant ${a.name}: ${e.message}`);
3233
3335
  }
3234
3336
  }
3235
- await this.deprovisionBroker(a);
3337
+ try {
3338
+ const res = await this.deprovisionBroker(a);
3339
+ const h = this.retiring.get(a.name);
3340
+ if (h && h.lifecycleUid === a.lifecycleUid)
3341
+ h.lastResources = res;
3342
+ }
3343
+ catch (e) {
3344
+ const h = this.retiring.get(a.name);
3345
+ if (h && h.lifecycleUid === a.lifecycleUid) {
3346
+ h.lastError = e.message;
3347
+ if (e instanceof DeprovisionError)
3348
+ h.lastResources = e.accounting;
3349
+ }
3350
+ throw e;
3351
+ }
3236
3352
  // #29 piece 3: after the footprint teardown, ask the AUTH plane to RETIRE the lifecycle over
3237
3353
  // the auth endpoint rail. The rail re-checks the SERVE-ISSUANCE GATE at serve time (not the
3238
3354
  // space-manager lease - that check was replaced in 02794b2f) and refuses unless the registration
@@ -3324,46 +3440,44 @@ export class Manager {
3324
3440
  : await mintCreds(this.auth, requestIdentity, "retirement-requester", {
3325
3441
  retirementRequester: { ...caller, target: retirementTarget },
3326
3442
  });
3327
- const nc = await this.dial({ authenticator: credsAuthenticator(new TextEncoder().encode(creds)), maxReconnectAttempts: 0 });
3443
+ // The requester grant subscribes `_INBOX_<id>.>` only (provision.ts): the endpoint path's
3444
+ // JetStream API requests (the contract-store read inside resolveService) must reply under it, not under the client default `_INBOX.`.
3445
+ const nc = await this.dial({ authenticator: credsAuthenticator(new TextEncoder().encode(creds)), inboxPrefix: `_INBOX_${requestIdentity.id}`, maxReconnectAttempts: 0 });
3328
3446
  try {
3329
- // §13.2 nonce: >=128 bits of CSPRNG entropy, base64url (the `endpoint-invoke` idiom).
3330
- const nonce = randomBytes(24).toString("base64url");
3331
- // The caller-chosen request id the reply MUST echo (the minimal correctness guard from the
3332
- // not-yet-migrated endpoint envelope - see the residual in the auth listener).
3333
- const requestId = randomBytes(16).toString("base64url");
3334
- const subject = epRequestSubject(this.space, {
3335
- route: { mode: "one" },
3336
- endpoint: AUTH_ENDPOINT,
3337
- command: EP_CMD_RETIRE_LIFECYCLE,
3338
- // The TARGET rides the subject (authz mode `handle`, arity 3) — broker-enforced, and
3339
- // pinned by the grant minted above, so it is not a body claim the caller can vary.
3340
- target: { mode: "handle", tOwner: target.owner, tActor: target.actor, tUid: a.lifecycleUid },
3341
- caller,
3342
- nonce,
3447
+ // The service resolves FRESH on every attempt (never cached on `this`): a plane restart
3448
+ // mid-despawn then lands on the new epoch through an ordinary resolve, rather than a
3449
+ // caller pinned to a now-superseded incarnation.
3450
+ const service = await resolveService(nc, this.space, AUTH_ENDPOINT, caller, { deadlineMs: 10_000 });
3451
+ // The TARGET rides the invoke's own target (authz mode `exact`, arity 3) — broker-enforced,
3452
+ // and pinned by the grant minted above, so it is not a body claim the caller can vary.
3453
+ const attributed = await invokeCommand(nc, this.space, service, EP_CMD_RETIRE_LIFECYCLE, {
3454
+ opId,
3455
+ serveEndpoint: MANAGER_ENDPOINT, serveInstanceId: this.managerInstanceId, serveEpoch,
3456
+ }, {
3457
+ target: { mode: "exact", owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid },
3458
+ deadlineMs: 20_000,
3343
3459
  });
3344
- // The responder derives its OWN reply subject from this request (caller triple + nonce,
3345
- // prefixed with the RESPONDER's instance identity), so a caller-supplied `reply` header
3346
- // would be ignored — that is the confused-deputy boundary being structural. The caller
3347
- // therefore reads its own reply-plane filter and binds the answer off the reply SUBJECT.
3348
- const m = await epAwaitReply(nc, this.space, caller, nonce, requestId, subject,
3349
- // Declare THIS manager instance's serve identity. Since #350 these SELECT the gate row;
3350
- // they no longer authorize — the rail refuses unless the row they name belongs to this
3351
- // caller's own subject-derived principal. A superseded predecessor (same instanceId, OLD
3352
- // epoch after a restart) is still refused by the epoch comparison.
3353
- JSON.stringify({ id: requestId, op: "retireLifecycle", args: {
3354
- opId,
3355
- serveEndpoint: MANAGER_ENDPOINT, serveInstanceId: this.managerInstanceId, serveEpoch,
3356
- } }), 20_000);
3357
- const r = m;
3460
+ const r = attributed.reply;
3358
3461
  if (r.ok) {
3359
3462
  this.confirmRetirement(a);
3360
3463
  }
3464
+ else if (r.error?.code === "expired") {
3465
+ // A stale-epoch refusal: the responder answered, but at another epoch than this resolve
3466
+ // bound (a plane restart mid-despawn landed on the new epoch) - the retry copy, never a
3467
+ // hard despawn failure line, since a same-name spawn (or the auth service's own boot
3468
+ // resume) re-drives the same teardown.
3469
+ const copy = uncertain(r.error.message);
3470
+ if (held)
3471
+ held.lastError = copy;
3472
+ console.error(`despawn ${a.name}: ${copy}`);
3473
+ }
3361
3474
  else {
3362
3475
  // The rail's refusal is already the operator copy (lease-loss/stale/foreign-op faces,
3363
3476
  // full-no-op statements included) - surface it INTACT, never flattened.
3477
+ const message = r.error?.message ?? "the auth service refused the retirement without a reason";
3364
3478
  if (held)
3365
- held.lastError = r.error ?? "the auth service refused the retirement without a reason";
3366
- console.error(`despawn ${a.name}: ${r.error ?? "the auth service refused the retirement without a reason"}`);
3479
+ held.lastError = message;
3480
+ console.error(`despawn ${a.name}: ${message}`);
3367
3481
  }
3368
3482
  }
3369
3483
  finally {
@@ -3414,13 +3528,59 @@ export class Manager {
3414
3528
  // LIFECYCLE-PINNED (SPEC 13.1): both the credential's exact-name grants and the delete names
3415
3529
  // carry a.lifecycleUid, so a stale/replayed teardown for this retired incarnation is broker-denied
3416
3530
  // against a same-name successor's footprint (its names embed a different uid).
3531
+ // Durable membership rows are lifecycle-keyed per concrete channel. Start with the launch's
3532
+ // concrete read channels, then include wildcard-covered and unnamed channels from the inventory.
3533
+ const memberChannels = new Set((a.launch?.allowSubscribe ?? []).filter(isConcreteChannel));
3534
+ // Complete the set from the delivery daemon's lifecycle-exact inventory, which covers rows on
3535
+ // wildcard-granted channels and teardowns that carry no launch spec. An inventory that cannot be
3536
+ // read leaves those rows RETAINED and says so; it never widens the delete beyond exact keys.
3537
+ const inventory = await this.lifecycleMembershipInventory(a);
3538
+ for (const ch of inventory.channels)
3539
+ memberChannels.add(ch);
3540
+ if (!inventory.complete)
3541
+ console.error(`deprovision ${a.name} (${a.id}): membership inventory incomplete (${inventory.reason}); rows on channels outside its launch policy stay RETAINED for uid ${a.lifecycleUid}`);
3417
3542
  const creds = await mintCreds(this.auth, newIdentity(), "deprovisioner", {
3418
- deprovisionTarget: { principal: a.id, lifecycleUid: a.lifecycleUid },
3543
+ deprovisionTarget: { principal: a.id, lifecycleUid: a.lifecycleUid, memberChannels: [...memberChannels] },
3419
3544
  });
3420
3545
  // Bound the detached broker teardown so a wedged broker can't leave the deprovision promise pending
3421
3546
  // forever with no log — the timeout rejects into freeSlot's fail-loud `.catch` (paired with the
3422
3547
  // helper's own fail-fast connect). The durables/ACL row still fall to space teardown as a backstop.
3423
- await withTimeout(deprovisionAgent({ servers: this.servers ?? DEFAULT_SERVER, space: this.space, targetId: a.id, lifecycleUid: a.lifecycleUid, creds }), DEPROVISION_TIMEOUT_MS, `deprovision ${a.name} (${a.id}): broker teardown timed out`);
3548
+ let accounting;
3549
+ try {
3550
+ accounting = await withTimeout(deprovisionAgent({
3551
+ servers: this.servers ?? DEFAULT_SERVER,
3552
+ space: this.space,
3553
+ targetId: a.id,
3554
+ lifecycleUid: a.lifecycleUid,
3555
+ memberChannels: [...memberChannels],
3556
+ creds,
3557
+ }), DEPROVISION_TIMEOUT_MS, `deprovision ${a.name} (${a.id}): broker teardown timed out`);
3558
+ this.lastDeprovisionResources = accounting;
3559
+ }
3560
+ catch (e) {
3561
+ if (e instanceof DeprovisionError) {
3562
+ this.lastDeprovisionResources = e.accounting;
3563
+ }
3564
+ throw e;
3565
+ }
3566
+ if (!inventory.complete)
3567
+ throw new Error(`membership inventory incomplete (${inventory.reason}); rows on channels outside its launch policy stay RETAINED for uid ${a.lifecycleUid}`);
3568
+ return accounting;
3569
+ }
3570
+ /** The retiring lifecycle's durable membership channels, read from the delivery daemon's
3571
+ * `lifecycleMemberships` admin verb (read-only, lifecycle-exact). Incomplete on any refusal. */
3572
+ async lifecycleMembershipInventory(a) {
3573
+ const t = deprovisionTargetPrincipal({ principal: a.id, lifecycleUid: a.lifecycleUid });
3574
+ try {
3575
+ const r = await this.ep.requestDeliveryAdmin("lifecycleMemberships", { principal: principalKey(t.owner, t.actor).key, lifecycleUid: t.lifecycleUid }, 5_000);
3576
+ const data = r.data;
3577
+ if (!r.ok || data?.complete !== true || !Array.isArray(data.channels) || !data.channels.every((c) => typeof c === "string" && isConcreteChannel(c)))
3578
+ return { complete: false, channels: [], reason: r.ok ? "malformed inventory reply" : (r.error ?? "refused") };
3579
+ return { complete: true, channels: data.channels };
3580
+ }
3581
+ catch (e) {
3582
+ return { complete: false, channels: [], reason: e.message };
3583
+ }
3424
3584
  }
3425
3585
  /** Reap a parent's children on its exit (P4b). Every descendant remains managed until the runtime's
3426
3586
  * authoritative wait proves exit; the wait participates in the lifecycle drain, so preservation can
@@ -4328,7 +4488,7 @@ export class Manager {
4328
4488
  return { ok: false, error: `the name "${identityName}" is still reconciling at manager startup; its prior lifecycle terminal owns this alias until it completes. Retry shortly.` };
4329
4489
  const held = this.retiring.get(identityName);
4330
4490
  if (held !== undefined) {
4331
- void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner, secretPaths: held.secretPaths }).catch(() => { });
4491
+ void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner, secretPaths: held.secretPaths, launch: held.launch }).catch(() => { });
4332
4492
  const err = lifecycleBlocked("failed-precondition", `the name "${identityName}" is reserved pending retirement: its previous agent's despawn started that lifecycle's teardown (footprint + standing-authority revoke + auth-side retirement), and the name frees only when all of it completes${held.lastError !== undefined ? ` (last attempt: ${held.lastError})` : ""}. NEXT: wait a moment and retry this spawn (retrying re-drives the whole teardown), or pick another name.`, { blockedOp: "retirement", headState: "retiring", opId: held.opId, remedy: "retry" });
4333
4493
  return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
4334
4494
  }
@@ -5623,8 +5783,14 @@ export class Manager {
5623
5783
  * This is NEVER the manager's standing seed/supervisor connection. */
5624
5784
  async withEndpointServeExecutor(fn) {
5625
5785
  const identity = this.remoteAuthority?.identities.executor ?? newIdentity();
5626
- if (this.remoteAuthority && (!this.remoteExecutorCreds || inspectCredHealth(this.remoteExecutorCreds).state !== "healthy"))
5627
- await this.renewRemoteExecutor(true);
5786
+ if (this.remoteAuthority && (!this.remoteExecutorCreds || inspectCredHealth(this.remoteExecutorCreds).state !== "healthy")) {
5787
+ // Shutdown has drained and discarded the standing serve context. Renew only its scoped
5788
+ // maintenance executor through the existing host operation; never resurrect standing duties.
5789
+ if (this.leaseStopping)
5790
+ await this.renewRemoteExecutor(true);
5791
+ else
5792
+ await (this.remoteAuthority.renewStandingBundle ? this.renewRemoteStandingBundle(true) : this.renewRemoteExecutor(true));
5793
+ }
5628
5794
  const creds = this.remoteExecutorCreds ?? (this.auth
5629
5795
  ? await mintCreds(this.auth, identity, "endpoint-serve-executor", {
5630
5796
  endpointServeExecutor: { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId },
@@ -5755,7 +5921,6 @@ export class Manager {
5755
5921
  inboxPrefix: `_INBOX_${state.identity.id}`,
5756
5922
  maxReconnectAttempts: -1,
5757
5923
  });
5758
- nc.closed().then((err) => { void this.onServeConnectionClosed(err ?? undefined); });
5759
5924
  let handle;
5760
5925
  try {
5761
5926
  // The 1b typed surface + the derived `describe`. The descriptor stays PUBLIC in static
@@ -5770,6 +5935,13 @@ export class Manager {
5770
5935
  // durable slot rows mirror it). Static mode carries no mapping-revision dimension, so
5771
5936
  // the revision is the constant 0 — a caller that pins a revision pins 0.
5772
5937
  resolveTarget: (t) => {
5938
+ if (this.remoteAuthority) {
5939
+ const key = principalKey(t.owner, t.actor).key;
5940
+ for (const a of this.agents.values())
5941
+ if (a.id === key)
5942
+ return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
5943
+ return undefined;
5944
+ }
5773
5945
  if (t.owner === DEV_OWNER) {
5774
5946
  for (const a of this.agents.values())
5775
5947
  if (!a.userOwner && a.id === t.actor)
@@ -5789,6 +5961,9 @@ export class Manager {
5789
5961
  await nc.drain().catch(() => nc.close());
5790
5962
  throw e;
5791
5963
  }
5964
+ // Wired only once the connection serves: a connection this method drains itself after a
5965
+ // refused serve is not a fault to recover, and the caller already sees the refusal.
5966
+ nc.closed().then((err) => { void this.onServeConnectionClosed(err ?? undefined); });
5792
5967
  return { nc, handle };
5793
5968
  }
5794
5969
  /** #2073: re-mint the SAME `endpoint-serve` identity through the SAME scoped one-shot executor and
@@ -5823,7 +5998,9 @@ export class Manager {
5823
5998
  const recovered = await this.boundedReconnect("service endpoint", async () => {
5824
5999
  if (!s)
5825
6000
  return false;
5826
- if (inspectCredHealth(s.creds ?? "").state !== "healthy" && s.creds !== undefined) {
6001
+ if (this.remoteAuthority)
6002
+ await this.renewRemoteBeforeRedial("serve", () => s.creds);
6003
+ else if (inspectCredHealth(s.creds ?? "").state !== "healthy" && s.creds !== undefined) {
5827
6004
  s.creds = await this.remintServeCreds(s);
5828
6005
  this.scheduleServeRenewal(s.creds);
5829
6006
  }
@@ -5867,28 +6044,9 @@ export class Manager {
5867
6044
  grant: remote.serveGrant,
5868
6045
  creds: remote.serveCreds,
5869
6046
  };
5870
- const enc = new TextEncoder();
5871
- const nc = await this.dial({
5872
- authenticator: (nonce) => credsAuthenticator(enc.encode(state.creds))(nonce),
5873
- inboxPrefix: `_INBOX_${state.identity.id}`,
5874
- maxReconnectAttempts: -1,
5875
- });
5876
- try {
5877
- state.handle = serveEndpoint(nc, this.space, state.grant, this.managerServiceDefs(), { public: true }, {
5878
- resolveTarget: (t) => {
5879
- const key = principalKey(t.owner, t.actor).key;
5880
- for (const a of this.agents.values())
5881
- if (a.id === key)
5882
- return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
5883
- return undefined;
5884
- },
5885
- ...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
5886
- });
5887
- }
5888
- catch (e) {
5889
- await nc.drain().catch(() => nc.close());
5890
- throw e;
5891
- }
6047
+ // The shared dialer wires the closed-connection handler, which re-dials through the all-duty family.
6048
+ const { nc, handle } = await this.dialServeConnection(state);
6049
+ state.handle = handle;
5892
6050
  state.nc = nc;
5893
6051
  this.serviceServe = state;
5894
6052
  console.error(`remote manager service endpoint activated: ${MANAGER_ENDPOINT}/${this.managerInstanceId} (epoch ${state.grant.epoch})`);
@@ -6198,7 +6356,9 @@ export class Manager {
6198
6356
  const recovered = await this.boundedReconnect("goal-writer", async () => {
6199
6357
  if (!gw)
6200
6358
  return false;
6201
- if (gw.creds !== undefined && inspectCredHealth(gw.creds).state !== "healthy") {
6359
+ if (this.remoteAuthority)
6360
+ await this.renewRemoteBeforeRedial("goal-writer", () => gw.creds);
6361
+ else if (gw.creds !== undefined && inspectCredHealth(gw.creds).state !== "healthy") {
6202
6362
  gw.creds = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageGoalWriter(authKv));
6203
6363
  this.goalWriterCreds = gw.creds;
6204
6364
  }
@@ -6313,7 +6473,9 @@ export class Manager {
6313
6473
  const recovered = await this.boundedReconnect("session-ledger", async () => {
6314
6474
  if (!sw || !identity)
6315
6475
  return false;
6316
- if (sw.creds !== undefined && inspectCredHealth(sw.creds).state !== "healthy") {
6476
+ if (this.remoteAuthority)
6477
+ await this.renewRemoteBeforeRedial("session-ledger", () => sw.creds);
6478
+ else if (sw.creds !== undefined && inspectCredHealth(sw.creds).state !== "healthy") {
6317
6479
  sw.creds = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageSessionLedger(authKv));
6318
6480
  this.sessionLedgerCreds = sw.creds;
6319
6481
  }
@@ -7921,6 +8083,10 @@ export class Manager {
7921
8083
  finally {
7922
8084
  await nc.drain().catch(() => nc.close());
7923
8085
  }
8086
+ let retiredSlots = 0;
8087
+ let preservedForeign = 0;
8088
+ let preservedAdopted = 0;
8089
+ let deferredSlots = 0;
7924
8090
  const terminalRows = [];
7925
8091
  for (const row of slotRows) {
7926
8092
  if (row.phase === "retired") {
@@ -7928,6 +8094,7 @@ export class Manager {
7928
8094
  // regardless of which instance owned it, so a sibling-retired incarnation's copied credential
7929
8095
  // is refused at this control surface too. Ownership gates only the DESTRUCTIVE sweep below.
7930
8096
  this.retiredPrincipals.add(principalKey(row.owner, row.actor).key);
8097
+ retiredSlots++;
7931
8098
  continue;
7932
8099
  }
7933
8100
  // 3b-2 RECONCILE OWNERSHIP (multi-manager-per-space): a manager adjudicates ONLY the non-retired
@@ -7936,19 +8103,26 @@ export class Manager {
7936
8103
  // hazard, now cross-instance). A legacy row (pre-3b-2, no owner recorded) predates multi-manager,
7937
8104
  // so this manager is its legitimate single-manager-past successor and reconciles it. An orphaned
7938
8105
  // sibling row is reclaimed only by an explicit operator CAS takeover (ruling 1), never here.
7939
- if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId)
8106
+ if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId) {
8107
+ preservedForeign++;
7940
8108
  continue;
8109
+ }
7941
8110
  // ADOPTION is genuine membership: a slot backed by a live managed agent THIS process owns
7942
8111
  // at the SAME uid is never an orphan (empty at boot; exactly the adopted set at the
7943
8112
  // post-adoption sweep — the fix for the F3 resume hole).
7944
8113
  const live = this.agents.get(row.alias);
7945
8114
  const adopted = live !== undefined && live.lifecycleUid === row.lifecycleUid;
8115
+ if (adopted) {
8116
+ preservedAdopted++;
8117
+ }
7946
8118
  // Boot sweep with a resume PENDING: an active slot may yet be adopted (the resume path runs
7947
8119
  // AFTER this boot sweep), so DEFER it — the post-adoption sweep terminalizes any the resume
7948
8120
  // did not claim. provisioning/terminalizing NEVER defer (they are crashed operations, never
7949
8121
  // an agent to adopt). At `postAdoption` (or a non-resume boot) nothing defers.
7950
- if (!postAdoption && row.phase === "active" && !adopted && this.resumeRequired)
8122
+ if (!postAdoption && row.phase === "active" && !adopted && this.resumeRequired) {
8123
+ deferredSlots++;
7951
8124
  continue;
8125
+ }
7952
8126
  if (planStaticSlotResume(row, adopted) !== "none")
7953
8127
  terminalRows.push(row);
7954
8128
  }
@@ -7957,6 +8131,17 @@ export class Manager {
7957
8131
  // alias, but the initial serial sweep must close every discovery-to-await gap up front.
7958
8132
  for (const row of terminalRows)
7959
8133
  this.reconcilingAliases.add(row.alias);
8134
+ const sweepDeprovision = {
8135
+ examined: 0,
8136
+ deleted: 0,
8137
+ absent: 0,
8138
+ refused: 0,
8139
+ acknowledged: 0,
8140
+ disappeared: 0,
8141
+ consumers: { examined: 0, deleted: 0, absent: 0, refused: 0, acknowledged: 0, disappeared: 0 },
8142
+ acls: { examined: 0, deleted: 0, absent: 0, refused: 0, disappeared: 0 },
8143
+ members: { examined: 0, deleted: 0, absent: 0, refused: 0, disappeared: 0 },
8144
+ };
7960
8145
  for (const row of terminalRows) {
7961
8146
  if (this.staticReconcileStopping)
7962
8147
  break;
@@ -7976,7 +8161,33 @@ export class Manager {
7976
8161
  };
7977
8162
  this.staticReconcileItems.set(key, item);
7978
8163
  }
8164
+ this.lastDeprovisionResources = undefined;
7979
8165
  const succeeded = await this.attemptStaticReconcile(key, item, row);
8166
+ const acc = this.lastDeprovisionResources;
8167
+ if (acc) {
8168
+ sweepDeprovision.examined += acc.examined;
8169
+ sweepDeprovision.deleted = sweepDeprovision.deleted === null || acc.deleted === null ? null : sweepDeprovision.deleted + acc.deleted;
8170
+ sweepDeprovision.absent += acc.absent;
8171
+ sweepDeprovision.refused += acc.refused;
8172
+ sweepDeprovision.acknowledged += acc.acknowledged;
8173
+ sweepDeprovision.disappeared += acc.disappeared;
8174
+ sweepDeprovision.consumers.examined += acc.consumers.examined;
8175
+ sweepDeprovision.consumers.deleted = sweepDeprovision.consumers.deleted === null || acc.consumers.deleted === null ? null : sweepDeprovision.consumers.deleted + acc.consumers.deleted;
8176
+ sweepDeprovision.consumers.absent += acc.consumers.absent;
8177
+ sweepDeprovision.consumers.refused += acc.consumers.refused;
8178
+ sweepDeprovision.consumers.acknowledged += acc.consumers.acknowledged;
8179
+ sweepDeprovision.consumers.disappeared += acc.consumers.disappeared;
8180
+ sweepDeprovision.acls.examined += acc.acls.examined;
8181
+ sweepDeprovision.acls.deleted += acc.acls.deleted;
8182
+ sweepDeprovision.acls.absent += acc.acls.absent;
8183
+ sweepDeprovision.acls.refused += acc.acls.refused;
8184
+ sweepDeprovision.acls.disappeared += acc.acls.disappeared;
8185
+ sweepDeprovision.members.examined += acc.members.examined;
8186
+ sweepDeprovision.members.deleted += acc.members.deleted;
8187
+ sweepDeprovision.members.absent += acc.members.absent;
8188
+ sweepDeprovision.members.refused += acc.members.refused;
8189
+ sweepDeprovision.members.disappeared += acc.members.disappeared;
8190
+ }
7980
8191
  if (succeeded)
7981
8192
  sweep.succeeded++;
7982
8193
  else
@@ -7984,6 +8195,18 @@ export class Manager {
7984
8195
  this.reconcilingAliases.delete(row.alias);
7985
8196
  }
7986
8197
  sweep.completedAt = new Date().toISOString();
8198
+ this.staticReconcileLastSweepResources = {
8199
+ slots: {
8200
+ examined: slotRows.length,
8201
+ retired: retiredSlots,
8202
+ preservedForeign,
8203
+ preservedAdopted,
8204
+ deferred: deferredSlots,
8205
+ terminalized: sweep.succeeded,
8206
+ failed: sweep.failed,
8207
+ },
8208
+ deprovision: sweepDeprovision,
8209
+ };
7987
8210
  for (const row of terminalRows) {
7988
8211
  const key = this.staticReconcileKey(row);
7989
8212
  const item = this.staticReconcileItems.get(key);