@cotal-ai/manager 0.65.0 → 0.67.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
@@ -2,10 +2,10 @@ import { execFile } from "node:child_process";
2
2
  import { createHash, randomUUID, randomBytes } from "node:crypto";
3
3
  import { hostname } from "node:os";
4
4
  import { credsAuthenticator } from "@nats-io/transport-node";
5
- import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
5
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
6
  import { isAbsolute, join, dirname, resolve } from "node:path";
7
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, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
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";
8
+ import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimManagerSiblingIdentities, 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";
11
11
  import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers } from "./endpoint-evict.js";
@@ -17,11 +17,12 @@ import { controlShutdown } from "./control-shutdown.js";
17
17
  import { RunHosting } from "./run-hosting.js";
18
18
  import { controlSession } from "./control-session.js";
19
19
  import { MAX_AGENTS, parseResumeCommitArgs, parseResumeControlArgs, parseResumeFinalizeArgs } from "./resume.js";
20
+ import { TranscriptReceiver } from "./transcript-receive.js";
20
21
  // Unit B (the static §13.1 lifecycle executor): the shared grammar/stores from core plus the
21
22
  // manager-side adapter (transport + slot orchestration + the F1 terminal) — see static-lifecycle.ts.
22
23
  import { jetstream, jetstreamManager } from "@nats-io/jetstream";
23
24
  import { Kvm } from "@nats-io/kv";
24
- import { recordsBucket, epAuthBucket, ensureAuthorityStores, ensureContractStore, createEndpointStreams, contractStoreContext, publishContractArtifact, contractArtifactCanonicalBytes, standaloneConnectOpts, STATIC_SLOT_PREFIX, rawDigest, STANDING_RENEWABLE_TTL_SEC as MANAGED_STATIC_TTL_SEC, newArtifactSigner, RotatingSigner, generationAnchor, sessionsBucket, SESSION_GRANT_MAX_TTL_MS, } from "@cotal-ai/core";
25
+ import { recordsBucket, epAuthBucket, ensureAuthorityStores, ensureContractStore, createEndpointStreams, contractStoreContext, publishContractArtifact, contractArtifactCanonicalBytes, standaloneConnectOpts, STATIC_SLOT_PREFIX, rawDigest, STANDING_RENEWABLE_TTL_SEC as MANAGED_STATIC_TTL_SEC, newArtifactSigner, RotatingSigner, generationAnchor, sessionsBucket, SESSION_GRANT_MAX_TTL_MS, REMOTE_MANAGER_IDENTITY_NAMES, } from "@cotal-ai/core";
25
26
  // P2 item 6: the manager's ONE §13.6 session plane — offer mint + one-use redeem + PTY-bridge
26
27
  // standup for `attach`, over a dedicated standing session-LEDGER connection (the byte rails ride
27
28
  // per-session credentials on their own short-lived connections).
@@ -29,7 +30,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
29
30
  // P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
30
31
  // endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
31
32
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
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
+ 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
34
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
34
35
  import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
35
36
  /** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
@@ -172,6 +173,8 @@ const FREE_SLOT_CAUSE_TEXT = {
172
173
  "supervise-recovery-failed": "this manager retired it after a supervised restart failed",
173
174
  "session-bind-failed": "this manager stopped it: its host session could not be bound at launch",
174
175
  "resume-session-rebind-failed": "this manager stopped it: its host session could not be rebound on resume",
176
+ "carried-fork-refused": "this manager stopped it: it recorded no fork of the carried transcript",
177
+ "resume-custody-unrecorded": "this manager stopped it: its custody reference could not be recorded on resume",
175
178
  };
176
179
  /** The stop family's sentences — the same job {@link FREE_SLOT_CAUSE_TEXT} does for the string
177
180
  * causes, beside it so both stay the single place a cause becomes a sentence. A requested stop's
@@ -467,6 +470,8 @@ export class Manager {
467
470
  /** The complete boot task. stop() joins it after fencing static reconciliation, so a registration
468
471
  * already past an earlier shutdown check cannot finish after stop() returns. */
469
472
  startTask;
473
+ /** This instance's side of a carried resume transcript (#1499); absent where it has no transfer reader. */
474
+ transcripts;
470
475
  /** The one stop this manager runs, with the agent policy it was asked for. A stop takes seats out
471
476
  * of `agents` before they have exited, so a second teardown would find none and report success
472
477
  * while the first still waits on a live seat. */
@@ -877,11 +882,11 @@ export class Manager {
877
882
  catch (e) {
878
883
  // Our OWN instance id already holds a live key ⇒ refuse. Anything else (e.g. a KV/JS error) is a
879
884
  // real failure to surface, not a silent "held" — keep the cause so it isn't misread as a conflict.
880
- const held = await this.ep.readManagerLease().catch(() => undefined);
885
+ const held = (await this.ep.readOwnManagerLease(this.managerInstanceId).catch(() => undefined))?.info;
881
886
  await this.ep.stop();
882
887
  await this.attach.stop();
883
888
  throw new Error(held
884
- ? `manager instance ${this.managerInstanceId} already serves space "${this.space}" from this workspace root (${held.runtime}, pid ${held.pid}, root ${held.root}) - stop it first before restarting the same instance`
889
+ ? `manager instance ${this.managerInstanceId} already serves space "${this.space}" from ${held.root === this.leaseInfo.root ? "this" : "another"} workspace root (${held.runtime}, pid ${held.pid}, root ${held.root}) - stop it first before restarting the same instance`
885
890
  : `could not acquire the manager lease for space "${this.space}": ${e.message}`);
886
891
  }
887
892
  this.leaseTimer = setInterval(() => { void this.renewLease(); }, MANAGER_LEASE_RENEW_MS);
@@ -1042,6 +1047,18 @@ export class Manager {
1042
1047
  });
1043
1048
  await this.runHosting.reconcile();
1044
1049
  }
1050
+ // A carried resume (#1499) needs this instance's transfer reader: minted from the space's signing
1051
+ // seed, issued by the host to a remote manager, or none on an open mesh.
1052
+ if (!this.remoteAuthority || this.remoteAuthority.mintTransferReader) {
1053
+ this.transcripts = new TranscriptReceiver({
1054
+ space: this.space,
1055
+ instanceId: this.managerInstanceId,
1056
+ workspaceRoot: this.workspaceRoot,
1057
+ withReader: (fn) => this.withTransferReader(fn),
1058
+ log: (line) => console.error(line),
1059
+ });
1060
+ await this.transcripts.start();
1061
+ }
1045
1062
  // Plane-3 (durable backstop) is NOT the manager's job — the manager only manages agent lifecycle.
1046
1063
  // The server-side delivery daemon hosts the fan-out writer + trusted reader, owns the durable
1047
1064
  // membership registry, and serves the runtime durable join/leave/list ops (on `ctl.delivery`). The
@@ -1483,10 +1500,9 @@ export class Manager {
1483
1500
  let candidate;
1484
1501
  try {
1485
1502
  candidate = await remote.renewStandingBundle(processEpoch);
1486
- const names = ["supervisor", "executor", "serve", "goalWriter", "sessionLedger"];
1487
- if (Object.keys(candidate).sort().join(",") !== [...names].sort().join(","))
1503
+ if (Object.keys(candidate).sort().join(",") !== [...REMOTE_MANAGER_IDENTITY_NAMES].sort().join(","))
1488
1504
  throw new Error("host returned an incomplete or widened standing credential family");
1489
- for (const name of names) {
1505
+ for (const name of REMOTE_MANAGER_IDENTITY_NAMES) {
1490
1506
  if (typeof candidate[name] !== "string" || inspectCredHealth(candidate[name]).state === "unreadable")
1491
1507
  throw new Error(`host returned an unreadable ${name} credential`);
1492
1508
  if (!remote.accountPublicKey || accountFromCreds(candidate[name]) !== remote.accountPublicKey)
@@ -1676,10 +1692,14 @@ export class Manager {
1676
1692
  });
1677
1693
  }
1678
1694
  // A later manager does not resume a delegated seat, and its close runs only inside its
1679
- // retirement, so a cut that holds one is refused before any child stops.
1680
- for (const a of this.agents.values())
1695
+ // retirement, so a cut that holds one is refused before any child stops. A carried resume seat's
1696
+ // private home is removed when its slot is freed, so a later manager would find nothing to fork.
1697
+ for (const a of this.agents.values()) {
1681
1698
  if (a.handedOff)
1682
1699
  failures.push({ name: a.name, id: a.id, error: "a delegated seat is not resumed by a later manager; stop it before preserving" });
1700
+ if (a.seatHome)
1701
+ failures.push({ name: a.name, id: a.id, error: "a carried resume seat's private home is not kept for a later manager; stop it before preserving" });
1702
+ }
1683
1703
  const unverifiedStops = this.unverifiedStops.filter((stopped) => {
1684
1704
  try {
1685
1705
  if (!stopped.authoritative && stopped.handle.status() === "exited")
@@ -2049,6 +2069,7 @@ export class Manager {
2049
2069
  }
2050
2070
  async stopOnce(withAgents) {
2051
2071
  this.leaseStopping = true;
2072
+ this.transcripts?.stop();
2052
2073
  this.staticReconcileStopping = true;
2053
2074
  const starting = this.startTask;
2054
2075
  for (const item of this.staticReconcileItems.values()) {
@@ -2554,8 +2575,13 @@ export class Manager {
2554
2575
  async epAdminReach(caller) {
2555
2576
  if (!this.userMode)
2556
2577
  return true;
2557
- if (this.remoteAuthority)
2558
- return this.remoteAuthority.authorizeAdmin({ owner: caller.owner, actor: caller.actor, lifecycleUid: caller.uid });
2578
+ if (this.remoteAuthority) {
2579
+ // A hosted run's own caller is derived from its id and holds no ledger row: it acts for the
2580
+ // caller its run was admitted for, so that caller's current grant decides, and a revoked
2581
+ // login demotes the run's next spawn.
2582
+ const asked = this.runHosting?.admittedCaller(caller) ?? caller;
2583
+ return this.remoteAuthority.authorizeAdmin({ owner: asked.owner, actor: asked.actor, lifecycleUid: asked.uid });
2584
+ }
2559
2585
  try {
2560
2586
  const scope = await resolveAuthProvider().actorScope({
2561
2587
  dir: userAuthStateDir(this.workspaceRoot, this.space),
@@ -2878,6 +2904,11 @@ export class Manager {
2878
2904
  preparePreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("preparePreservation", args(ctx)))),
2879
2905
  commitPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("commitPreservation", args(ctx)))),
2880
2906
  abortPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("abortPreservation", args(ctx)))),
2907
+ transcriptReceive: (ctx) => this.serveGated(ctx, () => adminGated(ctx, () => {
2908
+ if (!this.transcripts)
2909
+ throw new EpEnvelopeError("failed-precondition", "transcript-receive: this manager does not receive carried transcripts (its remote authority issues no transfer reader)");
2910
+ return this.transcripts.receive(args(ctx));
2911
+ })),
2881
2912
  });
2882
2913
  }
2883
2914
  async commitResumeActivation(attemptId) {
@@ -3459,6 +3490,8 @@ export class Manager {
3459
3490
  this.recordContinuityAtStop(a, "stop");
3460
3491
  if (a.restart?.sessionStatePath)
3461
3492
  rmSync(a.restart.sessionStatePath, { force: true });
3493
+ if (a.seatHome)
3494
+ rmSync(a.seatHome, { recursive: true, force: true });
3462
3495
  // P2 item 6 (pin 4): end any live §13.6 attach session bound to THIS incarnation with the honest
3463
3496
  // `target-despawn` reason. Fires once per agent on every free path (despawn / self-stop / reap /
3464
3497
  // exit) via the `agents` guard above; a no-op when no plane or no live session for the target.
@@ -3929,8 +3962,15 @@ export class Manager {
3929
3962
  * record cannot be read or does not name this seat's source, it is absent: `ps` then shows the
3930
3963
  * source id alone rather than failing the listing for every other seat. */
3931
3964
  forkProvenance(a) {
3932
- if (a.launch.resumed || !a.launch.resumeRecordPath || !a.launch.forkSource)
3965
+ // A carried seat's provenance is set only by refuseCarriedFork, once its record names the claim.
3966
+ if (a.launch.resumed || a.launch.carried)
3933
3967
  return a.launch.resumed;
3968
+ a.launch.resumed = this.readForkRecord(a);
3969
+ return a.launch.resumed;
3970
+ }
3971
+ readForkRecord(a) {
3972
+ if (!a.launch.resumeRecordPath || !a.launch.forkSource)
3973
+ return undefined;
3934
3974
  let record;
3935
3975
  try {
3936
3976
  record = JSON.parse(readFileSync(a.launch.resumeRecordPath, "utf8"));
@@ -3942,8 +3982,43 @@ export class Manager {
3942
3982
  if (r.source !== a.launch.forkSource || typeof r.transcriptSha256 !== "string" || !/^[a-f0-9]{64}$/.test(r.transcriptSha256) ||
3943
3983
  !(r.title === undefined || r.title === null || (typeof r.title === "string" && r.title.length <= 1024)))
3944
3984
  return undefined;
3945
- a.launch.resumed = { source: r.source, ...(typeof r.title === "string" && r.title ? { title: r.title } : {}), transcriptSha256: r.transcriptSha256 };
3946
- return a.launch.resumed;
3985
+ return { source: r.source, ...(typeof r.title === "string" && r.title ? { title: r.title } : {}), transcriptSha256: r.transcriptSha256 };
3986
+ }
3987
+ /** Design section 9: a carried seat must fork the bytes the operator carried. Wait for its fork
3988
+ * record and hold it to the claim; on a match it becomes the seat's provenance, otherwise the
3989
+ * refusal is returned. A seat that writes no record cannot be shown to have forked the claim. */
3990
+ async refuseCarriedFork(a, timeoutMs) {
3991
+ const claim = a.launch.carried;
3992
+ const deadline = Date.now() + timeoutMs;
3993
+ let record;
3994
+ while (!(record = this.readForkRecord(a))) {
3995
+ if (a.terminalizing || a.handle.status() === "exited")
3996
+ return `${a.name} stopped before it wrote a fork record`;
3997
+ if (Date.now() >= deadline)
3998
+ return `${a.name} wrote no fork record within ${timeoutMs} ms, so it cannot be shown to have forked the carried transcript`;
3999
+ await new Promise((resolve) => setTimeout(resolve, 100));
4000
+ }
4001
+ if (record.transcriptSha256 !== claim.transcriptSha256)
4002
+ return `${a.name} forked a transcript with sha256:${record.transcriptSha256}, not the carried sha256:${claim.transcriptSha256}`;
4003
+ a.launch.resumed = { ...record, host: claim.host, transferredAt: claim.transferredAt };
4004
+ return undefined;
4005
+ }
4006
+ /** A carried seat whose launch was uncertain is held to the claim once it joins, as a started one
4007
+ * is; the wait ends when the seat stops or exits, which its exit watcher reaps. */
4008
+ async refuseLateCarriedFork(a, timeoutMs) {
4009
+ for (;;) {
4010
+ const late = await this.awaitReadiness(a, timeoutMs, { reapOnExit: false });
4011
+ if (late.ok)
4012
+ break;
4013
+ if (!late.uncertain || a.terminalizing)
4014
+ return;
4015
+ }
4016
+ const refused = await this.refuseCarriedFork(a, timeoutMs);
4017
+ if (refused === undefined || a.terminalizing || a.handle.status() === "exited")
4018
+ return;
4019
+ console.error(`! ${refused}; stopping it`);
4020
+ this.stopHandle(a, false);
4021
+ this.freeSlot(a, true, "carried-fork-refused");
3947
4022
  }
3948
4023
  readManagedSession(a) {
3949
4024
  return this.readManagedSessionState(a).sessionId;
@@ -4096,10 +4171,10 @@ export class Manager {
4096
4171
  }
4097
4172
  a.handle = handle;
4098
4173
  a.control = spec.control;
4174
+ await this.recordSlotRuntime(a);
4099
4175
  replacement = undefined;
4100
4176
  restart.opts = opts;
4101
4177
  restart.recovering = false;
4102
- await this.recordSlotRuntime(a);
4103
4178
  if (continueSession !== undefined)
4104
4179
  console.error(`! ${a.name}: recovered Pi session ${continueSession} after crash (${restart.crashes.length}/${limit})`);
4105
4180
  else
@@ -4316,6 +4391,8 @@ export class Manager {
4316
4391
  variant: args.variant ? String(args.variant) : undefined,
4317
4392
  launchOptions: args.launchOptions,
4318
4393
  resume: args.resume ? String(args.resume) : undefined,
4394
+ resumeClaim: args.resumeClaim !== undefined ? String(args.resumeClaim) : undefined,
4395
+ resumeAgent: args.resumeAgent !== undefined ? String(args.resumeAgent) : undefined,
4319
4396
  events,
4320
4397
  eventsNotice,
4321
4398
  cwd: args.cwd ? String(args.cwd) : undefined,
@@ -4711,6 +4788,25 @@ export class Manager {
4711
4788
  // reject-before-side-effects window as the harness preflight above; buildLaunch stays the backstop.
4712
4789
  if (opts.resume && !connector.supportsResume)
4713
4790
  return { ok: false, error: `${agent} connector does not support resuming an existing session (resume)` };
4791
+ // A carried resume (#1499) is checked here too, before anything is minted. The claim is only
4792
+ // looked at: the connector may still refuse the launch, and a refused launch leaves it unspent.
4793
+ let carried;
4794
+ if (opts.resumeClaim !== undefined) {
4795
+ if (opts.resume === undefined)
4796
+ return { ok: false, error: "resumeClaim: names carried bytes of a resume; the launch carries no resume" };
4797
+ if (!connector.resumeTranscript)
4798
+ return { ok: false, error: `${agent} connector does not take a carried transcript (resumeClaim)` };
4799
+ if (opts.resumeAgent !== agent)
4800
+ return { ok: false, error: `resumeClaim: the session was carried for agent "${opts.resumeAgent ?? "<none>"}", and this launch resolves agent "${agent}"` };
4801
+ if (!this.transcripts)
4802
+ return { ok: false, error: "resumeClaim: this manager does not receive carried transcripts" };
4803
+ try {
4804
+ carried = this.transcripts.peek(opts.resumeClaim, opts.resume);
4805
+ }
4806
+ catch (e) {
4807
+ return { ok: false, error: e.message };
4808
+ }
4809
+ }
4714
4810
  // A restart policy this host cannot honour is refused at accept, never accepted and ignored.
4715
4811
  // External runtimes (tmux/cmux/orca/herdr) attach to a process they do not own and stream no
4716
4812
  // exit, so a name cannot be respawned in place. User-mode seats have no static slot that
@@ -4880,7 +4976,7 @@ export class Manager {
4880
4976
  const held = this.retiring.get(identityName);
4881
4977
  if (held !== undefined) {
4882
4978
  void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner, delegated: held.delegated, secretPaths: held.secretPaths, delegatedHandle: held.delegatedHandle, launch: held.launch }).catch(() => { });
4883
- 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" });
4979
+ 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", opId: held.opId, remedy: "retry" });
4884
4980
  return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
4885
4981
  }
4886
4982
  if (variant && !connector.supportsModelVariant)
@@ -4981,6 +5077,8 @@ export class Manager {
4981
5077
  // and retires over a live process. The slot row holds the same reference; this is the copy the
4982
5078
  // in-process rollback can actually read.
4983
5079
  let provisioned;
5080
+ // A carried resume's seat-private home, removed by the finally unless a live seat took it.
5081
+ let seatHome;
4984
5082
  try {
4985
5083
  // A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
4986
5084
  // COTAL_ID); the seed is retained to mint matching creds later.
@@ -5223,7 +5321,14 @@ export class Manager {
5223
5321
  handle = this.runtime.spawnDelegated({ agent, persona: readFileSync(configPath, "utf8"), role, model, variant, prompt, launchOptions: launchOptions, events }, handoff);
5224
5322
  }
5225
5323
  else {
5324
+ if (carried) {
5325
+ seatHome = join(this.workspaceRoot, ".cotal", "seat-homes", lifecycleUid);
5326
+ mkdirSync(seatHome, { recursive: true, mode: 0o700 });
5327
+ launchOpts.carried = { home: seatHome, transcript: carried.path, cwd };
5328
+ }
5226
5329
  spec = connector.buildLaunch(launchOpts);
5330
+ if (carried)
5331
+ this.transcripts.consume(opts.resumeClaim, opts.resume);
5227
5332
  spec.env = { ...spec.env, COTAL_MANAGER_INSTANCE: this.managerInstanceId };
5228
5333
  handle = await this.spawnCustodied(name, spec, cwd, custody);
5229
5334
  }
@@ -5232,6 +5337,7 @@ export class Manager {
5232
5337
  name,
5233
5338
  role,
5234
5339
  agent,
5340
+ ...(seatHome ? { seatHome } : {}),
5235
5341
  id: userLaunch ? principalKey(userLaunch.owner, name).key : identity.id,
5236
5342
  lifecycleUid,
5237
5343
  // The lifecycle-keyed family this spawn just materialized (absent on an open mesh) — the
@@ -5272,6 +5378,7 @@ export class Manager {
5272
5378
  shareTools: opts.shareTools,
5273
5379
  forkSource: opts.resume,
5274
5380
  ...(opts.resume !== undefined && spec?.resumeRecordPath ? { resumeRecordPath: spec.resumeRecordPath } : {}),
5381
+ ...(carried ? { carried: { transcriptSha256: carried.sha256, host: carried.sourceHost, transferredAt: carried.stagedAt } } : {}),
5275
5382
  // Opaque values may contain secrets. Preserve only their keys and require the referenced
5276
5383
  // persona/manifest to resolve the values again; imperative overrides have no safe payload.
5277
5384
  unresolvedLaunchOptionKeys: opts.launchOptions && Object.keys(opts.launchOptions).length
@@ -5311,6 +5418,7 @@ export class Manager {
5311
5418
  // The live slot now owns teardown — freeSlot deprovisions this identity on exit — so the
5312
5419
  // orphan-rollback in `finally` no longer applies to it.
5313
5420
  provisioned = undefined;
5421
+ seatHome = undefined;
5314
5422
  // #159 B1: reply on a REAL outcome, not a timer. Wait for the agent to actually join the mesh
5315
5423
  // (presence) → started, the child to exit → failed (with its last output; already reaped), or
5316
5424
  // neither in time → uncertain. `✓ started` therefore means "it joined", never just "a process
@@ -5336,9 +5444,24 @@ export class Manager {
5336
5444
  // death — including one that follows an `uncertain` verdict, which deliberately does NOT deprovision).
5337
5445
  if (!readiness.ok) {
5338
5446
  this.watchExit(managed);
5447
+ if (managed.launch.carried)
5448
+ void this.refuseLateCarriedFork(managed, readinessTimeoutMs);
5339
5449
  await hooks?.onOutcome?.({ kind: "uncertain", data: { reason: readiness.detail } });
5340
5450
  return { ok: false, error: readiness.detail };
5341
5451
  }
5452
+ if (managed.launch.carried) {
5453
+ const refused = await this.refuseCarriedFork(managed, readinessTimeoutMs);
5454
+ if (refused !== undefined && managed.terminalizing) {
5455
+ hooks?.onTerminalDeferred?.();
5456
+ return { ok: false, error: refused };
5457
+ }
5458
+ if (refused !== undefined) {
5459
+ this.stopHandle(managed, false);
5460
+ this.freeSlot(managed, true, "carried-fork-refused");
5461
+ await hooks?.onOutcome?.({ kind: "failed", data: { error: refused } });
5462
+ return { ok: false, error: refused };
5463
+ }
5464
+ }
5342
5465
  if (managed.restart) {
5343
5466
  if (connector.supportsSessionContinuation) {
5344
5467
  try {
@@ -5393,6 +5516,8 @@ export class Manager {
5393
5516
  finally {
5394
5517
  this.reserved.delete(name);
5395
5518
  this.reservedLive.delete(name);
5519
+ if (seatHome)
5520
+ rmSync(seatHome, { recursive: true, force: true });
5396
5521
  // Minted but never handed to a live slot (buildLaunch / runtime.spawn threw after mint) → tear the
5397
5522
  // orphan down (detached, fail-loud) so a failed spawn leaves no creds/durables behind (#159 B).
5398
5523
  if (provisioned) {
@@ -5981,7 +6106,24 @@ export class Manager {
5981
6106
  this.reservedLive.add(entry.name);
5982
6107
  if (this.resumeAttemptId)
5983
6108
  this.resumedAgentNames.add(entry.name);
5984
- await this.recordSlotRuntime(managed);
6109
+ try {
6110
+ await this.recordSlotRuntime(managed);
6111
+ }
6112
+ catch (error) {
6113
+ const detail = `${managed.name} resumed, but ${error.message}`;
6114
+ this.stopHandle(managed, false);
6115
+ try {
6116
+ await this.awaitHandleExit(managed.handle);
6117
+ }
6118
+ catch (exit) {
6119
+ // A resumed seat keeps its retained credentials, so freeing it runs no deprovision and
6120
+ // nothing would reap it: it stays managed until it exits or the manager stops it.
6121
+ this.watchExit(managed);
6122
+ return { ok: false, error: `${detail}; it stays managed because its stop is unproven: ${exit.message}` };
6123
+ }
6124
+ this.freeSlot(managed, true, "resume-custody-unrecorded", true);
6125
+ return { ok: false, error: detail };
6126
+ }
5985
6127
  const readiness = await this.awaitReadiness(managed, readinessTimeoutMs);
5986
6128
  if (!readiness.ok && !readiness.uncertain)
5987
6129
  return { ok: false, error: readiness.detail };
@@ -6752,13 +6894,15 @@ export class Manager {
6752
6894
  // with a fresh bounded exp; a restart re-provisions the same (idempotent) gate + re-registers.
6753
6895
  const serveIdentity = this.managerServeIdentity;
6754
6896
  const servePrincipal = principalKey(DEV_OWNER, serveIdentity.id).key;
6897
+ // An open mesh mints no credentials, so it has no family for a fresh pair to grow.
6898
+ const siblings = auth ? claimManagerSiblingIdentities(this.workspaceRoot, this.space) : { goalWriter: newIdentity(), sessionLedger: newIdentity() };
6755
6899
  // must-5 (b): the STABLE goal-writer identity — a SIBLING credential in the same §13.1 family
6756
- // (not the gate's bound serving principal), minted here so the run block can family-stage it.
6757
- this.goalWriterIdentity = newIdentity();
6900
+ // (not the gate's bound serving principal), so the run block can family-stage it.
6901
+ this.goalWriterIdentity = siblings.goalWriter;
6758
6902
  // P2 item 6: the STABLE session-LEDGER identity — another SIBLING in the SAME §13.1 family, so
6759
6903
  // the takeover barrier revokes a deposed manager's ledger cred alongside its goal-writer. The
6760
6904
  // per-session serving creds join the same family, each with its own fresh identity.
6761
- this.sessionLedgerIdentity = newIdentity();
6905
+ this.sessionLedgerIdentity = siblings.sessionLedger;
6762
6906
  // One registration operation for this boot. Fresh executors may replace a dead connection, but
6763
6907
  // they must resume THIS freeze rather than minting a new op that discards Phase-2 progress.
6764
6908
  const registrationOpId = mintLifecycleUid();
@@ -6809,7 +6953,7 @@ export class Manager {
6809
6953
  ...(auth ? { evict: makeManagerEndpointHolderEvictor({ space: this.space, servers: this.servers ?? DEFAULT_SERVER, auth, log: (line) => console.error(line), unreachableWaitMs: DELIVERY_ADMIN_BOOT_WAIT_MS }) } : {}),
6810
6954
  });
6811
6955
  const spec = { endpoint: MANAGER_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
6812
- const { registrationRevision, processEpoch } = await registerServiceInstance(recordsKv, {
6956
+ const { grant } = await registerServingInstance(recordsKv, {
6813
6957
  space: this.space, spec, instanceId: iid, registrant: { owner: DEV_OWNER }, authority, barrier, readClusterArtifact,
6814
6958
  // #1393: when a FOREIGN instance holds the endpoint governance slot, let core tell an
6815
6959
  // in-flight registration from one abandoned by a predecessor that died between its
@@ -6817,43 +6961,9 @@ export class Manager {
6817
6961
  // grant (no new grant, no new writer); core refuses on anything but a gate provably
6818
6962
  // reopened past the slot's stamp.
6819
6963
  observeHolderGeneration: (holderInstanceId) => readEndpointGateGeneration(authKv, { endpoint: MANAGER_ENDPOINT, instanceId: holderInstanceId }),
6964
+ status: { connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })) },
6820
6965
  });
6821
- // processEpoch is the one this registration's reopen committed (checklist 4: never derived
6822
- // from the uid string). A gate read here could already show a successor's epoch; the fences
6823
- // below refuse that successor instead of serving at it. The fence is also the mint's §13.1
6824
- // release CAS.
6825
6966
  const fence = serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: iid });
6826
- const grant = await authorizeServeGrant(recordsKv, {
6827
- space: this.space, endpoint: MANAGER_ENDPOINT, instanceId: iid, epoch: processEpoch,
6828
- holder: { owner: DEV_OWNER }, authority, readClusterArtifact,
6829
- readProcessEpoch: async () => {
6830
- const g = await fence.observe();
6831
- if (g === null)
6832
- throw new Error(`no issuance gate for ${MANAGER_ENDPOINT}/${iid}`);
6833
- return g.processEpoch;
6834
- },
6835
- });
6836
- // P2 item 3 (class scatter): write this instance's CONVERGED svc status so it is a §13.5
6837
- // scatter member — `freezeExpectedSet` skips any instance whose status is absent or lags the
6838
- // current registration. Instance-side `ready` at the just-registered spec revision, epoch-fenced
6839
- // to the gate's processEpoch (the same leader-served reader `authorizeServeGrant` used); on a
6840
- // restart it CAS-updates the predecessor's status forward (the advanced epoch supersedes the old
6841
- // one). Key-pinned to this instance's own status key on the SAME executor.
6842
- await writeServiceStatus(recordsKv, {
6843
- endpoint: MANAGER_ENDPOINT, instanceId: iid, epoch: processEpoch,
6844
- status: {
6845
- state: SERVICE_READY,
6846
- epoch: processEpoch,
6847
- observedSpecRevision: registrationRevision,
6848
- connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
6849
- },
6850
- readProcessEpoch: async () => {
6851
- const g = await fence.observe();
6852
- if (g === null)
6853
- throw new Error(`no issuance gate for ${MANAGER_ENDPOINT}/${iid}`);
6854
- return g.processEpoch;
6855
- },
6856
- });
6857
6967
  // Open mesh: NO mint - the §13.1 fence is issuance-only and nothing is ever issued, so the
6858
6968
  // gate keeps an empty `epcred` family; the serve connection below stays bare.
6859
6969
  const creds = auth ? await mintCreds(auth, serveIdentity, "endpoint-serve", { serveIssuance: fence, endpointServe: grant }) : undefined;
@@ -7502,9 +7612,7 @@ export class Manager {
7502
7612
  * 3. OPEN MESH. No credential family exists, so the revoke/evict loop is vacuous and this belt
7503
7613
  * is COOPERATIVE only: a non-conformant process simply does not run it.
7504
7614
  * The named follow-up that would close 2 and 3 is the gate-linearized commit (routing the
7505
- * terminal through the issuance gate's own CAS), deliberately deferred as substrate territory.
7506
- * An earlier revision of this comment claimed the residual was "closed by item-3 slice 3.0,
7507
- * never a permanent residual". That asserted a closure that does not exist. */
7615
+ * terminal through the issuance gate's own CAS), deliberately deferred as substrate territory. */
7508
7616
  async assertGoalWriterEpochCurrent(epoch) {
7509
7617
  const gate = this.goalWriter?.gate;
7510
7618
  if (!gate)
@@ -8227,8 +8335,7 @@ export class Manager {
8227
8335
  /** Re-record the custody reference of a managed agent's CURRENT handle on its active slot: a
8228
8336
  * same-lifecycle restart or a resume binds a new custody under the old uid, and the successor's
8229
8337
  * reap must address the live one. Static auth only; a runtime without durable custody records
8230
- * nothing (there is nothing to reap by reference). Loud on failure, never fatal to the bind: the
8231
- * handle is already live and a stale reference is refused by identity at reap time. */
8338
+ * nothing (there is nothing to reap by reference). */
8232
8339
  async recordSlotRuntime(a) {
8233
8340
  if (a.handle.reference === undefined)
8234
8341
  return;
@@ -8236,7 +8343,9 @@ export class Manager {
8236
8343
  }
8237
8344
  /** Record one custody reference on an agent's ACTIVE slot. Split from {@link recordSlotRuntime}
8238
8345
  * so a restart or a resume can record the reference it RESERVED before spawning, while no
8239
- * handle exists yet. Static auth only; a runtime without durable custody reserves nothing. */
8346
+ * handle exists yet. Static auth only; a runtime without durable custody reserves nothing.
8347
+ * Throws when the slot does not take the reference: a slot left on the previous one reaps that
8348
+ * seat, already gone, so a successor would retire the lifecycle over the new one. */
8240
8349
  async recordSlotCustody(a, runtime) {
8241
8350
  if (!this.auth || this.userMode)
8242
8351
  return;
@@ -8244,14 +8353,14 @@ export class Manager {
8244
8353
  await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
8245
8354
  const slot = await readStaticSlot(t, DEV_OWNER, a.name);
8246
8355
  if (slot === undefined || slot.row.lifecycleUid !== a.lifecycleUid || slot.row.phase !== "active")
8247
- return;
8356
+ throw new Error(`the slot is ${slot === undefined ? "absent" : `${slot.row.phase} at uid ${slot.row.lifecycleUid}`}`);
8248
8357
  if (slot.row.runtime?.kind === runtime.kind && slot.row.runtime.id === runtime.id)
8249
8358
  return;
8250
8359
  await casStaticSlot(t, { ...slot.row, runtime }, slot.revision);
8251
8360
  });
8252
8361
  }
8253
8362
  catch (e) {
8254
- console.error(`! ${a.name}: could not record the custody reference ${runtime.kind}:${runtime.id} on its static slot: ${e.message} - a successor reaps this seat only by an up-to-date reference`);
8363
+ throw new Error(`could not record the custody reference ${runtime.kind}:${runtime.id} on the static slot of "${a.name}": ${e.message}`);
8255
8364
  }
8256
8365
  }
8257
8366
  /** Mint the custody reference for a seat this manager is about to spawn, BEFORE the processes
@@ -8939,6 +9048,28 @@ export class Manager {
8939
9048
  await prov.stop();
8940
9049
  }
8941
9050
  }
9051
+ /** A connection holding this instance's transfer reader grants (docs/design/resume-transfer.md
9052
+ * section 6) for this one receive or sweep: minted from the space's signing seed, or issued by the
9053
+ * host to a remote manager. An open mesh enforces no grants, so a bare one-shot connection is that
9054
+ * reader there. */
9055
+ async withTransferReader(fn) {
9056
+ const issue = this.remoteAuthority?.mintTransferReader;
9057
+ if (this.remoteAuthority && !issue)
9058
+ throw new Error("withTransferReader: this manager's remote authority issues no transfer reader");
9059
+ const identity = newIdentity();
9060
+ const creds = issue
9061
+ ? await issue(identity)
9062
+ : this.auth && await mintCreds(this.auth, identity, "transfer-reader", { transferReader: { instanceId: this.managerInstanceId } });
9063
+ const nc = await this.dial(creds
9064
+ ? { ...standaloneConnectOpts({ creds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 }
9065
+ : { maxReconnectAttempts: 0 });
9066
+ try {
9067
+ return await fn(nc);
9068
+ }
9069
+ finally {
9070
+ await nc.drain().catch(() => nc.close());
9071
+ }
9072
+ }
8942
9073
  /** Purge the space's retained message backlog (chat, optionally DMs). Privileged — the manager mints a
8943
9074
  * short-lived "purger" cred (same destructive grant as `cotal history clear`, isolated off the
8944
9075
  * supervisor); regular agents are denied STREAM.PURGE under auth. Cleanup only: leaves live agents and
@@ -9234,7 +9365,8 @@ export class Manager {
9234
9365
  }
9235
9366
  // establishAttach releases the claim it was handed on every exit; nothing to unwind here.
9236
9367
  const { grant } = await this.sessionPlane.establishAttach(caller, { name: a.name, lifecycleUid: a.lifecycleUid }, session, slot);
9237
- return { ok: true, data: { grant } };
9368
+ const carried = a.launch.resumed?.host;
9369
+ return { ok: true, data: { grant, ...(carried ? { resumedFrom: { host: carried, source: a.launch.resumed.source } } : {}) } };
9238
9370
  }
9239
9371
  /** P2 item 6: the console's mesh §13.6 session establisher (backing `POST /session/<name>` on the
9240
9372
  * loopback face). Drives THE ONE plane — same establishAttach as the ep `attach` command — with