@cotal-ai/manager 0.90.1 → 0.91.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/chat-session.d.ts +165 -0
- package/dist/chat-session.d.ts.map +1 -0
- package/dist/chat-session.js +281 -0
- package/dist/chat-session.js.map +1 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +374 -163
- package/dist/commands.js.map +1 -1
- package/dist/endpoint-evict.d.ts +2 -0
- package/dist/endpoint-evict.d.ts.map +1 -1
- package/dist/endpoint-evict.js +1 -0
- package/dist/endpoint-evict.js.map +1 -1
- package/dist/holder-liveness.d.ts +1 -0
- package/dist/holder-liveness.d.ts.map +1 -1
- package/dist/holder-liveness.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/manager-service-contract.d.ts +52 -1
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +138 -4
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +105 -2
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +515 -49
- package/dist/manager.js.map +1 -1
- package/dist/operator-deregister.d.ts +4 -0
- package/dist/operator-deregister.d.ts.map +1 -0
- package/dist/operator-deregister.js +75 -0
- package/dist/operator-deregister.js.map +1 -0
- package/dist/service-adapter.d.ts +12 -0
- package/dist/service-adapter.d.ts.map +1 -0
- package/dist/service-adapter.js +174 -0
- package/dist/service-adapter.js.map +1 -0
- package/dist/service-child.d.ts +37 -0
- package/dist/service-child.d.ts.map +1 -0
- package/dist/service-child.js +195 -0
- package/dist/service-child.js.map +1 -0
- package/dist/service-observation.d.ts +19 -0
- package/dist/service-observation.d.ts.map +1 -0
- package/dist/service-observation.js +97 -0
- package/dist/service-observation.js.map +1 -0
- package/dist/static-lifecycle.d.ts +8 -6
- package/dist/static-lifecycle.d.ts.map +1 -1
- package/dist/static-lifecycle.js +19 -8
- package/dist/static-lifecycle.js.map +1 -1
- package/package.json +13 -12
package/dist/manager.js
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, s
|
|
|
5
5
|
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
6
6
|
import Headless from "@xterm/headless";
|
|
7
7
|
import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, } from "@cotal-ai/core";
|
|
8
|
+
import { loadLaunchPersonaFile, PersonaSourceError } from "@cotal-ai/workspace";
|
|
8
9
|
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimIdentityRecord, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, probeLiveness, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, spaceKey, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
10
|
import { createRuntime, exitProof, isCustodialRuntime, pollProvedExit, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, watchProvedExit, } from "./runtime/index.js";
|
|
10
11
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
@@ -33,6 +34,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
|
|
|
33
34
|
import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, requestGoalCancel, goalAlreadyTerminal, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
|
|
34
35
|
import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
|
|
35
36
|
import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, walkStaticSlots, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
|
|
37
|
+
import { CHAT_ALIAS_PREFIX, CHAT_EXPIRY_CONCURRENCY, ChatSessionExpiry, chatOpenLists, chatRetireRefusal, drawChatAlias, isChatAlias, readChatOpenInput, readChatRetireInput, sealChatAdmission, } from "./chat-session.js";
|
|
36
38
|
// Capture the loaded package version before an installation can replace its files.
|
|
37
39
|
const MANAGER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
38
40
|
/** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
|
|
@@ -415,6 +417,7 @@ export class Manager {
|
|
|
415
417
|
* {@link provisionEndpointGateOpen} idempotent (no core barrier change) and gives verified
|
|
416
418
|
* eviction a stable target (the predecessor's connections under this principal). Set in start(). */
|
|
417
419
|
managerServeIdentity;
|
|
420
|
+
onServiceIdentity;
|
|
418
421
|
/** The manager's v0.4 service-endpoint serve state — the serve handle +
|
|
419
422
|
* its dedicated connection, the STABLE serve identity (renewals re-mint the same nkey), the
|
|
420
423
|
* branded serve grant, and the CURRENT credential (the connection's authenticator reads it on
|
|
@@ -525,6 +528,15 @@ export class Manager {
|
|
|
525
528
|
/** Static aliases whose startup exact-op terminal is still in flight. Scoped per alias: control
|
|
526
529
|
* serves during the sweep, but no caller can reuse or attach an alias the sweep still owns. */
|
|
527
530
|
reconcilingAliases = new Set();
|
|
531
|
+
/** In-flight static chat-session terminals, by lifecycle UID, so every caller that retires one
|
|
532
|
+
* session joins one terminal instead of racing its CAS sequence. */
|
|
533
|
+
chatRetirements = new Map();
|
|
534
|
+
/** The expiry side of every chat session this process admitted and nothing retired sooner. */
|
|
535
|
+
chatExpiry = new ChatSessionExpiry((session) => this.expireStaticChatSession(session), {
|
|
536
|
+
retryDelaysMs: STATIC_RECONCILE_RETRY_DELAYS_MS,
|
|
537
|
+
concurrency: CHAT_EXPIRY_CONCURRENCY,
|
|
538
|
+
log: (line) => console.error(line),
|
|
539
|
+
});
|
|
528
540
|
/** Current failed/recovered static reconciliation coordinates. This is an operator snapshot, not
|
|
529
541
|
* lifecycle authority: every re-drive re-reads broker KV and re-runs planStaticSlotResume. */
|
|
530
542
|
staticReconcileItems = new Map();
|
|
@@ -641,11 +653,16 @@ export class Manager {
|
|
|
641
653
|
return dialerFor(servers)({ ...opts, servers });
|
|
642
654
|
}
|
|
643
655
|
constructor(opts) {
|
|
656
|
+
if (opts.onServiceIdentity !== undefined && typeof opts.onServiceIdentity !== "function")
|
|
657
|
+
throw new Error("manager onServiceIdentity must be a trusted embedding callback");
|
|
644
658
|
if (opts.execution !== undefined && opts.execution !== "runtime" && opts.execution !== "none")
|
|
645
659
|
throw new Error(`unknown manager execution capability ${JSON.stringify(opts.execution)}`);
|
|
646
|
-
if (opts.execution === "none" && (
|
|
647
|
-
|
|
648
|
-
|
|
660
|
+
if (opts.execution === "none" && ((opts.pooled !== undefined && typeof opts.pooled !== "boolean") ||
|
|
661
|
+
(opts.remoteAuthority !== undefined && (!opts.remoteAuthority || typeof opts.remoteAuthority !== "object" || Array.isArray(opts.remoteAuthority)))))
|
|
662
|
+
throw new Error("execution: none requires boolean pooled and a non-null remote authority object when supplied");
|
|
663
|
+
if (opts.execution === "none" && (opts.runtime !== undefined || (!!opts.pooled !== !!opts.remoteAuthority)))
|
|
664
|
+
throw new Error("execution: none requires pooled signerless remote authority for remote composition, or genuine local authority, and no runtime option");
|
|
665
|
+
if (opts.execution === "none" && opts.remoteAuthority && ["renewExecutor", "renewStandingBundle", "mintSessionServing", "mintRetirementRequester", "prepareAgentRetirement", "validateRetainedAgent", "scanGoalIndex", "authorizeAdmin"].some((key) => typeof opts.remoteAuthority?.[key] !== "function"))
|
|
649
666
|
throw new Error("execution: none requires complete closed remote authority callbacks before construction");
|
|
650
667
|
if (opts.pooled && (!opts.remoteAuthority || (opts.execution !== "none" && (opts.runtime === undefined || opts.runtime === "auto" || opts.runtime === "pty"))))
|
|
651
668
|
throw new Error("pooled control requires signerless remote authority and an explicit non-PTY runtime; local custodial execution is forbidden");
|
|
@@ -655,6 +672,7 @@ export class Manager {
|
|
|
655
672
|
this.servers = opts.servers;
|
|
656
673
|
this.name = opts.name ?? "manager";
|
|
657
674
|
this.workspaceRoot = opts.workspaceRoot ?? findCotalRoot();
|
|
675
|
+
this.onServiceIdentity = opts.onServiceIdentity;
|
|
658
676
|
this.eventsRequired = opts.eventsRequired === true;
|
|
659
677
|
this.maxSessions = opts.maxSessions;
|
|
660
678
|
this.remoteAuthority = opts.remoteAuthority;
|
|
@@ -670,6 +688,11 @@ export class Manager {
|
|
|
670
688
|
this.secretStoreIdentity = injectedSecretStoreIdentity(opts.secretStore);
|
|
671
689
|
this.installedExtensions = opts.installedExtensions ?? false;
|
|
672
690
|
this.executionState = opts.execution === "none" ? { kind: "none" } : { kind: "runtime" };
|
|
691
|
+
if (!this.remoteAuthority) {
|
|
692
|
+
const local = loadManagerInstanceIdentity(this.workspaceRoot, this.space);
|
|
693
|
+
if (local && existsSync(this.executionRecordPath(local.instanceId)))
|
|
694
|
+
this.claimExecutionRecord(local.instanceId);
|
|
695
|
+
}
|
|
673
696
|
if (this.executionState.kind === "runtime")
|
|
674
697
|
this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
|
|
675
698
|
if (opts.pooled && this.executionState.kind === "runtime" && isCustodialRuntime(this.requiredRuntime))
|
|
@@ -707,7 +730,7 @@ export class Manager {
|
|
|
707
730
|
return this.executionState.kind;
|
|
708
731
|
}
|
|
709
732
|
executionRefusal() {
|
|
710
|
-
return "execution: none forbids seat launch
|
|
733
|
+
return "execution: none forbids seat launch, workflow hosting, model inspection and caller-driven writes on this control instance; select the participant Manager with an instance-pinned manager-caller view";
|
|
711
734
|
}
|
|
712
735
|
/** A defensive last barrier, not a substitute Runtime. Entry admission runs before effects. */
|
|
713
736
|
get requiredRuntime() {
|
|
@@ -730,6 +753,37 @@ export class Manager {
|
|
|
730
753
|
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
731
754
|
return requireRuntimeAdopt(this.requiredRuntime, reference);
|
|
732
755
|
}
|
|
756
|
+
/** Snapshot this embedding's own registration and stop policy. It is not a fresh serving
|
|
757
|
+
* response, registration-leader read or authorization for another process to act. Wire callers
|
|
758
|
+
* still use status through its fresh owner/admin visibility gate. No credential or URL leaves. */
|
|
759
|
+
localServiceObservation() {
|
|
760
|
+
const state = this.serviceServe;
|
|
761
|
+
if (!state)
|
|
762
|
+
return undefined;
|
|
763
|
+
const identity = this.serviceIdentityObservation();
|
|
764
|
+
if (state.grant.endpoint !== identity.endpoint || state.grant.instanceId !== identity.instanceId || state.grant.owner !== identity.principal.owner)
|
|
765
|
+
throw new Error("the manager's local service observation does not match its owned serve incarnation");
|
|
766
|
+
const stopImpact = this.managerStatusData(undefined).stopImpact;
|
|
767
|
+
if (!stopImpact)
|
|
768
|
+
throw new Error("the manager did not expose its own native stop policy");
|
|
769
|
+
// The local receipt keeps its revision-32 shape: an older controller's closed reader refuses an
|
|
770
|
+
// extra key. The preserved-exit check reads the binding through `localPreservation` instead.
|
|
771
|
+
const { preservation: _preservation, ...ordinary } = stopImpact;
|
|
772
|
+
return { registration: { ...identity, epoch: state.grant.epoch }, stopImpact: ordinary };
|
|
773
|
+
}
|
|
774
|
+
/** The attempt and inventory digest this embedding holds while maintenance is not `active`, for
|
|
775
|
+
* its own native child only. Absent when active or before an inventory exists. */
|
|
776
|
+
localPreservation() {
|
|
777
|
+
return this.managerStatusData(undefined).stopImpact?.preservation;
|
|
778
|
+
}
|
|
779
|
+
serviceIdentityObservation() {
|
|
780
|
+
if (!this.managerInstanceId || !this.managerServeIdentity)
|
|
781
|
+
throw new Error("manager native service identity has not been selected");
|
|
782
|
+
const principal = this.remoteAuthority
|
|
783
|
+
? { owner: this.remoteAuthority.owner, actor: this.remoteAuthority.actors.serve, uid: this.managerLifecycleUid }
|
|
784
|
+
: { owner: DEV_OWNER, actor: this.managerServeIdentity.id, uid: this.managerLifecycleUid };
|
|
785
|
+
return Object.freeze({ endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId, principal: Object.freeze(principal) });
|
|
786
|
+
}
|
|
733
787
|
/** The console page URL (manager-hosted, loopback). */
|
|
734
788
|
get consoleUrl() {
|
|
735
789
|
return this.attach.consoleUrl();
|
|
@@ -831,12 +885,64 @@ export class Manager {
|
|
|
831
885
|
this.startTask = wrapped;
|
|
832
886
|
return wrapped;
|
|
833
887
|
}
|
|
888
|
+
executionRecordPath(instanceId) {
|
|
889
|
+
return join(this.workspaceRoot, ".cotal", `manager-execution.${spaceKey(this.space)}.${instanceId}.json`);
|
|
890
|
+
}
|
|
891
|
+
claimExecutionRecord(instanceId) {
|
|
892
|
+
const record = claimIdentityRecord(this.executionRecordPath(instanceId), "manager execution capability", (raw) => {
|
|
893
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
894
|
+
return undefined;
|
|
895
|
+
const r = raw;
|
|
896
|
+
return Object.keys(r).sort().join(",") === "execution,instanceId,space,v" && r.v === 1 && r.space === this.space &&
|
|
897
|
+
r.instanceId === instanceId && (r.execution === "none" || r.execution === "runtime") ? r : undefined;
|
|
898
|
+
}, () => ({ v: 1, space: this.space, instanceId, execution: this.execution }));
|
|
899
|
+
if (record.execution !== this.execution)
|
|
900
|
+
throw new Error("manager execution capability differs from its retained instance record; execution cannot change across restart");
|
|
901
|
+
}
|
|
834
902
|
async admitExecutionState() {
|
|
903
|
+
if (!this.remoteAuthority) {
|
|
904
|
+
let local = loadManagerInstanceIdentity(this.workspaceRoot, this.space);
|
|
905
|
+
if (this.execution === "none") {
|
|
906
|
+
const recorded = loadMeshes().find((m) => m.space === this.space);
|
|
907
|
+
const auth = hasUserAuthState(this.workspaceRoot, this.space) && recorded?.mode === "user"
|
|
908
|
+
? await getSpaceAuth(this.secrets, this.space) : undefined;
|
|
909
|
+
if (!auth)
|
|
910
|
+
throw new Error("execution: none requires a genuine hosting user-auth root and its user-mode mesh record");
|
|
911
|
+
if (local && !existsSync(this.executionRecordPath(local.instanceId)))
|
|
912
|
+
throw new Error("execution: none refuses an existing unclassified manager instance; keep its runtime and retained state, or register a fresh control instance");
|
|
913
|
+
if (!local) {
|
|
914
|
+
const proposed = { instanceId: mintLifecycleUid(), serveIdentity: newIdentity() };
|
|
915
|
+
local = createManagerInstanceIdentity(this.workspaceRoot, this.space, proposed);
|
|
916
|
+
if (local.instanceId !== proposed.instanceId)
|
|
917
|
+
throw new Error("execution: none refuses a concurrently claimed unclassified manager identity");
|
|
918
|
+
}
|
|
919
|
+
const localInstanceId = local.instanceId;
|
|
920
|
+
this.claimExecutionRecord(localInstanceId);
|
|
921
|
+
const nc = await this.dial({ ...standaloneConnectOpts({ creds: await mintCreds(auth, newIdentity(), "provisioner"), tls: false }), maxReconnectAttempts: 0 });
|
|
922
|
+
try {
|
|
923
|
+
const kvm = new Kvm(nc);
|
|
924
|
+
await ensureAuthorityStores(await jetstreamManager(nc), kvm, this.space);
|
|
925
|
+
const entries = await listGoalIndex(await kvm.open(recordsBucket(this.space)), MANAGER_ENDPOINT);
|
|
926
|
+
if (entries.some((entry) => entry.iid === localInstanceId && (entry.allocated !== undefined || entry.note !== undefined)))
|
|
927
|
+
throw new Error("execution: none refuses retained seat or turn inventory before recovery; keep the execution-owning Manager");
|
|
928
|
+
}
|
|
929
|
+
finally {
|
|
930
|
+
await nc.drain().catch(() => nc.close());
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
local ??= createManagerInstanceIdentity(this.workspaceRoot, this.space, { instanceId: mintLifecycleUid(), serveIdentity: newIdentity() });
|
|
935
|
+
this.claimExecutionRecord(local.instanceId);
|
|
936
|
+
}
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
835
939
|
if (this.remoteAuthority) {
|
|
940
|
+
if (this.execution === "none" && (await localTrustOfSpace(this.secrets, this.space)).present)
|
|
941
|
+
throw new Error("remote manager-service authority cannot be combined with local space signing trust - choose one authority path, never a fallback");
|
|
836
942
|
// Execution is fixed for a registered instance. An older instance with no capability
|
|
837
943
|
// record might own retained seats/runs, so it cannot be converted to none by omission.
|
|
838
944
|
const remote = this.remoteAuthority;
|
|
839
|
-
const path =
|
|
945
|
+
const path = this.executionRecordPath(remote.instanceId);
|
|
840
946
|
if (this.execution === "none" && !existsSync(path) && remote.serveGrant.epoch !== 0)
|
|
841
947
|
throw new Error("execution: none refuses an existing unclassified manager instance; keep its runtime and retained state, or register a fresh control instance");
|
|
842
948
|
if (this.execution === "none") {
|
|
@@ -844,17 +950,8 @@ export class Manager {
|
|
|
844
950
|
if (entries.some((entry) => entry.iid === remote.instanceId && (entry.allocated !== undefined || entry.note !== undefined)))
|
|
845
951
|
throw new Error("execution: none refuses retained seat or turn inventory before recovery; keep the execution-owning Manager");
|
|
846
952
|
}
|
|
847
|
-
if (this.execution === "none" || existsSync(path))
|
|
848
|
-
|
|
849
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
850
|
-
return undefined;
|
|
851
|
-
const r = raw;
|
|
852
|
-
return Object.keys(r).sort().join(",") === "execution,instanceId,space,v" && r.v === 1 && r.space === this.space &&
|
|
853
|
-
r.instanceId === remote.instanceId && (r.execution === "none" || r.execution === "runtime") ? r : undefined;
|
|
854
|
-
}, () => ({ v: 1, space: this.space, instanceId: remote.instanceId, execution: this.execution }));
|
|
855
|
-
if (record.execution !== this.execution)
|
|
856
|
-
throw new Error("manager execution capability differs from its retained instance record; execution cannot change across restart");
|
|
857
|
-
}
|
|
953
|
+
if (this.execution === "none" || existsSync(path))
|
|
954
|
+
this.claimExecutionRecord(remote.instanceId);
|
|
858
955
|
}
|
|
859
956
|
}
|
|
860
957
|
async runStart() {
|
|
@@ -931,6 +1028,7 @@ export class Manager {
|
|
|
931
1028
|
}
|
|
932
1029
|
}
|
|
933
1030
|
}
|
|
1031
|
+
await this.onServiceIdentity?.(this.serviceIdentityObservation());
|
|
934
1032
|
let creds;
|
|
935
1033
|
let id;
|
|
936
1034
|
if (this.remoteAuthority) {
|
|
@@ -1164,7 +1262,7 @@ export class Manager {
|
|
|
1164
1262
|
}
|
|
1165
1263
|
// A carried resume (#1499) needs this instance's transfer reader: minted from the space's signing
|
|
1166
1264
|
// seed, issued by the host to a remote manager, or none on an open mesh.
|
|
1167
|
-
if (!this.remoteAuthority || this.remoteAuthority.mintTransferReader) {
|
|
1265
|
+
if (this.execution !== "none" && (!this.remoteAuthority || this.remoteAuthority.mintTransferReader)) {
|
|
1168
1266
|
this.transcripts = new TranscriptReceiver({
|
|
1169
1267
|
space: this.space,
|
|
1170
1268
|
instanceId: this.managerInstanceId,
|
|
@@ -1754,6 +1852,8 @@ export class Manager {
|
|
|
1754
1852
|
/** Fence and build the inventory without stopping a child. The coordinator must durably persist
|
|
1755
1853
|
* this exact plan before calling commitPreservation with the same attempt id. */
|
|
1756
1854
|
preparePreservation(attemptId) {
|
|
1855
|
+
if (this.execution === "none")
|
|
1856
|
+
return Promise.reject(new EpEnvelopeError("unimplemented", this.executionRefusal()));
|
|
1757
1857
|
if (!attemptId.trim())
|
|
1758
1858
|
return Promise.reject(new Error("preservation attemptId is required"));
|
|
1759
1859
|
if (this.preservationAttemptId && this.preservationAttemptId !== attemptId)
|
|
@@ -1857,6 +1957,8 @@ export class Manager {
|
|
|
1857
1957
|
/** Stop children only after the coordinator has persisted the prepared inventory. Same-attempt
|
|
1858
1958
|
* retries are idempotent; a different attempt is refused. */
|
|
1859
1959
|
commitPreservation(attemptId) {
|
|
1960
|
+
if (this.execution === "none")
|
|
1961
|
+
return Promise.reject(new EpEnvelopeError("unimplemented", this.executionRefusal()));
|
|
1860
1962
|
if (!this.preservationAttemptId || this.preservationAttemptId !== attemptId)
|
|
1861
1963
|
return Promise.reject(new Error(`preservation attempt ${attemptId} was not prepared by this manager`));
|
|
1862
1964
|
if (!this.preservationInventory)
|
|
@@ -1882,6 +1984,8 @@ export class Manager {
|
|
|
1882
1984
|
/** Recover an abandoned prepare before any child stop. Once commit begins, preservation is
|
|
1883
1985
|
* irreversible and remains fenced until the coordinator records failure/recourse. */
|
|
1884
1986
|
abortPreservation(attemptId) {
|
|
1987
|
+
if (this.execution === "none")
|
|
1988
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
1885
1989
|
if (this.preservationAttemptId !== attemptId)
|
|
1886
1990
|
throw new Error(`preservation attempt ${attemptId} is not the active manager attempt`);
|
|
1887
1991
|
if (this.preparationTask || this.lifecycleInFlight > 0)
|
|
@@ -2144,11 +2248,14 @@ export class Manager {
|
|
|
2144
2248
|
if (failures.length)
|
|
2145
2249
|
throw new Error(`manager shutdown could not prove every seat exited: ${failures.join("; ")}`);
|
|
2146
2250
|
}
|
|
2251
|
+
isProcessBoundSeat(agent) {
|
|
2252
|
+
return agent.handle.kind === "pty" && typeof agent.handle.release !== "function";
|
|
2253
|
+
}
|
|
2147
2254
|
/** Drop this manager's local custody of every seat that can outlive it. A pty seat whose handle
|
|
2148
2255
|
* cannot be released lives in this process and closes with it, so it is left for {@link stop} to
|
|
2149
2256
|
* stop instead. */
|
|
2150
2257
|
releaseManagedAgents() {
|
|
2151
|
-
const managed = [...this.agents.values()].filter((a) => !(a
|
|
2258
|
+
const managed = [...this.agents.values()].filter((a) => !this.isProcessBoundSeat(a));
|
|
2152
2259
|
const failures = [];
|
|
2153
2260
|
for (const a of managed) {
|
|
2154
2261
|
try {
|
|
@@ -2248,6 +2355,7 @@ export class Manager {
|
|
|
2248
2355
|
this.leaseStopping = true;
|
|
2249
2356
|
this.transcripts?.stop();
|
|
2250
2357
|
this.staticReconcileStopping = true;
|
|
2358
|
+
this.chatExpiry.stop();
|
|
2251
2359
|
const starting = this.startTask;
|
|
2252
2360
|
for (const item of this.staticReconcileItems.values()) {
|
|
2253
2361
|
if (item.timer)
|
|
@@ -2286,7 +2394,7 @@ export class Manager {
|
|
|
2286
2394
|
else {
|
|
2287
2395
|
// An in-process seat cannot be detached from the process that is exiting, so the default
|
|
2288
2396
|
// stop stops and deprovisions it as `withAgents` does instead of orphaning its footprint.
|
|
2289
|
-
const inProcess = [...this.agents.values()].filter((a) =>
|
|
2397
|
+
const inProcess = [...this.agents.values()].filter((a) => this.isProcessBoundSeat(a));
|
|
2290
2398
|
releaseFailures = this.releaseManagedAgents();
|
|
2291
2399
|
await this.teardownManagedAgents(inProcess);
|
|
2292
2400
|
}
|
|
@@ -2310,6 +2418,13 @@ export class Manager {
|
|
|
2310
2418
|
failures.push(`${name}: ${rejectionText(e)}`);
|
|
2311
2419
|
}
|
|
2312
2420
|
};
|
|
2421
|
+
// The expiry that would end a chat session stops with this process, and nothing can adopt one,
|
|
2422
|
+
// so an active stop retires every session this process admitted, under either agent policy, as
|
|
2423
|
+
// it does an in-process seat. A preserving stop leaves them to the next start's sweep.
|
|
2424
|
+
if (this.maintenanceState === "active" && !this.resumeRequired)
|
|
2425
|
+
await step("retire chat sessions", () => this.retireChatSessionsOnStop());
|
|
2426
|
+
else
|
|
2427
|
+
this.reportChatSessionsLeftAtStop(this.resumeRequired ? "this manager is waiting for a resume" : "this manager is preserving its state");
|
|
2313
2428
|
// A seat that may still be alive keeps this instance's leases and registration (see
|
|
2314
2429
|
// teardownManagedAgents): they lapse on their TTL instead of being handed to a successor.
|
|
2315
2430
|
if (seatFailure === undefined) {
|
|
@@ -2555,6 +2670,8 @@ export class Manager {
|
|
|
2555
2670
|
return { kind: "held", revision: current.revision };
|
|
2556
2671
|
}
|
|
2557
2672
|
async opFinalizeResume(rawArgs) {
|
|
2673
|
+
if (this.execution === "none")
|
|
2674
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
2558
2675
|
{
|
|
2559
2676
|
let args;
|
|
2560
2677
|
try {
|
|
@@ -2605,6 +2722,8 @@ export class Manager {
|
|
|
2605
2722
|
}
|
|
2606
2723
|
}
|
|
2607
2724
|
async opCommitResume(rawArgs) {
|
|
2725
|
+
if (this.execution === "none")
|
|
2726
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
2608
2727
|
{
|
|
2609
2728
|
let attemptId;
|
|
2610
2729
|
try {
|
|
@@ -2638,11 +2757,11 @@ export class Manager {
|
|
|
2638
2757
|
}
|
|
2639
2758
|
}
|
|
2640
2759
|
async opResumePreserved(rawArgs) {
|
|
2760
|
+
if (this.execution === "none")
|
|
2761
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
2641
2762
|
{
|
|
2642
2763
|
try {
|
|
2643
2764
|
const args = parseResumeControlArgs(rawArgs);
|
|
2644
|
-
if (this.execution === "none" && args.inventory.agents.length !== 0)
|
|
2645
|
-
return { ok: false, error: this.executionRefusal() };
|
|
2646
2765
|
const inventoryDigest = createHash("sha256").update(JSON.stringify(args.inventory)).digest("hex");
|
|
2647
2766
|
if (!this.resumeAttemptId)
|
|
2648
2767
|
return { ok: false, error: "resumePreserved requires a manager started with --resume-attempt" };
|
|
@@ -2675,6 +2794,8 @@ export class Manager {
|
|
|
2675
2794
|
}
|
|
2676
2795
|
}
|
|
2677
2796
|
async opPreservationCtl(op, rawArgs) {
|
|
2797
|
+
if (this.execution === "none")
|
|
2798
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
2678
2799
|
if (this.resumeRequired)
|
|
2679
2800
|
return { ok: false, error: this.maintenanceError() };
|
|
2680
2801
|
const attemptId = String(rawArgs?.attemptId ?? "").trim();
|
|
@@ -2726,8 +2847,9 @@ export class Manager {
|
|
|
2726
2847
|
async serveGated(ctx, fn) {
|
|
2727
2848
|
const caller = principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
|
|
2728
2849
|
const admission = this.admitControl(caller);
|
|
2850
|
+
// Both fences refuse before the handler runs, so nothing ran (SPEC 13.3).
|
|
2729
2851
|
if (admission.refusal !== undefined)
|
|
2730
|
-
throw new EpEnvelopeError(admission.fence === "membership" ? "permission-denied" : "unavailable", admission.refusal);
|
|
2852
|
+
throw new EpEnvelopeError(admission.fence === "membership" ? "permission-denied" : "unavailable", admission.refusal, undefined, "not-executed");
|
|
2731
2853
|
try {
|
|
2732
2854
|
return await fn();
|
|
2733
2855
|
}
|
|
@@ -2735,6 +2857,29 @@ export class Manager {
|
|
|
2735
2857
|
admission.release();
|
|
2736
2858
|
}
|
|
2737
2859
|
}
|
|
2860
|
+
/** `status` only: {@link serveGated}, except that a fully preserved manager, and an active one
|
|
2861
|
+
* launched for a resume that has not committed it yet, still answer it. A preserved-exit stop's
|
|
2862
|
+
* caller-attributed preview needs the preserved manager's stop impact, and a resumed service's
|
|
2863
|
+
* readiness reads the same status; the maintenance fence refuses every other command in both
|
|
2864
|
+
* states. The read is counted in `lifecycleInFlight` like any admitted operation and released in
|
|
2865
|
+
* `finally`, so a drain waits at most for one status snapshot; the membership gate still
|
|
2866
|
+
* applies. `preserving` and a stopping manager stay fenced. Status writes nothing. */
|
|
2867
|
+
async serveObservation(ctx, fn) {
|
|
2868
|
+
const preserved = this.maintenanceState === "preserved" && !this.resumeRequired;
|
|
2869
|
+
const resumePending = this.maintenanceState === "active" && this.resumeRequired;
|
|
2870
|
+
if (this.leaseStopping || !preserved && !resumePending)
|
|
2871
|
+
return this.serveGated(ctx, fn);
|
|
2872
|
+
const membership = this.lifecycleMembershipRefusal(principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key);
|
|
2873
|
+
if (membership)
|
|
2874
|
+
throw new EpEnvelopeError("permission-denied", membership, undefined, "not-executed");
|
|
2875
|
+
this.lifecycleInFlight++;
|
|
2876
|
+
try {
|
|
2877
|
+
return await fn();
|
|
2878
|
+
}
|
|
2879
|
+
finally {
|
|
2880
|
+
this.releaseLifecycle();
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2738
2883
|
/** The ep door's ADMIN flag for a caller. Static mesh: `true` — the
|
|
2739
2884
|
* admin-grade rows (any-mode despawn/attach, the `manager.admin` family, `launch`) are minted
|
|
2740
2885
|
* only into operator instruments (§13.2: `any` is operator-policy-mintable; the agent/spawn
|
|
@@ -2898,20 +3043,22 @@ export class Manager {
|
|
|
2898
3043
|
// serveGated BYPASS (those ops must run while the maintenance fence holds) but not the gate.
|
|
2899
3044
|
const adminGated = async (ctx, fn) => {
|
|
2900
3045
|
if (!(await this.epAdminReach(ctx.subject.caller)))
|
|
2901
|
-
throw new EpEnvelopeError("permission-denied", `${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)
|
|
3046
|
+
throw new EpEnvelopeError("permission-denied", `${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)`, undefined, "not-executed");
|
|
2902
3047
|
return fn();
|
|
2903
3048
|
};
|
|
2904
3049
|
const targetAgent = (ctx) => {
|
|
3050
|
+
if (this.execution === "none")
|
|
3051
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
2905
3052
|
const t = ctx.request.target; // targeted commands only: the serve boundary enforced body-target presence + fresh currency
|
|
2906
3053
|
const a = this.findManagedByTarget(t);
|
|
2907
3054
|
if (!a)
|
|
2908
|
-
throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} (lifecycle ${t.lifecycleUid}) is not a live managed agent of this manager
|
|
3055
|
+
throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} (lifecycle ${t.lifecycleUid}) is not a live managed agent of this manager`, undefined, "not-executed");
|
|
2909
3056
|
return a;
|
|
2910
3057
|
};
|
|
2911
3058
|
return managerCommandDefs({
|
|
2912
3059
|
// A remote Manager files every seat under its own owner, so only a user mesh's shared local
|
|
2913
3060
|
// Manager narrows the count, and status never waits on a remote admin check.
|
|
2914
|
-
status: (ctx) => this.
|
|
3061
|
+
status: (ctx) => this.serveObservation(ctx, async () => this.managerStatusData(this.remoteAuthority ? undefined : await this.psOwnerFilter(ctx.subject.caller, false))),
|
|
2915
3062
|
ps: (ctx) => this.serveGated(ctx, async () => this.list(await this.psOwnerFilter(ctx.subject.caller, false))),
|
|
2916
3063
|
inspect: (ctx) => this.serveGated(ctx, async () => {
|
|
2917
3064
|
const name = String(args(ctx).name ?? "").trim();
|
|
@@ -2966,7 +3113,11 @@ export class Manager {
|
|
|
2966
3113
|
]);
|
|
2967
3114
|
let observed;
|
|
2968
3115
|
try {
|
|
2969
|
-
|
|
3116
|
+
const query = args(ctx);
|
|
3117
|
+
observed = await listStaticSlotObservations(recordsKv, DEV_OWNER, this.managerInstanceId, {
|
|
3118
|
+
...(query.names === undefined ? {} : { names: query.names }),
|
|
3119
|
+
...(query.includeRetired === true ? { includeRetired: true } : {}),
|
|
3120
|
+
});
|
|
2970
3121
|
}
|
|
2971
3122
|
catch (error) {
|
|
2972
3123
|
throw new EpEnvelopeError("unavailable", `the durable static slot state could not be scanned: ${rejectionText(error)}`, [
|
|
@@ -2995,9 +3146,11 @@ export class Manager {
|
|
|
2995
3146
|
}),
|
|
2996
3147
|
resolveCwd: (ctx) => this.serveGated(ctx, () => this.resolveSpawnCwd(args(ctx).cwd)),
|
|
2997
3148
|
resolveAgent: (ctx) => this.serveGated(ctx, async () => {
|
|
3149
|
+
if (this.execution === "none")
|
|
3150
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
2998
3151
|
const persona = await this.spawnPersona(args(ctx), callerOf(ctx), ctx.subject.caller);
|
|
2999
3152
|
if ("error" in persona)
|
|
3000
|
-
throw new EpEnvelopeError(persona.error.startsWith("no persona") ? "not-found" : "failed-precondition", persona.error);
|
|
3153
|
+
throw new EpEnvelopeError(persona.code ?? (persona.error.startsWith("no persona") ? "not-found" : "failed-precondition"), persona.error);
|
|
3001
3154
|
return { agent: persona.agent };
|
|
3002
3155
|
}),
|
|
3003
3156
|
// `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
|
|
@@ -3011,14 +3164,14 @@ export class Manager {
|
|
|
3011
3164
|
const a = targetAgent(ctx);
|
|
3012
3165
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
3013
3166
|
if (denied)
|
|
3014
|
-
throw new EpEnvelopeError("permission-denied", denied);
|
|
3167
|
+
throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
|
|
3015
3168
|
return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true, callerOf(ctx)));
|
|
3016
3169
|
}),
|
|
3017
3170
|
attach: (ctx) => this.serveGated(ctx, async () => {
|
|
3018
3171
|
const a = targetAgent(ctx);
|
|
3019
3172
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
3020
3173
|
if (denied)
|
|
3021
|
-
throw new EpEnvelopeError("permission-denied", denied);
|
|
3174
|
+
throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
|
|
3022
3175
|
return unwrap(await this.attachAuthorized(a, ctx.subject.caller));
|
|
3023
3176
|
}),
|
|
3024
3177
|
// C3 `input`: the SAME authorization as `attach`, deliberately written out rather than
|
|
@@ -3028,7 +3181,7 @@ export class Manager {
|
|
|
3028
3181
|
const a = targetAgent(ctx);
|
|
3029
3182
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
3030
3183
|
if (denied)
|
|
3031
|
-
throw new EpEnvelopeError("permission-denied", denied);
|
|
3184
|
+
throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
|
|
3032
3185
|
return this.inputAuthorized(a, args(ctx));
|
|
3033
3186
|
}),
|
|
3034
3187
|
// The turn relay (§8 durable actions): `turn` shares despawn/input's reach policy — the
|
|
@@ -3038,7 +3191,7 @@ export class Manager {
|
|
|
3038
3191
|
const a = targetAgent(ctx);
|
|
3039
3192
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
3040
3193
|
if (denied)
|
|
3041
|
-
throw new EpEnvelopeError("permission-denied", denied);
|
|
3194
|
+
throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
|
|
3042
3195
|
return this.serveTurnGoal(ctx, a);
|
|
3043
3196
|
}),
|
|
3044
3197
|
// The seat's own half of the relay (`manager.self`, stop-self's tier): a seat pulls the
|
|
@@ -3050,6 +3203,10 @@ export class Manager {
|
|
|
3050
3203
|
cancel: (ctx) => this.serveGated(ctx, () => this.serveTurnCancel(ctx)),
|
|
3051
3204
|
stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
|
|
3052
3205
|
definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
|
|
3206
|
+
// Reach is the broker's: only operator instruments carry these rows. The serve gate is the
|
|
3207
|
+
// maintenance fence, so neither runs while a shutdown or resume holds control.
|
|
3208
|
+
chatSessionOpen: (ctx) => this.serveGated(ctx, () => this.openStaticChatSession(args(ctx))),
|
|
3209
|
+
chatSessionRetire: (ctx) => this.serveGated(ctx, () => this.retireStaticChatSession(args(ctx))),
|
|
3053
3210
|
listPersonas: (ctx) => this.serveGated(ctx, () => unwrap(this.opListPersonas(callerOf(ctx), false))),
|
|
3054
3211
|
showPersona: (ctx) => this.serveGated(ctx, () => {
|
|
3055
3212
|
const r = this.opShowPersona(args(ctx), callerOf(ctx), false);
|
|
@@ -3094,6 +3251,8 @@ export class Manager {
|
|
|
3094
3251
|
commitPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("commitPreservation", args(ctx)))),
|
|
3095
3252
|
abortPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("abortPreservation", args(ctx)))),
|
|
3096
3253
|
transcriptReceive: (ctx) => this.serveGated(ctx, () => adminGated(ctx, () => {
|
|
3254
|
+
if (this.execution === "none")
|
|
3255
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
3097
3256
|
if (!this.transcripts)
|
|
3098
3257
|
throw new EpEnvelopeError("failed-precondition", "transcript-receive: this manager does not receive carried transcripts (its remote authority issues no transfer reader)");
|
|
3099
3258
|
return this.transcripts.receive(args(ctx));
|
|
@@ -3231,6 +3390,8 @@ export class Manager {
|
|
|
3231
3390
|
* hitting another agent. Non-managed callers (human CLI, the manager itself, observers) find
|
|
3232
3391
|
* no match and get a loud error, not a silent no-op. */
|
|
3233
3392
|
opStopSelf(callerId, args) {
|
|
3393
|
+
if (this.execution === "none")
|
|
3394
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
3234
3395
|
const target = [...this.agents.values()].find((a) => this.managedPrincipal(a) === callerId);
|
|
3235
3396
|
if (!target)
|
|
3236
3397
|
return { ok: false, error: `self-stop: caller ${callerId} is not a managed agent` };
|
|
@@ -3390,6 +3551,9 @@ export class Manager {
|
|
|
3390
3551
|
parent: spawnerPr ? opts.spawner : undefined,
|
|
3391
3552
|
label: opts.label,
|
|
3392
3553
|
lifecycleUid: opts.lifecycleUid,
|
|
3554
|
+
// This manager attests its own persisted logical instance id: the same id a static slot
|
|
3555
|
+
// records as its owner. Recovery binds a managed row to a manager only through this.
|
|
3556
|
+
custody: { kind: "local-manager", instanceId: this.managerInstanceId },
|
|
3393
3557
|
});
|
|
3394
3558
|
// Durables + ACL row, LIFECYCLE-keyed (SPEC 13.1) — the same onboarding as static agents minus
|
|
3395
3559
|
// the mint (a user agent's credential is its bearer, minted by the callout per connect from the
|
|
@@ -4631,6 +4795,8 @@ export class Manager {
|
|
|
4631
4795
|
/** Resolve a placed spawn directory on this manager's host. Delegated execution refuses before
|
|
4632
4796
|
* filesystem lookup, since its seats cannot use host cwd. Creates nothing and never substitutes the workspace root. */
|
|
4633
4797
|
resolveSpawnCwd(value) {
|
|
4798
|
+
if (this.execution === "none")
|
|
4799
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
4634
4800
|
if (this.execution === "runtime" && this.requiredRuntime.spawnDelegated)
|
|
4635
4801
|
throw new EpEnvelopeError("failed-precondition", `runtime "${this.requiredRuntime.kind}" starts seats outside this host, so it cannot resolve a directory on this host's filesystem (cwd); the seat runs in its runtime resource's own directory`);
|
|
4636
4802
|
const asked = typeof value === "string" ? value : String(value ?? "");
|
|
@@ -4786,6 +4952,8 @@ export class Manager {
|
|
|
4786
4952
|
* local model-list API reports `supported:false` rather than blocking the manager. A connector that
|
|
4787
4953
|
* fails to import shows an `error:` row (from manifest enumeration) and never blocks the others. */
|
|
4788
4954
|
async opModels(args) {
|
|
4955
|
+
if (this.execution === "none")
|
|
4956
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
4789
4957
|
const requested = String(args.agent ?? "").trim();
|
|
4790
4958
|
const refresh = args.refresh === true;
|
|
4791
4959
|
const one = async (connector) => {
|
|
@@ -4796,7 +4964,7 @@ export class Manager {
|
|
|
4796
4964
|
return { agent: connector.name, supported: true, models: [], error: harness.refusal };
|
|
4797
4965
|
try {
|
|
4798
4966
|
const catalog = await connector.listModels({ refresh });
|
|
4799
|
-
return { agent: connector.name, supported:
|
|
4967
|
+
return { agent: connector.name, supported: catalog.unavailable === undefined, ...catalog };
|
|
4800
4968
|
}
|
|
4801
4969
|
catch (e) {
|
|
4802
4970
|
return { agent: connector.name, supported: true, models: [], error: rejectionText(e) };
|
|
@@ -4883,6 +5051,7 @@ export class Manager {
|
|
|
4883
5051
|
spec = parseLaunchSpec(args.spec, `inline launch spec (run ${runId})`);
|
|
4884
5052
|
if (spec.runId !== runId)
|
|
4885
5053
|
return { ok: false, error: `inline launch spec runId "${spec.runId}" does not match requested "${runId}"` };
|
|
5054
|
+
hooks?.onEffect?.();
|
|
4886
5055
|
persistLaunchSpec(this.workspaceRoot, spec);
|
|
4887
5056
|
}
|
|
4888
5057
|
catch (e) {
|
|
@@ -4914,6 +5083,7 @@ export class Manager {
|
|
|
4914
5083
|
}
|
|
4915
5084
|
let configPath;
|
|
4916
5085
|
try {
|
|
5086
|
+
hooks?.onEffect?.();
|
|
4917
5087
|
configPath = materializePersona(this.workspaceRoot, runId, la);
|
|
4918
5088
|
}
|
|
4919
5089
|
catch (e) {
|
|
@@ -4995,10 +5165,10 @@ export class Manager {
|
|
|
4995
5165
|
let def;
|
|
4996
5166
|
if (!opts.resolved) {
|
|
4997
5167
|
try {
|
|
4998
|
-
def =
|
|
5168
|
+
def = loadLaunchPersonaFile(configPath);
|
|
4999
5169
|
}
|
|
5000
5170
|
catch (e) {
|
|
5001
|
-
return { error: rejectionText(e) };
|
|
5171
|
+
return { error: rejectionText(e), ...(e instanceof PersonaSourceError ? { code: "bad-request" } : {}) };
|
|
5002
5172
|
}
|
|
5003
5173
|
// Shared operator files remain launchable. Owned content follows the catalog's visibility
|
|
5004
5174
|
// decision before connector loading or any allocation. A hosted run acts for its admission;
|
|
@@ -5032,7 +5202,7 @@ export class Manager {
|
|
|
5032
5202
|
}
|
|
5033
5203
|
const persona = await this.spawnPersona(opts, spawner, authenticatedCaller);
|
|
5034
5204
|
if ("error" in persona)
|
|
5035
|
-
return { ok: false, error: persona.error };
|
|
5205
|
+
return { ok: false, error: persona.error, ...(persona.code ? { code: persona.code } : {}) };
|
|
5036
5206
|
const { ref, configPath, def, agent } = persona;
|
|
5037
5207
|
// Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
|
|
5038
5208
|
// `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
|
|
@@ -5044,7 +5214,12 @@ export class Manager {
|
|
|
5044
5214
|
connector = await this.resolveConnector(agent);
|
|
5045
5215
|
}
|
|
5046
5216
|
catch (e) {
|
|
5047
|
-
|
|
5217
|
+
const error = rejectionText(e);
|
|
5218
|
+
// An action refusal here precedes capacity, allocation and onAccepted. Preserve that proof
|
|
5219
|
+
// through serveSpawnGoal without changing the direct startAgent ControlReply contract.
|
|
5220
|
+
if (hooks?.onAccepted)
|
|
5221
|
+
throw new EpEnvelopeError("failed-precondition", error, undefined, "not-executed");
|
|
5222
|
+
return { ok: false, error };
|
|
5048
5223
|
}
|
|
5049
5224
|
const readinessTimeoutMs = connector.readinessTimeoutMs ?? this.readinessTimeoutMs;
|
|
5050
5225
|
if (!Number.isSafeInteger(readinessTimeoutMs) || readinessTimeoutMs <= 0)
|
|
@@ -5265,6 +5440,7 @@ export class Manager {
|
|
|
5265
5440
|
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.` };
|
|
5266
5441
|
const held = this.retiring.get(identityName);
|
|
5267
5442
|
if (held !== undefined) {
|
|
5443
|
+
hooks?.onEffect?.();
|
|
5268
5444
|
void this.deprovision(held.target).catch(() => { });
|
|
5269
5445
|
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" });
|
|
5270
5446
|
return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
|
|
@@ -5328,6 +5504,11 @@ export class Manager {
|
|
|
5328
5504
|
else {
|
|
5329
5505
|
name = this.uniqueName(identityName);
|
|
5330
5506
|
}
|
|
5507
|
+
// The `chat_` prefix belongs to static chat sessions, whose aliases only `chat-session-open`
|
|
5508
|
+
// draws. Checked once the name is fixed by either branch above, so a persona name, a numbered
|
|
5509
|
+
// collision, a manifest name and a `--name` override all meet it, and before the reservation.
|
|
5510
|
+
if (name.startsWith(CHAT_ALIAS_PREFIX))
|
|
5511
|
+
return { ok: false, error: `the name "${name}" starts with "${CHAT_ALIAS_PREFIX}", which is reserved for chat sessions; pick another name` };
|
|
5331
5512
|
// Set once the agent's footprint (durables + creds, or the user-mode grant + secret files)
|
|
5332
5513
|
// exists; cleared when a live slot takes ownership. If it survives to `finally`, the spawn threw
|
|
5333
5514
|
// AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
|
|
@@ -5343,6 +5524,7 @@ export class Manager {
|
|
|
5343
5524
|
let seatHome;
|
|
5344
5525
|
// Taken right before the try so its finally is the only release; a refusal belongs above the
|
|
5345
5526
|
// allocation, where there is no name to give back.
|
|
5527
|
+
hooks?.onEffect?.();
|
|
5346
5528
|
this.reserved.add(name);
|
|
5347
5529
|
try {
|
|
5348
5530
|
// A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
|
|
@@ -5824,7 +6006,7 @@ export class Manager {
|
|
|
5824
6006
|
* principal without provisioning. A later runtime launch failure is reported per-agent, but malformed
|
|
5825
6007
|
* or missing inventory material can never produce a partially resumed set. */
|
|
5826
6008
|
async resumePreserved(inventory) {
|
|
5827
|
-
if (this.execution === "none"
|
|
6009
|
+
if (this.execution === "none")
|
|
5828
6010
|
return { ok: false, agents: [], error: this.executionRefusal() };
|
|
5829
6011
|
const release = this.beginLifecycle(true);
|
|
5830
6012
|
if (!release)
|
|
@@ -6957,6 +7139,7 @@ export class Manager {
|
|
|
6957
7139
|
managerStatusData(ownerFilter) {
|
|
6958
7140
|
return {
|
|
6959
7141
|
instanceId: this.managerInstanceId,
|
|
7142
|
+
lifecycleUid: this.managerLifecycleUid,
|
|
6960
7143
|
host: hostname(),
|
|
6961
7144
|
version: MANAGER_VERSION,
|
|
6962
7145
|
execution: this.execution,
|
|
@@ -6969,6 +7152,34 @@ export class Manager {
|
|
|
6969
7152
|
connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
|
|
6970
7153
|
classSpawn: this.classSpawnEnabled(),
|
|
6971
7154
|
staticReconciliation: this.staticReconciliationStatus(),
|
|
7155
|
+
stopImpact: {
|
|
7156
|
+
agentScope: ownerFilter === undefined ? "manager" : "owner-filtered",
|
|
7157
|
+
observedAt: new Date().toISOString(),
|
|
7158
|
+
maintenance: this.maintenanceState,
|
|
7159
|
+
resumeRequired: this.resumeRequired,
|
|
7160
|
+
stopping: this.leaseStopping,
|
|
7161
|
+
lifecycleInFlight: this.lifecycleInFlight,
|
|
7162
|
+
reservedLaunches: this.reserved.size,
|
|
7163
|
+
retirementsPending: this.retiring.size,
|
|
7164
|
+
deprovisioningInFlight: this.deprovisioningFlight.size,
|
|
7165
|
+
staticReconciliationInFlight: this.staticReconcileSweepsInFlight,
|
|
7166
|
+
agents: [...this.agents.values()]
|
|
7167
|
+
.filter((a) => ownerFilter === undefined || a.userOwner === ownerFilter)
|
|
7168
|
+
.map((a) => ({
|
|
7169
|
+
name: a.name,
|
|
7170
|
+
id: a.id,
|
|
7171
|
+
lifecycleUid: a.lifecycleUid,
|
|
7172
|
+
mode: a.handle.kind,
|
|
7173
|
+
defaultAction: this.maintenanceState !== "active" || this.resumeRequired
|
|
7174
|
+
? "stop-retaining-state" : this.isProcessBoundSeat(a) ? "stop" : "release-custody",
|
|
7175
|
+
withAgentsAction: this.maintenanceState !== "active" || this.resumeRequired ? "stop-retaining-state" : "stop",
|
|
7176
|
+
})),
|
|
7177
|
+
// The same digest form `opResumePreserved` binds, so a preserved-exit stop and the later
|
|
7178
|
+
// resume name one inventory.
|
|
7179
|
+
...(this.maintenanceState !== "active" && this.preservationAttemptId && this.preservationInventory
|
|
7180
|
+
? { preservation: { attemptId: this.preservationAttemptId, inventorySha256: createHash("sha256").update(JSON.stringify(this.preservationInventory)).digest("hex") } }
|
|
7181
|
+
: {}),
|
|
7182
|
+
},
|
|
6972
7183
|
};
|
|
6973
7184
|
}
|
|
6974
7185
|
/** Render the current operator snapshot. Recovered rows live until the next sweep starts, then
|
|
@@ -7973,15 +8184,16 @@ export class Manager {
|
|
|
7973
8184
|
* Returns the acceptance floor payload {name, owner, actor, uid, goalId, fingerprint, executor}
|
|
7974
8185
|
* (the ALLOCATED identity). goalId = the request id (env.id). */
|
|
7975
8186
|
async serveSpawnGoal(ctx, run) {
|
|
8187
|
+
// These four refuse before anything is accepted or allocated, so they state it (SPEC 13.3).
|
|
7976
8188
|
if (this.execution === "none")
|
|
7977
|
-
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
8189
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal(), undefined, "not-executed");
|
|
7978
8190
|
const gw = this.goalWriter;
|
|
7979
8191
|
if (!gw)
|
|
7980
|
-
throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)");
|
|
8192
|
+
throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)", undefined, "not-executed");
|
|
7981
8193
|
// Refuse to accept until the boot reconcile of inherited goals completes, so a fresh
|
|
7982
8194
|
// acceptance never races the sweep (settling a live goal mid-flight would steal its real terminal).
|
|
7983
8195
|
if (!this.goalReconcileDone)
|
|
7984
|
-
throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; retry shortly (SPEC 13.6)");
|
|
8196
|
+
throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; retry shortly (SPEC 13.6)", undefined, "not-executed");
|
|
7985
8197
|
const goalId = ctx.request.id;
|
|
7986
8198
|
const { fingerprint } = submissionFingerprint(ctx.request, ctx.subject);
|
|
7987
8199
|
const ref = goalRefOf(ctx.subject, goalId);
|
|
@@ -8002,7 +8214,7 @@ export class Manager {
|
|
|
8002
8214
|
const accepted = prior ?? ended;
|
|
8003
8215
|
if (accepted !== undefined) {
|
|
8004
8216
|
if (accepted.fingerprint !== fingerprint)
|
|
8005
|
-
throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted under a different submission; one goalId never carries two specs (SPEC 13.6)
|
|
8217
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted under a different submission; one goalId never carries two specs (SPEC 13.6)`, undefined, "not-executed");
|
|
8006
8218
|
return prior ?? this.cachedSpawnAcceptance(ref, goalId, fingerprint, executor);
|
|
8007
8219
|
}
|
|
8008
8220
|
let resolveAccept;
|
|
@@ -8029,6 +8241,8 @@ export class Manager {
|
|
|
8029
8241
|
// (#357): it had entered no terminal, so the fallback wrote one for it, using its own abort
|
|
8030
8242
|
// message as the caller-visible outcome.
|
|
8031
8243
|
let ownsGoal = false;
|
|
8244
|
+
// Set by onEffect. A refusal that arrives with it still false changed nothing.
|
|
8245
|
+
let effectStarted = false;
|
|
8032
8246
|
// The terminal commits OFF-handler on the goal-writer connection (manager-only authority; a
|
|
8033
8247
|
// caller cannot publish it). TWO COMPOSED FENCES (defense in depth): (a) reads THIS
|
|
8034
8248
|
// incarnation's OWN gate epoch and REFUSES a superseded commit (the currency belt), and (b)
|
|
@@ -8148,12 +8362,15 @@ export class Manager {
|
|
|
8148
8362
|
// it and commits `cancel`. This only stops the non-ok reply below from manufacturing a
|
|
8149
8363
|
// `failed` that would race that `cancel` (first-terminal-fact-wins).
|
|
8150
8364
|
onTerminalDeferred: () => { terminalEntered = true; },
|
|
8365
|
+
onEffect: () => { effectStarted = true; },
|
|
8151
8366
|
});
|
|
8152
8367
|
bg.then((reply) => {
|
|
8153
8368
|
// Refused BEFORE onAccepted (hard-pin collision, capacity, persona-not-found) — no goal bound.
|
|
8369
|
+
// Only a refusal before the first effect states `not-executed`; a later one, such as the
|
|
8370
|
+
// alias gate that re-drives a teardown, leaves the outcome unknown.
|
|
8154
8371
|
if (acceptance === undefined) {
|
|
8155
8372
|
const details = reply.details;
|
|
8156
|
-
rejectAccept(new EpEnvelopeError("failed-precondition", reply.error ?? "spawn refused at accept", details));
|
|
8373
|
+
rejectAccept(new EpEnvelopeError("failed-precondition", reply.error ?? "spawn refused at accept", details, effectStarted ? undefined : "not-executed"));
|
|
8157
8374
|
return;
|
|
8158
8375
|
}
|
|
8159
8376
|
// H1: `run` CATCHES its own body (a throw in buildLaunch/runtime.spawn) and RESOLVES `{ok:false}`
|
|
@@ -8415,6 +8632,8 @@ export class Manager {
|
|
|
8415
8632
|
* `succeeded` carrying the TurnResult. A yield AFTER the deadline drives the deadline terminal
|
|
8416
8633
|
* instead and refuses — the expiry outcome stands, never a late success over it. */
|
|
8417
8634
|
async serveTurnYield(c, raw) {
|
|
8635
|
+
if (this.execution === "none")
|
|
8636
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
8418
8637
|
// Same boot window as the pull, and checked first because it is the earlier boot phase: before
|
|
8419
8638
|
// the index is rebuilt a predecessor's accepted turn is not in `pendingTurns`, and `not-found`
|
|
8420
8639
|
// would tell the seat to drop work it is holding.
|
|
@@ -8503,6 +8722,8 @@ export class Manager {
|
|
|
8503
8722
|
* whose outcome cannot be read back stays out and converges through the goal index at the
|
|
8504
8723
|
* next boot, as a failed deadline commit does. */
|
|
8505
8724
|
async serveTurnCancel(ctx) {
|
|
8725
|
+
if (this.execution === "none")
|
|
8726
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
8506
8727
|
if (!this.goalReconcileDone)
|
|
8507
8728
|
throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; the pending-turn index is not rebuilt yet, retry (SPEC 13.6)");
|
|
8508
8729
|
const gw = this.goalWriter;
|
|
@@ -9141,6 +9362,229 @@ export class Manager {
|
|
|
9141
9362
|
throw e;
|
|
9142
9363
|
}
|
|
9143
9364
|
}
|
|
9365
|
+
/** `chat-session-open`: admit one static chat session. The manager draws the `chat_` alias and a
|
|
9366
|
+
* fresh nkey identity, then runs the same native steps a static spawn does: the slot intent and
|
|
9367
|
+
* the shared activation saga, the provisioner's bind-only DM and DELIVER durables plus the ACL
|
|
9368
|
+
* row, and the agent-profile mint. The leaf carries the spawn's three lists under the spawn's
|
|
9369
|
+
* rules (`resolveReadAcl`, a default-deny post list, the foreign event-channel refusal) and
|
|
9370
|
+
* nothing else: no role (so no TASK queue), no capabilities, no endpoint rows, no issuance. It
|
|
9371
|
+
* expires at `ttl` with no renewal, because no managed row exists to renew it.
|
|
9372
|
+
*
|
|
9373
|
+
* The session is registered with the tracker before the first durable write and scheduled for
|
|
9374
|
+
* its expiry before the reply is sent, or after a rollback that did not complete, so this
|
|
9375
|
+
* process retires it at the expiry even when the reply is lost or the client never retires.
|
|
9376
|
+
*
|
|
9377
|
+
* The credential is ledgered before it leaves this process (slot credentialId, then the `cred.`
|
|
9378
|
+
* row), so the static terminal marks the row revoked and verify-evicts the session's principal;
|
|
9379
|
+
* the broker keeps accepting the leaf until its expiry. The direct ledger write is deliberate:
|
|
9380
|
+
* the leaf is not an issuance, so the issuance seam, which writes the same row inside its
|
|
9381
|
+
* finalization, never runs for it, and without the row the terminal's eviction set would be
|
|
9382
|
+
* empty. The credential leaves only sealed to `sealTo`, bound to the space, this instance and
|
|
9383
|
+
* the reply's fields, so a reply printed anywhere carries no usable credential. A failure at
|
|
9384
|
+
* any step drives the terminal before the refusal is returned, and the refusal says whether it
|
|
9385
|
+
* completed. */
|
|
9386
|
+
async openStaticChatSession(raw) {
|
|
9387
|
+
if (!this.auth || this.userMode)
|
|
9388
|
+
throw new EpEnvelopeError("unimplemented", "chat-session-open admits static chat sessions only, and this manager is not a static-auth manager", undefined, "not-executed");
|
|
9389
|
+
const input = readChatOpenInput(raw);
|
|
9390
|
+
if ("error" in input)
|
|
9391
|
+
throw new EpEnvelopeError("bad-request", input.error, undefined, "not-executed");
|
|
9392
|
+
const lists = chatOpenLists(input);
|
|
9393
|
+
if ("error" in lists)
|
|
9394
|
+
throw new EpEnvelopeError("bad-request", lists.error, undefined, "not-executed");
|
|
9395
|
+
const alias = drawChatAlias();
|
|
9396
|
+
const identity = newIdentity();
|
|
9397
|
+
// The spawn's rule, against the drawn actor: a concrete event channel of any other principal is
|
|
9398
|
+
// refused, and a wildcard is an ordinary grant.
|
|
9399
|
+
const foreign = foreignEventChannels([...lists.allowSubscribe, ...lists.allowPublish], DEV_OWNER, identity.id);
|
|
9400
|
+
if (foreign.length)
|
|
9401
|
+
throw new EpEnvelopeError("bad-request", `chat-session-open asks for another agent's event channel: ${foreign.join(", ")}. A session may be granted its OWN event plane and no other, because that plane carries the session's tool inputs and outputs. Grant a reader out of band rather than through a chat session: ${this.readerRemedy(DEV_OWNER, foreign[0])}`, undefined, "not-executed");
|
|
9402
|
+
const lifecycleUid = mintLifecycleUid();
|
|
9403
|
+
const exp = Math.floor(Date.now() / 1000) + input.ttl;
|
|
9404
|
+
const pin = { owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias };
|
|
9405
|
+
const session = { alias, actor: identity.id, lifecycleUid, channels: lists.allowSubscribe, expiresAt: exp * 1000 };
|
|
9406
|
+
// Registered before the first durable write, so the startup sweep never mistakes this opening's
|
|
9407
|
+
// provisioning row for a crashed spawn, and the stop knows it.
|
|
9408
|
+
try {
|
|
9409
|
+
this.chatExpiry.register(session);
|
|
9410
|
+
}
|
|
9411
|
+
catch (e) {
|
|
9412
|
+
throw new EpEnvelopeError("unavailable", rejectionText(e), undefined, "not-executed");
|
|
9413
|
+
}
|
|
9414
|
+
try {
|
|
9415
|
+
await this.withLifecycleExecutor(pin, (t) => activateStaticLifecycle(t, { owner: DEV_OWNER, alias, actor: identity.id, lifecycleUid, managerInstance: this.managerLifecycleUid, ownerInstanceId: this.managerInstanceId }));
|
|
9416
|
+
const creds = await this.withProvisioner((prov) => provisionAgent(prov, this.auth, identity, { subscribe: lists.subscribe, allowSubscribe: lists.allowSubscribe, allowPublish: lists.allowPublish, lifecycleUid, expiresAt: exp }));
|
|
9417
|
+
// The same credential id the issuance path derives, so a re-sent identical credential maps to
|
|
9418
|
+
// the same row.
|
|
9419
|
+
const credentialId = rawDigest(creds).replace("sha256:", "sha256-");
|
|
9420
|
+
await this.withLifecycleExecutor(pin, async (t) => {
|
|
9421
|
+
await recordSlotCredential(t, DEV_OWNER, alias, lifecycleUid, credentialId);
|
|
9422
|
+
await appendStaticCredentialRow(t, { lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, identity.id).key, exp });
|
|
9423
|
+
const slot = await readStaticSlot(t, DEV_OWNER, alias);
|
|
9424
|
+
if (slot === undefined || slot.row.lifecycleUid !== lifecycleUid || slot.row.phase !== "provisioning")
|
|
9425
|
+
throw new Error(`the static slot for "${alias}" is ${slot === undefined ? "absent" : `${slot.row.phase} at uid ${slot.row.lifecycleUid}`}, not this opening's provisioning intent`);
|
|
9426
|
+
await casStaticSlot(t, { ...slot.row, phase: "active" }, slot.revision);
|
|
9427
|
+
});
|
|
9428
|
+
const { sealer, sealed } = sealChatAdmission({
|
|
9429
|
+
space: this.space, managerInstanceId: this.managerInstanceId, alias, actor: identity.id, lifecycleUid, expiresAt: exp * 1000, sealTo: input.sealTo, creds,
|
|
9430
|
+
});
|
|
9431
|
+
this.chatExpiry.arm(lifecycleUid);
|
|
9432
|
+
console.error(`chat session ${alias} admitted as ${identity.id} (uid ${lifecycleUid}), expires ${new Date(exp * 1000).toISOString()}`);
|
|
9433
|
+
return { alias, actor: identity.id, lifecycleUid, expiresAt: exp * 1000, sealer, sealed };
|
|
9434
|
+
}
|
|
9435
|
+
catch (e) {
|
|
9436
|
+
// A confirmed rollback leaves a known state, a retired slot. An unconfirmed one does not, so
|
|
9437
|
+
// the session stays registered and its expiry, or the stop, retries the same terminal.
|
|
9438
|
+
let rollback;
|
|
9439
|
+
try {
|
|
9440
|
+
await this.chatTerminal(session);
|
|
9441
|
+
}
|
|
9442
|
+
catch (r) {
|
|
9443
|
+
rollback = rejectionText(r);
|
|
9444
|
+
try {
|
|
9445
|
+
this.chatExpiry.arm(lifecycleUid);
|
|
9446
|
+
}
|
|
9447
|
+
catch (t) {
|
|
9448
|
+
rollback += `; ${rejectionText(t)}`;
|
|
9449
|
+
}
|
|
9450
|
+
}
|
|
9451
|
+
throw new EpEnvelopeError(e instanceof EpEnvelopeError ? e.code : "unavailable", `chat-session-open did not complete for ${alias}: ${rejectionText(e)}; ${rollback === undefined ? "its rollback retired the slot" : `its rollback was not confirmed (${rollback}), so the slot stays held until a retire, its expiry here, this manager's stop, or the next start of this manager instance`}`, undefined, rollback === undefined ? "executed" : "unknown");
|
|
9452
|
+
}
|
|
9453
|
+
}
|
|
9454
|
+
/** `chat-session-retire`: end one static chat session through the native static terminal (ledger
|
|
9455
|
+
* row marked revoked, verified eviction of its principal's live connections, footprint cleanup,
|
|
9456
|
+
* gate and head retirement). It refuses before any effect unless {@link admitChatRetirement}
|
|
9457
|
+
* admits the request. A terminal that cannot complete answers `failed` with the reason, never
|
|
9458
|
+
* success; the slot stays held. The broker keeps accepting the leaf until its expiry either way. */
|
|
9459
|
+
async retireStaticChatSession(raw) {
|
|
9460
|
+
if (!this.auth || this.userMode)
|
|
9461
|
+
throw new EpEnvelopeError("unimplemented", "chat-session-retire retires static chat sessions only, and this manager is not a static-auth manager", undefined, "not-executed");
|
|
9462
|
+
const input = readChatRetireInput(raw);
|
|
9463
|
+
if ("error" in input)
|
|
9464
|
+
throw new EpEnvelopeError("bad-request", input.error, undefined, "not-executed");
|
|
9465
|
+
if (await this.admitChatRetirement(input) === "alreadyRetired")
|
|
9466
|
+
return { outcome: "alreadyRetired" };
|
|
9467
|
+
try {
|
|
9468
|
+
await this.chatTerminal({ ...input, channels: this.chatExpiry.channels(input.lifecycleUid) });
|
|
9469
|
+
}
|
|
9470
|
+
catch (e) {
|
|
9471
|
+
return { outcome: "failed", reason: `retirement pending: ${rejectionText(e)}` };
|
|
9472
|
+
}
|
|
9473
|
+
return { outcome: "retired" };
|
|
9474
|
+
}
|
|
9475
|
+
/** The expiry cleanup's retirement of one session this process admitted. It is lifecycle work, so
|
|
9476
|
+
* it is admitted like a control call and a stop drains it. A throw is a failure the tracker
|
|
9477
|
+
* retries; a slot that is no longer this instance's to retire ends the tracking instead. */
|
|
9478
|
+
async expireStaticChatSession(session) {
|
|
9479
|
+
const release = this.beginLifecycle();
|
|
9480
|
+
if (!release)
|
|
9481
|
+
throw new Error(this.maintenanceError());
|
|
9482
|
+
try {
|
|
9483
|
+
console.error(`chat session ${session.alias} (uid ${session.lifecycleUid}) reached its expiry; retiring it`);
|
|
9484
|
+
let admitted;
|
|
9485
|
+
try {
|
|
9486
|
+
admitted = await this.admitChatRetirement(session);
|
|
9487
|
+
}
|
|
9488
|
+
catch (e) {
|
|
9489
|
+
if (e instanceof EpEnvelopeError && (e.code === "not-found" || e.code === "failed-precondition")) {
|
|
9490
|
+
console.error(`! chat session ${session.alias} expired, but its slot is not this instance's to retire (${e.message}); expiry tracking ends`);
|
|
9491
|
+
return;
|
|
9492
|
+
}
|
|
9493
|
+
throw e;
|
|
9494
|
+
}
|
|
9495
|
+
if (admitted === "admitted")
|
|
9496
|
+
await this.chatTerminal(session);
|
|
9497
|
+
}
|
|
9498
|
+
finally {
|
|
9499
|
+
release();
|
|
9500
|
+
}
|
|
9501
|
+
}
|
|
9502
|
+
/** The ownership admission shared by `chat-session-retire`, the expiry and the stop. It throws a
|
|
9503
|
+
* not-executed refusal unless the alias is a chat alias whose slot this logical instance owns
|
|
9504
|
+
* at the requested UID and actor. An already retired slot ends its expiry tracking. */
|
|
9505
|
+
async admitChatRetirement(s) {
|
|
9506
|
+
if (!isChatAlias(s.alias))
|
|
9507
|
+
throw new EpEnvelopeError("permission-denied", `"${s.alias}" is not a chat session alias`, undefined, "not-executed");
|
|
9508
|
+
if (this.reconcilingAliases.has(s.alias))
|
|
9509
|
+
throw new EpEnvelopeError("unavailable", `the chat session "${s.alias}" is still reconciling at manager startup; that terminal owns it`, undefined, "not-executed");
|
|
9510
|
+
const recordsKv = this.goalWriter?.ctx.kv;
|
|
9511
|
+
if (!recordsKv)
|
|
9512
|
+
throw new EpEnvelopeError("unavailable", `the durable static slot store is not ready while retiring "${s.alias}"`, undefined, "not-executed");
|
|
9513
|
+
let slot;
|
|
9514
|
+
try {
|
|
9515
|
+
slot = await readStaticSlot(staticLifecycleTransport(recordsKv, recordsKv), DEV_OWNER, s.alias);
|
|
9516
|
+
}
|
|
9517
|
+
catch (e) {
|
|
9518
|
+
throw new EpEnvelopeError("unavailable", `the durable slot for "${s.alias}" could not be read: ${rejectionText(e)}`, undefined, "not-executed");
|
|
9519
|
+
}
|
|
9520
|
+
const refused = chatRetireRefusal(slot?.row, s, this.managerInstanceId);
|
|
9521
|
+
if (refused)
|
|
9522
|
+
throw new EpEnvelopeError(refused.code, refused.message, undefined, "not-executed");
|
|
9523
|
+
if (slot.row.phase !== "retired")
|
|
9524
|
+
return "admitted";
|
|
9525
|
+
this.chatExpiry.forget(s.lifecycleUid);
|
|
9526
|
+
return "alreadyRetired";
|
|
9527
|
+
}
|
|
9528
|
+
/** One static terminal per chat session UID, however many callers ask: the explicit retire, the
|
|
9529
|
+
* expiry cleanup and the open's rollback join it instead of racing its CAS sequence. A completed
|
|
9530
|
+
* terminal ends the expiry tracking. `channels` names the membership rows to delete by name;
|
|
9531
|
+
* without it the delivery inventory alone finds them. */
|
|
9532
|
+
chatTerminal(s) {
|
|
9533
|
+
let flight = this.chatRetirements.get(s.lifecycleUid);
|
|
9534
|
+
if (!flight) {
|
|
9535
|
+
flight = this.driveStaticRetirement({ id: s.actor, name: s.alias, lifecycleUid: s.lifecycleUid, secretPaths: {}, ...(s.channels ? { launch: { allowSubscribe: s.channels } } : {}) }, true)
|
|
9536
|
+
.then(() => this.chatExpiry.forget(s.lifecycleUid))
|
|
9537
|
+
.finally(() => this.chatRetirements.delete(s.lifecycleUid));
|
|
9538
|
+
this.chatRetirements.set(s.lifecycleUid, flight);
|
|
9539
|
+
}
|
|
9540
|
+
return flight;
|
|
9541
|
+
}
|
|
9542
|
+
/** Whether `row` is a chat slot this process registered, at its exact alias, actor and UID,
|
|
9543
|
+
* under this logical instance and this process's incarnation. */
|
|
9544
|
+
holdsLiveChatSlot(row) {
|
|
9545
|
+
return row.ownerInstanceId === this.managerInstanceId && row.managerInstance === this.managerLifecycleUid && this.chatExpiry.holdsExact(row);
|
|
9546
|
+
}
|
|
9547
|
+
/** The stop's retirement of every chat session this process still tracks, through the same
|
|
9548
|
+
* ownership admission and terminal as `chat-session-retire`, at most
|
|
9549
|
+
* {@link CHAT_EXPIRY_CONCURRENCY} at a time. It runs after the lifecycle fence closed and every
|
|
9550
|
+
* admitted operation drained, as the seat teardown does, and mints nothing the terminal does not
|
|
9551
|
+
* already mint. Every outcome is logged. It throws, naming each one, when any session was not
|
|
9552
|
+
* retired; that session stays held until the next start of this manager instance. */
|
|
9553
|
+
async retireChatSessionsOnStop() {
|
|
9554
|
+
const queue = this.chatExpiry.remaining();
|
|
9555
|
+
const failed = [];
|
|
9556
|
+
const retireOne = async (session) => {
|
|
9557
|
+
const at = `chat session ${session.alias} (uid ${session.lifecycleUid})`;
|
|
9558
|
+
try {
|
|
9559
|
+
if (await this.admitChatRetirement(session) === "alreadyRetired") {
|
|
9560
|
+
console.error(`${at} was already retired at stop`);
|
|
9561
|
+
return;
|
|
9562
|
+
}
|
|
9563
|
+
await this.chatTerminal(session);
|
|
9564
|
+
console.error(`${at} retired at stop`);
|
|
9565
|
+
}
|
|
9566
|
+
catch (e) {
|
|
9567
|
+
if (e instanceof EpEnvelopeError && (e.code === "not-found" || e.code === "failed-precondition")) {
|
|
9568
|
+
this.chatExpiry.forget(session.lifecycleUid);
|
|
9569
|
+
console.error(`! ${at} was not retired at stop: its slot is not this instance's to retire (${e.message})`);
|
|
9570
|
+
return;
|
|
9571
|
+
}
|
|
9572
|
+
failed.push(`${session.alias} (uid ${session.lifecycleUid}): ${rejectionText(e)}`);
|
|
9573
|
+
console.error(`! ${at} was not retired at stop (${rejectionText(e)}); it stays held until the next start of this manager instance`);
|
|
9574
|
+
}
|
|
9575
|
+
};
|
|
9576
|
+
await Promise.all(Array.from({ length: Math.min(CHAT_EXPIRY_CONCURRENCY, queue.length) }, async () => {
|
|
9577
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift())
|
|
9578
|
+
await retireOne(next);
|
|
9579
|
+
}));
|
|
9580
|
+
if (failed.length)
|
|
9581
|
+
throw new Error(`${failed.length} chat session(s) were not retired and stay held until the next start of this manager instance: ${failed.join("; ")}`);
|
|
9582
|
+
}
|
|
9583
|
+
/** Name, at a stop that does not retire them, each chat session this process still tracks. */
|
|
9584
|
+
reportChatSessionsLeftAtStop(why) {
|
|
9585
|
+
for (const session of this.chatExpiry.remaining())
|
|
9586
|
+
console.error(`! chat session ${session.alias} (uid ${session.lifecycleUid}) was not retired because ${why}; the next start of this manager instance retires it`);
|
|
9587
|
+
}
|
|
9144
9588
|
/** F5(b) push renewal of ONE live managed-static credential (Unit B): re-mint the SAME nkey
|
|
9145
9589
|
* identity with the SAME scope (recorded on the managed row at spawn) and a fresh bounded
|
|
9146
9590
|
* exp, ledger the new credentialId (slot record first, then the row, then the file — a
|
|
@@ -9505,6 +9949,15 @@ export class Manager {
|
|
|
9505
9949
|
preservedForeign++;
|
|
9506
9950
|
continue;
|
|
9507
9951
|
}
|
|
9952
|
+
// A chat session this process is opening or holds is live membership, as an adopted seat
|
|
9953
|
+
// is: its own open, expiry or stop runs its terminal, which this sweep must not race. The
|
|
9954
|
+
// match is the exact registered alias, actor and UID under this logical instance and this
|
|
9955
|
+
// process's incarnation, so a foreign, legacy or unrecorded row, or any UID this process
|
|
9956
|
+
// did not register, falls through to the ordinary plan below.
|
|
9957
|
+
if (this.holdsLiveChatSlot(row)) {
|
|
9958
|
+
preservedAdopted++;
|
|
9959
|
+
continue;
|
|
9960
|
+
}
|
|
9508
9961
|
// ADOPTION is genuine membership: a slot backed by a live managed agent THIS process owns
|
|
9509
9962
|
// at the SAME uid is never an orphan (empty at boot; exactly the adopted set at the
|
|
9510
9963
|
// post-adoption sweep — the fix for the F3 resume hole).
|
|
@@ -9721,6 +10174,8 @@ export class Manager {
|
|
|
9721
10174
|
* supervisor); regular agents are denied STREAM.PURGE under auth. Cleanup only: leaves live agents and
|
|
9722
10175
|
* the TASK queue alone. */
|
|
9723
10176
|
async opPurge(args, _caller) {
|
|
10177
|
+
if (this.execution === "none")
|
|
10178
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
9724
10179
|
const includeDms = args.includeDms === true;
|
|
9725
10180
|
try {
|
|
9726
10181
|
const creds = this.auth ? await mintCreds(this.auth, newIdentity(), "purger") : undefined;
|
|
@@ -9748,6 +10203,8 @@ export class Manager {
|
|
|
9748
10203
|
* only if `file.owner == caller`, else admin is required. Fail-closed: an ownerless file
|
|
9749
10204
|
* (legacy / operator-written) is admin-only. */
|
|
9750
10205
|
opDefinePersona(args, caller, admin) {
|
|
10206
|
+
if (this.execution === "none")
|
|
10207
|
+
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
9751
10208
|
const name = String(args.name ?? "").trim();
|
|
9752
10209
|
if (!name)
|
|
9753
10210
|
return { ok: false, error: "name required" };
|
|
@@ -9898,13 +10355,16 @@ export class Manager {
|
|
|
9898
10355
|
* `enter` defaults to true: a harness command typed but not submitted has not been delivered.
|
|
9899
10356
|
* Nothing is echoed back; the caller reads the resulting turns from the event plane. */
|
|
9900
10357
|
async inputAuthorized(a, args) {
|
|
10358
|
+
// These three refuse before the first write, so nothing was typed (SPEC 13.3). A write the
|
|
10359
|
+
// runtime rejects below states no outcome: the runtime contract does not say a rejected write
|
|
10360
|
+
// delivered nothing, and an earlier slice may already have reached the seat.
|
|
9901
10361
|
if (this.agents.get(a.name) !== a)
|
|
9902
|
-
throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry
|
|
10362
|
+
throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry`, undefined, "not-executed");
|
|
9903
10363
|
if (a.handle.status() !== "running")
|
|
9904
|
-
throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" is not running (${a.handle.status()}); nothing to type into
|
|
10364
|
+
throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" is not running (${a.handle.status()}); nothing to type into`, undefined, "not-executed");
|
|
9905
10365
|
const write = a.handle.write?.bind(a.handle);
|
|
9906
10366
|
if (!write)
|
|
9907
|
-
throw new EpEnvelopeError("unimplemented", `input is not supported by runtime ${a.handle.kind}
|
|
10367
|
+
throw new EpEnvelopeError("unimplemented", `input is not supported by runtime ${a.handle.kind}`, undefined, "not-executed");
|
|
9908
10368
|
// The contract validated `text` (non-empty, <= 64KiB) and `enter` (boolean) before this ran, so
|
|
9909
10369
|
// the only decision left is the carriage return. `!== false` and not `?? true`: an ABSENT enter
|
|
9910
10370
|
// and an explicit `true` must behave identically, and only `false` may suppress the return.
|
|
@@ -9978,18 +10438,19 @@ export class Manager {
|
|
|
9978
10438
|
* redeems it over the mesh with a per-session rails-only cred it mints itself. Only streamable
|
|
9979
10439
|
* backends (pty/host) attach; an external runtime's attach() throws with per-runtime guidance. */
|
|
9980
10440
|
async attachAuthorized(a, caller) {
|
|
10441
|
+
// Every refusal before establishAttach below has opened no session, so it states that (SPEC 13.3).
|
|
9981
10442
|
if (!this.sessionPlane)
|
|
9982
|
-
|
|
10443
|
+
throw new EpEnvelopeError("failed-precondition", "the manager session plane is not available (the manager is not fully started)", undefined, "not-executed");
|
|
9983
10444
|
// A name is a reusable slot and the authorization above can await (user mode reads the ledger).
|
|
9984
10445
|
// The ep target is incarnation-pinned, so `a` cannot BE a successor — but the slot it names can
|
|
9985
10446
|
// have been stopped and refilled while we waited, and everything below must act on the
|
|
9986
10447
|
// incarnation this caller was actually authorized for, never on whoever holds the name now.
|
|
9987
10448
|
if (this.agents.get(a.name) !== a)
|
|
9988
|
-
|
|
10449
|
+
throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry`, undefined, "not-executed");
|
|
9989
10450
|
// Never establish a session over a dead agent — a doomed session (the caller would get an
|
|
9990
10451
|
// immediate process-exit at best, a confusing empty terminal at worst). Refuse honestly.
|
|
9991
10452
|
if (a.handle.status() !== "running")
|
|
9992
|
-
|
|
10453
|
+
throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" is not running (${a.handle.status()}); nothing to attach`, undefined, "not-executed");
|
|
9993
10454
|
// CLAIM the session slot BEFORE attaching the target's PTY, matching the console door: this door
|
|
9994
10455
|
// used to attach first and check capacity inside establishAttach, so a `resource-exhausted`
|
|
9995
10456
|
// refusal landed AFTER the attach. The claim is carried INTO establishAttach, so one reservation
|
|
@@ -10007,7 +10468,8 @@ export class Manager {
|
|
|
10007
10468
|
}
|
|
10008
10469
|
catch (e) {
|
|
10009
10470
|
slot.release();
|
|
10010
|
-
|
|
10471
|
+
// The external runtimes refuse attach() before acquiring anything (see the residual above).
|
|
10472
|
+
throw new EpEnvelopeError("failed-precondition", rejectionText(e), undefined, "not-executed");
|
|
10011
10473
|
}
|
|
10012
10474
|
// establishAttach releases the claim it was handed on every exit; nothing to unwind here.
|
|
10013
10475
|
const { grant } = await this.sessionPlane.establishAttach(caller, { name: a.name, lifecycleUid: a.lifecycleUid }, session, slot);
|
|
@@ -10066,6 +10528,7 @@ export class Manager {
|
|
|
10066
10528
|
* owner-domain bound); undefined = unbounded. {@link NO_OWNER_MATCHES} matches nothing. */
|
|
10067
10529
|
list(ownerFilter) {
|
|
10068
10530
|
const roster = new Map(this.ep.getRoster().map((p) => [p.card.name, p]));
|
|
10531
|
+
const observedAt = Date.now();
|
|
10069
10532
|
// The roster is only evidence while THIS observer's presence watch is fresh. A stale view
|
|
10070
10533
|
// (whole-bucket silence past TTL) or an unpopulated one (snapshot not yet replayed) cannot
|
|
10071
10534
|
// support "offline" or "absent" for anyone: a live deployment on 2026-09-09 rendered every live seat as
|
|
@@ -10085,6 +10548,7 @@ export class Manager {
|
|
|
10085
10548
|
// The connector-reported provider from presence (#785): the live roster card's `meta.provider`,
|
|
10086
10549
|
// when it is a non-empty string. Absent when the connector reported none — never fabricated.
|
|
10087
10550
|
const seen = roster.get(a.name);
|
|
10551
|
+
const presenceIdentityVerified = this.isOwnPresence(a, seen);
|
|
10088
10552
|
const providerMeta = seen?.card.meta?.provider;
|
|
10089
10553
|
const provider = typeof providerMeta === "string" && providerMeta ? providerMeta : undefined;
|
|
10090
10554
|
// The harness-reported condition beside the mesh status (#618): a turn that died upstream reads
|
|
@@ -10129,6 +10593,8 @@ export class Manager {
|
|
|
10129
10593
|
// `current` is the only state in which `mesh` is a verdict; the other two are the
|
|
10130
10594
|
// observer's own condition and travel on the row (older CLIs ignore the field).
|
|
10131
10595
|
meshView: view.state,
|
|
10596
|
+
presenceIdentityVerified,
|
|
10597
|
+
...(view.state === "current" && presenceIdentityVerified ? { presenceObservedAt: observedAt } : {}),
|
|
10132
10598
|
...(offlineSince !== undefined ? { offlineSince } : {}),
|
|
10133
10599
|
// The incarnation coordinate (SPEC 13.1) — with `id`, exactly what a v0.4 caller needs to
|
|
10134
10600
|
// build a targeted (`despawn`/`attach`) request against THIS incarnation.
|