@cotal-ai/manager 0.56.1 → 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";
@@ -31,7 +31,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
31
31
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
32
32
  import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, 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
- import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
34
+ import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
35
35
  /** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
36
36
  * before living this long leaves a cooling stamp that still counts toward the ceiling until it
37
37
  * expires — so churn (spawn↔despawn or spawn↔fast-exit) can't outrun the concurrency bound. */
@@ -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
  }
@@ -2478,6 +2580,32 @@ export class Manager {
2478
2580
  }
2479
2581
  return row;
2480
2582
  }),
2583
+ slots: (ctx) => this.serveGated(ctx, async () => {
2584
+ // No manager-local durable static slot store exists for a user-mode or open manager: an
2585
+ // empty list here would claim nothing is stranded from a store that was never consulted,
2586
+ // the very ambiguity #1274 is about.
2587
+ if (!this.auth || this.userMode)
2588
+ throw new EpEnvelopeError("failed-precondition", "user-mode and open managers own no durable static slot rows; the projection is static-manager local");
2589
+ const recordsKv = this.goalWriter?.ctx.kv;
2590
+ if (!recordsKv)
2591
+ throw new EpEnvelopeError("unavailable", "the durable static slot store is not ready", [
2592
+ { kind: STATIC_SLOT_READ_FAILED_DETAIL, name: "*", record: "slot", operation: "scan" },
2593
+ ]);
2594
+ let observed;
2595
+ try {
2596
+ observed = await listStaticSlotObservations(recordsKv, DEV_OWNER, this.managerInstanceId);
2597
+ }
2598
+ catch (error) {
2599
+ throw new EpEnvelopeError("unavailable", `the durable static slot state could not be scanned: ${error.message}`, [
2600
+ { kind: STATIC_SLOT_READ_FAILED_DETAIL, name: "*", record: error instanceof StaticSlotReadError ? error.record : "slot-or-head", operation: "scan" },
2601
+ ]);
2602
+ }
2603
+ return observed.map(({ kind: _kind, ...detail }) => ({
2604
+ ...detail,
2605
+ live: this.agents.has(detail.name),
2606
+ managerInstanceId: this.managerInstanceId,
2607
+ }));
2608
+ }),
2481
2609
  models: (ctx) => this.serveGated(ctx, async () => {
2482
2610
  const data = unwrap(await this.opModels(args(ctx)));
2483
2611
  return { catalogs: Array.isArray(data) ? data : [data] };
@@ -3075,14 +3203,14 @@ export class Manager {
3075
3203
  if (this.userMode) {
3076
3204
  const p = parsePrincipalKey(a.id);
3077
3205
  if (p)
3078
- 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() });
3079
3207
  }
3080
3208
  else if (this.auth) {
3081
3209
  // Unit B: a STATIC lifecycle now also holds its name pending its own terminal (the F1
3082
3210
  // static retirement the detached deprovision below drives) — the alias frees only when the
3083
3211
  // gate+head terminal completes, exactly the user-mode discipline. The wire principal is the
3084
3212
  // incarnation-unique nkey (F5-bind); owner is the dev owner.
3085
- 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() });
3086
3214
  }
3087
3215
  // Auth mode: tear down the departed agent's minted broker footprint + creds file (#159 B2). The
3088
3216
  // process is already gone, so this must never block the slot free or throw into the caller — it runs
@@ -3206,7 +3334,21 @@ export class Manager {
3206
3334
  console.error(`revoke agent grant ${a.name}: ${e.message}`);
3207
3335
  }
3208
3336
  }
3209
- 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
+ }
3210
3352
  // #29 piece 3: after the footprint teardown, ask the AUTH plane to RETIRE the lifecycle over
3211
3353
  // the auth endpoint rail. The rail re-checks the SERVE-ISSUANCE GATE at serve time (not the
3212
3354
  // space-manager lease - that check was replaced in 02794b2f) and refuses unless the registration
@@ -3298,46 +3440,44 @@ export class Manager {
3298
3440
  : await mintCreds(this.auth, requestIdentity, "retirement-requester", {
3299
3441
  retirementRequester: { ...caller, target: retirementTarget },
3300
3442
  });
3301
- 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 });
3302
3446
  try {
3303
- // §13.2 nonce: >=128 bits of CSPRNG entropy, base64url (the `endpoint-invoke` idiom).
3304
- const nonce = randomBytes(24).toString("base64url");
3305
- // The caller-chosen request id the reply MUST echo (the minimal correctness guard from the
3306
- // not-yet-migrated endpoint envelope - see the residual in the auth listener).
3307
- const requestId = randomBytes(16).toString("base64url");
3308
- const subject = epRequestSubject(this.space, {
3309
- route: { mode: "one" },
3310
- endpoint: AUTH_ENDPOINT,
3311
- command: EP_CMD_RETIRE_LIFECYCLE,
3312
- // The TARGET rides the subject (authz mode `handle`, arity 3) — broker-enforced, and
3313
- // pinned by the grant minted above, so it is not a body claim the caller can vary.
3314
- target: { mode: "handle", tOwner: target.owner, tActor: target.actor, tUid: a.lifecycleUid },
3315
- caller,
3316
- 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,
3317
3459
  });
3318
- // The responder derives its OWN reply subject from this request (caller triple + nonce,
3319
- // prefixed with the RESPONDER's instance identity), so a caller-supplied `reply` header
3320
- // would be ignored — that is the confused-deputy boundary being structural. The caller
3321
- // therefore reads its own reply-plane filter and binds the answer off the reply SUBJECT.
3322
- const m = await epAwaitReply(nc, this.space, caller, nonce, requestId, subject,
3323
- // Declare THIS manager instance's serve identity. Since #350 these SELECT the gate row;
3324
- // they no longer authorize — the rail refuses unless the row they name belongs to this
3325
- // caller's own subject-derived principal. A superseded predecessor (same instanceId, OLD
3326
- // epoch after a restart) is still refused by the epoch comparison.
3327
- JSON.stringify({ id: requestId, op: "retireLifecycle", args: {
3328
- opId,
3329
- serveEndpoint: MANAGER_ENDPOINT, serveInstanceId: this.managerInstanceId, serveEpoch,
3330
- } }), 20_000);
3331
- const r = m;
3460
+ const r = attributed.reply;
3332
3461
  if (r.ok) {
3333
3462
  this.confirmRetirement(a);
3334
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
+ }
3335
3474
  else {
3336
3475
  // The rail's refusal is already the operator copy (lease-loss/stale/foreign-op faces,
3337
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";
3338
3478
  if (held)
3339
- held.lastError = r.error ?? "the auth service refused the retirement without a reason";
3340
- 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}`);
3341
3481
  }
3342
3482
  }
3343
3483
  finally {
@@ -3388,13 +3528,59 @@ export class Manager {
3388
3528
  // LIFECYCLE-PINNED (SPEC 13.1): both the credential's exact-name grants and the delete names
3389
3529
  // carry a.lifecycleUid, so a stale/replayed teardown for this retired incarnation is broker-denied
3390
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}`);
3391
3542
  const creds = await mintCreds(this.auth, newIdentity(), "deprovisioner", {
3392
- deprovisionTarget: { principal: a.id, lifecycleUid: a.lifecycleUid },
3543
+ deprovisionTarget: { principal: a.id, lifecycleUid: a.lifecycleUid, memberChannels: [...memberChannels] },
3393
3544
  });
3394
3545
  // Bound the detached broker teardown so a wedged broker can't leave the deprovision promise pending
3395
3546
  // forever with no log — the timeout rejects into freeSlot's fail-loud `.catch` (paired with the
3396
3547
  // helper's own fail-fast connect). The durables/ACL row still fall to space teardown as a backstop.
3397
- 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
+ }
3398
3584
  }
3399
3585
  /** Reap a parent's children on its exit (P4b). Every descendant remains managed until the runtime's
3400
3586
  * authoritative wait proves exit; the wait participates in the lifecycle drain, so preservation can
@@ -4302,7 +4488,7 @@ export class Manager {
4302
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.` };
4303
4489
  const held = this.retiring.get(identityName);
4304
4490
  if (held !== undefined) {
4305
- 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(() => { });
4306
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" });
4307
4493
  return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
4308
4494
  }
@@ -5597,8 +5783,14 @@ export class Manager {
5597
5783
  * This is NEVER the manager's standing seed/supervisor connection. */
5598
5784
  async withEndpointServeExecutor(fn) {
5599
5785
  const identity = this.remoteAuthority?.identities.executor ?? newIdentity();
5600
- if (this.remoteAuthority && (!this.remoteExecutorCreds || inspectCredHealth(this.remoteExecutorCreds).state !== "healthy"))
5601
- 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
+ }
5602
5794
  const creds = this.remoteExecutorCreds ?? (this.auth
5603
5795
  ? await mintCreds(this.auth, identity, "endpoint-serve-executor", {
5604
5796
  endpointServeExecutor: { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId },
@@ -5729,7 +5921,6 @@ export class Manager {
5729
5921
  inboxPrefix: `_INBOX_${state.identity.id}`,
5730
5922
  maxReconnectAttempts: -1,
5731
5923
  });
5732
- nc.closed().then((err) => { void this.onServeConnectionClosed(err ?? undefined); });
5733
5924
  let handle;
5734
5925
  try {
5735
5926
  // The 1b typed surface + the derived `describe`. The descriptor stays PUBLIC in static
@@ -5744,6 +5935,13 @@ export class Manager {
5744
5935
  // durable slot rows mirror it). Static mode carries no mapping-revision dimension, so
5745
5936
  // the revision is the constant 0 — a caller that pins a revision pins 0.
5746
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
+ }
5747
5945
  if (t.owner === DEV_OWNER) {
5748
5946
  for (const a of this.agents.values())
5749
5947
  if (!a.userOwner && a.id === t.actor)
@@ -5763,6 +5961,9 @@ export class Manager {
5763
5961
  await nc.drain().catch(() => nc.close());
5764
5962
  throw e;
5765
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); });
5766
5967
  return { nc, handle };
5767
5968
  }
5768
5969
  /** #2073: re-mint the SAME `endpoint-serve` identity through the SAME scoped one-shot executor and
@@ -5797,7 +5998,9 @@ export class Manager {
5797
5998
  const recovered = await this.boundedReconnect("service endpoint", async () => {
5798
5999
  if (!s)
5799
6000
  return false;
5800
- 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) {
5801
6004
  s.creds = await this.remintServeCreds(s);
5802
6005
  this.scheduleServeRenewal(s.creds);
5803
6006
  }
@@ -5841,28 +6044,9 @@ export class Manager {
5841
6044
  grant: remote.serveGrant,
5842
6045
  creds: remote.serveCreds,
5843
6046
  };
5844
- const enc = new TextEncoder();
5845
- const nc = await this.dial({
5846
- authenticator: (nonce) => credsAuthenticator(enc.encode(state.creds))(nonce),
5847
- inboxPrefix: `_INBOX_${state.identity.id}`,
5848
- maxReconnectAttempts: -1,
5849
- });
5850
- try {
5851
- state.handle = serveEndpoint(nc, this.space, state.grant, this.managerServiceDefs(), { public: true }, {
5852
- resolveTarget: (t) => {
5853
- const key = principalKey(t.owner, t.actor).key;
5854
- for (const a of this.agents.values())
5855
- if (a.id === key)
5856
- return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
5857
- return undefined;
5858
- },
5859
- ...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
5860
- });
5861
- }
5862
- catch (e) {
5863
- await nc.drain().catch(() => nc.close());
5864
- throw e;
5865
- }
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;
5866
6050
  state.nc = nc;
5867
6051
  this.serviceServe = state;
5868
6052
  console.error(`remote manager service endpoint activated: ${MANAGER_ENDPOINT}/${this.managerInstanceId} (epoch ${state.grant.epoch})`);
@@ -6172,7 +6356,9 @@ export class Manager {
6172
6356
  const recovered = await this.boundedReconnect("goal-writer", async () => {
6173
6357
  if (!gw)
6174
6358
  return false;
6175
- 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") {
6176
6362
  gw.creds = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageGoalWriter(authKv));
6177
6363
  this.goalWriterCreds = gw.creds;
6178
6364
  }
@@ -6287,7 +6473,9 @@ export class Manager {
6287
6473
  const recovered = await this.boundedReconnect("session-ledger", async () => {
6288
6474
  if (!sw || !identity)
6289
6475
  return false;
6290
- 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") {
6291
6479
  sw.creds = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageSessionLedger(authKv));
6292
6480
  this.sessionLedgerCreds = sw.creds;
6293
6481
  }
@@ -7895,6 +8083,10 @@ export class Manager {
7895
8083
  finally {
7896
8084
  await nc.drain().catch(() => nc.close());
7897
8085
  }
8086
+ let retiredSlots = 0;
8087
+ let preservedForeign = 0;
8088
+ let preservedAdopted = 0;
8089
+ let deferredSlots = 0;
7898
8090
  const terminalRows = [];
7899
8091
  for (const row of slotRows) {
7900
8092
  if (row.phase === "retired") {
@@ -7902,6 +8094,7 @@ export class Manager {
7902
8094
  // regardless of which instance owned it, so a sibling-retired incarnation's copied credential
7903
8095
  // is refused at this control surface too. Ownership gates only the DESTRUCTIVE sweep below.
7904
8096
  this.retiredPrincipals.add(principalKey(row.owner, row.actor).key);
8097
+ retiredSlots++;
7905
8098
  continue;
7906
8099
  }
7907
8100
  // 3b-2 RECONCILE OWNERSHIP (multi-manager-per-space): a manager adjudicates ONLY the non-retired
@@ -7910,19 +8103,26 @@ export class Manager {
7910
8103
  // hazard, now cross-instance). A legacy row (pre-3b-2, no owner recorded) predates multi-manager,
7911
8104
  // so this manager is its legitimate single-manager-past successor and reconciles it. An orphaned
7912
8105
  // sibling row is reclaimed only by an explicit operator CAS takeover (ruling 1), never here.
7913
- if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId)
8106
+ if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId) {
8107
+ preservedForeign++;
7914
8108
  continue;
8109
+ }
7915
8110
  // ADOPTION is genuine membership: a slot backed by a live managed agent THIS process owns
7916
8111
  // at the SAME uid is never an orphan (empty at boot; exactly the adopted set at the
7917
8112
  // post-adoption sweep — the fix for the F3 resume hole).
7918
8113
  const live = this.agents.get(row.alias);
7919
8114
  const adopted = live !== undefined && live.lifecycleUid === row.lifecycleUid;
8115
+ if (adopted) {
8116
+ preservedAdopted++;
8117
+ }
7920
8118
  // Boot sweep with a resume PENDING: an active slot may yet be adopted (the resume path runs
7921
8119
  // AFTER this boot sweep), so DEFER it — the post-adoption sweep terminalizes any the resume
7922
8120
  // did not claim. provisioning/terminalizing NEVER defer (they are crashed operations, never
7923
8121
  // an agent to adopt). At `postAdoption` (or a non-resume boot) nothing defers.
7924
- if (!postAdoption && row.phase === "active" && !adopted && this.resumeRequired)
8122
+ if (!postAdoption && row.phase === "active" && !adopted && this.resumeRequired) {
8123
+ deferredSlots++;
7925
8124
  continue;
8125
+ }
7926
8126
  if (planStaticSlotResume(row, adopted) !== "none")
7927
8127
  terminalRows.push(row);
7928
8128
  }
@@ -7931,6 +8131,17 @@ export class Manager {
7931
8131
  // alias, but the initial serial sweep must close every discovery-to-await gap up front.
7932
8132
  for (const row of terminalRows)
7933
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
+ };
7934
8145
  for (const row of terminalRows) {
7935
8146
  if (this.staticReconcileStopping)
7936
8147
  break;
@@ -7950,7 +8161,33 @@ export class Manager {
7950
8161
  };
7951
8162
  this.staticReconcileItems.set(key, item);
7952
8163
  }
8164
+ this.lastDeprovisionResources = undefined;
7953
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
+ }
7954
8191
  if (succeeded)
7955
8192
  sweep.succeeded++;
7956
8193
  else
@@ -7958,6 +8195,18 @@ export class Manager {
7958
8195
  this.reconcilingAliases.delete(row.alias);
7959
8196
  }
7960
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
+ };
7961
8210
  for (const row of terminalRows) {
7962
8211
  const key = this.staticReconcileKey(row);
7963
8212
  const item = this.staticReconcileItems.get(key);
@@ -8399,6 +8648,10 @@ export class Manager {
8399
8648
  const health = a.userOwner
8400
8649
  ? agentAuthState(a.secretPaths?.health ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid).health)
8401
8650
  : undefined;
8651
+ // The connector-reported provider from presence (#785): the live roster card's `meta.provider`,
8652
+ // when it is a non-empty string. Absent when the connector reported none — never fabricated.
8653
+ const providerMeta = roster.get(a.name)?.card.meta?.provider;
8654
+ const provider = typeof providerMeta === "string" && providerMeta ? providerMeta : undefined;
8402
8655
  return {
8403
8656
  name: a.name,
8404
8657
  // The spawned agent's id (nkey, or the user-mode principal) — lets an operator tool (e.g.
@@ -8424,6 +8677,7 @@ export class Manager {
8424
8677
  // pid is absent on runtimes that do not own a real process; a launch may pin no model).
8425
8678
  model: a.launch.model,
8426
8679
  variant: a.launch.variant,
8680
+ ...(provider ? { provider } : {}),
8427
8681
  cwd: a.launch.cwd,
8428
8682
  pid: a.handle.pid,
8429
8683
  spawner: a.spawner,