@cotal-ai/manager 0.100.2 → 0.101.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/manager.js CHANGED
@@ -4,7 +4,7 @@ import { credsAuthenticator } from "@nats-io/transport-node";
4
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
5
5
  import { isAbsolute, join, dirname, resolve } from "node:path";
6
6
  import Headless from "@xterm/headless";
7
- import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, } from "@cotal-ai/core";
7
+ import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, addDeprovisionAccounting, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, oneLine, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, } from "@cotal-ai/core";
8
8
  import { loadLaunchPersonaFile, PersonaSourceError } from "@cotal-ai/workspace";
9
9
  import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimIdentityRecord, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, probeLiveness, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, spaceKey, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
10
10
  import { createRuntime, exitProof, isCustodialRuntime, pollProvedExit, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, watchProvedExit, } from "./runtime/index.js";
@@ -16,6 +16,7 @@ import { launchSpecForRun, materializePersona, launchAgentToStartOpts, parseLaun
16
16
  import { authorizeLaunch, authorizeNamedControl } from "./authorize.js";
17
17
  import { controlShutdown } from "./control-shutdown.js";
18
18
  import { RunHosting } from "./run-hosting.js";
19
+ import { enrolledAcl } from "./remote-authority.js";
19
20
  import { controlSession } from "./control-session.js";
20
21
  import { MAX_AGENTS, parseResumeCommitArgs, parseResumeControlArgs, parseResumeFinalizeArgs, readSeatRecord, removeSeatRecord, writeSeatRecord } from "./resume.js";
21
22
  import { TranscriptReceiver } from "./transcript-receive.js";
@@ -140,7 +141,7 @@ const NO_OWNER_MATCHES = "-no-owner-";
140
141
  * getter or `toString` throws. A handler that throws while building its text skips the work after
141
142
  * it, and on a detached chain ends the manager with an unhandled rejection, so the coercion to text
142
143
  * runs inside the guard. */
143
- function rejectionText(e) {
144
+ export function rejectionText(e) {
144
145
  try {
145
146
  return String(e?.message ?? e);
146
147
  }
@@ -260,13 +261,13 @@ function parseTurnNote(raw) {
260
261
  return undefined;
261
262
  const n = o;
262
263
  if (typeof n.payload !== "string" || typeof n.deadlineAt !== "number"
263
- || typeof n.holdEpoch !== "number" || typeof n.owner !== "string")
264
+ || typeof n.holdEpoch !== "number")
264
265
  return undefined;
265
266
  if (n.holdToken !== undefined && typeof n.holdToken !== "string")
266
267
  return undefined;
267
268
  if (n.handoffFrom !== undefined && typeof n.handoffFrom !== "string")
268
269
  return undefined;
269
- return { payload: n.payload, deadlineAt: n.deadlineAt, holdEpoch: n.holdEpoch, owner: n.owner, ...(typeof n.holdToken === "string" ? { holdToken: n.holdToken } : {}), ...(typeof n.handoffFrom === "string" ? { handoffFrom: n.handoffFrom } : {}) };
270
+ return { payload: n.payload, deadlineAt: n.deadlineAt, holdEpoch: n.holdEpoch, ...(typeof n.holdToken === "string" ? { holdToken: n.holdToken } : {}), ...(typeof n.handoffFrom === "string" ? { handoffFrom: n.handoffFrom } : {}) };
270
271
  }
271
272
  /** A goal's key in the manager's maps: the caller triple and goal id the goal plane stores the
272
273
  * goal under, because a goal id is the caller's own (SPEC 13.6 item 1) and two callers may pick
@@ -550,7 +551,6 @@ export class Manager {
550
551
  staticReconcileItems = new Map();
551
552
  staticReconcileLastSweep;
552
553
  staticReconcileLastSweepResources;
553
- lastDeprovisionResources;
554
554
  staticReconcileSweepsInFlight = 0;
555
555
  staticReconcileDrainWaiters = [];
556
556
  staticReconcileStopping = false;
@@ -2482,7 +2482,7 @@ export class Manager {
2482
2482
  await this.ep.releaseManagerLease(this.managerInstanceId, own.revision);
2483
2483
  }
2484
2484
  catch {
2485
- // Best-effort, like the release it wraps: a broker failure here is recovered by the bucket TTL.
2485
+ // Best-effort: a release that did not commit is reported by the re-read below.
2486
2486
  }
2487
2487
  try {
2488
2488
  const stillOurs = await this.ep.readOwnManagerLease(this.managerInstanceId);
@@ -2534,7 +2534,7 @@ export class Manager {
2534
2534
  catch (e) {
2535
2535
  console.error(`! could not deregister manager instance ${iid}: ${rejectionText(e)}\n` +
2536
2536
  ` Its registration now outlives this process and will be frozen into every class scatter in space "${this.space}", each paying the full deadline.\n` +
2537
- ` NEXT: remove it with \`cotal deregister-instance --instance ${iid}\` once this process is gone.`);
2537
+ ` NEXT: once this process is gone, start this manager again and stop it cleanly; on a static space \`cotal endpoint deregister manager ${iid} --yes\` from the space's own root also removes it.`);
2538
2538
  }
2539
2539
  }
2540
2540
  /** Stop the v0.4 service-endpoint serve loop (drain subscriptions, await in-flight handlers)
@@ -3172,7 +3172,7 @@ export class Manager {
3172
3172
  // follows owner-domain reach on a user mesh. Resolve the caller's admin tier once for
3173
3173
  // the residual cross-owner case; opStart decides before connector resolution or grant
3174
3174
  // mutation. Static meshes resolve true and nothing changes.
3175
- spawn: (ctx) => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route, ctx.subject.caller))),
3175
+ spawn: (ctx) => this.loggedSpawnRefusal(ctx, () => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route, ctx.subject.caller)))),
3176
3176
  despawn: (ctx) => this.serveGated(ctx, async () => {
3177
3177
  const a = targetAgent(ctx);
3178
3178
  const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
@@ -3265,7 +3265,7 @@ export class Manager {
3265
3265
  return this.runHost().amend(input, this.runAnswerer(ctx), (accepted) => this.authorizeRunAmend(ctx, accepted), ctx);
3266
3266
  return this.runHost().answer(input, this.runAnswerer(ctx), (open) => this.authorizeRunAnswer(ctx, input, open), ctx);
3267
3267
  }),
3268
- runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx))),
3268
+ runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx), ctx)),
3269
3269
  runPs: (ctx) => this.serveGated(ctx, () => this.runHost().list(args(ctx), ctx)),
3270
3270
  preparePreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("preparePreservation", args(ctx)))),
3271
3271
  commitPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("commitPreservation", args(ctx)))),
@@ -3726,7 +3726,14 @@ export class Manager {
3726
3726
  for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files])
3727
3727
  await this.shredUserSecrets(unshredded, family);
3728
3728
  const leftover = unshredded.length ? `; cleanup failed: ${unshredded.join("; ")}` : "";
3729
- return { error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}` };
3729
+ // A host that refused the enrollment enrolled nothing, so once every staged secret is gone
3730
+ // this attempt left nothing behind. A failure after the host answered, or a host that did not
3731
+ // answer, may have left a grant there.
3732
+ const refusal = material === undefined && !unshredded.length ? caughtEnvelope(e) : undefined;
3733
+ return {
3734
+ error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}`,
3735
+ ...(refusal?.outcome === "not-executed" ? { refused: refusal.code } : {}),
3736
+ };
3730
3737
  }
3731
3738
  }
3732
3739
  /** Shred a user agent's secret family from the store and from disk. Every removal is attempted,
@@ -4233,7 +4240,8 @@ export class Manager {
4233
4240
  * is NOT freed while any teardown phase is still in flight — the hold clears only after the
4234
4241
  * standing-authority revoke AND the lifecycle retirement both confirm (see {@link driveRetirement}) —
4235
4242
  * but the deletes here are still lifecycle-uid-pinned so even a replayed/stale teardown can never
4236
- * reach a same-name successor's footprint (its names embed a different uid). */
4243
+ * reach a same-name successor's footprint (its names embed a different uid). Each call reports its
4244
+ * own accounting: the return value, or the {@link DeprovisionError} it throws once the deletes ran. */
4237
4245
  async deprovisionBroker(a) {
4238
4246
  // LIFECYCLE-PINNED (SPEC 13.1): both the credential's exact-name grants and the delete names
4239
4247
  // carry a.lifecycleUid, so a stale/replayed teardown for this retired incarnation is broker-denied
@@ -4255,26 +4263,16 @@ export class Manager {
4255
4263
  // Bound the detached broker teardown so a wedged broker can't leave the deprovision promise pending
4256
4264
  // forever with no log — the timeout rejects into freeSlot's fail-loud `.catch` (paired with the
4257
4265
  // helper's own fail-fast connect). The durables/ACL row still fall to space teardown as a backstop.
4258
- let accounting;
4259
- try {
4260
- accounting = await withTimeout(deprovisionAgent({
4261
- servers: this.servers ?? DEFAULT_SERVER,
4262
- space: this.space,
4263
- targetId: a.id,
4264
- lifecycleUid: a.lifecycleUid,
4265
- memberChannels: [...memberChannels],
4266
- creds,
4267
- }), DEPROVISION_TIMEOUT_MS, `deprovision ${a.name} (${a.id}): broker teardown timed out`);
4268
- this.lastDeprovisionResources = accounting;
4269
- }
4270
- catch (e) {
4271
- if (e instanceof DeprovisionError) {
4272
- this.lastDeprovisionResources = e.accounting;
4273
- }
4274
- throw e;
4275
- }
4266
+ const accounting = await withTimeout(deprovisionAgent({
4267
+ servers: this.servers ?? DEFAULT_SERVER,
4268
+ space: this.space,
4269
+ targetId: a.id,
4270
+ lifecycleUid: a.lifecycleUid,
4271
+ memberChannels: [...memberChannels],
4272
+ creds,
4273
+ }), DEPROVISION_TIMEOUT_MS, `deprovision ${a.name} (${a.id}): broker teardown timed out`);
4276
4274
  if (!inventory.complete)
4277
- throw new Error(`membership inventory incomplete (${inventory.reason}); rows on channels outside its launch policy stay RETAINED for uid ${a.lifecycleUid}`);
4275
+ throw new DeprovisionError(`membership inventory incomplete (${inventory.reason}); rows on channels outside its launch policy stay RETAINED for uid ${a.lifecycleUid}`, accounting);
4278
4276
  return accounting;
4279
4277
  }
4280
4278
  /** The retiring lifecycle's durable membership channels, read from the delivery daemon's
@@ -5175,7 +5173,7 @@ export class Manager {
5175
5173
  else {
5176
5174
  configPath = agentFilePath(this.workspaceRoot, ref);
5177
5175
  if (!existsSync(configPath))
5178
- return { error: `no persona "${ref}" - ${configPath} not found; create it or pass --config (see \`cotal personas list\`)` };
5176
+ return { error: `no persona "${ref}" in the catalog of manager ${this.managerInstanceId} on host ${hostname()}: ${configPath} not found; create it there` };
5179
5177
  }
5180
5178
  // Load the persona ONCE, here, ahead of the connector choice. The file's `agent:` pin feeds the
5181
5179
  // harness resolution below; the spawn path's identity/ACL profile block consumes the SAME def (no
@@ -5656,8 +5654,11 @@ export class Manager {
5656
5654
  }
5657
5655
  : undefined,
5658
5656
  });
5659
- if ("error" in prep)
5657
+ if ("error" in prep) {
5658
+ if (prep.refused !== undefined)
5659
+ hooks?.onUndone?.(prep.refused);
5660
5660
  return { ok: false, error: prep.error };
5661
+ }
5661
5662
  // THE HOST'S UID WINS (#1972). On the local arm this is the value passed in, unchanged. On
5662
5663
  // the hosted enrollment arm the host selected it, and from here every lifecycle-keyed thing
5663
5664
  // this spawn records — the slot, the launch, the managed row, the teardown credential — must
@@ -5786,7 +5787,7 @@ export class Manager {
5786
5787
  if (this.delegatedLaunched.has(lifecycleUid))
5787
5788
  throw new Error(`lifecycle ${lifecycleUid} was already handed to runtime "${this.requiredRuntime.kind}"; a lifecycle is handed off at most once`);
5788
5789
  this.delegatedLaunched.add(lifecycleUid);
5789
- handle = this.requiredRuntime.spawnDelegated({ agent, persona: readFileSync(configPath, "utf8"), role, model, variant, prompt, launchOptions: launchOptions, events }, handoff);
5790
+ handle = this.requiredRuntime.spawnDelegated({ agent, persona: readFileSync(configPath, "utf8"), managerInstanceId: this.managerInstanceId, role, model, variant, prompt, launchOptions: launchOptions, events }, handoff);
5790
5791
  }
5791
5792
  else {
5792
5793
  if (carried) {
@@ -5984,7 +5985,9 @@ export class Manager {
5984
5985
  // canonicalJson (undefined never coerces to null, SPEC 13.6), so a role-less spawn would
5985
5986
  // otherwise fail its succeeded terminal. The CLI/connector already render an absent role as
5986
5987
  // "no role", so dropping the key preserves the reply (surfaced by readiness:live).
5987
- const okData = { name, agent, id: managed.id, mode: handle.kind, lifecycleUid, ...(role !== undefined ? { role } : {}), ...(opts.eventsNotice !== undefined ? { eventsNotice: opts.eventsNotice } : {}) };
5988
+ // A host that granted other channels than this spawn asked for: the receipt carries both.
5989
+ const acl = enrolled ? enrolledAcl({ allowSubscribe, allowPublish }, enrolled.material) : undefined;
5990
+ const okData = { name, agent, id: managed.id, mode: handle.kind, lifecycleUid, ...(role !== undefined ? { role } : {}), ...(opts.eventsNotice !== undefined ? { eventsNotice: opts.eventsNotice } : {}), ...(acl ? { acl } : {}) };
5988
5991
  await hooks?.onOutcome?.({ kind: "succeeded", data: okData });
5989
5992
  return { ok: true, data: okData };
5990
5993
  }
@@ -6822,7 +6825,7 @@ export class Manager {
6822
6825
  const uncertain = () => finish({
6823
6826
  ok: false,
6824
6827
  uncertain: true,
6825
- detail: `${a.name} (${a.id}): launch status uncertain - no process exit and no mesh presence within ${Math.round(readinessTimeoutMs / 1000)}s; it may still be booting or stuck before connector startup. Inspect with \`cotal attach ${a.name}\` / \`cotal ps\`; do not stop it solely because this bounded wait elapsed.`,
6828
+ detail: `${a.name} (${a.id}): launch status uncertain - no process exit and no mesh presence within ${readinessTimeoutMs}ms; it may still be booting or stuck before connector startup. Inspect with \`cotal attach ${a.name}\` / \`cotal ps\`; do not stop it solely because this bounded wait elapsed.`,
6826
6829
  });
6827
6830
  // The exit poll read the runtime up to a poll ago, and a window can be shorter than a poll, so
6828
6831
  // the window closes on a fresh read: a child that exited by then is reported as exited. Nothing
@@ -8194,6 +8197,18 @@ export class Manager {
8194
8197
  if (observed.processEpoch !== epoch)
8195
8198
  throw new EpEnvelopeError("expired", `the manager's issuance gate epoch is ${observed.processEpoch} but this goal was accepted under epoch ${epoch}; a superseded incarnation never commits a goal terminal (SPEC 13.6)`);
8196
8199
  }
8200
+ /** A spawn's refusal reaches only its caller's terminal, so the manager also writes one line for it
8201
+ * to its own log, naming the request ID the caller printed, the persona and the reason. */
8202
+ async loggedSpawnRefusal(ctx, serve) {
8203
+ try {
8204
+ return await serve();
8205
+ }
8206
+ catch (e) {
8207
+ const persona = ctx.request.args?.name;
8208
+ console.error(oneLine(`! spawn request ${ctx.request.id} for persona "${String(persona ?? "")}" refused (${e instanceof EpEnvelopeError ? e.code : "error"}): ${rejectionText(e)}`));
8209
+ throw e;
8210
+ }
8211
+ }
8197
8212
  /** Serve `spawn`/`launch` as an ACTION. Authz already ran in {@link serveGated}. The
8198
8213
  * accept path runs INLINE on the handler ({@link startAgent} with hooks): the goal binds + the
8199
8214
  * acceptance replies the moment the identity is minted, BEFORE any provision, or on the
@@ -8261,6 +8276,8 @@ export class Manager {
8261
8276
  let ownsGoal = false;
8262
8277
  // Set by onEffect. A refusal that arrives with it still false changed nothing.
8263
8278
  let effectStarted = false;
8279
+ // Set by onUndone: the host's code for a later refusal that left nothing behind.
8280
+ let undone;
8264
8281
  // The terminal commits OFF-handler on the goal-writer connection (manager-only authority; a
8265
8282
  // caller cannot publish it). TWO COMPOSED FENCES (defense in depth): (a) reads THIS
8266
8283
  // incarnation's OWN gate epoch and REFUSES a superseded commit (the currency belt), and (b)
@@ -8381,14 +8398,16 @@ export class Manager {
8381
8398
  // `failed` that would race that `cancel` (first-terminal-fact-wins).
8382
8399
  onTerminalDeferred: () => { terminalEntered = true; },
8383
8400
  onEffect: () => { effectStarted = true; },
8401
+ onUndone: (code) => { undone = code; },
8384
8402
  });
8385
8403
  bg.then((reply) => {
8386
8404
  // Refused BEFORE onAccepted (hard-pin collision, capacity, persona-not-found) — no goal bound.
8387
- // Only a refusal before the first effect states `not-executed`; a later one, such as the
8388
- // alias gate that re-drives a teardown, leaves the outcome unknown.
8405
+ // Only a refusal before the first effect, or one whose effects were all removed, states
8406
+ // `not-executed`; a later one, such as the alias gate that re-drives a teardown, leaves the
8407
+ // outcome unknown.
8389
8408
  if (acceptance === undefined) {
8390
8409
  const details = reply.details;
8391
- rejectAccept(new EpEnvelopeError("failed-precondition", reply.error ?? "spawn refused at accept", details, effectStarted ? undefined : "not-executed"));
8410
+ rejectAccept(new EpEnvelopeError(undone ?? "failed-precondition", reply.error ?? "spawn refused at accept", details, effectStarted && undone === undefined ? undefined : "not-executed"));
8392
8411
  return;
8393
8412
  }
8394
8413
  // H1: `run` CATCHES its own body (a throw in buildLaunch/runtime.spawn) and RESOLVES `{ok:false}`
@@ -8506,7 +8525,7 @@ export class Manager {
8506
8525
  const deadlineMs = deadlineAt - acceptedAt;
8507
8526
  const handoffFrom = raw.handoffFrom === undefined ? undefined : String(raw.handoffFrom);
8508
8527
  const holdToken = turnHoldToken(key);
8509
- const note = JSON.stringify({ payload, deadlineAt, holdEpoch: executor.epoch, owner: t.owner, holdToken, ...(handoffFrom !== undefined ? { handoffFrom } : {}) });
8528
+ const note = JSON.stringify({ payload, deadlineAt, holdEpoch: executor.epoch, holdToken, ...(handoffFrom !== undefined ? { handoffFrom } : {}) });
8510
8529
  // A goal that already ENDED is never accepted again, whatever the retry carries: the bind is
8511
8530
  // create-only and outlives an unwind (the failed terminal, the cleared index), so a retry of an
8512
8531
  // unwound accept would otherwise re-record an index entry over a tombstone and, before that
@@ -8518,7 +8537,7 @@ export class Manager {
8518
8537
  throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" already ended ${ended.state}; a bound goal is never accepted again (SPEC 13.6)`);
8519
8538
  // Index-CAS-before-bind, exactly as spawn: the entry (floor + note) is the durable relay
8520
8539
  // record a successor rebuilds from, so it must exist before the acceptance is servable.
8521
- const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name: a.name, actor: t.actor, uid: t.lifecycleUid, readinessDeadlineMs: deadlineMs }, note);
8540
+ const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name: a.name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid, readinessDeadlineMs: deadlineMs }, note);
8522
8541
  if (!idx.recorded && idx.existing.iid !== executor.lifecycleUid)
8523
8542
  throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted by instance "${idx.existing.iid}"; that instance owns its relay and this attempt provisions nothing (SPEC 13.6)`);
8524
8543
  const b = await bindGoal(gw.ctx, ref, fingerprint);
@@ -8917,8 +8936,8 @@ export class Manager {
8917
8936
  * entry carrying a parseable note rebuilds its pending relay — never an `uncertain` settle,
8918
8937
  * because the deadline hold IS this goal's bounded ending and it survived the restart. No
8919
8938
  * liveness verdict is made here (the agents map is empty during reconcile, so absence means
8920
- * "not yet re-registered", never "dead"); a garbled note or a missing floor is logged and
8921
- * left, the dead-pointer honesty rule. */
8939
+ * "not yet re-registered", never "dead"); a garbled note or a floor that does not name the
8940
+ * seat's owner is logged and left, the dead-pointer honesty rule. */
8922
8941
  async adoptTurnGoal(entry) {
8923
8942
  const gw = this.goalWriter;
8924
8943
  if (!gw)
@@ -8935,8 +8954,8 @@ export class Manager {
8935
8954
  return;
8936
8955
  }
8937
8956
  const parsed = entry.note !== undefined ? parseTurnNote(entry.note) : undefined;
8938
- if (entry.allocated === undefined || parsed === undefined) {
8939
- console.error(`turn reconcile ${entry.ref.goalId}: the relay record is garbled (no floor, or an unparseable note); left unsettled`);
8957
+ if (entry.allocated?.owner === undefined || parsed === undefined) {
8958
+ console.error(`turn reconcile ${entry.ref.goalId}: the relay record is garbled (no floor naming the seat's owner, or an unparseable note); left unsettled`);
8940
8959
  return;
8941
8960
  }
8942
8961
  const spec = await readGoalSpec(gw.ctx, entry.ref);
@@ -8951,7 +8970,7 @@ export class Manager {
8951
8970
  }
8952
8971
  const p = {
8953
8972
  ref: entry.ref, goalId: entry.ref.goalId,
8954
- seat: { name: entry.allocated.name, owner: parsed.owner, actor: entry.allocated.actor, uid: entry.allocated.uid },
8973
+ seat: { name: entry.allocated.name, owner: entry.allocated.owner, actor: entry.allocated.actor, uid: entry.allocated.uid },
8955
8974
  payload: parsed.payload,
8956
8975
  acceptedAt: spec.value.acceptedAt,
8957
8976
  deadlineAt: parsed.deadlineAt,
@@ -9307,8 +9326,9 @@ export class Manager {
9307
9326
  * wire principal joins {@link retiredPrincipals} (the F5 refusal index) and the name hold
9308
9327
  * clears (ABA-guarded by uid). A PRE-UNIT-B lifecycle (no slot row — spawned before the
9309
9328
  * durable registry existed) has nothing to terminalize: its footprint teardown runs directly
9310
- * and the hold clears, the honest upgrade path. */
9311
- async driveStaticRetirement(a, surfaceFailure = false) {
9329
+ * and the hold clears, the honest upgrade path. `onDeprovision` receives the accounting of the
9330
+ * broker teardown this call ran, on success or on a {@link DeprovisionError}. */
9331
+ async driveStaticRetirement(a, surfaceFailure = false, onDeprovision) {
9312
9332
  // A renewal that published its flight before the synchronous terminal latch is accepted work.
9313
9333
  // Drain it before the durable terminal enumerates credential ids and before cleanup deletes its
9314
9334
  // material; a failed renewal must not block retirement because it may still have staged an id.
@@ -9340,7 +9360,17 @@ export class Manager {
9340
9360
  await attemptCleanup(failed, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
9341
9361
  await attemptCleanup(failed, `file ${creds}`, () => rmSync(creds, { force: true }));
9342
9362
  }
9343
- await attemptCleanup(failed, "broker teardown", () => this.deprovisionBroker(a));
9363
+ await attemptCleanup(failed, "broker teardown", async () => {
9364
+ try {
9365
+ const accounting = await this.deprovisionBroker(a);
9366
+ onDeprovision?.(accounting);
9367
+ }
9368
+ catch (e) {
9369
+ if (e instanceof DeprovisionError)
9370
+ onDeprovision?.(e.accounting);
9371
+ throw e;
9372
+ }
9373
+ });
9344
9374
  if (failed.length)
9345
9375
  throw new Error(`cleanup failed: ${failed.join("; ")}`);
9346
9376
  };
@@ -9799,12 +9829,12 @@ export class Manager {
9799
9829
  if (item.flight)
9800
9830
  return item.flight;
9801
9831
  if (item.timer)
9802
- return Promise.resolve(false);
9832
+ return Promise.resolve({ succeeded: false });
9803
9833
  // A timer/retry driver increments before its mandatory durable re-read, then calls with
9804
9834
  // countAttempt=false. Let that already-consumed budget entry enter the terminal. Only a fresh
9805
9835
  // sweep entry (countAttempt=true) is refused once the per-process budget is exhausted.
9806
9836
  if (countAttempt && item.attempts >= item.maxAttempts)
9807
- return Promise.resolve(false);
9837
+ return Promise.resolve({ succeeded: false });
9808
9838
  const flight = (async () => {
9809
9839
  if (countAttempt)
9810
9840
  item.attempts++;
@@ -9813,8 +9843,9 @@ export class Manager {
9813
9843
  item.nextRetryAt = undefined;
9814
9844
  this.reconcilingAliases.add(row.alias);
9815
9845
  console.error(`static reconcile terminal alias=${row.alias} phase=${row.phase} uid=${row.lifecycleUid} attempt=${item.attempts}/${item.maxAttempts}`);
9846
+ let deprovision;
9816
9847
  try {
9817
- await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid, runtime: row.runtime }, true);
9848
+ await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid, runtime: row.runtime }, true, (accounting) => { deprovision = accounting; });
9818
9849
  if (item.attempts === 1) {
9819
9850
  this.staticReconcileItems.delete(key);
9820
9851
  }
@@ -9825,7 +9856,7 @@ export class Manager {
9825
9856
  item.lastError = undefined;
9826
9857
  console.error(`✓ static reconcile recovered alias=${item.alias} phase=retired uid=${item.lifecycleUid} attempts=${item.attempts}/${item.maxAttempts}`);
9827
9858
  }
9828
- return true;
9859
+ return { succeeded: true, deprovision };
9829
9860
  }
9830
9861
  catch (error) {
9831
9862
  item.lastError = rejectionText(error);
@@ -9856,7 +9887,7 @@ export class Manager {
9856
9887
  else
9857
9888
  this.scheduleStaticReconcileRetry(key, item);
9858
9889
  }
9859
- return false;
9890
+ return { succeeded: false, deprovision };
9860
9891
  }
9861
9892
  finally {
9862
9893
  this.reconcilingAliases.delete(row.alias);
@@ -10058,34 +10089,10 @@ export class Manager {
10058
10089
  };
10059
10090
  this.staticReconcileItems.set(key, item);
10060
10091
  }
10061
- this.lastDeprovisionResources = undefined;
10062
- const succeeded = await this.attemptStaticReconcile(key, item, row);
10063
- const acc = this.lastDeprovisionResources;
10064
- if (acc) {
10065
- sweepDeprovision.examined += acc.examined;
10066
- sweepDeprovision.deleted = sweepDeprovision.deleted === null || acc.deleted === null ? null : sweepDeprovision.deleted + acc.deleted;
10067
- sweepDeprovision.absent += acc.absent;
10068
- sweepDeprovision.refused += acc.refused;
10069
- sweepDeprovision.acknowledged += acc.acknowledged;
10070
- sweepDeprovision.disappeared += acc.disappeared;
10071
- sweepDeprovision.consumers.examined += acc.consumers.examined;
10072
- sweepDeprovision.consumers.deleted = sweepDeprovision.consumers.deleted === null || acc.consumers.deleted === null ? null : sweepDeprovision.consumers.deleted + acc.consumers.deleted;
10073
- sweepDeprovision.consumers.absent += acc.consumers.absent;
10074
- sweepDeprovision.consumers.refused += acc.consumers.refused;
10075
- sweepDeprovision.consumers.acknowledged += acc.consumers.acknowledged;
10076
- sweepDeprovision.consumers.disappeared += acc.consumers.disappeared;
10077
- sweepDeprovision.acls.examined += acc.acls.examined;
10078
- sweepDeprovision.acls.deleted += acc.acls.deleted;
10079
- sweepDeprovision.acls.absent += acc.acls.absent;
10080
- sweepDeprovision.acls.refused += acc.acls.refused;
10081
- sweepDeprovision.acls.disappeared += acc.acls.disappeared;
10082
- sweepDeprovision.members.examined += acc.members.examined;
10083
- sweepDeprovision.members.deleted += acc.members.deleted;
10084
- sweepDeprovision.members.absent += acc.members.absent;
10085
- sweepDeprovision.members.refused += acc.members.refused;
10086
- sweepDeprovision.members.disappeared += acc.members.disappeared;
10087
- }
10088
- if (succeeded)
10092
+ const attempt = await this.attemptStaticReconcile(key, item, row);
10093
+ if (attempt.deprovision)
10094
+ addDeprovisionAccounting(sweepDeprovision, attempt.deprovision);
10095
+ if (attempt.succeeded)
10089
10096
  sweep.succeeded++;
10090
10097
  else
10091
10098
  sweep.failed++;