@cotal-ai/manager 0.70.0 → 0.71.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
@@ -1,11 +1,10 @@
1
- import { execFile } from "node:child_process";
2
1
  import { createHash, randomUUID, randomBytes } from "node:crypto";
3
2
  import { hostname } from "node:os";
4
3
  import { credsAuthenticator } from "@nats-io/transport-node";
5
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
5
  import { isAbsolute, join, dirname, resolve } from "node:path";
7
6
  import Headless from "@xterm/headless";
8
- import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, 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, bearerCommandFailure, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
7
+ import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, 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, } from "@cotal-ai/core";
9
8
  import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
10
9
  import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
11
10
  import { AttachEndpoint } from "./attach-endpoint.js";
@@ -31,7 +30,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
31
30
  // P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
32
31
  // endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
33
32
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
34
- import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, 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
+ import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, requestGoalCancel, goalAlreadyTerminal, 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";
35
34
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
36
35
  import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
37
36
  /** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
@@ -148,19 +147,6 @@ async function attemptCleanup(failed, what, step) {
148
147
  failed.push(`${what}: ${rejectionText(err)}`);
149
148
  }
150
149
  }
151
- /** Run the agent's bearer argv once, pre-launch — the end-to-end auth preflight (state dir, daemon,
152
- * ledger row, secret). Its stderr is the provider command's operator-exact sentence; surface it
153
- * verbatim as the spawn refusal. */
154
- function execBearerPreflight(argv) {
155
- const timeout = 30_000;
156
- return new Promise((res, rej) => {
157
- execFile(argv[0], argv.slice(1), { timeout, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
158
- if (err)
159
- return rej(bearerCommandFailure(err, stderr, timeout));
160
- res();
161
- });
162
- });
163
- }
164
150
  /** Reject `p` with `Error(msg)` if it hasn't settled within `ms`; clears the timer when `p` settles so it
165
151
  * never keeps the loop alive. Used to bound the detached deprovision so its fail-loud log is guaranteed. */
166
152
  function withTimeout(p, ms, msg) {
@@ -173,6 +159,18 @@ function withTimeout(p, ms, msg) {
173
159
  function sameStrings(a, b) {
174
160
  return JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
175
161
  }
162
+ /** The caller keeps the arrays it passed, so a launch form is admitted with its own copies: a later
163
+ * write to the caller's array cannot change what the manager grants, shares or preserves. */
164
+ function ownLists(form) {
165
+ return {
166
+ ...form,
167
+ ...(form.subscribe && { subscribe: [...form.subscribe] }),
168
+ ...(form.allowSubscribe && { allowSubscribe: [...form.allowSubscribe] }),
169
+ ...(form.allowPublish && { allowPublish: [...form.allowPublish] }),
170
+ ...(form.capabilities && { capabilities: [...form.capabilities] }),
171
+ ...(form.shareTools && { shareTools: [...form.shareTools] }),
172
+ };
173
+ }
176
174
  /** Operator-facing phrasing per cause. Kept beside the union so adding a member without a sentence
177
175
  * is a type error rather than a blank in the log. The stop family's sentences carry the principal
178
176
  * from the cause, so they render through {@link STOP_CAUSE_TEXT} under the same exhaustive-Record
@@ -252,6 +250,20 @@ function foreignEventChannels(channels, owner, actor) {
252
250
  return p !== null && !(p.owner === owner && p.actor === actor);
253
251
  });
254
252
  }
253
+ /** One connector's declared harness binaries resolved on PATH now. The refusal sentence is built only
254
+ * here, so the boot row and the check for a connector registered after boot can never word it apart. */
255
+ function resolveHarness(name, requires) {
256
+ const binaries = {};
257
+ const missing = [];
258
+ for (const bin of requires) {
259
+ const path = resolveOnPath(bin);
260
+ if (path)
261
+ binaries[bin] = path;
262
+ else
263
+ missing.push(bin);
264
+ }
265
+ return missing.length ? { binaries, reason: `${name} harness needs ${missing.join(", ")} on PATH - not found` } : { binaries };
266
+ }
255
267
  function injectedManagerStoreIdentity(store) {
256
268
  if (store.identity !== undefined)
257
269
  return parseSecretStoreIdentity(store.identity);
@@ -2875,6 +2887,9 @@ export class Manager {
2875
2887
  // turns addressed to ITS incarnation and yields them; no reach beyond itself exists here.
2876
2888
  turnPending: (ctx) => this.serveGated(ctx, () => this.turnPendingFor(ctx.subject.caller)),
2877
2889
  turnYield: (ctx) => this.serveGated(ctx, () => this.serveTurnYield(ctx.subject.caller, args(ctx))),
2890
+ // The reserved goal cancel (SPEC 13.6 item 4), served for the relay: the goal ref is the
2891
+ // authenticated caller's own, so a run withdraws only the turns it submitted.
2892
+ cancel: (ctx) => this.serveGated(ctx, () => this.serveTurnCancel(ctx)),
2878
2893
  stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
2879
2894
  definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
2880
2895
  listPersonas: (ctx) => this.serveGated(ctx, () => unwrap(this.opListPersonas(callerOf(ctx), false))),
@@ -3248,7 +3263,9 @@ export class Manager {
3248
3263
  "--token-file", tokenPath,
3249
3264
  "--health-file", healthPath,
3250
3265
  ];
3251
- await execBearerPreflight(bearerCmd);
3266
+ // One bearer run before the launch is the end-to-end auth preflight (state dir, daemon, ledger
3267
+ // row, secret); its failure sentence is the spawn refusal.
3268
+ await runAgentBearer(bearerCmd);
3252
3269
  return { owner, lifecycleUid: opts.lifecycleUid, files, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
3253
3270
  }
3254
3271
  catch (e) {
@@ -3350,7 +3367,7 @@ export class Manager {
3350
3367
  "--token-file", files.actorToken,
3351
3368
  "--health-file", files.health,
3352
3369
  ];
3353
- await execBearerPreflight(bearerCmd);
3370
+ await runAgentBearer(bearerCmd);
3354
3371
  return {
3355
3372
  owner: material.owner,
3356
3373
  lifecycleUid: material.lifecycleUid,
@@ -4330,6 +4347,8 @@ export class Manager {
4330
4347
  // but a raw control message could otherwise slip an empty value through and silently start fresh.
4331
4348
  if (args.resume !== undefined && !String(args.resume).trim())
4332
4349
  return Promise.resolve({ ok: false, error: "resume: session id must not be empty" });
4350
+ if (args.model !== undefined && !String(args.model).trim())
4351
+ return Promise.resolve({ ok: false, error: "model: must not be empty" });
4333
4352
  if (args.variant !== undefined && !String(args.variant).trim())
4334
4353
  return Promise.resolve({ ok: false, error: "variant: must not be empty" });
4335
4354
  if (args.defaultAgent !== undefined && !String(args.defaultAgent).trim())
@@ -4496,19 +4515,9 @@ export class Manager {
4496
4515
  else {
4497
4516
  declared = registry.all("connector").map((connector) => ({ name: connector.name, requires: connector.requires ?? [] }));
4498
4517
  }
4499
- for (const connector of declared) {
4500
- const name = connector.name;
4501
- const binaries = {};
4502
- const missing = [];
4503
- for (const bin of connector.requires) {
4504
- const path = resolveOnPath(bin);
4505
- if (path)
4506
- binaries[bin] = path;
4507
- else
4508
- missing.push(bin);
4509
- }
4510
- if (missing.length) {
4511
- const reason = `${name} harness needs ${missing.join(", ")} on PATH - not found`;
4518
+ for (const { name, requires } of declared) {
4519
+ const { binaries, reason } = resolveHarness(name, requires);
4520
+ if (reason) {
4512
4521
  rows.push({ agent: name, state: "unavailable", binaries, reason });
4513
4522
  console.error(`! manager boot: connector ${name} unavailable - ${reason}`);
4514
4523
  }
@@ -4540,18 +4549,17 @@ export class Manager {
4540
4549
  return `${reason}. This manager cannot launch that harness. The space may have other managers; pin one with --on <instance> (the whole id, as ps prints it)`;
4541
4550
  }
4542
4551
  /** The harness binaries a managed launch runs, as boot resolved them, or the refusal naming what is
4543
- * missing. Spawn and resume both take it, so a seat launches from the same files whichever path
4544
- * starts it. A connector registered after boot has no inventory row and is checked on PATH now. */
4552
+ * missing. Spawn, resume and the model catalog all take it, so a seat launches from the same files
4553
+ * whichever path starts it and the catalog never disagrees with a launch about whether the harness
4554
+ * is there. A connector registered after boot has no inventory row and is checked on PATH now. */
4545
4555
  launchHarness(connector) {
4546
4556
  const row = this.connectorStatuses.find((status) => status.agent === connector.name);
4547
4557
  if (row?.state === "unavailable")
4548
4558
  return { refusal: row.reason ?? `${connector.name} harness is unavailable` };
4549
4559
  if (row)
4550
4560
  return { binaries: row.binaries };
4551
- const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
4552
- if (missing.length)
4553
- return { refusal: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found` };
4554
- return {};
4561
+ const { reason } = resolveHarness(connector.name, connector.requires ?? []);
4562
+ return reason ? { refusal: reason } : {};
4555
4563
  }
4556
4564
  /** The refusal for a launch choice the connector cannot honour. Spawn and resume both ask before
4557
4565
  * any reserve or mint, so a rule changed here holds on both paths. */
@@ -4573,14 +4581,9 @@ export class Manager {
4573
4581
  const one = async (connector) => {
4574
4582
  if (!connector.listModels)
4575
4583
  return { agent: connector.name, supported: false, models: [] };
4576
- const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
4577
- if (missing.length)
4578
- return {
4579
- agent: connector.name,
4580
- supported: true,
4581
- models: [],
4582
- error: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found`,
4583
- };
4584
+ const harness = this.launchHarness(connector);
4585
+ if ("refusal" in harness)
4586
+ return { agent: connector.name, supported: true, models: [], error: harness.refusal };
4584
4587
  try {
4585
4588
  const catalog = await connector.listModels({ refresh });
4586
4589
  return { agent: connector.name, supported: true, ...catalog };
@@ -4729,9 +4732,10 @@ export class Manager {
4729
4732
  }
4730
4733
  }
4731
4734
  async startAgentActive(opts, spawner, hooks) {
4732
- // The caller keeps its array, so the selection is copied before the first await: a later write
4733
- // to it cannot change what this launch shares or what preservation retains.
4734
- const shareTools = opts.shareTools && [...opts.shareTools];
4735
+ // Before the first await, so the grant, the launch and the retained form all read the lists as admitted.
4736
+ opts = ownLists(opts);
4737
+ if (opts.resolved)
4738
+ opts.resolved = ownLists(opts.resolved);
4735
4739
  if (opts.delegatedIntent) {
4736
4740
  if (!this.remoteAuthority?.executeDelegatedUserIntent)
4737
4741
  return { ok: false, error: `"${opts.name}" names a delegated user intent, and this manager has no host execution for one` };
@@ -4869,7 +4873,7 @@ export class Manager {
4869
4873
  return { ok: false, error: `${delegatedBy}, so it cannot reopen a session held on this host (continuity: exact)` };
4870
4874
  if (typeof opts.cwd === "string" && opts.cwd !== "")
4871
4875
  return { ok: false, error: `${delegatedBy}, so it cannot run in a directory on this host's filesystem (cwd); the seat runs in its runtime resource's own directory` };
4872
- const shared = Object.keys(connectorServers(loadCotalConfig(this.workspaceRoot), agent, shareTools));
4876
+ const shared = Object.keys(connectorServers(loadCotalConfig(this.workspaceRoot), agent, opts.shareTools));
4873
4877
  if (shared.length)
4874
4878
  return { ok: false, error: `${delegatedBy}, so it cannot share MCP servers that run on this host (${shared.join(", ")}); pass --share-tools none` };
4875
4879
  }
@@ -4905,7 +4909,7 @@ export class Manager {
4905
4909
  if (opts.resolved) {
4906
4910
  // A manifest launch is the access + identity authority: imperative overrides arriving
4907
4911
  // alongside `resolved` are a caller contract error, not something to merge (no fallbacks).
4908
- if (opts.subscribe || opts.allowSubscribe || opts.allowPublish || opts.prompt || shareTools || opts.identity)
4912
+ if (opts.subscribe || opts.allowSubscribe || opts.allowPublish || opts.prompt || opts.shareTools || opts.identity)
4909
4913
  return { ok: false, error: "a manifest launch (resolved) rejects imperative overrides (identity/subscribe/allow*/prompt/shareTools)" };
4910
4914
  const r = opts.resolved;
4911
4915
  identityName = r.name;
@@ -4952,12 +4956,12 @@ export class Manager {
4952
4956
  catch (e) {
4953
4957
  return { ok: false, error: opts.resolved ? `launch agent: ${e.message}` : `persona ${configPath}: ${e.message}` };
4954
4958
  }
4955
- // #651: an empty or whitespace-only model string is not a pin. Coerce it to undefined here, at
4956
- // the single point every path (persona, manifest, imperative) has resolved `model`, so it
4957
- // serializes ABSENT rather than present-but-empty (`"model": ""`), which a key-presence consumer
4958
- // would misread as "a pin was recorded".
4959
- if (model !== undefined && model.trim() === "")
4960
- model = undefined;
4959
+ // Checked after every path has resolved its selectors: a direct `startAgent` call skips the
4960
+ // `start` op's checks, and no connector may receive a blank one (#2862).
4961
+ if (model !== undefined && !model.trim())
4962
+ return { ok: false, error: "model: must not be empty" };
4963
+ if (variant !== undefined && !variant.trim())
4964
+ return { ok: false, error: "variant: must not be empty" };
4961
4965
  const idErr = this.nameError(identityName);
4962
4966
  if (idErr)
4963
4967
  return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${configPath}: ${idErr}` };
@@ -5294,7 +5298,7 @@ export class Manager {
5294
5298
  // (cotal config; default none → isolated, the memory-safe default this guards), narrowed by
5295
5299
  // an optional --share-tools selection (absent → all declared, the pre-merge behavior).
5296
5300
  const cotalConfig = loadCotalConfig(this.workspaceRoot);
5297
- const mcpServers = connectorServers(cotalConfig, agent, shareTools);
5301
+ const mcpServers = connectorServers(cotalConfig, agent, opts.shareTools);
5298
5302
  // The operator's spawn-env policy travels the same route: absent means no extras (the OS
5299
5303
  // allow-list + operator knobs + connector-declared inputs), present means those names too.
5300
5304
  // A connector never reads the config itself.
@@ -5421,7 +5425,7 @@ export class Manager {
5421
5425
  allowPublish,
5422
5426
  capabilities,
5423
5427
  events,
5424
- shareTools,
5428
+ shareTools: opts.shareTools,
5425
5429
  forkSource: opts.resume,
5426
5430
  ...(opts.resume !== undefined && spec?.resumeRecordPath ? { resumeRecordPath: spec.resumeRecordPath } : {}),
5427
5431
  ...(carried ? { carried: { transcriptSha256: carried.sha256, host: carried.sourceHost, transferredAt: carried.stagedAt } } : {}),
@@ -5598,9 +5602,9 @@ export class Manager {
5598
5602
  return { ok: false, agents: [], error: `unsupported manager resume inventory version ${String(inventory.version)}` };
5599
5603
  if (inventory.space !== this.space)
5600
5604
  return { ok: false, agents: [], error: `resume inventory belongs to space "${inventory.space}", not "${this.space}"` };
5601
- // The caller keeps its inventory, so each selection is copied before the first await: a later
5602
- // write to it cannot change what a resumed seat shares or what the next preservation retains.
5603
- const entries = inventory.agents.map((entry) => ({ ...entry, launch: { ...entry.launch, shareTools: entry.launch.shareTools && [...entry.launch.shareTools] } }));
5605
+ // The caller keeps its inventory, so it is copied before the first await: a later write to it
5606
+ // cannot change what a resumed seat launches with or what the next preservation retains.
5607
+ const entries = structuredClone(inventory.agents);
5604
5608
  const seen = new Set();
5605
5609
  const principals = new Set();
5606
5610
  const orphans = [];
@@ -8176,6 +8180,58 @@ export class Manager {
8176
8180
  this.maybeStopTurnSweep();
8177
8181
  return { goalId, state: fact.state };
8178
8182
  }
8183
+ /** The caller withdraws a turn it submitted (the reserved `cancel`, SPEC 13.6 item 4), so the
8184
+ * seat is not shown it again and a late yield is answered from the cancelled terminal. Only a
8185
+ * relay this incarnation holds is moved to `cancelling`, because only its holder can commit the
8186
+ * terminal; an ended goal is refused with its cached outcome, which keeps a repeated cancel
8187
+ * idempotent. The pending entry is the latch the yield and the deadline sweep race on, taken
8188
+ * before the first await so a concurrent cancel finds nothing to take. A failed cancel puts it
8189
+ * back so the relay still ends at its deadline, unless something ended the relay while it was
8190
+ * held: a yield that read the entry first, or this commit with only its reply lost. An entry
8191
+ * whose outcome cannot be read back stays out and converges through the goal index at the
8192
+ * next boot, as a failed deadline commit does. */
8193
+ async serveTurnCancel(ctx) {
8194
+ if (!this.goalReconcileDone)
8195
+ throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; the pending-turn index is not rebuilt yet, retry (SPEC 13.6)");
8196
+ const gw = this.goalWriter;
8197
+ if (!gw)
8198
+ throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing (SPEC 13.6)");
8199
+ const raw = (ctx.request.args ?? {});
8200
+ const goalId = String(raw.goalId);
8201
+ const ref = goalRefOf(ctx.subject, goalId);
8202
+ const p = this.pendingTurns.get(goalId);
8203
+ const c = p?.ref.caller;
8204
+ if (p === undefined || c?.owner !== ref.caller.owner || c.actor !== ref.caller.actor || c.uid !== ref.caller.uid) {
8205
+ const ended = await readGoalResult(gw.ctx, ref);
8206
+ if (ended !== undefined)
8207
+ throw goalAlreadyTerminal(goalId, ended);
8208
+ throw new EpEnvelopeError("failed-precondition", `no turn "${goalId}" of this caller is pending on this manager; cancel withdraws a relayed turn (SPEC 13.6)`);
8209
+ }
8210
+ this.pendingTurns.delete(goalId);
8211
+ const epoch = this.serviceServe?.grant.epoch ?? 0;
8212
+ let fact;
8213
+ try {
8214
+ await requestGoalCancel(gw.ctx, { request: ctx.subject, goalId, mode: (raw.mode ?? "graceful") });
8215
+ await this.assertGoalWriterEpochCurrent(epoch);
8216
+ ({ fact } = await commitGoalResult(gw.ctx, { ref, now: Date.now(), cause: "cancel", data: { cancelledBy: "caller" }, committer: { instanceId: this.managerInstanceId, epoch } }));
8217
+ }
8218
+ catch (e) {
8219
+ // The remembered answer is read after the durable one, with no await before the put back:
8220
+ // a yield that has dropped the entry remembered its answer, and one still committing drops
8221
+ // the entry it finds. An ended relay is stamped so its acceptance still ages off.
8222
+ if ((await readGoalResult(gw.ctx, ref)) !== undefined)
8223
+ this.markTurnLatchDropped(goalId);
8224
+ else if (this.turnAcceptances.get(goalId)?.settled === undefined)
8225
+ this.pendingTurns.set(goalId, p);
8226
+ throw e;
8227
+ }
8228
+ this.emitGoalProgress(ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
8229
+ this.markTurnLatchDropped(goalId);
8230
+ this.rememberSettledTurn(goalId, fact.state, Date.now());
8231
+ await clearGoalIndex(gw.ctx, ref);
8232
+ this.maybeStopTurnSweep();
8233
+ return { goalId, state: fact.state };
8234
+ }
8179
8235
  /** Commit the deadline terminal for one pending turn whose hold settled EXPIRED: the deny's
8180
8236
  * predicate is that recorded settle, verified by core against the spec's goal binding. The map
8181
8237
  * delete is the idempotency latch (yield-loss and sweep race here); a commit failure after it
@@ -8537,11 +8593,17 @@ export class Manager {
8537
8593
  console.error(`static retirement ${a.name}: retired ${retired} issuance(s) bound to uid ${a.lifecycleUid}`);
8538
8594
  const secrets = this.secrets;
8539
8595
  const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
8596
+ // The broker teardown mints its own credential, so a creds file that cannot be removed must not
8597
+ // skip it. The failures are thrown together, so the terminal still stops at an incomplete cleanup.
8598
+ const failed = [];
8540
8599
  if (files.creds) {
8541
- await secrets.delete(agentSecretKeyForFile(files.creds, this.space));
8542
- rmSync(files.creds, { force: true });
8600
+ const creds = files.creds;
8601
+ await attemptCleanup(failed, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
8602
+ await attemptCleanup(failed, `file ${creds}`, () => rmSync(creds, { force: true }));
8543
8603
  }
8544
- await this.deprovisionBroker(a);
8604
+ await attemptCleanup(failed, "broker teardown", () => this.deprovisionBroker(a));
8605
+ if (failed.length)
8606
+ throw new Error(`cleanup failed: ${failed.join("; ")}`);
8545
8607
  };
8546
8608
  // The process goes before its footprint: reap the orphan seat by reference, then tear down.
8547
8609
  const reapThenCleanup = async () => {