@cotal-ai/manager 0.72.1 → 0.76.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/commands.js +7 -2
- package/dist/commands.js.map +1 -1
- package/dist/deregister-instance.d.ts.map +1 -1
- package/dist/deregister-instance.js +3 -9
- package/dist/deregister-instance.js.map +1 -1
- package/dist/endpoint-evict.d.ts +5 -4
- package/dist/endpoint-evict.d.ts.map +1 -1
- package/dist/endpoint-evict.js +6 -5
- package/dist/endpoint-evict.js.map +1 -1
- package/dist/manager-service-contract.d.ts +19 -4
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +74 -61
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +25 -15
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +281 -223
- package/dist/manager.js.map +1 -1
- package/dist/remote-authority.d.ts +8 -3
- package/dist/remote-authority.d.ts.map +1 -1
- package/dist/remote-authority.js +32 -22
- package/dist/remote-authority.js.map +1 -1
- package/dist/run-hosting.d.ts +7 -2
- package/dist/run-hosting.d.ts.map +1 -1
- package/dist/run-hosting.js +10 -3
- package/dist/run-hosting.js.map +1 -1
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +24 -12
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/pty.d.ts.map +1 -1
- package/dist/runtime/pty.js +3 -1
- package/dist/runtime/pty.js.map +1 -1
- package/dist/session/establish.d.ts +3 -3
- package/dist/session/establish.js +1 -1
- package/package.json +12 -12
package/dist/manager.js
CHANGED
|
@@ -4,11 +4,11 @@ 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, 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";
|
|
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";
|
|
9
|
-
import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
|
|
7
|
+
import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, 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";
|
|
8
|
+
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, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, spaceKey, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
|
+
import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, watchProvedExit, } from "./runtime/index.js";
|
|
10
10
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
11
|
-
import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers } from "./endpoint-evict.js";
|
|
11
|
+
import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers, withScopedEndpoint } from "./endpoint-evict.js";
|
|
12
12
|
import { makeManagerHolderLivenessProbe } from "./holder-liveness.js";
|
|
13
13
|
import { GateReconcileRefused, reconcileEndpointGate } from "./reconcile-gate.js";
|
|
14
14
|
import { launchSpecForRun, materializePersona, launchAgentToStartOpts, parseLaunchSpec, persistLaunchSpec, readContinuityAssignment, writeContinuityAssignment } from "./launch.js";
|
|
@@ -294,14 +294,6 @@ function resolveHarness(name, requires) {
|
|
|
294
294
|
}
|
|
295
295
|
return missing.length ? { binaries, reason: `${name} harness needs ${missing.join(", ")} on PATH - not found` } : { binaries };
|
|
296
296
|
}
|
|
297
|
-
function injectedManagerStoreIdentity(store) {
|
|
298
|
-
if (store.identity !== undefined)
|
|
299
|
-
return parseSecretStoreIdentity(store.identity);
|
|
300
|
-
const coordinate = process.env.COTAL_SECRET_STORE;
|
|
301
|
-
if (!coordinate)
|
|
302
|
-
throw new Error("ManagerOptions.secretStore must declare its identity or set COTAL_SECRET_STORE so the manager and delivery daemon can name the same authority (never a silent local-root fallback)");
|
|
303
|
-
return { kind: "injected", coordinate };
|
|
304
|
-
}
|
|
305
297
|
/** True only when the delivery-admin rail has no bound responder. A timeout is a hung rail, not absence. */
|
|
306
298
|
function isAbsentDeliveryAdmin(msg) {
|
|
307
299
|
if (/timeout/i.test(msg))
|
|
@@ -341,7 +333,7 @@ export class Manager {
|
|
|
341
333
|
secretStoreIdentity;
|
|
342
334
|
/** See {@link ManagerOptions.installedExtensions}. */
|
|
343
335
|
installedExtensions;
|
|
344
|
-
|
|
336
|
+
executionState;
|
|
345
337
|
/** Internal test seam. Production leaves this undefined and uses the scoped delivery-admin evictor. */
|
|
346
338
|
staticLifecycleEvict;
|
|
347
339
|
preserveStopTimeoutMs;
|
|
@@ -629,7 +621,13 @@ export class Manager {
|
|
|
629
621
|
return dialerFor(servers)({ ...opts, servers });
|
|
630
622
|
}
|
|
631
623
|
constructor(opts) {
|
|
632
|
-
if (opts.
|
|
624
|
+
if (opts.execution !== undefined && opts.execution !== "runtime" && opts.execution !== "none")
|
|
625
|
+
throw new Error(`unknown manager execution capability ${JSON.stringify(opts.execution)}`);
|
|
626
|
+
if (opts.execution === "none" && (!opts.pooled || !opts.remoteAuthority || opts.runtime !== undefined))
|
|
627
|
+
throw new Error("execution: none requires pooled signerless remote authority and no runtime option");
|
|
628
|
+
if (opts.execution === "none" && ["renewExecutor", "renewStandingBundle", "mintSessionServing", "mintRetirementRequester", "prepareAgentRetirement", "validateRetainedAgent", "scanGoalIndex", "authorizeAdmin"].some((key) => typeof opts.remoteAuthority?.[key] !== "function"))
|
|
629
|
+
throw new Error("execution: none requires complete closed remote authority callbacks before construction");
|
|
630
|
+
if (opts.pooled && (!opts.remoteAuthority || (opts.execution !== "none" && (opts.runtime === undefined || opts.runtime === "auto" || opts.runtime === "pty"))))
|
|
633
631
|
throw new Error("pooled control requires signerless remote authority and an explicit non-PTY runtime; local custodial execution is forbidden");
|
|
634
632
|
if (opts.pooled && !opts.remoteAuthority?.renewStandingBundle)
|
|
635
633
|
throw new Error("pooled control requires a closed host-issued all-duty renewal callback before construction");
|
|
@@ -649,11 +647,13 @@ export class Manager {
|
|
|
649
647
|
this.managerLifecycleUid = opts.remoteAuthority.lifecycleUid;
|
|
650
648
|
this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
|
|
651
649
|
if (opts.secretStore)
|
|
652
|
-
this.secretStoreIdentity =
|
|
650
|
+
this.secretStoreIdentity = injectedSecretStoreIdentity(opts.secretStore);
|
|
653
651
|
this.installedExtensions = opts.installedExtensions ?? false;
|
|
654
|
-
this.
|
|
655
|
-
if (
|
|
656
|
-
|
|
652
|
+
this.executionState = opts.execution === "none" ? { kind: "none" } : { kind: "runtime" };
|
|
653
|
+
if (this.executionState.kind === "runtime")
|
|
654
|
+
this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
|
|
655
|
+
if (opts.pooled && this.executionState.kind === "runtime" && isCustodialRuntime(this.requiredRuntime))
|
|
656
|
+
throw new Error(`pooled control refuses custodial runtime "${this.requiredRuntime.kind}" before starting agents`);
|
|
657
657
|
this.preserveStopTimeoutMs = opts.preserveStopTimeoutMs ?? PRESERVE_STOP_TIMEOUT_MS;
|
|
658
658
|
this.endpointServeExecutorExpiresInSeconds = opts.endpointServeExecutorExpiresInSeconds;
|
|
659
659
|
if (opts.resumeAttemptId && !/^[A-Za-z0-9_-]{1,128}$/.test(opts.resumeAttemptId))
|
|
@@ -681,18 +681,34 @@ export class Manager {
|
|
|
681
681
|
opts.attachHost ?? "127.0.0.1");
|
|
682
682
|
}
|
|
683
683
|
get runtimeKind() {
|
|
684
|
-
return this.
|
|
684
|
+
return this.executionState.kind === "none" ? "none" : this.requiredRuntime.kind;
|
|
685
|
+
}
|
|
686
|
+
get execution() {
|
|
687
|
+
return this.executionState.kind;
|
|
688
|
+
}
|
|
689
|
+
executionRefusal() {
|
|
690
|
+
return "execution: none forbids seat launch and workflow hosting on this control instance; select the participant Manager with an instance-pinned manager-caller view";
|
|
691
|
+
}
|
|
692
|
+
/** A defensive last barrier, not a substitute Runtime. Entry admission runs before effects. */
|
|
693
|
+
get requiredRuntime() {
|
|
694
|
+
if (this.executionState.kind === "none")
|
|
695
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
696
|
+
return this.runtime;
|
|
685
697
|
}
|
|
686
698
|
/** What a default process stop does with this manager's seats. `release` drops manager-local
|
|
687
699
|
* custody and leaves every seat running. `stop` means this runtime keeps its PTYs inside the
|
|
688
700
|
* manager process, so those seats close with it: the stop stops and deprovisions them, and still
|
|
689
701
|
* releases every seat that can outlive it. */
|
|
690
702
|
get spareSeats() {
|
|
691
|
-
|
|
703
|
+
if (this.execution === "none")
|
|
704
|
+
return "release";
|
|
705
|
+
return this.requiredRuntime.kind !== "pty" || this.requiredRuntime.supportsRelease === true ? "release" : "stop";
|
|
692
706
|
}
|
|
693
707
|
/** Reattach this manager's runtime to a durable handle. Refuses by name when adopt is absent. */
|
|
694
708
|
adoptRuntimeHandle(reference) {
|
|
695
|
-
|
|
709
|
+
if (this.execution === "none")
|
|
710
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
711
|
+
return requireRuntimeAdopt(this.requiredRuntime, reference);
|
|
696
712
|
}
|
|
697
713
|
/** The console page URL (manager-hosted, loopback). */
|
|
698
714
|
get consoleUrl() {
|
|
@@ -795,7 +811,34 @@ export class Manager {
|
|
|
795
811
|
this.startTask = wrapped;
|
|
796
812
|
return wrapped;
|
|
797
813
|
}
|
|
814
|
+
async admitExecutionState() {
|
|
815
|
+
if (this.remoteAuthority) {
|
|
816
|
+
// Execution is fixed for a registered instance. An older instance with no capability
|
|
817
|
+
// record might own retained seats/runs, so it cannot be converted to none by omission.
|
|
818
|
+
const remote = this.remoteAuthority;
|
|
819
|
+
const path = join(this.workspaceRoot, ".cotal", `manager-execution.${spaceKey(this.space)}.${remote.instanceId}.json`);
|
|
820
|
+
if (this.execution === "none" && !existsSync(path) && remote.serveGrant.epoch !== 0)
|
|
821
|
+
throw new Error("execution: none refuses an existing unclassified manager instance; keep its runtime and retained state, or register a fresh control instance");
|
|
822
|
+
if (this.execution === "none") {
|
|
823
|
+
const entries = await remote.scanGoalIndex();
|
|
824
|
+
if (entries.some((entry) => entry.iid === remote.instanceId && (entry.allocated !== undefined || entry.note !== undefined)))
|
|
825
|
+
throw new Error("execution: none refuses retained seat or turn inventory before recovery; keep the execution-owning Manager");
|
|
826
|
+
}
|
|
827
|
+
if (this.execution === "none" || existsSync(path)) {
|
|
828
|
+
const record = claimIdentityRecord(path, "manager execution capability", (raw) => {
|
|
829
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
830
|
+
return undefined;
|
|
831
|
+
const r = raw;
|
|
832
|
+
return Object.keys(r).sort().join(",") === "execution,instanceId,space,v" && r.v === 1 && r.space === this.space &&
|
|
833
|
+
r.instanceId === remote.instanceId && (r.execution === "none" || r.execution === "runtime") ? r : undefined;
|
|
834
|
+
}, () => ({ v: 1, space: this.space, instanceId: remote.instanceId, execution: this.execution }));
|
|
835
|
+
if (record.execution !== this.execution)
|
|
836
|
+
throw new Error("manager execution capability differs from its retained instance record; execution cannot change across restart");
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
798
840
|
async runStart() {
|
|
841
|
+
await this.admitExecutionState();
|
|
799
842
|
this.staticReconcileStopping = false;
|
|
800
843
|
await this.inspectConnectorsAtBoot();
|
|
801
844
|
await this.attach.start();
|
|
@@ -842,9 +885,10 @@ export class Manager {
|
|
|
842
885
|
// dir) - two managers in ONE space are two workspace roots.
|
|
843
886
|
//
|
|
844
887
|
// First mint on a fresh root is exclusive create (#1263). Two processes that both observe no
|
|
845
|
-
// file must not keep different in-memory ids: of N concurrent starts exactly one
|
|
846
|
-
// file
|
|
847
|
-
//
|
|
888
|
+
// file must not keep different in-memory ids: of N concurrent starts exactly one publishes the
|
|
889
|
+
// file by hard link and the others adopt that identity before taking a lease, or refuse with
|
|
890
|
+
// identity-record-create-lost. A filesystem without hard links refuses the first start with
|
|
891
|
+
// atomic-publish-unsupported. Atomic rename is not enough, because the loser
|
|
848
892
|
// of a replace would still serve under the id it minted.
|
|
849
893
|
{
|
|
850
894
|
if (this.remoteAuthority) {
|
|
@@ -921,7 +965,7 @@ export class Manager {
|
|
|
921
965
|
this.ep.on("roster", () => this.logSeatMeshTransitions());
|
|
922
966
|
this.ep.on("presence-view", () => this.logSeatMeshTransitions());
|
|
923
967
|
await this.ep.start();
|
|
924
|
-
await this.ep.setActivity(`supervisor (${this.
|
|
968
|
+
await this.ep.setActivity(`supervisor (${this.runtimeKind})`);
|
|
925
969
|
// Per-instance liveness lease (P2 item 3 — the old per-space singleton is DEMOTED per D9). Acquire
|
|
926
970
|
// THIS logical instance's own key (atomic CAS create). A DIFFERENT instance (a second manager in a
|
|
927
971
|
// second workspace root) has a distinct id ⇒ a distinct key ⇒ it coexists; the create THROWS only
|
|
@@ -929,7 +973,7 @@ export class Manager {
|
|
|
929
973
|
// crashed predecessor's not-yet-expired key), and we REFUSE loud. A crashed holder's key auto-expires
|
|
930
974
|
// (bucket TTL). Losing the key LATER ends this process only when another process holds it; otherwise
|
|
931
975
|
// the renew loop puts it back or keeps retrying ({@link renewLease}).
|
|
932
|
-
this.leaseInfo = { holder: this.ep.ref().id, instanceId: this.managerInstanceId, runtime: this.
|
|
976
|
+
this.leaseInfo = { holder: this.ep.ref().id, instanceId: this.managerInstanceId, runtime: this.runtimeKind, root: resolve(this.workspaceRoot), pid: process.pid };
|
|
933
977
|
try {
|
|
934
978
|
this.leaseRevision = await this.ep.acquireManagerLease(this.leaseInfo);
|
|
935
979
|
}
|
|
@@ -1073,7 +1117,7 @@ export class Manager {
|
|
|
1073
1117
|
// refuses them until its reconcile has returned. A remote user-auth manager uses its four
|
|
1074
1118
|
// registered issuer callbacks instead of holding a signer; a local user mesh stands none up.
|
|
1075
1119
|
const remoteRuns = this.remoteAuthority?.runHosting;
|
|
1076
|
-
if (this.remoteAuthority && remoteRuns) {
|
|
1120
|
+
if (this.execution !== "none" && this.remoteAuthority && remoteRuns) {
|
|
1077
1121
|
this.runHosting = new RunHosting({
|
|
1078
1122
|
space: this.space,
|
|
1079
1123
|
servers: this.servers,
|
|
@@ -1083,6 +1127,7 @@ export class Manager {
|
|
|
1083
1127
|
auth: undefined,
|
|
1084
1128
|
log: (line) => console.error(line),
|
|
1085
1129
|
admitRun: remoteRuns.admitRun,
|
|
1130
|
+
revokeRun: remoteRuns.revokeRun,
|
|
1086
1131
|
issueAttempt: remoteRuns.issueAttempt,
|
|
1087
1132
|
issueOperator: remoteRuns.issueOperator,
|
|
1088
1133
|
renewRun: remoteRuns.renewRun,
|
|
@@ -1525,6 +1570,8 @@ export class Manager {
|
|
|
1525
1570
|
async renewRemoteStandingBundle(force = false) {
|
|
1526
1571
|
if (this.remoteBundleRenewal)
|
|
1527
1572
|
return this.remoteBundleRenewal;
|
|
1573
|
+
if (this.leaseStopping)
|
|
1574
|
+
return;
|
|
1528
1575
|
const renewal = this.renewRemoteStandingBundleOnce(force);
|
|
1529
1576
|
this.remoteBundleRenewal = renewal;
|
|
1530
1577
|
try {
|
|
@@ -2040,12 +2087,17 @@ export class Manager {
|
|
|
2040
2087
|
* touches NEITHER the lease NOR the endpoints — the caller owns those. */
|
|
2041
2088
|
async teardownManagedAgents(managed = [...this.agents.values()]) {
|
|
2042
2089
|
const failures = [];
|
|
2090
|
+
const stopped = [];
|
|
2043
2091
|
for (const a of managed) {
|
|
2044
2092
|
// Free the slot + hard-stop each; `stopHandle` is best-effort (never throws — see it), so one bad
|
|
2045
2093
|
// stop can't strand the rest, and every snapshot entry is deprovisioned below regardless.
|
|
2046
2094
|
this.recordContinuityAtStop(a, "shutdown");
|
|
2047
2095
|
this.agents.delete(a.name);
|
|
2048
|
-
this.stopHandle(a, false);
|
|
2096
|
+
const refused = this.stopHandle(a, false);
|
|
2097
|
+
if (refused === undefined)
|
|
2098
|
+
stopped.push(a);
|
|
2099
|
+
else
|
|
2100
|
+
failures.push(`${a.name}: stop failed: ${refused}`);
|
|
2049
2101
|
}
|
|
2050
2102
|
// The slot delete above bypasses freeSlot, which is where the reap line lives — so say it here,
|
|
2051
2103
|
// once per seat, with the one stop cause that names this path: the shutdown itself. Without
|
|
@@ -2056,7 +2108,7 @@ export class Manager {
|
|
|
2056
2108
|
// while any seat may still hold this instance's broker rails: that creates the exact orphan
|
|
2057
2109
|
// window where a successor sees a dead manager but live predecessor authority. Every shipped
|
|
2058
2110
|
// runtime now supplies waitForExit; absence or timeout is a failed shutdown, never a clean one.
|
|
2059
|
-
await Promise.all(
|
|
2111
|
+
await Promise.all(stopped.map(async (a) => {
|
|
2060
2112
|
try {
|
|
2061
2113
|
await this.awaitHandleExit(a.handle);
|
|
2062
2114
|
}
|
|
@@ -2151,6 +2203,9 @@ export class Manager {
|
|
|
2151
2203
|
// still move the key's revision after we clear the timer, and releasing against a cached
|
|
2152
2204
|
// revision it has since moved is a CAS the broker correctly refuses, silently, forever.
|
|
2153
2205
|
await this.leaseRenewTask?.catch(() => { });
|
|
2206
|
+
// An already accepted host issuance must finish before standing transports close. The
|
|
2207
|
+
// epoch/stop fence refuses adoption, but does not cancel the issuer's in-flight request.
|
|
2208
|
+
await this.remoteBundleRenewal?.catch(() => { });
|
|
2154
2209
|
// A spawn admitted before the fence closed may still be launching. Its seat must be in the
|
|
2155
2210
|
// snapshot below, or it would outlive a stop that reported success.
|
|
2156
2211
|
await this.awaitLifecycleDrain();
|
|
@@ -2267,13 +2322,7 @@ export class Manager {
|
|
|
2267
2322
|
endpoint: MANAGER_ENDPOINT,
|
|
2268
2323
|
instanceId: iid,
|
|
2269
2324
|
registrationRevision,
|
|
2270
|
-
observeGeneration:
|
|
2271
|
-
const key = epgateKey(MANAGER_ENDPOINT, iid);
|
|
2272
|
-
const entry = await authKv.get(key);
|
|
2273
|
-
if (!entry || entry.operation !== "PUT")
|
|
2274
|
-
throw new Error(`no issuance gate at ${key}`);
|
|
2275
|
-
return parseEndpointGate(entry.value, key).generation;
|
|
2276
|
-
},
|
|
2325
|
+
observeGeneration: () => readEndpointGateGeneration(authKv, { endpoint: MANAGER_ENDPOINT, instanceId: iid }),
|
|
2277
2326
|
});
|
|
2278
2327
|
try {
|
|
2279
2328
|
const outcome = await ((this.auth || this.remoteAuthority)
|
|
@@ -2524,6 +2573,8 @@ export class Manager {
|
|
|
2524
2573
|
{
|
|
2525
2574
|
try {
|
|
2526
2575
|
const args = parseResumeControlArgs(rawArgs);
|
|
2576
|
+
if (this.execution === "none" && args.inventory.agents.length !== 0)
|
|
2577
|
+
return { ok: false, error: this.executionRefusal() };
|
|
2527
2578
|
const inventoryDigest = createHash("sha256").update(JSON.stringify(args.inventory)).digest("hex");
|
|
2528
2579
|
if (!this.resumeAttemptId)
|
|
2529
2580
|
return { ok: false, error: "resumePreserved requires a manager started with --resume-attempt" };
|
|
@@ -2674,6 +2725,8 @@ export class Manager {
|
|
|
2674
2725
|
* surface comes up before the host): `unavailable`, retry. The three are told apart, since
|
|
2675
2726
|
* the first sentence steers a caller to `--local` and the others must not. */
|
|
2676
2727
|
runHost() {
|
|
2728
|
+
if (this.execution === "none")
|
|
2729
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
2677
2730
|
if (this.runHosting)
|
|
2678
2731
|
return this.runHosting;
|
|
2679
2732
|
if (this.remoteAuthority && !this.remoteAuthority.runHosting)
|
|
@@ -2871,13 +2924,19 @@ export class Manager {
|
|
|
2871
2924
|
return { goalId, ...(result === undefined ? {} : { result }) };
|
|
2872
2925
|
}),
|
|
2873
2926
|
resolveCwd: (ctx) => this.serveGated(ctx, () => this.resolveSpawnCwd(args(ctx).cwd)),
|
|
2927
|
+
resolveAgent: (ctx) => this.serveGated(ctx, async () => {
|
|
2928
|
+
const persona = await this.spawnPersona(args(ctx), callerOf(ctx), ctx.subject.caller);
|
|
2929
|
+
if ("error" in persona)
|
|
2930
|
+
throw new EpEnvelopeError(persona.error.startsWith("no persona") ? "not-found" : "failed-precondition", persona.error);
|
|
2931
|
+
return { agent: persona.agent };
|
|
2932
|
+
}),
|
|
2874
2933
|
// P2 item 2: `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
|
|
2875
2934
|
// progress + terminal off-handler (no ~30s block). The blocking reply path is gone (pin 8).
|
|
2876
2935
|
// #373: arming the event plane (events: true, or the omission that arms it by default)
|
|
2877
2936
|
// follows owner-domain reach on a user mesh. Resolve the caller's admin tier once for
|
|
2878
2937
|
// the residual cross-owner case; opStart decides before connector resolution or grant
|
|
2879
2938
|
// mutation. Static meshes resolve true and nothing changes.
|
|
2880
|
-
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))),
|
|
2939
|
+
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))),
|
|
2881
2940
|
despawn: (ctx) => this.serveGated(ctx, async () => {
|
|
2882
2941
|
const a = targetAgent(ctx);
|
|
2883
2942
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
@@ -3121,11 +3180,11 @@ export class Manager {
|
|
|
3121
3180
|
* grace window. POSIX delivers SIGTERM→SIGKILL natively, so it keeps the signal path. A hard stop
|
|
3122
3181
|
* (`graceful:false`, e.g. emergency reap) skips the cooperative step on every platform.
|
|
3123
3182
|
*
|
|
3124
|
-
*
|
|
3125
|
-
*
|
|
3126
|
-
*
|
|
3127
|
-
*
|
|
3128
|
-
*
|
|
3183
|
+
* Never throws (#159 B2): a runtime hard-stop CAN throw (tmux `closeWindow` / cmux `closeWorkspace`
|
|
3184
|
+
* are direct calls), and a throwing stop must not abort a caller's cleanup and leak the agent's
|
|
3185
|
+
* footprint, nor (in `reapChildrenOf`) abort the reap of later siblings. The failure is logged and
|
|
3186
|
+
* returned: a caller that must prove exit (shutdown, a failed resume) reports it as the cause at
|
|
3187
|
+
* once instead of waiting out a stop the runtime already refused. */
|
|
3129
3188
|
stopHandle(a, graceful) {
|
|
3130
3189
|
// The F5 TERMINALIZING latch (Unit B): flipped SYNCHRONOUSLY, before any await anywhere on
|
|
3131
3190
|
// this stop path — later renewals refuse, while an already-admitted renewal drains before the
|
|
@@ -3143,7 +3202,9 @@ export class Manager {
|
|
|
3143
3202
|
a.handle.stop({ graceful });
|
|
3144
3203
|
}
|
|
3145
3204
|
catch (e) {
|
|
3146
|
-
|
|
3205
|
+
const failure = rejectionText(e);
|
|
3206
|
+
console.error(`stop ${a.name} (${a.id}): ${failure}`);
|
|
3207
|
+
return failure;
|
|
3147
3208
|
}
|
|
3148
3209
|
}
|
|
3149
3210
|
/** Keep an accepted stop inside the lifecycle drain until the runtime proves the child is gone,
|
|
@@ -3487,7 +3548,7 @@ export class Manager {
|
|
|
3487
3548
|
const info = a.handle.exitInfo?.();
|
|
3488
3549
|
detail = info === undefined
|
|
3489
3550
|
? `exit detail unavailable from runtime "${a.handle.kind}"`
|
|
3490
|
-
: `exit code ${info.code ?? "unknown"}${info.signal === undefined ? "" : `, signal ${info.signal}`}${info.diagnostic ? `;
|
|
3551
|
+
: `exit code ${info.code ?? "unknown"}${info.signal === undefined ? "" : `, signal ${info.signal}`}${info.diagnostic ? `; diagnostic: ${info.diagnostic}` : ""}`;
|
|
3491
3552
|
}
|
|
3492
3553
|
catch (e) {
|
|
3493
3554
|
// A runtime that throws while being asked has told us something real; it must not take the
|
|
@@ -3561,6 +3622,10 @@ export class Manager {
|
|
|
3561
3622
|
this.failSeatTurns(a.name, a.lifecycleUid);
|
|
3562
3623
|
if (floor && Date.now() - a.startedAt < MIN_LIFETIME)
|
|
3563
3624
|
this.cooling.push(a.startedAt + MIN_LIFETIME);
|
|
3625
|
+
// The departed agent's own custody reference rides the teardown: the exit that freed this slot
|
|
3626
|
+
// is usually the seat ending, but a stop that could not prove the process gone leaves one the
|
|
3627
|
+
// terminal must still address before it frees the alias.
|
|
3628
|
+
const target = { id: a.id, name: a.name, lifecycleUid: a.lifecycleUid, userOwner: a.userOwner, delegated: a.delegated, secretPaths: a.secretPaths, runtime: a.handle?.reference, ...(a.handedOff ? { delegatedHandle: a.handle } : {}), launch: { allowSubscribe: a.launch.allowSubscribe } };
|
|
3564
3629
|
// #29 piece 3: on a USER mesh the name is RESERVED PENDING RETIREMENT — despawn started this
|
|
3565
3630
|
// lifecycle's FULL teardown (footprint + standing-authority revoke + the auth-side retirement),
|
|
3566
3631
|
// and the alias frees only when all of it completes (not the retirement alone). The detached
|
|
@@ -3572,24 +3637,22 @@ export class Manager {
|
|
|
3572
3637
|
if (this.userMode) {
|
|
3573
3638
|
const p = parsePrincipalKey(a.id);
|
|
3574
3639
|
if (p)
|
|
3575
|
-
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid),
|
|
3640
|
+
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), owner: p.owner, actor: p.actor, target, startedAt: Date.now() });
|
|
3576
3641
|
}
|
|
3577
3642
|
else if (this.auth) {
|
|
3578
3643
|
// Unit B: a STATIC lifecycle now also holds its name pending its own terminal (the F1
|
|
3579
3644
|
// static retirement the detached deprovision below drives) — the alias frees only when the
|
|
3580
3645
|
// gate+head terminal completes, exactly the user-mode discipline. The wire principal is the
|
|
3581
3646
|
// incarnation-unique nkey (F5-bind); owner is the dev owner.
|
|
3582
|
-
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid),
|
|
3647
|
+
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), owner: DEV_OWNER, actor: a.id, target, startedAt: Date.now() });
|
|
3583
3648
|
}
|
|
3584
3649
|
// Auth mode: tear down the departed agent's minted broker footprint + creds file (#159 B2). The
|
|
3585
3650
|
// process is already gone, so this must never block the slot free or throw into the caller — it runs
|
|
3586
3651
|
// detached, and a failure is logged loudly (never swallowed), not retried. The `agents` guard above
|
|
3587
3652
|
// makes this fire exactly once per agent across every free path (despawn / self-stop / reap / exit).
|
|
3588
|
-
// The
|
|
3589
|
-
// is usually the seat ending, but a stop that could not prove the process gone leaves one the
|
|
3590
|
-
// terminal must still address before it frees the alias.
|
|
3653
|
+
// The agent's own fields ride along for its accepted renewal, which the static terminal drains first.
|
|
3591
3654
|
if (!a.suppressCleanup && (this.maintenanceState === "active" || acceptedBeforeFence))
|
|
3592
|
-
this.trackDeprovision({ ...a,
|
|
3655
|
+
this.trackDeprovision({ ...a, ...target });
|
|
3593
3656
|
}
|
|
3594
3657
|
/** Tear down a departed agent's minted footprint (#159 B2, auth mode): its local-principal durables
|
|
3595
3658
|
* (`dm_local-<id>`, `dlv_local-<id>`), its read-ACL row, and its creds file — everything the spawn's
|
|
@@ -3627,7 +3690,7 @@ export class Manager {
|
|
|
3627
3690
|
// The agent is its user's: only that user's retirement intent retires it, so the name stays
|
|
3628
3691
|
// held until retireDelegatedAgent confirms the host retired this UID.
|
|
3629
3692
|
const held = this.retiring.get(a.name);
|
|
3630
|
-
if (held?.lifecycleUid === a.lifecycleUid)
|
|
3693
|
+
if (held?.target.lifecycleUid === a.lifecycleUid)
|
|
3631
3694
|
held.lastError = `"${a.name}" was launched under a delegated user intent and retires only through its user's retirement intent`;
|
|
3632
3695
|
return;
|
|
3633
3696
|
}
|
|
@@ -3697,7 +3760,7 @@ export class Manager {
|
|
|
3697
3760
|
// legible operator copy. A retry re-drives this whole teardown (the same-name-spawn nudge routes
|
|
3698
3761
|
// through deprovision, not the rail alone), so the revoke is re-attempted, not stranded.
|
|
3699
3762
|
const holdRevoke = this.retiring.get(a.name);
|
|
3700
|
-
if (holdRevoke && holdRevoke.lifecycleUid === a.lifecycleUid)
|
|
3763
|
+
if (holdRevoke && holdRevoke.target.lifecycleUid === a.lifecycleUid)
|
|
3701
3764
|
holdRevoke.standingAuthorityLive = true;
|
|
3702
3765
|
try {
|
|
3703
3766
|
await resolveAuthProvider().revokeAgent({
|
|
@@ -3706,12 +3769,12 @@ export class Manager {
|
|
|
3706
3769
|
actor: a.name,
|
|
3707
3770
|
});
|
|
3708
3771
|
const done = this.retiring.get(a.name);
|
|
3709
|
-
if (done && done.lifecycleUid === a.lifecycleUid)
|
|
3772
|
+
if (done && done.target.lifecycleUid === a.lifecycleUid)
|
|
3710
3773
|
done.standingAuthorityLive = false;
|
|
3711
3774
|
}
|
|
3712
3775
|
catch (e) {
|
|
3713
3776
|
const h = this.retiring.get(a.name);
|
|
3714
|
-
if (h && h.lifecycleUid === a.lifecycleUid)
|
|
3777
|
+
if (h && h.target.lifecycleUid === a.lifecycleUid)
|
|
3715
3778
|
h.lastError = `the agent's standing mint authority could not be revoked (${rejectionText(e)}); the name stays held so a copied actor token cannot mint fresh credentials. NEXT: a same-name spawn re-drives the full teardown (including the revoke), or recover the auth state.`;
|
|
3716
3779
|
console.error(`revoke agent grant ${a.name}: ${rejectionText(e)}`);
|
|
3717
3780
|
}
|
|
@@ -3720,12 +3783,12 @@ export class Manager {
|
|
|
3720
3783
|
try {
|
|
3721
3784
|
const res = await this.deprovisionBroker(a);
|
|
3722
3785
|
const h = this.retiring.get(a.name);
|
|
3723
|
-
if (h && h.lifecycleUid === a.lifecycleUid)
|
|
3786
|
+
if (h && h.target.lifecycleUid === a.lifecycleUid)
|
|
3724
3787
|
h.lastResources = res;
|
|
3725
3788
|
}
|
|
3726
3789
|
catch (e) {
|
|
3727
3790
|
const h = this.retiring.get(a.name);
|
|
3728
|
-
if (h && h.lifecycleUid === a.lifecycleUid) {
|
|
3791
|
+
if (h && h.target.lifecycleUid === a.lifecycleUid) {
|
|
3729
3792
|
h.lastError = rejectionText(e);
|
|
3730
3793
|
if (e instanceof DeprovisionError)
|
|
3731
3794
|
h.lastResources = e.accounting;
|
|
@@ -3885,7 +3948,7 @@ export class Manager {
|
|
|
3885
3948
|
// CAS the hold clear (audit #1 ABA): free the alias ONLY if the current hold is still THIS
|
|
3886
3949
|
// lifecycle's. A late reply for a retired predecessor must never clear a successor's newer hold.
|
|
3887
3950
|
const cur = this.retiring.get(a.name);
|
|
3888
|
-
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
3951
|
+
if (cur && cur.target.lifecycleUid === a.lifecycleUid) {
|
|
3889
3952
|
if (cur.standingAuthorityLive) {
|
|
3890
3953
|
// INT-2: the auth-plane lifecycle retired, but the manager-side STANDING mint authority is
|
|
3891
3954
|
// not yet revoked. A copied token could still mint, so neither free the alias nor classify
|
|
@@ -4158,6 +4221,8 @@ export class Manager {
|
|
|
4158
4221
|
* comes from `restart.policy` when spawn carried `supervise`; otherwise the Pi session-recovery
|
|
4159
4222
|
* constants. Spending the budget falls through to normal retirement. */
|
|
4160
4223
|
recoverManagedSession(a) {
|
|
4224
|
+
if (this.execution === "none")
|
|
4225
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
4161
4226
|
const restart = a.restart;
|
|
4162
4227
|
if (!restart || !restart.armed || restart.recovering || a.terminalizing)
|
|
4163
4228
|
return;
|
|
@@ -4370,18 +4435,7 @@ export class Manager {
|
|
|
4370
4435
|
return false;
|
|
4371
4436
|
}
|
|
4372
4437
|
/** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
|
|
4373
|
-
opStart(args, caller, admin, hooks, route = "inst") {
|
|
4374
|
-
// `resume`, when present, must be a non-empty session id. An empty/whitespace value is a
|
|
4375
|
-
// malformed request, not an implicit "spawn fresh" (no fallbacks). The CLI surfaces reject it,
|
|
4376
|
-
// but a raw control message could otherwise slip an empty value through and silently start fresh.
|
|
4377
|
-
if (args.resume !== undefined && !String(args.resume).trim())
|
|
4378
|
-
return Promise.resolve({ ok: false, error: "resume: session id must not be empty" });
|
|
4379
|
-
if (args.model !== undefined && !String(args.model).trim())
|
|
4380
|
-
return Promise.resolve({ ok: false, error: "model: must not be empty" });
|
|
4381
|
-
if (args.variant !== undefined && !String(args.variant).trim())
|
|
4382
|
-
return Promise.resolve({ ok: false, error: "variant: must not be empty" });
|
|
4383
|
-
if (args.defaultAgent !== undefined && !String(args.defaultAgent).trim())
|
|
4384
|
-
return Promise.resolve({ ok: false, error: "defaultAgent: must not be empty" });
|
|
4438
|
+
opStart(args, caller, admin, hooks, route = "inst", authenticatedCaller) {
|
|
4385
4439
|
// Opaque launch options, when present, must be a mapping — a raw control message could send a
|
|
4386
4440
|
// scalar/array (the CLI never does). Core doesn't interpret the keys; the connector validates them.
|
|
4387
4441
|
if (args.launchOptions !== undefined && (typeof args.launchOptions !== "object" || args.launchOptions === null || Array.isArray(args.launchOptions)))
|
|
@@ -4454,28 +4508,28 @@ export class Manager {
|
|
|
4454
4508
|
}
|
|
4455
4509
|
return this.startAgent({
|
|
4456
4510
|
name: String(args.name ?? "").trim(),
|
|
4457
|
-
agent: args.agent
|
|
4458
|
-
defaultAgent: args.defaultAgent
|
|
4459
|
-
role: args.role
|
|
4460
|
-
config: args.config
|
|
4461
|
-
identity: args.identity
|
|
4462
|
-
model: args.model
|
|
4463
|
-
variant: args.variant
|
|
4511
|
+
agent: args.agent,
|
|
4512
|
+
defaultAgent: args.defaultAgent,
|
|
4513
|
+
role: args.role,
|
|
4514
|
+
config: args.config,
|
|
4515
|
+
identity: args.identity,
|
|
4516
|
+
model: args.model,
|
|
4517
|
+
variant: args.variant,
|
|
4464
4518
|
launchOptions: args.launchOptions,
|
|
4465
|
-
resume: args.resume
|
|
4466
|
-
resumeClaim: args.resumeClaim
|
|
4467
|
-
resumeAgent: args.resumeAgent
|
|
4519
|
+
resume: args.resume,
|
|
4520
|
+
resumeClaim: args.resumeClaim,
|
|
4521
|
+
resumeAgent: args.resumeAgent,
|
|
4468
4522
|
events,
|
|
4469
4523
|
eventsNotice,
|
|
4470
|
-
cwd: args.cwd
|
|
4471
|
-
prompt: args.prompt
|
|
4524
|
+
cwd: args.cwd,
|
|
4525
|
+
prompt: args.prompt,
|
|
4472
4526
|
subscribe,
|
|
4473
4527
|
allowSubscribe,
|
|
4474
4528
|
allowPublish,
|
|
4475
4529
|
shareTools,
|
|
4476
4530
|
...(supervise !== undefined ? { supervise } : {}),
|
|
4477
4531
|
route,
|
|
4478
|
-
}, caller, hooks);
|
|
4532
|
+
}, caller, hooks, authenticatedCaller);
|
|
4479
4533
|
}
|
|
4480
4534
|
/** The realpath + directory checks a placed spawn directory must pass on this manager's host,
|
|
4481
4535
|
* shared by the served `resolve-cwd` command and the `spawn` admission check (#385 item 2): one
|
|
@@ -4534,6 +4588,10 @@ export class Manager {
|
|
|
4534
4588
|
* reports ready. A missing harness degrades only that connector, never unrelated lifecycle work:
|
|
4535
4589
|
* boot continues, but prints a named refusal and records it on both manager status surfaces. */
|
|
4536
4590
|
async inspectConnectorsAtBoot() {
|
|
4591
|
+
if (this.execution === "none") {
|
|
4592
|
+
this.connectorStatuses = [];
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4537
4595
|
const rows = [];
|
|
4538
4596
|
let declared;
|
|
4539
4597
|
if (this.installedExtensions) {
|
|
@@ -4569,6 +4627,8 @@ export class Manager {
|
|
|
4569
4627
|
* inventory load) skips the class `one` subscribe for spawn/launch so this member cannot
|
|
4570
4628
|
* consume an unpinned request a sibling could serve. */
|
|
4571
4629
|
classSpawnEnabled() {
|
|
4630
|
+
if (this.execution === "none")
|
|
4631
|
+
return false;
|
|
4572
4632
|
return this.connectorStatuses.some((row) => row.state === "available");
|
|
4573
4633
|
}
|
|
4574
4634
|
omitClassCommands() {
|
|
@@ -4692,6 +4752,8 @@ export class Manager {
|
|
|
4692
4752
|
* `runId`, and resolved `hash`. USER mesh: a privileged-tier launch is owner-equality-authorized
|
|
4693
4753
|
* (spec owner === caller owner) before any side effect; the admin tier keeps operator behavior. */
|
|
4694
4754
|
async opLaunch(args, caller, admin, hooks, route = "inst") {
|
|
4755
|
+
if (this.execution === "none")
|
|
4756
|
+
return { ok: false, error: this.executionRefusal() };
|
|
4695
4757
|
const runId = String(args.runId ?? "").trim();
|
|
4696
4758
|
const name = String(args.name ?? "").trim();
|
|
4697
4759
|
if (!runId || !name)
|
|
@@ -4756,30 +4818,23 @@ export class Manager {
|
|
|
4756
4818
|
* `spawner` is the authenticated id of the peer that requested the spawn (`req.from.id`),
|
|
4757
4819
|
* defaulting to the manager's own id for roster/pre-spawn — recorded for the spawner
|
|
4758
4820
|
* ledger (own-children despawn + reap-on-parent-exit). */
|
|
4759
|
-
async startAgent(opts, spawner, hooks) {
|
|
4821
|
+
async startAgent(opts, spawner, hooks, authenticatedCaller) {
|
|
4822
|
+
if (this.execution === "none")
|
|
4823
|
+
return { ok: false, error: this.executionRefusal() };
|
|
4760
4824
|
const release = this.beginLifecycle();
|
|
4761
4825
|
if (!release)
|
|
4762
4826
|
return { ok: false, error: this.maintenanceError() };
|
|
4763
4827
|
try {
|
|
4764
|
-
return await this.startAgentActive(opts, spawner, hooks);
|
|
4828
|
+
return await this.startAgentActive(opts, spawner, hooks, authenticatedCaller);
|
|
4765
4829
|
}
|
|
4766
4830
|
finally {
|
|
4767
4831
|
release();
|
|
4768
4832
|
}
|
|
4769
4833
|
}
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
opts.resolved = ownLists(opts.resolved);
|
|
4775
|
-
if (opts.delegatedIntent) {
|
|
4776
|
-
if (!this.remoteAuthority?.executeDelegatedUserIntent)
|
|
4777
|
-
return { ok: false, error: `"${opts.name}" names a delegated user intent, and this manager has no host execution for one` };
|
|
4778
|
-
if (opts.supervise)
|
|
4779
|
-
return { ok: false, error: `"${opts.name}" names a delegated user intent, and a delegated agent is never restarted (SPEC 13.16)` };
|
|
4780
|
-
// A delegated launch's spawner is the user's principal, never the composition root's call.
|
|
4781
|
-
spawner = opts.delegatedIntent.parent;
|
|
4782
|
-
}
|
|
4834
|
+
/** The persona file a spawn loads and the harness it resolves, shared by `spawn` and the served
|
|
4835
|
+
* `resolve-agent` read: a detached resume locates the session it carries with the connector this
|
|
4836
|
+
* names, so the two doors must not drift (#2899). */
|
|
4837
|
+
async spawnPersona(opts, spawner, authenticatedCaller) {
|
|
4783
4838
|
// The spawn argument is a persona REF — a filename in `.cotal/agents` (the unique spawn KEY), or
|
|
4784
4839
|
// a path via `--config`. It is NOT the mesh identity: the identity comes from inside the file
|
|
4785
4840
|
// (`name:`), so a persona can be filed descriptively (review-critic.md) yet present under a
|
|
@@ -4787,48 +4842,79 @@ export class Manager {
|
|
|
4787
4842
|
// filename (unique on disk); two files can't collide on the key.
|
|
4788
4843
|
const ref = opts.name.trim();
|
|
4789
4844
|
if (!ref)
|
|
4790
|
-
return {
|
|
4845
|
+
return { error: "name required" };
|
|
4791
4846
|
// A bare ref maps to `.cotal/agents/<ref>.md`, so it must be a safe token (no path traversal); a
|
|
4792
4847
|
// `--config` path is validated by existsSync below instead.
|
|
4793
4848
|
if (!opts.config) {
|
|
4794
4849
|
const refErr = this.nameError(ref);
|
|
4795
4850
|
if (refErr)
|
|
4796
|
-
return {
|
|
4851
|
+
return { error: refErr };
|
|
4797
4852
|
}
|
|
4798
4853
|
// Resolve the persona file (fail loud — NO silent default-ACL fallback). A missing persona used
|
|
4799
4854
|
// to mint DEFAULT creds (read `general` only, default-deny publish, no capabilities), so a
|
|
4800
4855
|
// typo'd / renamed / spawned-by-display-name agent became live with silently-wrong ACLs — a
|
|
4801
4856
|
// behavioral/security bug. Fail loud instead, matching `cotal spawn` (loadAgentFile throws).
|
|
4802
|
-
// This runs BEFORE the
|
|
4803
|
-
// harness choice (flag > file > COTAL_DEFAULT_AGENT > default, #869), which is impossible if
|
|
4804
|
-
// connector is materialized first.
|
|
4805
|
-
// reserve span below stays atomic.
|
|
4857
|
+
// This runs BEFORE the spawn path resolves its connector: the file's `agent:` pin participates in
|
|
4858
|
+
// the harness choice (flag > file > COTAL_DEFAULT_AGENT > default, #869), which is impossible if
|
|
4859
|
+
// the connector is materialized first.
|
|
4806
4860
|
let configPath;
|
|
4807
4861
|
if (opts.config) {
|
|
4808
4862
|
configPath = agentFilePath(this.workspaceRoot, opts.config);
|
|
4809
4863
|
if (!existsSync(configPath))
|
|
4810
|
-
return {
|
|
4864
|
+
return { error: `agent file not found: ${configPath}` };
|
|
4811
4865
|
}
|
|
4812
4866
|
else {
|
|
4813
4867
|
configPath = agentFilePath(this.workspaceRoot, ref);
|
|
4814
4868
|
if (!existsSync(configPath))
|
|
4815
|
-
return {
|
|
4869
|
+
return { error: `no persona "${ref}" - ${configPath} not found; create it or pass --config (see \`cotal personas list\`)` };
|
|
4816
4870
|
}
|
|
4817
4871
|
// Load the persona ONCE, here, ahead of the connector choice. The file's `agent:` pin feeds the
|
|
4818
|
-
// harness resolution below; the identity/ACL profile block
|
|
4819
|
-
// load). A manifest launch (`resolved`) materializes its own transient persona and the
|
|
4820
|
-
// NOT its authority, so its def (and its agent pin) is deliberately not consulted here —
|
|
4821
|
-
// manifest's own `agent:` field drove the launch object already.
|
|
4872
|
+
// harness resolution below; the spawn path's identity/ACL profile block consumes the SAME def (no
|
|
4873
|
+
// second load). A manifest launch (`resolved`) materializes its own transient persona and the
|
|
4874
|
+
// file is NOT its authority, so its def (and its agent pin) is deliberately not consulted here —
|
|
4875
|
+
// the manifest's own `agent:` field drove the launch object already.
|
|
4822
4876
|
let def;
|
|
4823
4877
|
if (!opts.resolved) {
|
|
4824
4878
|
try {
|
|
4825
4879
|
def = loadAgentFile(configPath);
|
|
4826
4880
|
}
|
|
4827
4881
|
catch (e) {
|
|
4828
|
-
return {
|
|
4882
|
+
return { error: rejectionText(e) };
|
|
4883
|
+
}
|
|
4884
|
+
// Shared operator files remain launchable. Owned content follows the catalog's visibility
|
|
4885
|
+
// decision before connector loading or any allocation. A hosted run acts for its admission;
|
|
4886
|
+
// only a complete authenticated caller can request the current ledger's admin decision.
|
|
4887
|
+
if (this.userMode && def.owner !== undefined) {
|
|
4888
|
+
const acting = authenticatedCaller === undefined ? undefined
|
|
4889
|
+
: this.runHosting?.admittedCaller(authenticatedCaller) ?? authenticatedCaller;
|
|
4890
|
+
const personaCaller = acting === undefined ? spawner ?? "" : principalKey(acting.owner, acting.actor).key;
|
|
4891
|
+
const admin = authenticatedCaller !== undefined && await this.epAdminReach(authenticatedCaller);
|
|
4892
|
+
if (!this.canReadPersona(def.owner, personaCaller, admin))
|
|
4893
|
+
return { error: `no persona "${ref}"` };
|
|
4829
4894
|
}
|
|
4830
4895
|
}
|
|
4831
|
-
|
|
4896
|
+
return { ref, configPath, def, agent: resolveAgentType({ flag: opts.agent, pin: def?.agent, callerDefault: opts.defaultAgent }) };
|
|
4897
|
+
}
|
|
4898
|
+
async startAgentActive(opts, spawner, hooks, authenticatedCaller) {
|
|
4899
|
+
// Before the first await, so the grant, the launch, the retained form and the restart slot all read
|
|
4900
|
+
// the lists and the restart policy as admitted.
|
|
4901
|
+
opts = ownLists(opts);
|
|
4902
|
+
if (opts.resolved)
|
|
4903
|
+
opts.resolved = ownLists(opts.resolved);
|
|
4904
|
+
if (opts.supervise)
|
|
4905
|
+
opts.supervise = { restarts: opts.supervise.restarts, windowMs: opts.supervise.windowMs };
|
|
4906
|
+
if (opts.delegatedIntent) {
|
|
4907
|
+
if (!this.remoteAuthority?.executeDelegatedUserIntent)
|
|
4908
|
+
return { ok: false, error: `"${opts.name}" names a delegated user intent, and this manager has no host execution for one` };
|
|
4909
|
+
if (opts.supervise)
|
|
4910
|
+
return { ok: false, error: `"${opts.name}" names a delegated user intent, and a delegated agent is never restarted (SPEC 13.16)` };
|
|
4911
|
+
// A delegated launch's spawner is the user's principal, never the composition root's call.
|
|
4912
|
+
spawner = opts.delegatedIntent.parent;
|
|
4913
|
+
}
|
|
4914
|
+
const persona = await this.spawnPersona(opts, spawner, authenticatedCaller);
|
|
4915
|
+
if ("error" in persona)
|
|
4916
|
+
return { ok: false, error: persona.error };
|
|
4917
|
+
const { ref, configPath, def, agent } = persona;
|
|
4832
4918
|
// Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
|
|
4833
4919
|
// `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
|
|
4834
4920
|
// below so that span stays fully SYNCHRONOUS and atomic. On the manifest binary this also honors a
|
|
@@ -4886,15 +4972,15 @@ export class Manager {
|
|
|
4886
4972
|
// exit, so a name cannot be respawned in place. User-mode seats have no static slot that
|
|
4887
4973
|
// keeps the incarnation owned across a process death, so the same refusal applies there.
|
|
4888
4974
|
if (opts.supervise !== undefined) {
|
|
4889
|
-
if (this.
|
|
4890
|
-
return { ok: false, error: `supervise is a restart policy this host cannot enforce: runtime "${this.
|
|
4975
|
+
if (this.requiredRuntime.kind !== "pty")
|
|
4976
|
+
return { ok: false, error: `supervise is a restart policy this host cannot enforce: runtime "${this.requiredRuntime.kind}" cannot respawn a name in place` };
|
|
4891
4977
|
if (this.userMode)
|
|
4892
4978
|
return { ok: false, error: "supervise is a restart policy this host cannot enforce: a user-mode seat has no static slot to keep the incarnation owned across a process death" };
|
|
4893
4979
|
}
|
|
4894
4980
|
// A delegated seat starts outside this host from values alone, so a choice only this host can
|
|
4895
4981
|
// honour is refused here, before enrollment, and named rather than dropped.
|
|
4896
|
-
if (this.
|
|
4897
|
-
const delegatedBy = `runtime "${this.
|
|
4982
|
+
if (this.requiredRuntime.spawnDelegated) {
|
|
4983
|
+
const delegatedBy = `runtime "${this.requiredRuntime.kind}" starts seats outside this host`;
|
|
4898
4984
|
if (!this.remoteAuthority?.enrollManagedAgent)
|
|
4899
4985
|
return { ok: false, error: `${delegatedBy}, which needs a host that enrolls managed agents (remoteAuthority.enrollManagedAgent); a delegated seat has no local grant path` };
|
|
4900
4986
|
try {
|
|
@@ -5055,7 +5141,7 @@ export class Manager {
|
|
|
5055
5141
|
return { ok: false, error: `the name "${identityName}" is still reconciling at manager startup; its prior lifecycle terminal owns this alias until it completes. Retry shortly.` };
|
|
5056
5142
|
const held = this.retiring.get(identityName);
|
|
5057
5143
|
if (held !== undefined) {
|
|
5058
|
-
void this.deprovision(
|
|
5144
|
+
void this.deprovision(held.target).catch(() => { });
|
|
5059
5145
|
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" });
|
|
5060
5146
|
return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
|
|
5061
5147
|
}
|
|
@@ -5076,9 +5162,9 @@ export class Manager {
|
|
|
5076
5162
|
}
|
|
5077
5163
|
}
|
|
5078
5164
|
// The child takes launch options as `--opt k=v`, so only string values can cross to it.
|
|
5079
|
-
const unportable = this.
|
|
5165
|
+
const unportable = this.requiredRuntime.spawnDelegated ? Object.keys(launchOptions ?? {}).filter((k) => typeof launchOptions[k] !== "string") : [];
|
|
5080
5166
|
if (unportable.length)
|
|
5081
|
-
return { ok: false, error: `runtime "${this.
|
|
5167
|
+
return { ok: false, error: `runtime "${this.requiredRuntime.kind}" starts seats outside this host, so it cannot pass non-string launch options (${unportable.join(", ")})` };
|
|
5082
5168
|
// The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
|
|
5083
5169
|
// anything is minted: a connector that cannot
|
|
5084
5170
|
// emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
|
|
@@ -5115,7 +5201,7 @@ export class Manager {
|
|
|
5115
5201
|
// incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent `_2`
|
|
5116
5202
|
// suffix (so an address-by-triple caller's pinned name can't be re-pointed). A PERSONA-DERIVED
|
|
5117
5203
|
// base name (no pin) keeps uniqueName's collision numbering, so multi-peer `spawn reviewer` twice
|
|
5118
|
-
// still yields reviewer + reviewer_2. The
|
|
5204
|
+
// still yields reviewer + reviewer_2. The alias-reuse gate above is orthogonal and already fired.
|
|
5119
5205
|
const hardPinned = opts.identity !== undefined || opts.resolved !== undefined;
|
|
5120
5206
|
let name;
|
|
5121
5207
|
if (hardPinned) {
|
|
@@ -5399,12 +5485,12 @@ export class Manager {
|
|
|
5399
5485
|
// manager path is produced for it, only the handoff its runtime writes into the child.
|
|
5400
5486
|
let spec;
|
|
5401
5487
|
let handle;
|
|
5402
|
-
if (this.
|
|
5488
|
+
if (this.requiredRuntime.spawnDelegated) {
|
|
5403
5489
|
const handoff = this.composeManagedHandoff(enrolled.material, enrolled.actorToken);
|
|
5404
5490
|
if (this.delegatedLaunched.has(lifecycleUid))
|
|
5405
|
-
throw new Error(`lifecycle ${lifecycleUid} was already handed to runtime "${this.
|
|
5491
|
+
throw new Error(`lifecycle ${lifecycleUid} was already handed to runtime "${this.requiredRuntime.kind}"; a lifecycle is handed off at most once`);
|
|
5406
5492
|
this.delegatedLaunched.add(lifecycleUid);
|
|
5407
|
-
handle = this.
|
|
5493
|
+
handle = this.requiredRuntime.spawnDelegated({ agent, persona: readFileSync(configPath, "utf8"), role, model, variant, prompt, launchOptions: launchOptions, events }, handoff);
|
|
5408
5494
|
}
|
|
5409
5495
|
else {
|
|
5410
5496
|
if (carried) {
|
|
@@ -5619,7 +5705,7 @@ export class Manager {
|
|
|
5619
5705
|
// retires it, and its name stays held for retireDelegatedAgent.
|
|
5620
5706
|
const p = orphan.delegated ? parsePrincipalKey(orphan.id) : null;
|
|
5621
5707
|
if (p)
|
|
5622
|
-
this.retiring.set(orphan.name, { opId: retireOpId(orphan.lifecycleUid),
|
|
5708
|
+
this.retiring.set(orphan.name, { opId: retireOpId(orphan.lifecycleUid), owner: p.owner, actor: p.actor, target: orphan, startedAt: Date.now() });
|
|
5623
5709
|
this.trackDeprovision(orphan, "(orphaned spawn)");
|
|
5624
5710
|
}
|
|
5625
5711
|
}
|
|
@@ -5628,6 +5714,8 @@ export class Manager {
|
|
|
5628
5714
|
* principal without provisioning. A later runtime launch failure is reported per-agent, but malformed
|
|
5629
5715
|
* or missing inventory material can never produce a partially resumed set. */
|
|
5630
5716
|
async resumePreserved(inventory) {
|
|
5717
|
+
if (this.execution === "none" && (!Array.isArray(inventory.agents) || inventory.agents.length !== 0))
|
|
5718
|
+
return { ok: false, agents: [], error: this.executionRefusal() };
|
|
5631
5719
|
const release = this.beginLifecycle(true);
|
|
5632
5720
|
if (!release)
|
|
5633
5721
|
return { ok: false, agents: [], error: this.maintenanceError() };
|
|
@@ -5660,19 +5748,11 @@ export class Manager {
|
|
|
5660
5748
|
// already removed the hold, so fresh-lifecycle resume still reaches the fail-closed roster gate.
|
|
5661
5749
|
const held = this.retiring.get(entry.name);
|
|
5662
5750
|
if (held) {
|
|
5663
|
-
void this.deprovision({
|
|
5664
|
-
id: held.agentId,
|
|
5665
|
-
name: entry.name,
|
|
5666
|
-
lifecycleUid: held.lifecycleUid,
|
|
5667
|
-
userOwner: held.userOwner,
|
|
5668
|
-
delegated: held.delegated,
|
|
5669
|
-
secretPaths: held.secretPaths,
|
|
5670
|
-
delegatedHandle: held.delegatedHandle,
|
|
5671
|
-
}).catch(() => { });
|
|
5751
|
+
void this.deprovision(held.target).catch(() => { });
|
|
5672
5752
|
return {
|
|
5673
5753
|
ok: false,
|
|
5674
5754
|
agents: [],
|
|
5675
|
-
error: `retained agent "${entry.name}" is reserved pending retirement: its previous lifecycle ${held.lifecycleUid} still owns the alias${held.lastError ? ` (last attempt: ${held.lastError})` : ""}; retrying re-drives that exact teardown`,
|
|
5755
|
+
error: `retained agent "${entry.name}" is reserved pending retirement: its previous lifecycle ${held.target.lifecycleUid} still owns the alias${held.lastError ? ` (last attempt: ${held.lastError})` : ""}; retrying re-drives that exact teardown`,
|
|
5676
5756
|
};
|
|
5677
5757
|
}
|
|
5678
5758
|
let principal;
|
|
@@ -5938,7 +6018,7 @@ export class Manager {
|
|
|
5938
6018
|
async orphanedResumeSeat(entry, principal, liveRoster) {
|
|
5939
6019
|
if (!this.auth || this.userMode || entry.identity.mode !== "static")
|
|
5940
6020
|
return undefined;
|
|
5941
|
-
if (typeof this.
|
|
6021
|
+
if (typeof this.requiredRuntime.reap !== "function")
|
|
5942
6022
|
return undefined;
|
|
5943
6023
|
if (liveRoster.some((p) => p.card.id === principal && p.lifecycleUid !== entry.identity.lifecycleUid))
|
|
5944
6024
|
return undefined;
|
|
@@ -5947,14 +6027,14 @@ export class Manager {
|
|
|
5947
6027
|
return undefined;
|
|
5948
6028
|
if (ownedBySibling(slot, this.managerInstanceId))
|
|
5949
6029
|
return undefined;
|
|
5950
|
-
return slot.runtime?.kind === this.
|
|
6030
|
+
return slot.runtime?.kind === this.requiredRuntime.kind ? slot.runtime : undefined;
|
|
5951
6031
|
}
|
|
5952
6032
|
/** Reap a seat {@link orphanedResumeSeat} found, then wait for its principal to leave presence so
|
|
5953
6033
|
* the relaunch's readiness cannot match the dead seat's row. Returns the refusal when the reap
|
|
5954
6034
|
* proves nothing or the principal stays live, which means another process holds it. */
|
|
5955
6035
|
async reapOrphanedResumeSeat(principal, reference) {
|
|
5956
6036
|
try {
|
|
5957
|
-
const evidence = await requireRuntimeReap(this.
|
|
6037
|
+
const evidence = await requireRuntimeReap(this.requiredRuntime, reference);
|
|
5958
6038
|
console.error(`resume: reaped orphaned seat ${reference.kind}:${reference.id} of retained principal "${principal}" (${evidence.detail})`);
|
|
5959
6039
|
}
|
|
5960
6040
|
catch (e) {
|
|
@@ -5971,14 +6051,16 @@ export class Manager {
|
|
|
5971
6051
|
/** Validate/relaunch one retained inventory entry. Called only through resumePreserved so all
|
|
5972
6052
|
* records pass the same preflight before the first child is exposed. */
|
|
5973
6053
|
async resumePreservedAgent(entry, preflightOnly = false, batchReserved = false, prepared) {
|
|
6054
|
+
if (this.execution === "none")
|
|
6055
|
+
return { ok: false, error: this.executionRefusal() };
|
|
5974
6056
|
const release = this.beginLifecycle(batchReserved);
|
|
5975
6057
|
if (!release)
|
|
5976
6058
|
return { ok: false, error: this.maintenanceError() };
|
|
5977
6059
|
try {
|
|
5978
6060
|
if (entry.space !== this.space)
|
|
5979
6061
|
return { ok: false, error: `retained agent ${entry.name} belongs to space "${entry.space}", not "${this.space}"` };
|
|
5980
|
-
if (entry.launch.runtime !== this.
|
|
5981
|
-
return { ok: false, error: `retained agent ${entry.name} requires runtime "${entry.launch.runtime}", current manager uses "${this.
|
|
6062
|
+
if (entry.launch.runtime !== this.requiredRuntime.kind)
|
|
6063
|
+
return { ok: false, error: `retained agent ${entry.name} requires runtime "${entry.launch.runtime}", current manager uses "${this.requiredRuntime.kind}"` };
|
|
5982
6064
|
const nameErr = this.nameError(entry.name);
|
|
5983
6065
|
if (nameErr)
|
|
5984
6066
|
return { ok: false, error: nameErr };
|
|
@@ -6259,13 +6341,13 @@ export class Manager {
|
|
|
6259
6341
|
* would reap it: it is freed only once its exit is proved, and otherwise stays managed until it
|
|
6260
6342
|
* exits or the manager stops it. */
|
|
6261
6343
|
async stopFailedResume(managed, cause, detail) {
|
|
6262
|
-
this.stopHandle(managed, false);
|
|
6263
|
-
|
|
6264
|
-
await this.awaitHandleExit(managed.handle)
|
|
6265
|
-
|
|
6266
|
-
|
|
6344
|
+
const refused = this.stopHandle(managed, false);
|
|
6345
|
+
const unproven = refused === undefined
|
|
6346
|
+
? await this.awaitHandleExit(managed.handle).then(() => undefined, rejectionText)
|
|
6347
|
+
: `stop failed: ${refused}`;
|
|
6348
|
+
if (unproven !== undefined) {
|
|
6267
6349
|
this.watchExit(managed);
|
|
6268
|
-
return { ok: false, error: `${detail}; it stays managed because its stop is unproven: ${
|
|
6350
|
+
return { ok: false, error: `${detail}; it stays managed because its stop is unproven: ${unproven}` };
|
|
6269
6351
|
}
|
|
6270
6352
|
this.freeSlot(managed, true, cause, true);
|
|
6271
6353
|
return { ok: false, error: detail };
|
|
@@ -6366,28 +6448,9 @@ export class Manager {
|
|
|
6366
6448
|
if (done || !s)
|
|
6367
6449
|
return;
|
|
6368
6450
|
clearTimeout(timer);
|
|
6369
|
-
//
|
|
6370
|
-
//
|
|
6371
|
-
//
|
|
6372
|
-
// for an agent that really did die on launch. `onAgentExit` reaches `freeSlot`, which sets
|
|
6373
|
-
// this SAME latch on its way through (see its "also covers exit/reap paths" comment), so a
|
|
6374
|
-
// read taken after that call is true for EVERY exit, deliberate or not.
|
|
6375
|
-
//
|
|
6376
|
-
// Read HERE — first statement, before the await and before `onAgentExit` — a set latch can
|
|
6377
|
-
// only have been set by someone else, and the only other setter on this path is
|
|
6378
|
-
// `stopHandle`, which latches SYNCHRONOUSLY and then kills with no suspension between. So a
|
|
6379
|
-
// despawn-caused exit is guaranteed observed with the latch UP and a natural exit with it
|
|
6380
|
-
// DOWN: the distinction holds by program order, never by winning a race.
|
|
6381
|
-
//
|
|
6382
|
-
// The same latch, read one function call apart, answers two different questions.
|
|
6383
|
-
//
|
|
6384
|
-
// TO RE-VERIFY (this is the mutation that proves it, and it is the exact regression a tidy
|
|
6385
|
-
// refactor produces): move this capture below `onAgentExit` and run
|
|
6386
|
-
// `pnpm smoke:manager-spawn-action`. M3 `process exit -> failed` must FAIL. Note that M4 —
|
|
6387
|
-
// the case this fix exists for — still PASSES under that mutation, so the suite this fix
|
|
6388
|
-
// was written against cannot catch its own regression. M3 catches it only because it
|
|
6389
|
-
// happens to share a file.
|
|
6390
|
-
// └──────────────────────────────────────────────────────────────────────────────────────┘
|
|
6451
|
+
// `stopHandle` sets `terminalizing` synchronously before it kills, and `onAgentExit` sets it
|
|
6452
|
+
// through `freeSlot` when it retires the seat, so only a read before the await and the reap
|
|
6453
|
+
// tells a deliberate stop from a launch failure.
|
|
6391
6454
|
const deliberate = a.terminalizing === true;
|
|
6392
6455
|
void (async () => {
|
|
6393
6456
|
// waitForExit may close the attach stream before this snapshot
|
|
@@ -6412,7 +6475,23 @@ export class Manager {
|
|
|
6412
6475
|
finish({ ok: false, deliberate: true, detail: `${a.name} was stopped before it reported ready` });
|
|
6413
6476
|
return;
|
|
6414
6477
|
}
|
|
6415
|
-
|
|
6478
|
+
// The screen's last row can be a dialog footer, so the exit's diagnostic is named beside it:
|
|
6479
|
+
// it carries the runtime's own reason when it stopped the seat. A connector that ends on its
|
|
6480
|
+
// diagnostic already shows it as the last row, and it is not repeated.
|
|
6481
|
+
let exitDetail = "";
|
|
6482
|
+
try {
|
|
6483
|
+
const diagnostic = a.handle.exitInfo?.()?.diagnostic;
|
|
6484
|
+
if (diagnostic && diagnostic !== tail)
|
|
6485
|
+
exitDetail = `; diagnostic: ${diagnostic}`;
|
|
6486
|
+
}
|
|
6487
|
+
catch (e) {
|
|
6488
|
+
// The backstop is already cleared, so a reader that throws must still let this settle.
|
|
6489
|
+
exitDetail = `; exit detail unreadable from runtime "${a.handle.kind}": ${rejectionText(e)}`;
|
|
6490
|
+
}
|
|
6491
|
+
// A runtime's text can carry a lone surrogate, which is not I-JSON, and the failed terminal
|
|
6492
|
+
// this detail becomes is refused with it.
|
|
6493
|
+
exitDetail = exitDetail.replace(/\p{Cs}/gu, "\uFFFD");
|
|
6494
|
+
finish({ ok: false, detail: `${a.name} exited on launch${tail ? ` - last output: ${tail}` : ""}${exitDetail}` });
|
|
6416
6495
|
})();
|
|
6417
6496
|
};
|
|
6418
6497
|
timer = setTimeout(() => finish({
|
|
@@ -6459,25 +6538,15 @@ export class Manager {
|
|
|
6459
6538
|
return chars.length > 160 ? `…${chars.slice(-160).join("")}` : text;
|
|
6460
6539
|
}
|
|
6461
6540
|
/** Subscribe to a managed agent's process-exit so a self-driven exit frees its slot and reaps
|
|
6462
|
-
* its children (P4b/P4c).
|
|
6463
|
-
*
|
|
6464
|
-
* by nothing until it's explicitly despawned (graceful-stop runs on despawn, not self-exit). The
|
|
6465
|
-
* cap still holds (a lingering corpse counts toward it); runtime-agnostic exit-reaping (a real
|
|
6466
|
-
* per-runtime `status()` → exited-sweep at the availability gate) is a tracked follow-up. */
|
|
6541
|
+
* its children (P4b/P4c). The watch is bound to the current handle: a restart swaps in a new one
|
|
6542
|
+
* and watches it, so a late exit proved for the old handle must not retire the row. */
|
|
6467
6543
|
watchExit(a) {
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
// subscribing and reap it now if it already went. onAgentExit is idempotent (freeSlot's guard).
|
|
6475
|
-
if (a.handle.status() === "exited")
|
|
6476
|
-
this.onAgentExit(a);
|
|
6477
|
-
}
|
|
6478
|
-
catch {
|
|
6479
|
-
/* runtime doesn't stream an exit signal — nothing to wire */
|
|
6480
|
-
}
|
|
6544
|
+
const handle = a.handle;
|
|
6545
|
+
const current = () => this.agents.get(a.name) === a && a.handle === handle;
|
|
6546
|
+
const watched = watchProvedExit(handle, () => { if (current())
|
|
6547
|
+
this.onAgentExit(a); }, current);
|
|
6548
|
+
if (!watched)
|
|
6549
|
+
console.error(`! ${a.name}: runtime "${handle.kind}" cannot prove a child exit (AgentHandle.waitForExit is not implemented) - it stays managed after it exits, until it is stopped`);
|
|
6481
6550
|
}
|
|
6482
6551
|
/** Prune expired cooling stamps (drop those at/before now) and return the live count — the
|
|
6483
6552
|
* recycle floor's contribution to the ceiling (P4c). Lazy: pruned only when the gate consults it. */
|
|
@@ -6571,8 +6640,8 @@ export class Manager {
|
|
|
6571
6640
|
try {
|
|
6572
6641
|
const live = this.agents.get(name);
|
|
6573
6642
|
const held = live ? undefined : this.retiring.get(name);
|
|
6574
|
-
const principal = live?.delegated ? parsePrincipalKey(live.id) : held?.delegated ? held : null;
|
|
6575
|
-
const lifecycleUid = live?.lifecycleUid ?? held?.lifecycleUid;
|
|
6643
|
+
const principal = live?.delegated ? parsePrincipalKey(live.id) : held?.target.delegated ? held : null;
|
|
6644
|
+
const lifecycleUid = live?.lifecycleUid ?? held?.target.lifecycleUid;
|
|
6576
6645
|
if (!principal || !lifecycleUid)
|
|
6577
6646
|
return { ok: false, error: `no agent named "${name}" was launched on this manager under a delegated user intent` };
|
|
6578
6647
|
const target = { owner: principal.owner, actor: principal.actor, lifecycleUid };
|
|
@@ -6587,7 +6656,7 @@ export class Manager {
|
|
|
6587
6656
|
catch (e) {
|
|
6588
6657
|
const error = `the delegated retirement under intent ${intentId} did not complete (${rejectionText(e)})`;
|
|
6589
6658
|
const hold = this.retiring.get(name);
|
|
6590
|
-
if (hold?.lifecycleUid === target.lifecycleUid)
|
|
6659
|
+
if (hold?.target.lifecycleUid === target.lifecycleUid)
|
|
6591
6660
|
hold.lastError = error;
|
|
6592
6661
|
return { ok: false, error: `${error}; "${name}" is unchanged` };
|
|
6593
6662
|
}
|
|
@@ -6733,8 +6802,11 @@ export class Manager {
|
|
|
6733
6802
|
managerStatusData() {
|
|
6734
6803
|
return {
|
|
6735
6804
|
instanceId: this.managerInstanceId,
|
|
6736
|
-
|
|
6737
|
-
|
|
6805
|
+
execution: this.execution,
|
|
6806
|
+
runtime: this.runtimeKind,
|
|
6807
|
+
custody: this.execution === "none" ? "none" : this.requiredRuntime.kind === "pty" && this.requiredRuntime.supportsRelease === true ? "custodied" : "legacy",
|
|
6808
|
+
runHosting: this.execution !== "none" && (this.remoteAuthority ? this.remoteAuthority.runHosting !== undefined : !this.userMode && this.auth !== undefined),
|
|
6809
|
+
terminalSessions: this.execution !== "none",
|
|
6738
6810
|
agentCount: this.agents.size,
|
|
6739
6811
|
uptimeMs: Date.now() - this.startedAtMs,
|
|
6740
6812
|
connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
|
|
@@ -7742,6 +7814,8 @@ export class Manager {
|
|
|
7742
7814
|
* Returns the acceptance floor payload {name, owner, actor, uid, goalId, fingerprint, executor}
|
|
7743
7815
|
* (the ALLOCATED identity). goalId = the request id (env.id, Q3). */
|
|
7744
7816
|
async serveSpawnGoal(ctx, run) {
|
|
7817
|
+
if (this.execution === "none")
|
|
7818
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
7745
7819
|
const gw = this.goalWriter;
|
|
7746
7820
|
if (!gw)
|
|
7747
7821
|
throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)");
|
|
@@ -8008,9 +8082,11 @@ export class Manager {
|
|
|
8008
8082
|
}
|
|
8009
8083
|
const raw = (ctx.request.args ?? {});
|
|
8010
8084
|
const payload = String(raw.payload);
|
|
8011
|
-
const
|
|
8085
|
+
const deadlineAt = raw.deadlineAt === undefined ? acceptedAt + Number(raw.deadlineMs) : Number(raw.deadlineAt);
|
|
8086
|
+
if (deadlineAt <= acceptedAt)
|
|
8087
|
+
throw new EpEnvelopeError("deadline-exceeded", `goal "${goalId}" names a deadline already past (${new Date(deadlineAt).toISOString()}); a turn with no time left to serve it is never accepted (SPEC 13.6)`);
|
|
8088
|
+
const deadlineMs = deadlineAt - acceptedAt;
|
|
8012
8089
|
const handoffFrom = raw.handoffFrom === undefined ? undefined : String(raw.handoffFrom);
|
|
8013
|
-
const deadlineAt = acceptedAt + deadlineMs;
|
|
8014
8090
|
const note = JSON.stringify({ payload, deadlineAt, holdEpoch: executor.epoch, owner: t.owner, ...(handoffFrom !== undefined ? { handoffFrom } : {}) });
|
|
8015
8091
|
// A goal that already ENDED is never accepted again, whatever the retry carries: the bind is
|
|
8016
8092
|
// create-only and outlives an unwind (the failed terminal, the cleared index), so a retry of an
|
|
@@ -8531,13 +8607,13 @@ export class Manager {
|
|
|
8531
8607
|
* exist. Absent for a runtime with no durable custody (there is nothing a successor could reap
|
|
8532
8608
|
* by reference), which is the only case that still spawns unreserved. */
|
|
8533
8609
|
reserveCustody() {
|
|
8534
|
-
return isCustodialRuntime(this.
|
|
8610
|
+
return isCustodialRuntime(this.requiredRuntime) ? this.requiredRuntime.reserve() : undefined;
|
|
8535
8611
|
}
|
|
8536
8612
|
/** Spawn under a reserved reference and prove the runtime honoured it. A runtime that minted its
|
|
8537
8613
|
* own id instead would leave every durable record addressing a seat that does not exist, so the
|
|
8538
8614
|
* mismatch tears the new seat down and throws rather than returning an unaddressable handle. */
|
|
8539
8615
|
async spawnCustodied(name, spec, cwd, reserved) {
|
|
8540
|
-
const handle = this.
|
|
8616
|
+
const handle = this.requiredRuntime.spawn(name, spec, cwd, reserved);
|
|
8541
8617
|
if (reserved === undefined)
|
|
8542
8618
|
return handle;
|
|
8543
8619
|
const got = handle.reference;
|
|
@@ -8559,7 +8635,7 @@ export class Manager {
|
|
|
8559
8635
|
}
|
|
8560
8636
|
else {
|
|
8561
8637
|
try {
|
|
8562
|
-
const evidence = await requireRuntimeReap(this.
|
|
8638
|
+
const evidence = await requireRuntimeReap(this.requiredRuntime, got);
|
|
8563
8639
|
disposal = `the spawned seat was reaped (${evidence.detail})`;
|
|
8564
8640
|
}
|
|
8565
8641
|
catch (e) {
|
|
@@ -8573,7 +8649,7 @@ export class Manager {
|
|
|
8573
8649
|
: `the spawned seat could NOT be reaped: ${rejectionText(e)}`;
|
|
8574
8650
|
}
|
|
8575
8651
|
}
|
|
8576
|
-
throw new Error(`runtime "${this.
|
|
8652
|
+
throw new Error(`runtime "${this.requiredRuntime.kind}" reserved custody ${reserved.kind}:${reserved.id} for "${name}" but spawned ` +
|
|
8577
8653
|
`${got ? `${got.kind}:${got.id}` : "a handle with no reference"}; the recorded reference would address no seat, so ${disposal}`);
|
|
8578
8654
|
}
|
|
8579
8655
|
/** The terminal's process step: an orphan (no live handle in THIS process) is reaped by the custody
|
|
@@ -8588,14 +8664,14 @@ export class Manager {
|
|
|
8588
8664
|
// before the reservation existed, or a caller that dropped the reference on the way here.
|
|
8589
8665
|
// Do not name the slot row: the row may well hold a reference this caller never read, and
|
|
8590
8666
|
// a diagnostic that guesses which is which sends the reader the wrong way.
|
|
8591
|
-
if (isCustodialRuntime(this.
|
|
8592
|
-
console.error(`static retirement ${a.name}: no custody reference reached this terminal, though runtime "${this.
|
|
8667
|
+
if (isCustodialRuntime(this.requiredRuntime))
|
|
8668
|
+
console.error(`static retirement ${a.name}: no custody reference reached this terminal, though runtime "${this.requiredRuntime.kind}" custodies its seats; any seat it launched is not addressable from here`);
|
|
8593
8669
|
return;
|
|
8594
8670
|
}
|
|
8595
8671
|
// An unproven reap throws from here rather than printing, so it propagates into
|
|
8596
8672
|
// driveStaticRetirement's catch, which records the failure and HOLDS the name. That is the whole
|
|
8597
8673
|
// point of refusing: the alias must not be freed while a seat nobody proved gone may still run.
|
|
8598
|
-
const evidence = await requireRuntimeReap(this.
|
|
8674
|
+
const evidence = await requireRuntimeReap(this.requiredRuntime, a.runtime);
|
|
8599
8675
|
console.error(`static retirement ${a.name}: orphan seat process ${evidence.detail}`);
|
|
8600
8676
|
}
|
|
8601
8677
|
/** The static F1 terminal for one departed incarnation (Unit B): delegates the gate/head CAS
|
|
@@ -8663,14 +8739,14 @@ export class Manager {
|
|
|
8663
8739
|
const principal = principalKey(DEV_OWNER, a.id).key;
|
|
8664
8740
|
this.retiredPrincipals.add(principal);
|
|
8665
8741
|
const cur = this.retiring.get(a.name);
|
|
8666
|
-
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
8742
|
+
if (cur && cur.target.lifecycleUid === a.lifecycleUid) {
|
|
8667
8743
|
this.retiring.delete(a.name); // ABA-guarded hold clear
|
|
8668
8744
|
this.confirmedRetiredPredecessors.set(a.name, { principal, lifecycleUid: a.lifecycleUid });
|
|
8669
8745
|
}
|
|
8670
8746
|
}
|
|
8671
8747
|
catch (e) {
|
|
8672
8748
|
const h = this.retiring.get(a.name);
|
|
8673
|
-
if (h && h.lifecycleUid === a.lifecycleUid)
|
|
8749
|
+
if (h && h.target.lifecycleUid === a.lifecycleUid)
|
|
8674
8750
|
h.lastError = `the static retirement did not complete (${rejectionText(e)}); the name stays held - a same-name spawn retries the same terminal (op ${opId})`;
|
|
8675
8751
|
console.error(`static retirement ${a.name} (${a.id}): ${rejectionText(e)}`);
|
|
8676
8752
|
if (surfaceFailure)
|
|
@@ -9173,9 +9249,9 @@ export class Manager {
|
|
|
9173
9249
|
: undefined;
|
|
9174
9250
|
}
|
|
9175
9251
|
for (const [name, hold] of this.retiring) {
|
|
9176
|
-
const held = hold.userOwner ? hold.
|
|
9252
|
+
const held = hold.target.userOwner ? hold.target.id : principalKey(DEV_OWNER, hold.target.id).key;
|
|
9177
9253
|
if (held === caller)
|
|
9178
|
-
return `the caller's lifecycle ${hold.lifecycleUid} (name "${name}") is retiring; a retiring incarnation's credential holds no control authority (F5)`;
|
|
9254
|
+
return `the caller's lifecycle ${hold.target.lifecycleUid} (name "${name}") is retiring; a retiring incarnation's credential holds no control authority (F5)`;
|
|
9179
9255
|
}
|
|
9180
9256
|
if (this.retiredPrincipals.has(caller))
|
|
9181
9257
|
return "the caller's lifecycle is retired; a retired incarnation's credential holds no control authority (F5)";
|
|
@@ -9189,26 +9265,8 @@ export class Manager {
|
|
|
9189
9265
|
async withProvisioner(fn) {
|
|
9190
9266
|
if (!this.auth)
|
|
9191
9267
|
throw new Error("withProvisioner: no space auth (an open mesh has no scoped creds)");
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
const prov = new CotalEndpoint({
|
|
9195
|
-
space: this.space,
|
|
9196
|
-
servers: this.servers,
|
|
9197
|
-
channels: [],
|
|
9198
|
-
creds,
|
|
9199
|
-
card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
|
|
9200
|
-
registerPresence: false,
|
|
9201
|
-
watchPresence: false,
|
|
9202
|
-
watchChannels: false,
|
|
9203
|
-
consume: false,
|
|
9204
|
-
});
|
|
9205
|
-
await prov.start();
|
|
9206
|
-
try {
|
|
9207
|
-
return await fn(prov);
|
|
9208
|
-
}
|
|
9209
|
-
finally {
|
|
9210
|
-
await prov.stop();
|
|
9211
|
-
}
|
|
9268
|
+
// Onboarding runs several provisioning ops, so it keeps the provisioner profile's five-minute window.
|
|
9269
|
+
return withScopedEndpoint({ space: this.space, servers: this.servers ?? DEFAULT_SERVER, auth: this.auth }, "provisioner", "provisioner", fn, 5 * 60);
|
|
9212
9270
|
}
|
|
9213
9271
|
/** A connection holding this instance's transfer reader grants (docs/design/resume-transfer.md
|
|
9214
9272
|
* section 6) for this one receive or sweep: minted from the space's signing seed, or issued by the
|