@cotal-ai/manager 0.73.0 → 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 +15 -4
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +52 -47
- 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 +265 -200
- 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();
|
|
@@ -922,7 +965,7 @@ export class Manager {
|
|
|
922
965
|
this.ep.on("roster", () => this.logSeatMeshTransitions());
|
|
923
966
|
this.ep.on("presence-view", () => this.logSeatMeshTransitions());
|
|
924
967
|
await this.ep.start();
|
|
925
|
-
await this.ep.setActivity(`supervisor (${this.
|
|
968
|
+
await this.ep.setActivity(`supervisor (${this.runtimeKind})`);
|
|
926
969
|
// Per-instance liveness lease (P2 item 3 — the old per-space singleton is DEMOTED per D9). Acquire
|
|
927
970
|
// THIS logical instance's own key (atomic CAS create). A DIFFERENT instance (a second manager in a
|
|
928
971
|
// second workspace root) has a distinct id ⇒ a distinct key ⇒ it coexists; the create THROWS only
|
|
@@ -930,7 +973,7 @@ export class Manager {
|
|
|
930
973
|
// crashed predecessor's not-yet-expired key), and we REFUSE loud. A crashed holder's key auto-expires
|
|
931
974
|
// (bucket TTL). Losing the key LATER ends this process only when another process holds it; otherwise
|
|
932
975
|
// the renew loop puts it back or keeps retrying ({@link renewLease}).
|
|
933
|
-
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 };
|
|
934
977
|
try {
|
|
935
978
|
this.leaseRevision = await this.ep.acquireManagerLease(this.leaseInfo);
|
|
936
979
|
}
|
|
@@ -1074,7 +1117,7 @@ export class Manager {
|
|
|
1074
1117
|
// refuses them until its reconcile has returned. A remote user-auth manager uses its four
|
|
1075
1118
|
// registered issuer callbacks instead of holding a signer; a local user mesh stands none up.
|
|
1076
1119
|
const remoteRuns = this.remoteAuthority?.runHosting;
|
|
1077
|
-
if (this.remoteAuthority && remoteRuns) {
|
|
1120
|
+
if (this.execution !== "none" && this.remoteAuthority && remoteRuns) {
|
|
1078
1121
|
this.runHosting = new RunHosting({
|
|
1079
1122
|
space: this.space,
|
|
1080
1123
|
servers: this.servers,
|
|
@@ -1084,6 +1127,7 @@ export class Manager {
|
|
|
1084
1127
|
auth: undefined,
|
|
1085
1128
|
log: (line) => console.error(line),
|
|
1086
1129
|
admitRun: remoteRuns.admitRun,
|
|
1130
|
+
revokeRun: remoteRuns.revokeRun,
|
|
1087
1131
|
issueAttempt: remoteRuns.issueAttempt,
|
|
1088
1132
|
issueOperator: remoteRuns.issueOperator,
|
|
1089
1133
|
renewRun: remoteRuns.renewRun,
|
|
@@ -1526,6 +1570,8 @@ export class Manager {
|
|
|
1526
1570
|
async renewRemoteStandingBundle(force = false) {
|
|
1527
1571
|
if (this.remoteBundleRenewal)
|
|
1528
1572
|
return this.remoteBundleRenewal;
|
|
1573
|
+
if (this.leaseStopping)
|
|
1574
|
+
return;
|
|
1529
1575
|
const renewal = this.renewRemoteStandingBundleOnce(force);
|
|
1530
1576
|
this.remoteBundleRenewal = renewal;
|
|
1531
1577
|
try {
|
|
@@ -2041,12 +2087,17 @@ export class Manager {
|
|
|
2041
2087
|
* touches NEITHER the lease NOR the endpoints — the caller owns those. */
|
|
2042
2088
|
async teardownManagedAgents(managed = [...this.agents.values()]) {
|
|
2043
2089
|
const failures = [];
|
|
2090
|
+
const stopped = [];
|
|
2044
2091
|
for (const a of managed) {
|
|
2045
2092
|
// Free the slot + hard-stop each; `stopHandle` is best-effort (never throws — see it), so one bad
|
|
2046
2093
|
// stop can't strand the rest, and every snapshot entry is deprovisioned below regardless.
|
|
2047
2094
|
this.recordContinuityAtStop(a, "shutdown");
|
|
2048
2095
|
this.agents.delete(a.name);
|
|
2049
|
-
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}`);
|
|
2050
2101
|
}
|
|
2051
2102
|
// The slot delete above bypasses freeSlot, which is where the reap line lives — so say it here,
|
|
2052
2103
|
// once per seat, with the one stop cause that names this path: the shutdown itself. Without
|
|
@@ -2057,7 +2108,7 @@ export class Manager {
|
|
|
2057
2108
|
// while any seat may still hold this instance's broker rails: that creates the exact orphan
|
|
2058
2109
|
// window where a successor sees a dead manager but live predecessor authority. Every shipped
|
|
2059
2110
|
// runtime now supplies waitForExit; absence or timeout is a failed shutdown, never a clean one.
|
|
2060
|
-
await Promise.all(
|
|
2111
|
+
await Promise.all(stopped.map(async (a) => {
|
|
2061
2112
|
try {
|
|
2062
2113
|
await this.awaitHandleExit(a.handle);
|
|
2063
2114
|
}
|
|
@@ -2152,6 +2203,9 @@ export class Manager {
|
|
|
2152
2203
|
// still move the key's revision after we clear the timer, and releasing against a cached
|
|
2153
2204
|
// revision it has since moved is a CAS the broker correctly refuses, silently, forever.
|
|
2154
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(() => { });
|
|
2155
2209
|
// A spawn admitted before the fence closed may still be launching. Its seat must be in the
|
|
2156
2210
|
// snapshot below, or it would outlive a stop that reported success.
|
|
2157
2211
|
await this.awaitLifecycleDrain();
|
|
@@ -2268,13 +2322,7 @@ export class Manager {
|
|
|
2268
2322
|
endpoint: MANAGER_ENDPOINT,
|
|
2269
2323
|
instanceId: iid,
|
|
2270
2324
|
registrationRevision,
|
|
2271
|
-
observeGeneration:
|
|
2272
|
-
const key = epgateKey(MANAGER_ENDPOINT, iid);
|
|
2273
|
-
const entry = await authKv.get(key);
|
|
2274
|
-
if (!entry || entry.operation !== "PUT")
|
|
2275
|
-
throw new Error(`no issuance gate at ${key}`);
|
|
2276
|
-
return parseEndpointGate(entry.value, key).generation;
|
|
2277
|
-
},
|
|
2325
|
+
observeGeneration: () => readEndpointGateGeneration(authKv, { endpoint: MANAGER_ENDPOINT, instanceId: iid }),
|
|
2278
2326
|
});
|
|
2279
2327
|
try {
|
|
2280
2328
|
const outcome = await ((this.auth || this.remoteAuthority)
|
|
@@ -2525,6 +2573,8 @@ export class Manager {
|
|
|
2525
2573
|
{
|
|
2526
2574
|
try {
|
|
2527
2575
|
const args = parseResumeControlArgs(rawArgs);
|
|
2576
|
+
if (this.execution === "none" && args.inventory.agents.length !== 0)
|
|
2577
|
+
return { ok: false, error: this.executionRefusal() };
|
|
2528
2578
|
const inventoryDigest = createHash("sha256").update(JSON.stringify(args.inventory)).digest("hex");
|
|
2529
2579
|
if (!this.resumeAttemptId)
|
|
2530
2580
|
return { ok: false, error: "resumePreserved requires a manager started with --resume-attempt" };
|
|
@@ -2675,6 +2725,8 @@ export class Manager {
|
|
|
2675
2725
|
* surface comes up before the host): `unavailable`, retry. The three are told apart, since
|
|
2676
2726
|
* the first sentence steers a caller to `--local` and the others must not. */
|
|
2677
2727
|
runHost() {
|
|
2728
|
+
if (this.execution === "none")
|
|
2729
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
2678
2730
|
if (this.runHosting)
|
|
2679
2731
|
return this.runHosting;
|
|
2680
2732
|
if (this.remoteAuthority && !this.remoteAuthority.runHosting)
|
|
@@ -2872,13 +2924,19 @@ export class Manager {
|
|
|
2872
2924
|
return { goalId, ...(result === undefined ? {} : { result }) };
|
|
2873
2925
|
}),
|
|
2874
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
|
+
}),
|
|
2875
2933
|
// P2 item 2: `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
|
|
2876
2934
|
// progress + terminal off-handler (no ~30s block). The blocking reply path is gone (pin 8).
|
|
2877
2935
|
// #373: arming the event plane (events: true, or the omission that arms it by default)
|
|
2878
2936
|
// follows owner-domain reach on a user mesh. Resolve the caller's admin tier once for
|
|
2879
2937
|
// the residual cross-owner case; opStart decides before connector resolution or grant
|
|
2880
2938
|
// mutation. Static meshes resolve true and nothing changes.
|
|
2881
|
-
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))),
|
|
2882
2940
|
despawn: (ctx) => this.serveGated(ctx, async () => {
|
|
2883
2941
|
const a = targetAgent(ctx);
|
|
2884
2942
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
@@ -3122,11 +3180,11 @@ export class Manager {
|
|
|
3122
3180
|
* grace window. POSIX delivers SIGTERM→SIGKILL natively, so it keeps the signal path. A hard stop
|
|
3123
3181
|
* (`graceful:false`, e.g. emergency reap) skips the cooperative step on every platform.
|
|
3124
3182
|
*
|
|
3125
|
-
*
|
|
3126
|
-
*
|
|
3127
|
-
*
|
|
3128
|
-
*
|
|
3129
|
-
*
|
|
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. */
|
|
3130
3188
|
stopHandle(a, graceful) {
|
|
3131
3189
|
// The F5 TERMINALIZING latch (Unit B): flipped SYNCHRONOUSLY, before any await anywhere on
|
|
3132
3190
|
// this stop path — later renewals refuse, while an already-admitted renewal drains before the
|
|
@@ -3144,7 +3202,9 @@ export class Manager {
|
|
|
3144
3202
|
a.handle.stop({ graceful });
|
|
3145
3203
|
}
|
|
3146
3204
|
catch (e) {
|
|
3147
|
-
|
|
3205
|
+
const failure = rejectionText(e);
|
|
3206
|
+
console.error(`stop ${a.name} (${a.id}): ${failure}`);
|
|
3207
|
+
return failure;
|
|
3148
3208
|
}
|
|
3149
3209
|
}
|
|
3150
3210
|
/** Keep an accepted stop inside the lifecycle drain until the runtime proves the child is gone,
|
|
@@ -3488,7 +3548,7 @@ export class Manager {
|
|
|
3488
3548
|
const info = a.handle.exitInfo?.();
|
|
3489
3549
|
detail = info === undefined
|
|
3490
3550
|
? `exit detail unavailable from runtime "${a.handle.kind}"`
|
|
3491
|
-
: `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}` : ""}`;
|
|
3492
3552
|
}
|
|
3493
3553
|
catch (e) {
|
|
3494
3554
|
// A runtime that throws while being asked has told us something real; it must not take the
|
|
@@ -3562,6 +3622,10 @@ export class Manager {
|
|
|
3562
3622
|
this.failSeatTurns(a.name, a.lifecycleUid);
|
|
3563
3623
|
if (floor && Date.now() - a.startedAt < MIN_LIFETIME)
|
|
3564
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 } };
|
|
3565
3629
|
// #29 piece 3: on a USER mesh the name is RESERVED PENDING RETIREMENT — despawn started this
|
|
3566
3630
|
// lifecycle's FULL teardown (footprint + standing-authority revoke + the auth-side retirement),
|
|
3567
3631
|
// and the alias frees only when all of it completes (not the retirement alone). The detached
|
|
@@ -3573,24 +3637,22 @@ export class Manager {
|
|
|
3573
3637
|
if (this.userMode) {
|
|
3574
3638
|
const p = parsePrincipalKey(a.id);
|
|
3575
3639
|
if (p)
|
|
3576
|
-
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() });
|
|
3577
3641
|
}
|
|
3578
3642
|
else if (this.auth) {
|
|
3579
3643
|
// Unit B: a STATIC lifecycle now also holds its name pending its own terminal (the F1
|
|
3580
3644
|
// static retirement the detached deprovision below drives) — the alias frees only when the
|
|
3581
3645
|
// gate+head terminal completes, exactly the user-mode discipline. The wire principal is the
|
|
3582
3646
|
// incarnation-unique nkey (F5-bind); owner is the dev owner.
|
|
3583
|
-
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() });
|
|
3584
3648
|
}
|
|
3585
3649
|
// Auth mode: tear down the departed agent's minted broker footprint + creds file (#159 B2). The
|
|
3586
3650
|
// process is already gone, so this must never block the slot free or throw into the caller — it runs
|
|
3587
3651
|
// detached, and a failure is logged loudly (never swallowed), not retried. The `agents` guard above
|
|
3588
3652
|
// makes this fire exactly once per agent across every free path (despawn / self-stop / reap / exit).
|
|
3589
|
-
// The
|
|
3590
|
-
// is usually the seat ending, but a stop that could not prove the process gone leaves one the
|
|
3591
|
-
// 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.
|
|
3592
3654
|
if (!a.suppressCleanup && (this.maintenanceState === "active" || acceptedBeforeFence))
|
|
3593
|
-
this.trackDeprovision({ ...a,
|
|
3655
|
+
this.trackDeprovision({ ...a, ...target });
|
|
3594
3656
|
}
|
|
3595
3657
|
/** Tear down a departed agent's minted footprint (#159 B2, auth mode): its local-principal durables
|
|
3596
3658
|
* (`dm_local-<id>`, `dlv_local-<id>`), its read-ACL row, and its creds file — everything the spawn's
|
|
@@ -3628,7 +3690,7 @@ export class Manager {
|
|
|
3628
3690
|
// The agent is its user's: only that user's retirement intent retires it, so the name stays
|
|
3629
3691
|
// held until retireDelegatedAgent confirms the host retired this UID.
|
|
3630
3692
|
const held = this.retiring.get(a.name);
|
|
3631
|
-
if (held?.lifecycleUid === a.lifecycleUid)
|
|
3693
|
+
if (held?.target.lifecycleUid === a.lifecycleUid)
|
|
3632
3694
|
held.lastError = `"${a.name}" was launched under a delegated user intent and retires only through its user's retirement intent`;
|
|
3633
3695
|
return;
|
|
3634
3696
|
}
|
|
@@ -3698,7 +3760,7 @@ export class Manager {
|
|
|
3698
3760
|
// legible operator copy. A retry re-drives this whole teardown (the same-name-spawn nudge routes
|
|
3699
3761
|
// through deprovision, not the rail alone), so the revoke is re-attempted, not stranded.
|
|
3700
3762
|
const holdRevoke = this.retiring.get(a.name);
|
|
3701
|
-
if (holdRevoke && holdRevoke.lifecycleUid === a.lifecycleUid)
|
|
3763
|
+
if (holdRevoke && holdRevoke.target.lifecycleUid === a.lifecycleUid)
|
|
3702
3764
|
holdRevoke.standingAuthorityLive = true;
|
|
3703
3765
|
try {
|
|
3704
3766
|
await resolveAuthProvider().revokeAgent({
|
|
@@ -3707,12 +3769,12 @@ export class Manager {
|
|
|
3707
3769
|
actor: a.name,
|
|
3708
3770
|
});
|
|
3709
3771
|
const done = this.retiring.get(a.name);
|
|
3710
|
-
if (done && done.lifecycleUid === a.lifecycleUid)
|
|
3772
|
+
if (done && done.target.lifecycleUid === a.lifecycleUid)
|
|
3711
3773
|
done.standingAuthorityLive = false;
|
|
3712
3774
|
}
|
|
3713
3775
|
catch (e) {
|
|
3714
3776
|
const h = this.retiring.get(a.name);
|
|
3715
|
-
if (h && h.lifecycleUid === a.lifecycleUid)
|
|
3777
|
+
if (h && h.target.lifecycleUid === a.lifecycleUid)
|
|
3716
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.`;
|
|
3717
3779
|
console.error(`revoke agent grant ${a.name}: ${rejectionText(e)}`);
|
|
3718
3780
|
}
|
|
@@ -3721,12 +3783,12 @@ export class Manager {
|
|
|
3721
3783
|
try {
|
|
3722
3784
|
const res = await this.deprovisionBroker(a);
|
|
3723
3785
|
const h = this.retiring.get(a.name);
|
|
3724
|
-
if (h && h.lifecycleUid === a.lifecycleUid)
|
|
3786
|
+
if (h && h.target.lifecycleUid === a.lifecycleUid)
|
|
3725
3787
|
h.lastResources = res;
|
|
3726
3788
|
}
|
|
3727
3789
|
catch (e) {
|
|
3728
3790
|
const h = this.retiring.get(a.name);
|
|
3729
|
-
if (h && h.lifecycleUid === a.lifecycleUid) {
|
|
3791
|
+
if (h && h.target.lifecycleUid === a.lifecycleUid) {
|
|
3730
3792
|
h.lastError = rejectionText(e);
|
|
3731
3793
|
if (e instanceof DeprovisionError)
|
|
3732
3794
|
h.lastResources = e.accounting;
|
|
@@ -3886,7 +3948,7 @@ export class Manager {
|
|
|
3886
3948
|
// CAS the hold clear (audit #1 ABA): free the alias ONLY if the current hold is still THIS
|
|
3887
3949
|
// lifecycle's. A late reply for a retired predecessor must never clear a successor's newer hold.
|
|
3888
3950
|
const cur = this.retiring.get(a.name);
|
|
3889
|
-
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
3951
|
+
if (cur && cur.target.lifecycleUid === a.lifecycleUid) {
|
|
3890
3952
|
if (cur.standingAuthorityLive) {
|
|
3891
3953
|
// INT-2: the auth-plane lifecycle retired, but the manager-side STANDING mint authority is
|
|
3892
3954
|
// not yet revoked. A copied token could still mint, so neither free the alias nor classify
|
|
@@ -4159,6 +4221,8 @@ export class Manager {
|
|
|
4159
4221
|
* comes from `restart.policy` when spawn carried `supervise`; otherwise the Pi session-recovery
|
|
4160
4222
|
* constants. Spending the budget falls through to normal retirement. */
|
|
4161
4223
|
recoverManagedSession(a) {
|
|
4224
|
+
if (this.execution === "none")
|
|
4225
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
4162
4226
|
const restart = a.restart;
|
|
4163
4227
|
if (!restart || !restart.armed || restart.recovering || a.terminalizing)
|
|
4164
4228
|
return;
|
|
@@ -4371,7 +4435,7 @@ export class Manager {
|
|
|
4371
4435
|
return false;
|
|
4372
4436
|
}
|
|
4373
4437
|
/** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
|
|
4374
|
-
opStart(args, caller, admin, hooks, route = "inst") {
|
|
4438
|
+
opStart(args, caller, admin, hooks, route = "inst", authenticatedCaller) {
|
|
4375
4439
|
// Opaque launch options, when present, must be a mapping — a raw control message could send a
|
|
4376
4440
|
// scalar/array (the CLI never does). Core doesn't interpret the keys; the connector validates them.
|
|
4377
4441
|
if (args.launchOptions !== undefined && (typeof args.launchOptions !== "object" || args.launchOptions === null || Array.isArray(args.launchOptions)))
|
|
@@ -4465,7 +4529,7 @@ export class Manager {
|
|
|
4465
4529
|
shareTools,
|
|
4466
4530
|
...(supervise !== undefined ? { supervise } : {}),
|
|
4467
4531
|
route,
|
|
4468
|
-
}, caller, hooks);
|
|
4532
|
+
}, caller, hooks, authenticatedCaller);
|
|
4469
4533
|
}
|
|
4470
4534
|
/** The realpath + directory checks a placed spawn directory must pass on this manager's host,
|
|
4471
4535
|
* shared by the served `resolve-cwd` command and the `spawn` admission check (#385 item 2): one
|
|
@@ -4524,6 +4588,10 @@ export class Manager {
|
|
|
4524
4588
|
* reports ready. A missing harness degrades only that connector, never unrelated lifecycle work:
|
|
4525
4589
|
* boot continues, but prints a named refusal and records it on both manager status surfaces. */
|
|
4526
4590
|
async inspectConnectorsAtBoot() {
|
|
4591
|
+
if (this.execution === "none") {
|
|
4592
|
+
this.connectorStatuses = [];
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4527
4595
|
const rows = [];
|
|
4528
4596
|
let declared;
|
|
4529
4597
|
if (this.installedExtensions) {
|
|
@@ -4559,6 +4627,8 @@ export class Manager {
|
|
|
4559
4627
|
* inventory load) skips the class `one` subscribe for spawn/launch so this member cannot
|
|
4560
4628
|
* consume an unpinned request a sibling could serve. */
|
|
4561
4629
|
classSpawnEnabled() {
|
|
4630
|
+
if (this.execution === "none")
|
|
4631
|
+
return false;
|
|
4562
4632
|
return this.connectorStatuses.some((row) => row.state === "available");
|
|
4563
4633
|
}
|
|
4564
4634
|
omitClassCommands() {
|
|
@@ -4682,6 +4752,8 @@ export class Manager {
|
|
|
4682
4752
|
* `runId`, and resolved `hash`. USER mesh: a privileged-tier launch is owner-equality-authorized
|
|
4683
4753
|
* (spec owner === caller owner) before any side effect; the admin tier keeps operator behavior. */
|
|
4684
4754
|
async opLaunch(args, caller, admin, hooks, route = "inst") {
|
|
4755
|
+
if (this.execution === "none")
|
|
4756
|
+
return { ok: false, error: this.executionRefusal() };
|
|
4685
4757
|
const runId = String(args.runId ?? "").trim();
|
|
4686
4758
|
const name = String(args.name ?? "").trim();
|
|
4687
4759
|
if (!runId || !name)
|
|
@@ -4746,33 +4818,23 @@ export class Manager {
|
|
|
4746
4818
|
* `spawner` is the authenticated id of the peer that requested the spawn (`req.from.id`),
|
|
4747
4819
|
* defaulting to the manager's own id for roster/pre-spawn — recorded for the spawner
|
|
4748
4820
|
* ledger (own-children despawn + reap-on-parent-exit). */
|
|
4749
|
-
async startAgent(opts, spawner, hooks) {
|
|
4821
|
+
async startAgent(opts, spawner, hooks, authenticatedCaller) {
|
|
4822
|
+
if (this.execution === "none")
|
|
4823
|
+
return { ok: false, error: this.executionRefusal() };
|
|
4750
4824
|
const release = this.beginLifecycle();
|
|
4751
4825
|
if (!release)
|
|
4752
4826
|
return { ok: false, error: this.maintenanceError() };
|
|
4753
4827
|
try {
|
|
4754
|
-
return await this.startAgentActive(opts, spawner, hooks);
|
|
4828
|
+
return await this.startAgentActive(opts, spawner, hooks, authenticatedCaller);
|
|
4755
4829
|
}
|
|
4756
4830
|
finally {
|
|
4757
4831
|
release();
|
|
4758
4832
|
}
|
|
4759
4833
|
}
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
if (opts.resolved)
|
|
4765
|
-
opts.resolved = ownLists(opts.resolved);
|
|
4766
|
-
if (opts.supervise)
|
|
4767
|
-
opts.supervise = { restarts: opts.supervise.restarts, windowMs: opts.supervise.windowMs };
|
|
4768
|
-
if (opts.delegatedIntent) {
|
|
4769
|
-
if (!this.remoteAuthority?.executeDelegatedUserIntent)
|
|
4770
|
-
return { ok: false, error: `"${opts.name}" names a delegated user intent, and this manager has no host execution for one` };
|
|
4771
|
-
if (opts.supervise)
|
|
4772
|
-
return { ok: false, error: `"${opts.name}" names a delegated user intent, and a delegated agent is never restarted (SPEC 13.16)` };
|
|
4773
|
-
// A delegated launch's spawner is the user's principal, never the composition root's call.
|
|
4774
|
-
spawner = opts.delegatedIntent.parent;
|
|
4775
|
-
}
|
|
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) {
|
|
4776
4838
|
// The spawn argument is a persona REF — a filename in `.cotal/agents` (the unique spawn KEY), or
|
|
4777
4839
|
// a path via `--config`. It is NOT the mesh identity: the identity comes from inside the file
|
|
4778
4840
|
// (`name:`), so a persona can be filed descriptively (review-critic.md) yet present under a
|
|
@@ -4780,48 +4842,79 @@ export class Manager {
|
|
|
4780
4842
|
// filename (unique on disk); two files can't collide on the key.
|
|
4781
4843
|
const ref = opts.name.trim();
|
|
4782
4844
|
if (!ref)
|
|
4783
|
-
return {
|
|
4845
|
+
return { error: "name required" };
|
|
4784
4846
|
// A bare ref maps to `.cotal/agents/<ref>.md`, so it must be a safe token (no path traversal); a
|
|
4785
4847
|
// `--config` path is validated by existsSync below instead.
|
|
4786
4848
|
if (!opts.config) {
|
|
4787
4849
|
const refErr = this.nameError(ref);
|
|
4788
4850
|
if (refErr)
|
|
4789
|
-
return {
|
|
4851
|
+
return { error: refErr };
|
|
4790
4852
|
}
|
|
4791
4853
|
// Resolve the persona file (fail loud — NO silent default-ACL fallback). A missing persona used
|
|
4792
4854
|
// to mint DEFAULT creds (read `general` only, default-deny publish, no capabilities), so a
|
|
4793
4855
|
// typo'd / renamed / spawned-by-display-name agent became live with silently-wrong ACLs — a
|
|
4794
4856
|
// behavioral/security bug. Fail loud instead, matching `cotal spawn` (loadAgentFile throws).
|
|
4795
|
-
// This runs BEFORE the
|
|
4796
|
-
// harness choice (flag > file > COTAL_DEFAULT_AGENT > default, #869), which is impossible if
|
|
4797
|
-
// connector is materialized first.
|
|
4798
|
-
// 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.
|
|
4799
4860
|
let configPath;
|
|
4800
4861
|
if (opts.config) {
|
|
4801
4862
|
configPath = agentFilePath(this.workspaceRoot, opts.config);
|
|
4802
4863
|
if (!existsSync(configPath))
|
|
4803
|
-
return {
|
|
4864
|
+
return { error: `agent file not found: ${configPath}` };
|
|
4804
4865
|
}
|
|
4805
4866
|
else {
|
|
4806
4867
|
configPath = agentFilePath(this.workspaceRoot, ref);
|
|
4807
4868
|
if (!existsSync(configPath))
|
|
4808
|
-
return {
|
|
4869
|
+
return { error: `no persona "${ref}" - ${configPath} not found; create it or pass --config (see \`cotal personas list\`)` };
|
|
4809
4870
|
}
|
|
4810
4871
|
// Load the persona ONCE, here, ahead of the connector choice. The file's `agent:` pin feeds the
|
|
4811
|
-
// harness resolution below; the identity/ACL profile block
|
|
4812
|
-
// load). A manifest launch (`resolved`) materializes its own transient persona and the
|
|
4813
|
-
// NOT its authority, so its def (and its agent pin) is deliberately not consulted here —
|
|
4814
|
-
// 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.
|
|
4815
4876
|
let def;
|
|
4816
4877
|
if (!opts.resolved) {
|
|
4817
4878
|
try {
|
|
4818
4879
|
def = loadAgentFile(configPath);
|
|
4819
4880
|
}
|
|
4820
4881
|
catch (e) {
|
|
4821
|
-
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}"` };
|
|
4822
4894
|
}
|
|
4823
4895
|
}
|
|
4824
|
-
|
|
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;
|
|
4825
4918
|
// Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
|
|
4826
4919
|
// `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
|
|
4827
4920
|
// below so that span stays fully SYNCHRONOUS and atomic. On the manifest binary this also honors a
|
|
@@ -4879,15 +4972,15 @@ export class Manager {
|
|
|
4879
4972
|
// exit, so a name cannot be respawned in place. User-mode seats have no static slot that
|
|
4880
4973
|
// keeps the incarnation owned across a process death, so the same refusal applies there.
|
|
4881
4974
|
if (opts.supervise !== undefined) {
|
|
4882
|
-
if (this.
|
|
4883
|
-
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` };
|
|
4884
4977
|
if (this.userMode)
|
|
4885
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" };
|
|
4886
4979
|
}
|
|
4887
4980
|
// A delegated seat starts outside this host from values alone, so a choice only this host can
|
|
4888
4981
|
// honour is refused here, before enrollment, and named rather than dropped.
|
|
4889
|
-
if (this.
|
|
4890
|
-
const delegatedBy = `runtime "${this.
|
|
4982
|
+
if (this.requiredRuntime.spawnDelegated) {
|
|
4983
|
+
const delegatedBy = `runtime "${this.requiredRuntime.kind}" starts seats outside this host`;
|
|
4891
4984
|
if (!this.remoteAuthority?.enrollManagedAgent)
|
|
4892
4985
|
return { ok: false, error: `${delegatedBy}, which needs a host that enrolls managed agents (remoteAuthority.enrollManagedAgent); a delegated seat has no local grant path` };
|
|
4893
4986
|
try {
|
|
@@ -5048,7 +5141,7 @@ export class Manager {
|
|
|
5048
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.` };
|
|
5049
5142
|
const held = this.retiring.get(identityName);
|
|
5050
5143
|
if (held !== undefined) {
|
|
5051
|
-
void this.deprovision(
|
|
5144
|
+
void this.deprovision(held.target).catch(() => { });
|
|
5052
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" });
|
|
5053
5146
|
return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
|
|
5054
5147
|
}
|
|
@@ -5069,9 +5162,9 @@ export class Manager {
|
|
|
5069
5162
|
}
|
|
5070
5163
|
}
|
|
5071
5164
|
// The child takes launch options as `--opt k=v`, so only string values can cross to it.
|
|
5072
|
-
const unportable = this.
|
|
5165
|
+
const unportable = this.requiredRuntime.spawnDelegated ? Object.keys(launchOptions ?? {}).filter((k) => typeof launchOptions[k] !== "string") : [];
|
|
5073
5166
|
if (unportable.length)
|
|
5074
|
-
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(", ")})` };
|
|
5075
5168
|
// The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
|
|
5076
5169
|
// anything is minted: a connector that cannot
|
|
5077
5170
|
// emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
|
|
@@ -5108,7 +5201,7 @@ export class Manager {
|
|
|
5108
5201
|
// incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent `_2`
|
|
5109
5202
|
// suffix (so an address-by-triple caller's pinned name can't be re-pointed). A PERSONA-DERIVED
|
|
5110
5203
|
// base name (no pin) keeps uniqueName's collision numbering, so multi-peer `spawn reviewer` twice
|
|
5111
|
-
// still yields reviewer + reviewer_2. The
|
|
5204
|
+
// still yields reviewer + reviewer_2. The alias-reuse gate above is orthogonal and already fired.
|
|
5112
5205
|
const hardPinned = opts.identity !== undefined || opts.resolved !== undefined;
|
|
5113
5206
|
let name;
|
|
5114
5207
|
if (hardPinned) {
|
|
@@ -5392,12 +5485,12 @@ export class Manager {
|
|
|
5392
5485
|
// manager path is produced for it, only the handoff its runtime writes into the child.
|
|
5393
5486
|
let spec;
|
|
5394
5487
|
let handle;
|
|
5395
|
-
if (this.
|
|
5488
|
+
if (this.requiredRuntime.spawnDelegated) {
|
|
5396
5489
|
const handoff = this.composeManagedHandoff(enrolled.material, enrolled.actorToken);
|
|
5397
5490
|
if (this.delegatedLaunched.has(lifecycleUid))
|
|
5398
|
-
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`);
|
|
5399
5492
|
this.delegatedLaunched.add(lifecycleUid);
|
|
5400
|
-
handle = this.
|
|
5493
|
+
handle = this.requiredRuntime.spawnDelegated({ agent, persona: readFileSync(configPath, "utf8"), role, model, variant, prompt, launchOptions: launchOptions, events }, handoff);
|
|
5401
5494
|
}
|
|
5402
5495
|
else {
|
|
5403
5496
|
if (carried) {
|
|
@@ -5612,7 +5705,7 @@ export class Manager {
|
|
|
5612
5705
|
// retires it, and its name stays held for retireDelegatedAgent.
|
|
5613
5706
|
const p = orphan.delegated ? parsePrincipalKey(orphan.id) : null;
|
|
5614
5707
|
if (p)
|
|
5615
|
-
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() });
|
|
5616
5709
|
this.trackDeprovision(orphan, "(orphaned spawn)");
|
|
5617
5710
|
}
|
|
5618
5711
|
}
|
|
@@ -5621,6 +5714,8 @@ export class Manager {
|
|
|
5621
5714
|
* principal without provisioning. A later runtime launch failure is reported per-agent, but malformed
|
|
5622
5715
|
* or missing inventory material can never produce a partially resumed set. */
|
|
5623
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() };
|
|
5624
5719
|
const release = this.beginLifecycle(true);
|
|
5625
5720
|
if (!release)
|
|
5626
5721
|
return { ok: false, agents: [], error: this.maintenanceError() };
|
|
@@ -5653,19 +5748,11 @@ export class Manager {
|
|
|
5653
5748
|
// already removed the hold, so fresh-lifecycle resume still reaches the fail-closed roster gate.
|
|
5654
5749
|
const held = this.retiring.get(entry.name);
|
|
5655
5750
|
if (held) {
|
|
5656
|
-
void this.deprovision({
|
|
5657
|
-
id: held.agentId,
|
|
5658
|
-
name: entry.name,
|
|
5659
|
-
lifecycleUid: held.lifecycleUid,
|
|
5660
|
-
userOwner: held.userOwner,
|
|
5661
|
-
delegated: held.delegated,
|
|
5662
|
-
secretPaths: held.secretPaths,
|
|
5663
|
-
delegatedHandle: held.delegatedHandle,
|
|
5664
|
-
}).catch(() => { });
|
|
5751
|
+
void this.deprovision(held.target).catch(() => { });
|
|
5665
5752
|
return {
|
|
5666
5753
|
ok: false,
|
|
5667
5754
|
agents: [],
|
|
5668
|
-
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`,
|
|
5669
5756
|
};
|
|
5670
5757
|
}
|
|
5671
5758
|
let principal;
|
|
@@ -5931,7 +6018,7 @@ export class Manager {
|
|
|
5931
6018
|
async orphanedResumeSeat(entry, principal, liveRoster) {
|
|
5932
6019
|
if (!this.auth || this.userMode || entry.identity.mode !== "static")
|
|
5933
6020
|
return undefined;
|
|
5934
|
-
if (typeof this.
|
|
6021
|
+
if (typeof this.requiredRuntime.reap !== "function")
|
|
5935
6022
|
return undefined;
|
|
5936
6023
|
if (liveRoster.some((p) => p.card.id === principal && p.lifecycleUid !== entry.identity.lifecycleUid))
|
|
5937
6024
|
return undefined;
|
|
@@ -5940,14 +6027,14 @@ export class Manager {
|
|
|
5940
6027
|
return undefined;
|
|
5941
6028
|
if (ownedBySibling(slot, this.managerInstanceId))
|
|
5942
6029
|
return undefined;
|
|
5943
|
-
return slot.runtime?.kind === this.
|
|
6030
|
+
return slot.runtime?.kind === this.requiredRuntime.kind ? slot.runtime : undefined;
|
|
5944
6031
|
}
|
|
5945
6032
|
/** Reap a seat {@link orphanedResumeSeat} found, then wait for its principal to leave presence so
|
|
5946
6033
|
* the relaunch's readiness cannot match the dead seat's row. Returns the refusal when the reap
|
|
5947
6034
|
* proves nothing or the principal stays live, which means another process holds it. */
|
|
5948
6035
|
async reapOrphanedResumeSeat(principal, reference) {
|
|
5949
6036
|
try {
|
|
5950
|
-
const evidence = await requireRuntimeReap(this.
|
|
6037
|
+
const evidence = await requireRuntimeReap(this.requiredRuntime, reference);
|
|
5951
6038
|
console.error(`resume: reaped orphaned seat ${reference.kind}:${reference.id} of retained principal "${principal}" (${evidence.detail})`);
|
|
5952
6039
|
}
|
|
5953
6040
|
catch (e) {
|
|
@@ -5964,14 +6051,16 @@ export class Manager {
|
|
|
5964
6051
|
/** Validate/relaunch one retained inventory entry. Called only through resumePreserved so all
|
|
5965
6052
|
* records pass the same preflight before the first child is exposed. */
|
|
5966
6053
|
async resumePreservedAgent(entry, preflightOnly = false, batchReserved = false, prepared) {
|
|
6054
|
+
if (this.execution === "none")
|
|
6055
|
+
return { ok: false, error: this.executionRefusal() };
|
|
5967
6056
|
const release = this.beginLifecycle(batchReserved);
|
|
5968
6057
|
if (!release)
|
|
5969
6058
|
return { ok: false, error: this.maintenanceError() };
|
|
5970
6059
|
try {
|
|
5971
6060
|
if (entry.space !== this.space)
|
|
5972
6061
|
return { ok: false, error: `retained agent ${entry.name} belongs to space "${entry.space}", not "${this.space}"` };
|
|
5973
|
-
if (entry.launch.runtime !== this.
|
|
5974
|
-
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}"` };
|
|
5975
6064
|
const nameErr = this.nameError(entry.name);
|
|
5976
6065
|
if (nameErr)
|
|
5977
6066
|
return { ok: false, error: nameErr };
|
|
@@ -6252,13 +6341,13 @@ export class Manager {
|
|
|
6252
6341
|
* would reap it: it is freed only once its exit is proved, and otherwise stays managed until it
|
|
6253
6342
|
* exits or the manager stops it. */
|
|
6254
6343
|
async stopFailedResume(managed, cause, detail) {
|
|
6255
|
-
this.stopHandle(managed, false);
|
|
6256
|
-
|
|
6257
|
-
await this.awaitHandleExit(managed.handle)
|
|
6258
|
-
|
|
6259
|
-
|
|
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) {
|
|
6260
6349
|
this.watchExit(managed);
|
|
6261
|
-
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}` };
|
|
6262
6351
|
}
|
|
6263
6352
|
this.freeSlot(managed, true, cause, true);
|
|
6264
6353
|
return { ok: false, error: detail };
|
|
@@ -6359,28 +6448,9 @@ export class Manager {
|
|
|
6359
6448
|
if (done || !s)
|
|
6360
6449
|
return;
|
|
6361
6450
|
clearTimeout(timer);
|
|
6362
|
-
//
|
|
6363
|
-
//
|
|
6364
|
-
//
|
|
6365
|
-
// for an agent that really did die on launch. `onAgentExit` reaches `freeSlot`, which sets
|
|
6366
|
-
// this SAME latch on its way through (see its "also covers exit/reap paths" comment), so a
|
|
6367
|
-
// read taken after that call is true for EVERY exit, deliberate or not.
|
|
6368
|
-
//
|
|
6369
|
-
// Read HERE — first statement, before the await and before `onAgentExit` — a set latch can
|
|
6370
|
-
// only have been set by someone else, and the only other setter on this path is
|
|
6371
|
-
// `stopHandle`, which latches SYNCHRONOUSLY and then kills with no suspension between. So a
|
|
6372
|
-
// despawn-caused exit is guaranteed observed with the latch UP and a natural exit with it
|
|
6373
|
-
// DOWN: the distinction holds by program order, never by winning a race.
|
|
6374
|
-
//
|
|
6375
|
-
// The same latch, read one function call apart, answers two different questions.
|
|
6376
|
-
//
|
|
6377
|
-
// TO RE-VERIFY (this is the mutation that proves it, and it is the exact regression a tidy
|
|
6378
|
-
// refactor produces): move this capture below `onAgentExit` and run
|
|
6379
|
-
// `pnpm smoke:manager-spawn-action`. M3 `process exit -> failed` must FAIL. Note that M4 —
|
|
6380
|
-
// the case this fix exists for — still PASSES under that mutation, so the suite this fix
|
|
6381
|
-
// was written against cannot catch its own regression. M3 catches it only because it
|
|
6382
|
-
// happens to share a file.
|
|
6383
|
-
// └──────────────────────────────────────────────────────────────────────────────────────┘
|
|
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.
|
|
6384
6454
|
const deliberate = a.terminalizing === true;
|
|
6385
6455
|
void (async () => {
|
|
6386
6456
|
// waitForExit may close the attach stream before this snapshot
|
|
@@ -6405,7 +6475,23 @@ export class Manager {
|
|
|
6405
6475
|
finish({ ok: false, deliberate: true, detail: `${a.name} was stopped before it reported ready` });
|
|
6406
6476
|
return;
|
|
6407
6477
|
}
|
|
6408
|
-
|
|
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}` });
|
|
6409
6495
|
})();
|
|
6410
6496
|
};
|
|
6411
6497
|
timer = setTimeout(() => finish({
|
|
@@ -6452,25 +6538,15 @@ export class Manager {
|
|
|
6452
6538
|
return chars.length > 160 ? `…${chars.slice(-160).join("")}` : text;
|
|
6453
6539
|
}
|
|
6454
6540
|
/** Subscribe to a managed agent's process-exit so a self-driven exit frees its slot and reaps
|
|
6455
|
-
* its children (P4b/P4c).
|
|
6456
|
-
*
|
|
6457
|
-
* by nothing until it's explicitly despawned (graceful-stop runs on despawn, not self-exit). The
|
|
6458
|
-
* cap still holds (a lingering corpse counts toward it); runtime-agnostic exit-reaping (a real
|
|
6459
|
-
* 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. */
|
|
6460
6543
|
watchExit(a) {
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
// subscribing and reap it now if it already went. onAgentExit is idempotent (freeSlot's guard).
|
|
6468
|
-
if (a.handle.status() === "exited")
|
|
6469
|
-
this.onAgentExit(a);
|
|
6470
|
-
}
|
|
6471
|
-
catch {
|
|
6472
|
-
/* runtime doesn't stream an exit signal — nothing to wire */
|
|
6473
|
-
}
|
|
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`);
|
|
6474
6550
|
}
|
|
6475
6551
|
/** Prune expired cooling stamps (drop those at/before now) and return the live count — the
|
|
6476
6552
|
* recycle floor's contribution to the ceiling (P4c). Lazy: pruned only when the gate consults it. */
|
|
@@ -6564,8 +6640,8 @@ export class Manager {
|
|
|
6564
6640
|
try {
|
|
6565
6641
|
const live = this.agents.get(name);
|
|
6566
6642
|
const held = live ? undefined : this.retiring.get(name);
|
|
6567
|
-
const principal = live?.delegated ? parsePrincipalKey(live.id) : held?.delegated ? held : null;
|
|
6568
|
-
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;
|
|
6569
6645
|
if (!principal || !lifecycleUid)
|
|
6570
6646
|
return { ok: false, error: `no agent named "${name}" was launched on this manager under a delegated user intent` };
|
|
6571
6647
|
const target = { owner: principal.owner, actor: principal.actor, lifecycleUid };
|
|
@@ -6580,7 +6656,7 @@ export class Manager {
|
|
|
6580
6656
|
catch (e) {
|
|
6581
6657
|
const error = `the delegated retirement under intent ${intentId} did not complete (${rejectionText(e)})`;
|
|
6582
6658
|
const hold = this.retiring.get(name);
|
|
6583
|
-
if (hold?.lifecycleUid === target.lifecycleUid)
|
|
6659
|
+
if (hold?.target.lifecycleUid === target.lifecycleUid)
|
|
6584
6660
|
hold.lastError = error;
|
|
6585
6661
|
return { ok: false, error: `${error}; "${name}" is unchanged` };
|
|
6586
6662
|
}
|
|
@@ -6726,8 +6802,11 @@ export class Manager {
|
|
|
6726
6802
|
managerStatusData() {
|
|
6727
6803
|
return {
|
|
6728
6804
|
instanceId: this.managerInstanceId,
|
|
6729
|
-
|
|
6730
|
-
|
|
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",
|
|
6731
6810
|
agentCount: this.agents.size,
|
|
6732
6811
|
uptimeMs: Date.now() - this.startedAtMs,
|
|
6733
6812
|
connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
|
|
@@ -7735,6 +7814,8 @@ export class Manager {
|
|
|
7735
7814
|
* Returns the acceptance floor payload {name, owner, actor, uid, goalId, fingerprint, executor}
|
|
7736
7815
|
* (the ALLOCATED identity). goalId = the request id (env.id, Q3). */
|
|
7737
7816
|
async serveSpawnGoal(ctx, run) {
|
|
7817
|
+
if (this.execution === "none")
|
|
7818
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
7738
7819
|
const gw = this.goalWriter;
|
|
7739
7820
|
if (!gw)
|
|
7740
7821
|
throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)");
|
|
@@ -8001,9 +8082,11 @@ export class Manager {
|
|
|
8001
8082
|
}
|
|
8002
8083
|
const raw = (ctx.request.args ?? {});
|
|
8003
8084
|
const payload = String(raw.payload);
|
|
8004
|
-
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;
|
|
8005
8089
|
const handoffFrom = raw.handoffFrom === undefined ? undefined : String(raw.handoffFrom);
|
|
8006
|
-
const deadlineAt = acceptedAt + deadlineMs;
|
|
8007
8090
|
const note = JSON.stringify({ payload, deadlineAt, holdEpoch: executor.epoch, owner: t.owner, ...(handoffFrom !== undefined ? { handoffFrom } : {}) });
|
|
8008
8091
|
// A goal that already ENDED is never accepted again, whatever the retry carries: the bind is
|
|
8009
8092
|
// create-only and outlives an unwind (the failed terminal, the cleared index), so a retry of an
|
|
@@ -8524,13 +8607,13 @@ export class Manager {
|
|
|
8524
8607
|
* exist. Absent for a runtime with no durable custody (there is nothing a successor could reap
|
|
8525
8608
|
* by reference), which is the only case that still spawns unreserved. */
|
|
8526
8609
|
reserveCustody() {
|
|
8527
|
-
return isCustodialRuntime(this.
|
|
8610
|
+
return isCustodialRuntime(this.requiredRuntime) ? this.requiredRuntime.reserve() : undefined;
|
|
8528
8611
|
}
|
|
8529
8612
|
/** Spawn under a reserved reference and prove the runtime honoured it. A runtime that minted its
|
|
8530
8613
|
* own id instead would leave every durable record addressing a seat that does not exist, so the
|
|
8531
8614
|
* mismatch tears the new seat down and throws rather than returning an unaddressable handle. */
|
|
8532
8615
|
async spawnCustodied(name, spec, cwd, reserved) {
|
|
8533
|
-
const handle = this.
|
|
8616
|
+
const handle = this.requiredRuntime.spawn(name, spec, cwd, reserved);
|
|
8534
8617
|
if (reserved === undefined)
|
|
8535
8618
|
return handle;
|
|
8536
8619
|
const got = handle.reference;
|
|
@@ -8552,7 +8635,7 @@ export class Manager {
|
|
|
8552
8635
|
}
|
|
8553
8636
|
else {
|
|
8554
8637
|
try {
|
|
8555
|
-
const evidence = await requireRuntimeReap(this.
|
|
8638
|
+
const evidence = await requireRuntimeReap(this.requiredRuntime, got);
|
|
8556
8639
|
disposal = `the spawned seat was reaped (${evidence.detail})`;
|
|
8557
8640
|
}
|
|
8558
8641
|
catch (e) {
|
|
@@ -8566,7 +8649,7 @@ export class Manager {
|
|
|
8566
8649
|
: `the spawned seat could NOT be reaped: ${rejectionText(e)}`;
|
|
8567
8650
|
}
|
|
8568
8651
|
}
|
|
8569
|
-
throw new Error(`runtime "${this.
|
|
8652
|
+
throw new Error(`runtime "${this.requiredRuntime.kind}" reserved custody ${reserved.kind}:${reserved.id} for "${name}" but spawned ` +
|
|
8570
8653
|
`${got ? `${got.kind}:${got.id}` : "a handle with no reference"}; the recorded reference would address no seat, so ${disposal}`);
|
|
8571
8654
|
}
|
|
8572
8655
|
/** The terminal's process step: an orphan (no live handle in THIS process) is reaped by the custody
|
|
@@ -8581,14 +8664,14 @@ export class Manager {
|
|
|
8581
8664
|
// before the reservation existed, or a caller that dropped the reference on the way here.
|
|
8582
8665
|
// Do not name the slot row: the row may well hold a reference this caller never read, and
|
|
8583
8666
|
// a diagnostic that guesses which is which sends the reader the wrong way.
|
|
8584
|
-
if (isCustodialRuntime(this.
|
|
8585
|
-
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`);
|
|
8586
8669
|
return;
|
|
8587
8670
|
}
|
|
8588
8671
|
// An unproven reap throws from here rather than printing, so it propagates into
|
|
8589
8672
|
// driveStaticRetirement's catch, which records the failure and HOLDS the name. That is the whole
|
|
8590
8673
|
// point of refusing: the alias must not be freed while a seat nobody proved gone may still run.
|
|
8591
|
-
const evidence = await requireRuntimeReap(this.
|
|
8674
|
+
const evidence = await requireRuntimeReap(this.requiredRuntime, a.runtime);
|
|
8592
8675
|
console.error(`static retirement ${a.name}: orphan seat process ${evidence.detail}`);
|
|
8593
8676
|
}
|
|
8594
8677
|
/** The static F1 terminal for one departed incarnation (Unit B): delegates the gate/head CAS
|
|
@@ -8656,14 +8739,14 @@ export class Manager {
|
|
|
8656
8739
|
const principal = principalKey(DEV_OWNER, a.id).key;
|
|
8657
8740
|
this.retiredPrincipals.add(principal);
|
|
8658
8741
|
const cur = this.retiring.get(a.name);
|
|
8659
|
-
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
8742
|
+
if (cur && cur.target.lifecycleUid === a.lifecycleUid) {
|
|
8660
8743
|
this.retiring.delete(a.name); // ABA-guarded hold clear
|
|
8661
8744
|
this.confirmedRetiredPredecessors.set(a.name, { principal, lifecycleUid: a.lifecycleUid });
|
|
8662
8745
|
}
|
|
8663
8746
|
}
|
|
8664
8747
|
catch (e) {
|
|
8665
8748
|
const h = this.retiring.get(a.name);
|
|
8666
|
-
if (h && h.lifecycleUid === a.lifecycleUid)
|
|
8749
|
+
if (h && h.target.lifecycleUid === a.lifecycleUid)
|
|
8667
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})`;
|
|
8668
8751
|
console.error(`static retirement ${a.name} (${a.id}): ${rejectionText(e)}`);
|
|
8669
8752
|
if (surfaceFailure)
|
|
@@ -9166,9 +9249,9 @@ export class Manager {
|
|
|
9166
9249
|
: undefined;
|
|
9167
9250
|
}
|
|
9168
9251
|
for (const [name, hold] of this.retiring) {
|
|
9169
|
-
const held = hold.userOwner ? hold.
|
|
9252
|
+
const held = hold.target.userOwner ? hold.target.id : principalKey(DEV_OWNER, hold.target.id).key;
|
|
9170
9253
|
if (held === caller)
|
|
9171
|
-
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)`;
|
|
9172
9255
|
}
|
|
9173
9256
|
if (this.retiredPrincipals.has(caller))
|
|
9174
9257
|
return "the caller's lifecycle is retired; a retired incarnation's credential holds no control authority (F5)";
|
|
@@ -9182,26 +9265,8 @@ export class Manager {
|
|
|
9182
9265
|
async withProvisioner(fn) {
|
|
9183
9266
|
if (!this.auth)
|
|
9184
9267
|
throw new Error("withProvisioner: no space auth (an open mesh has no scoped creds)");
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
const prov = new CotalEndpoint({
|
|
9188
|
-
space: this.space,
|
|
9189
|
-
servers: this.servers,
|
|
9190
|
-
channels: [],
|
|
9191
|
-
creds,
|
|
9192
|
-
card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
|
|
9193
|
-
registerPresence: false,
|
|
9194
|
-
watchPresence: false,
|
|
9195
|
-
watchChannels: false,
|
|
9196
|
-
consume: false,
|
|
9197
|
-
});
|
|
9198
|
-
await prov.start();
|
|
9199
|
-
try {
|
|
9200
|
-
return await fn(prov);
|
|
9201
|
-
}
|
|
9202
|
-
finally {
|
|
9203
|
-
await prov.stop();
|
|
9204
|
-
}
|
|
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);
|
|
9205
9270
|
}
|
|
9206
9271
|
/** A connection holding this instance's transfer reader grants (docs/design/resume-transfer.md
|
|
9207
9272
|
* section 6) for this one receive or sweep: minted from the space's signing seed, or issued by the
|