@cotal-ai/manager 0.48.2 → 0.49.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/README.md +4 -2
- package/dist/commands.d.ts +1 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +136 -41
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +17 -2
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +158 -26
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +623 -198
- package/dist/manager.js.map +1 -1
- package/dist/remote-authority.d.ts +22 -2
- package/dist/remote-authority.d.ts.map +1 -1
- package/dist/remote-authority.js +124 -2
- package/dist/remote-authority.js.map +1 -1
- package/dist/remote-register.d.ts.map +1 -1
- package/dist/remote-register.js +5 -1
- package/dist/remote-register.js.map +1 -1
- package/dist/resume.d.ts +5 -0
- package/dist/resume.d.ts.map +1 -1
- package/dist/resume.js +14 -2
- package/dist/resume.js.map +1 -1
- package/dist/run-hosting.d.ts +24 -3
- package/dist/run-hosting.d.ts.map +1 -1
- package/dist/run-hosting.js +97 -8
- package/dist/run-hosting.js.map +1 -1
- package/dist/runtime/custodial-pty.d.ts +10 -3
- package/dist/runtime/custodial-pty.d.ts.map +1 -1
- package/dist/runtime/custodial-pty.js +27 -5
- package/dist/runtime/custodial-pty.js.map +1 -1
- package/dist/runtime/index.d.ts +37 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +15 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/pty.d.ts.map +1 -1
- package/dist/runtime/pty.js +28 -26
- package/dist/runtime/pty.js.map +1 -1
- package/dist/session/plane.d.ts +2 -1
- package/dist/session/plane.d.ts.map +1 -1
- package/dist/session/plane.js +3 -2
- package/dist/session/plane.js.map +1 -1
- package/dist/static-lifecycle.d.ts +1 -0
- package/dist/static-lifecycle.d.ts.map +1 -1
- package/dist/static-lifecycle.js +5 -0
- package/dist/static-lifecycle.js.map +1 -1
- package/package.json +11 -10
package/dist/manager.js
CHANGED
|
@@ -4,9 +4,9 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
5
5
|
import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
|
|
6
6
|
import { join, dirname, resolve } from "node:path";
|
|
7
|
-
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
|
|
8
|
-
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath,
|
|
9
|
-
import { createRuntime, requireRuntimeAdopt, } from "./runtime/index.js";
|
|
7
|
+
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, divergentSecretStoreRefusal, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
|
|
8
|
+
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
|
+
import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, } from "./runtime/index.js";
|
|
10
10
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
11
11
|
import { makeManagerEndpointEvictionEvidence, makeManagerEndpointEvictor } from "./endpoint-evict.js";
|
|
12
12
|
import { makeManagerHolderLivenessProbe } from "./holder-liveness.js";
|
|
@@ -16,7 +16,7 @@ import { authorizeLaunch, authorizeNamedControl } from "./authorize.js";
|
|
|
16
16
|
import { controlShutdown } from "./control-shutdown.js";
|
|
17
17
|
import { RunHosting } from "./run-hosting.js";
|
|
18
18
|
import { controlSession } from "./control-session.js";
|
|
19
|
-
import { parseResumeCommitArgs, parseResumeControlArgs, parseResumeFinalizeArgs } from "./resume.js";
|
|
19
|
+
import { MAX_AGENTS, parseResumeCommitArgs, parseResumeControlArgs, parseResumeFinalizeArgs } from "./resume.js";
|
|
20
20
|
// Unit B (the static §13.1 lifecycle executor): the shared grammar/stores from core plus the
|
|
21
21
|
// manager-side adapter (transport + slot orchestration + the F1 terminal) — see static-lifecycle.ts.
|
|
22
22
|
import { jetstream, jetstreamManager } from "@nats-io/jetstream";
|
|
@@ -29,12 +29,9 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
|
|
|
29
29
|
// P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
|
|
30
30
|
// endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
|
|
31
31
|
// the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
|
|
32
|
-
import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, registerServiceInstance, deregisterServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
|
|
32
|
+
import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServiceInstance, deregisterServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
|
|
33
33
|
import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
|
|
34
34
|
import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
|
|
35
|
-
/** Concurrency ceiling — the manager refuses to hold more than this many live + in-flight +
|
|
36
|
-
* cooling slots at once (P4a). Bounds a fork-bomb: spawn is a full agent process per call. */
|
|
37
|
-
const MAX_AGENTS = 50;
|
|
38
35
|
/** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
|
|
39
36
|
* before living this long leaves a cooling stamp that still counts toward the ceiling until it
|
|
40
37
|
* expires — so churn (spawn↔despawn or spawn↔fast-exit) can't outrun the concurrency bound. */
|
|
@@ -106,7 +103,7 @@ const STATIC_RECONCILE_MAX_ATTEMPTS = STATIC_RECONCILE_RETRY_DELAYS_MS.length +
|
|
|
106
103
|
* operation (the rail's idempotence table needs exactly one op per retiring incarnation). 26 hex
|
|
107
104
|
* chars = in the lifecycle-token grammar `[a-z0-9]{26,32}`, collision-resistant. */
|
|
108
105
|
function retireOpId(lifecycleUid) {
|
|
109
|
-
return
|
|
106
|
+
return managedRetirementOpId(lifecycleUid);
|
|
110
107
|
}
|
|
111
108
|
/** Sentinel owner-filter value that matches NO agent's `userOwner` (owner tokens never contain a
|
|
112
109
|
* dash) — what {@link Manager.psOwnerFilter} returns for an unparseable caller so a malformed
|
|
@@ -252,6 +249,20 @@ function foreignEventChannels(channels, owner, actor) {
|
|
|
252
249
|
return p !== null && !(p.owner === owner && p.actor === actor);
|
|
253
250
|
});
|
|
254
251
|
}
|
|
252
|
+
function injectedManagerStoreIdentity(store) {
|
|
253
|
+
if (store.identity !== undefined)
|
|
254
|
+
return parseSecretStoreIdentity(store.identity);
|
|
255
|
+
const coordinate = process.env.COTAL_SECRET_STORE;
|
|
256
|
+
if (!coordinate)
|
|
257
|
+
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)");
|
|
258
|
+
return { kind: "injected", coordinate };
|
|
259
|
+
}
|
|
260
|
+
/** True only when the delivery-admin rail has no bound responder. A timeout is a hung rail, not absence. */
|
|
261
|
+
function isAbsentDeliveryAdmin(msg) {
|
|
262
|
+
if (/timeout/i.test(msg))
|
|
263
|
+
return false;
|
|
264
|
+
return /no responders|\b503\b/i.test(msg);
|
|
265
|
+
}
|
|
255
266
|
export class Manager {
|
|
256
267
|
space;
|
|
257
268
|
servers;
|
|
@@ -265,12 +276,15 @@ export class Manager {
|
|
|
265
276
|
/** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
|
|
266
277
|
* See {@link ManagerOptions.secretStore}. */
|
|
267
278
|
secrets;
|
|
279
|
+
/** Named identity of {@link secrets}: the same coordinate the delivery daemon must reload from. */
|
|
280
|
+
secretStoreIdentity;
|
|
268
281
|
/** See {@link ManagerOptions.installedExtensions}. */
|
|
269
282
|
installedExtensions;
|
|
270
283
|
runtime;
|
|
271
284
|
/** Internal test seam. Production leaves this undefined and uses the scoped delivery-admin evictor. */
|
|
272
285
|
staticLifecycleEvict;
|
|
273
286
|
preserveStopTimeoutMs;
|
|
287
|
+
endpointServeExecutorExpiresInSeconds;
|
|
274
288
|
agents = new Map();
|
|
275
289
|
/** Names whose spawn is in flight (reserved synchronously before the provision await) — counted
|
|
276
290
|
* toward the ceiling so two concurrent same-name spawns can't both pass the gate (P4a). */
|
|
@@ -286,6 +300,12 @@ export class Manager {
|
|
|
286
300
|
* truth is the auth-side lifecycle head itself (an unretired head refuses issuance — the
|
|
287
301
|
* named residual this belt narrows, not replaces). */
|
|
288
302
|
retiring = new Map();
|
|
303
|
+
/** Exact predecessor incarnations whose full hosted retirement reached a terminal answer. Presence
|
|
304
|
+
* is advisory and can retain that old lifecycle briefly after its process exits. Resume may ignore
|
|
305
|
+
* only this exact (alias, principal, lifecycleUid) row when adopting a different lifecycle; every
|
|
306
|
+
* unknown, current, or differently identified live row still refuses. A later retirement of the
|
|
307
|
+
* same alias replaces its prior predecessor coordinate. */
|
|
308
|
+
confirmedRetiredPredecessors = new Map();
|
|
289
309
|
/** SINGLE-FLIGHT guard for {@link requestRetirement} (audit #1): one in-flight rail round-trip per
|
|
290
310
|
* (name, lifecycleUid). The detached `deprovision` call and every same-name-spawn nudge for THAT
|
|
291
311
|
* lifecycle JOIN the same promise instead of stacking independent requests that dual-enter the
|
|
@@ -502,9 +522,13 @@ export class Manager {
|
|
|
502
522
|
if (opts.remoteAuthority)
|
|
503
523
|
this.managerLifecycleUid = opts.remoteAuthority.lifecycleUid;
|
|
504
524
|
this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
|
|
525
|
+
this.secretStoreIdentity = opts.secretStore
|
|
526
|
+
? injectedManagerStoreIdentity(opts.secretStore)
|
|
527
|
+
: { kind: "fs", root: resolve(this.workspaceRoot) };
|
|
505
528
|
this.installedExtensions = opts.installedExtensions ?? false;
|
|
506
529
|
this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
|
|
507
530
|
this.preserveStopTimeoutMs = opts.preserveStopTimeoutMs ?? PRESERVE_STOP_TIMEOUT_MS;
|
|
531
|
+
this.endpointServeExecutorExpiresInSeconds = opts.endpointServeExecutorExpiresInSeconds;
|
|
508
532
|
if (opts.resumeAttemptId && !/^[A-Za-z0-9_-]{1,128}$/.test(opts.resumeAttemptId))
|
|
509
533
|
throw new Error("resumeAttemptId must be a safe token (letters, digits, _, -; max 128)");
|
|
510
534
|
if (opts.resumeDurableCommitToken && !/^[a-f0-9]{64}$/.test(opts.resumeDurableCommitToken))
|
|
@@ -532,6 +556,10 @@ export class Manager {
|
|
|
532
556
|
get runtimeKind() {
|
|
533
557
|
return this.runtime.kind;
|
|
534
558
|
}
|
|
559
|
+
/** Whether a default process stop can drop manager-local custody without taking agents with it. */
|
|
560
|
+
get canSpareAgents() {
|
|
561
|
+
return this.runtime.kind !== "pty" || this.runtime.supportsRelease === true;
|
|
562
|
+
}
|
|
535
563
|
/** Reattach this manager's runtime to a durable handle. Refuses by name when adopt is absent. */
|
|
536
564
|
adoptRuntimeHandle(reference) {
|
|
537
565
|
return requireRuntimeAdopt(this.runtime, reference);
|
|
@@ -620,6 +648,12 @@ export class Manager {
|
|
|
620
648
|
// refused loud (no-fallbacks - a restart never silently becomes a fresh instance). A second
|
|
621
649
|
// manager in a DIFFERENT workspace root is a DIFFERENT logical id by construction (its own state
|
|
622
650
|
// dir) - two managers in ONE space are two workspace roots.
|
|
651
|
+
//
|
|
652
|
+
// First mint on a fresh root is exclusive create (#1263). Two processes that both observe no
|
|
653
|
+
// file must not keep different in-memory ids: of N concurrent starts exactly one creates the
|
|
654
|
+
// file (`link` / O_EXCL) and the others adopt that identity before taking a lease, or refuse
|
|
655
|
+
// with manager-instance-identity-create-lost. Atomic rename is not enough, because the loser
|
|
656
|
+
// of a replace would still serve under the id it minted.
|
|
623
657
|
{
|
|
624
658
|
if (this.remoteAuthority) {
|
|
625
659
|
this.managerInstanceId = this.remoteAuthority.instanceId;
|
|
@@ -632,9 +666,12 @@ export class Manager {
|
|
|
632
666
|
this.managerServeIdentity = persisted.serveIdentity;
|
|
633
667
|
}
|
|
634
668
|
else {
|
|
635
|
-
this.
|
|
636
|
-
|
|
637
|
-
|
|
669
|
+
const claimed = createManagerInstanceIdentity(this.workspaceRoot, this.space, {
|
|
670
|
+
instanceId: mintLifecycleUid(),
|
|
671
|
+
serveIdentity: newIdentity(),
|
|
672
|
+
});
|
|
673
|
+
this.managerInstanceId = claimed.instanceId;
|
|
674
|
+
this.managerServeIdentity = claimed.serveIdentity;
|
|
638
675
|
}
|
|
639
676
|
}
|
|
640
677
|
}
|
|
@@ -714,9 +751,10 @@ export class Manager {
|
|
|
714
751
|
this.leaseTimer.unref?.();
|
|
715
752
|
// Unit B (static §13.1): after this instance holds its lease, collect the durable static rows
|
|
716
753
|
// now, but do not let their exact-op terminals make the whole space unreachable. The service
|
|
717
|
-
// comes up below, then the sweep overlaps the remaining registration work.
|
|
718
|
-
//
|
|
719
|
-
// attach THAT alias until its terminal
|
|
754
|
+
// comes up below, then the sweep overlaps the remaining registration work. Two-window
|
|
755
|
+
// heal-then-register is slower than one executor, but the overlap is still the no-outage
|
|
756
|
+
// property: `reconcilingAliases` refuses spawn or attach of THAT alias until its terminal
|
|
757
|
+
// attempt returns.
|
|
720
758
|
const startupReconcile = this.auth && !this.userMode ? this.reconcileStaticLifecycles() : undefined;
|
|
721
759
|
if (startupReconcile)
|
|
722
760
|
void startupReconcile.catch((e) => console.error(`! ${STARTUP_RECONCILING}: ${e.message} - no per-alias retry could be planned; a later manager start re-reads unfinished durable terminals`));
|
|
@@ -731,6 +769,7 @@ export class Manager {
|
|
|
731
769
|
// every half-TTL: re-sign the daemon creds files for their EXISTING nkeys, request the explicit
|
|
732
770
|
// `reloadCreds` adoption on the delivery-admin rail, and persist the audit record doctor renders.
|
|
733
771
|
if (this.auth) {
|
|
772
|
+
await this.assertDaemonSharesSecretStore();
|
|
734
773
|
await this.renewDaemonCreds();
|
|
735
774
|
this.credRenewTimer = setInterval(() => { void this.renewDaemonCreds(); }, credRenewIntervalMs(STANDING_RENEWABLE_TTL_SEC));
|
|
736
775
|
this.credRenewTimer.unref?.();
|
|
@@ -785,6 +824,37 @@ export class Manager {
|
|
|
785
824
|
// manager records each agent's read ACL at spawn (`commitAcl`, in provisionAgent) so the daemon can
|
|
786
825
|
// re-authorize it; that is the only Plane-3 state the manager touches, and it rides minting.
|
|
787
826
|
}
|
|
827
|
+
/** Proof that this manager remints into the store the delivery daemon reloads from.
|
|
828
|
+
* Fingerprint-only `reloadCreds` is safe only after this. Two named, different stores
|
|
829
|
+
* refuse the pass. A daemon that is not bound yet is not a named store: start and
|
|
830
|
+
* remint still proceed (tests, delayed delivery), writing this manager's store. The
|
|
831
|
+
* challenge runs again before every remint, so a later daemon on a foreign store is
|
|
832
|
+
* refused then rather than certified by an earlier absence. A request timeout is not
|
|
833
|
+
* absence: a hung rail would skip the proof, so it fails closed. */
|
|
834
|
+
async assertDaemonSharesSecretStore() {
|
|
835
|
+
let reply;
|
|
836
|
+
try {
|
|
837
|
+
reply = await this.ep.requestDeliveryAdmin("reloadStoreIdentity", {}, 5_000);
|
|
838
|
+
}
|
|
839
|
+
catch (e) {
|
|
840
|
+
const msg = e.message;
|
|
841
|
+
if (isAbsentDeliveryAdmin(msg))
|
|
842
|
+
return "absent";
|
|
843
|
+
throw new Error(`could not challenge the delivery daemon's SecretStore: ${msg}`);
|
|
844
|
+
}
|
|
845
|
+
if (!reply.ok)
|
|
846
|
+
throw new Error(reply.error ?? "delivery daemon refused to name the SecretStore it reloads from");
|
|
847
|
+
let daemon;
|
|
848
|
+
try {
|
|
849
|
+
daemon = parseSecretStoreIdentity(reply.data);
|
|
850
|
+
}
|
|
851
|
+
catch (e) {
|
|
852
|
+
throw new Error(`delivery daemon named an unreadable SecretStore: ${e.message}`);
|
|
853
|
+
}
|
|
854
|
+
if (!sameSecretStoreIdentity(this.secretStoreIdentity, daemon))
|
|
855
|
+
throw new Error(divergentSecretStoreRefusal(this.secretStoreIdentity, daemon));
|
|
856
|
+
return "shared";
|
|
857
|
+
}
|
|
788
858
|
/** One class-2 renewal pass (D5 slice 5): re-sign `.cotal/delivery.creds` + `.cotal/membership-rw.creds`
|
|
789
859
|
* for their existing nkeys, then request the delivery daemon's EXPLICIT `reloadCreds` adoption on the
|
|
790
860
|
* delivery-admin rail and persist the audit record (`.cotal/renewal.json`) that `cotal doctor auth`
|
|
@@ -796,6 +866,7 @@ export class Manager {
|
|
|
796
866
|
if (!release)
|
|
797
867
|
return;
|
|
798
868
|
try {
|
|
869
|
+
await this.assertDaemonSharesSecretStore();
|
|
799
870
|
// Re-sign through the manager's ONE store — the SAME store the delivery daemon reads
|
|
800
871
|
// (`runDelivery(args, store)`), so a hosted remint writes the store the daemon renews from,
|
|
801
872
|
// never a divergent one. Locally this is the workstation FS store (`.cotal/*.creds`).
|
|
@@ -1305,7 +1376,7 @@ export class Manager {
|
|
|
1305
1376
|
? (() => {
|
|
1306
1377
|
if (!files?.creds)
|
|
1307
1378
|
throw new Error(`managed agent ${a.name} is static-auth but its credential path was not recorded`);
|
|
1308
|
-
return { mode: "static", id: principal.actor, lifecycleUid: a.lifecycleUid, credential: { kind: "file", path: files.creds, sha256: this.fileDigestOrEmpty(files.creds) } };
|
|
1379
|
+
return { mode: "static", id: principal.actor, lifecycleUid: a.lifecycleUid, credential: { kind: "file", path: files.creds, sha256: this.fileDigestOrEmpty(files.creds) }, ...(a.issued ? { issued: a.issued } : {}) };
|
|
1309
1380
|
})()
|
|
1310
1381
|
: { mode: "open", id: principal.actor, lifecycleUid: a.lifecycleUid };
|
|
1311
1382
|
const dependencies = [a.launch.source.configPath];
|
|
@@ -1339,7 +1410,7 @@ export class Manager {
|
|
|
1339
1410
|
startedAt: new Date(a.startedAt).toISOString(),
|
|
1340
1411
|
};
|
|
1341
1412
|
}
|
|
1342
|
-
/** Tear down every managed agent's footprint on
|
|
1413
|
+
/** Tear down every managed agent's footprint on an explicit destructive {@link stop}. A manager exit is
|
|
1343
1414
|
* a mass agent-exit, and without this its agents' footprints (creds files + `dm_`/`dlv_` durables + ACL
|
|
1344
1415
|
* rows) would orphan exactly as the per-agent exit path prevents. Hard-stop each child (an exit has no
|
|
1345
1416
|
* time for the graceful grace window) and AWAIT its deprovision — bounded per agent (`withTimeout`) and
|
|
@@ -1372,6 +1443,29 @@ export class Manager {
|
|
|
1372
1443
|
if (failures.length)
|
|
1373
1444
|
throw new Error(`manager shutdown could not prove every seat exited: ${failures.join("; ")}`);
|
|
1374
1445
|
}
|
|
1446
|
+
/** Drop only this manager's local custody handles. Validate the complete snapshot before releasing
|
|
1447
|
+
* any handle so a non-detachable runtime cannot leave a partially detached manager behind. */
|
|
1448
|
+
releaseManagedAgents() {
|
|
1449
|
+
const managed = [...this.agents.values()];
|
|
1450
|
+
const blocked = managed.filter((a) => a.handle.kind === "pty" && typeof a.handle.release !== "function");
|
|
1451
|
+
if (blocked.length) {
|
|
1452
|
+
return [
|
|
1453
|
+
`manager shutdown cannot detach ${blocked.map((a) => `${a.name} (${a.handle.kind})`).join(", ")}: runtime handle does not support release`,
|
|
1454
|
+
];
|
|
1455
|
+
}
|
|
1456
|
+
const failures = [];
|
|
1457
|
+
for (const a of managed) {
|
|
1458
|
+
try {
|
|
1459
|
+
a.handle.release?.();
|
|
1460
|
+
a.suppressCleanup = true;
|
|
1461
|
+
this.agents.delete(a.name);
|
|
1462
|
+
}
|
|
1463
|
+
catch (e) {
|
|
1464
|
+
failures.push(`${a.name}: ${e.message}`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
return failures;
|
|
1468
|
+
}
|
|
1375
1469
|
async stopRetainedAgentsOnExit() {
|
|
1376
1470
|
const managed = [...this.agents.values()];
|
|
1377
1471
|
for (const a of managed)
|
|
@@ -1396,7 +1490,7 @@ export class Manager {
|
|
|
1396
1490
|
if (failures.length)
|
|
1397
1491
|
throw new Error(`manager preservation shutdown incomplete: ${failures.join("; ")}`);
|
|
1398
1492
|
}
|
|
1399
|
-
async stop() {
|
|
1493
|
+
async stop(options = {}) {
|
|
1400
1494
|
this.staticReconcileStopping = true;
|
|
1401
1495
|
const starting = this.startTask;
|
|
1402
1496
|
for (const item of this.staticReconcileItems.values()) {
|
|
@@ -1413,8 +1507,12 @@ export class Manager {
|
|
|
1413
1507
|
clearInterval(this.credRenewTimer);
|
|
1414
1508
|
if (this.sessionKeyRenewTimer)
|
|
1415
1509
|
clearInterval(this.sessionKeyRenewTimer);
|
|
1510
|
+
let releaseFailures = [];
|
|
1416
1511
|
if (this.maintenanceState === "active" && !this.resumeRequired) {
|
|
1417
|
-
|
|
1512
|
+
if (options.withAgents)
|
|
1513
|
+
await this.teardownManagedAgents();
|
|
1514
|
+
else
|
|
1515
|
+
releaseFailures = this.releaseManagedAgents();
|
|
1418
1516
|
}
|
|
1419
1517
|
else {
|
|
1420
1518
|
// A signal after a partial preservation must never fall back into destructive teardown.
|
|
@@ -1437,6 +1535,8 @@ export class Manager {
|
|
|
1437
1535
|
await this.stopSessionPlane();
|
|
1438
1536
|
await this.ep.stop();
|
|
1439
1537
|
await this.attach.stop();
|
|
1538
|
+
if (releaseFailures.length)
|
|
1539
|
+
throw new Error(`manager shutdown could not release every detachable seat: ${releaseFailures.join("; ")}`);
|
|
1440
1540
|
}
|
|
1441
1541
|
/**
|
|
1442
1542
|
* REMOVE THIS INSTANCE'S SERVICE REGISTRATION on a clean stop (§13.5 deregistration).
|
|
@@ -1470,7 +1570,9 @@ export class Manager {
|
|
|
1470
1570
|
},
|
|
1471
1571
|
});
|
|
1472
1572
|
try {
|
|
1473
|
-
const outcome = await (this.auth
|
|
1573
|
+
const outcome = await ((this.auth || this.remoteAuthority)
|
|
1574
|
+
? this.withEndpointServeExecutor(dereg)
|
|
1575
|
+
: this.withOpenServeConnection(dereg));
|
|
1474
1576
|
if (outcome.removed)
|
|
1475
1577
|
console.error(`✓ deregistered manager instance ${iid} from the ${MANAGER_ENDPOINT} service registry (spec revision ${outcome.specRevision})`);
|
|
1476
1578
|
else if (outcome.reason === "superseded")
|
|
@@ -1791,23 +1893,25 @@ export class Manager {
|
|
|
1791
1893
|
* only into operator instruments (§13.2: `any` is operator-policy-mintable; the agent/spawn
|
|
1792
1894
|
* rollups never carry them), so REACHING the handler is holding the admin tier, exactly as
|
|
1793
1895
|
* holding `ctl.<admin>` is today. User mesh: the caller's CURRENT ledger scope must carry
|
|
1794
|
-
* `admin
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
1896
|
+
* `admin`. A local-host manager reads its own ledger fresh. A remote-authority manager relays the
|
|
1897
|
+
* subject-parsed caller tuple to its host-owned authorization callback, with no participant-ledger
|
|
1898
|
+
* fallback. The same fresh authority drives {@link psOwnerFilter}, so a revoked scope demotes the
|
|
1899
|
+
* very next call even on a still-valid bearer. Local ledger read failures authorize nothing;
|
|
1900
|
+
* remote host-state faults throw and fail the operation closed. NAMED RESIDUAL (critic,
|
|
1901
|
+
* 1c.2b): the static `true` has no serve-time
|
|
1797
1902
|
* re-check — a LEAKED static admin instrument keeps its reach until the credential's bounded
|
|
1798
1903
|
* TTL (the one-shot 5-minute profile), the same static-revoke≠reconnect-death class ruled
|
|
1799
1904
|
* across this campaign; static revocation is the TTL, not a ledger. */
|
|
1800
1905
|
async epAdminReach(caller) {
|
|
1801
1906
|
if (!this.userMode)
|
|
1802
1907
|
return true;
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
return false;
|
|
1908
|
+
if (this.remoteAuthority)
|
|
1909
|
+
return this.remoteAuthority.authorizeAdmin({ owner: caller.owner, actor: caller.actor, lifecycleUid: caller.uid });
|
|
1806
1910
|
try {
|
|
1807
1911
|
const scope = await resolveAuthProvider().actorScope({
|
|
1808
1912
|
dir: userAuthStateDir(this.workspaceRoot, this.space),
|
|
1809
|
-
owner:
|
|
1810
|
-
actor:
|
|
1913
|
+
owner: caller.owner,
|
|
1914
|
+
actor: caller.actor,
|
|
1811
1915
|
});
|
|
1812
1916
|
return scope?.includes("admin") === true;
|
|
1813
1917
|
}
|
|
@@ -1823,7 +1927,7 @@ export class Manager {
|
|
|
1823
1927
|
async epAnyModeAdmin(ctx) {
|
|
1824
1928
|
if (ctx.subject.target?.mode !== "any")
|
|
1825
1929
|
return false;
|
|
1826
|
-
if (!(await this.epAdminReach(
|
|
1930
|
+
if (!(await this.epAdminReach(ctx.subject.caller)))
|
|
1827
1931
|
throw new EpEnvelopeError("permission-denied", `an any-mode ${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)`);
|
|
1828
1932
|
return true;
|
|
1829
1933
|
}
|
|
@@ -1901,7 +2005,7 @@ export class Manager {
|
|
|
1901
2005
|
// call instead of riding the bearer's remaining JWT-row lifetime. The resume family keeps its
|
|
1902
2006
|
// serveGated BYPASS (those ops must run while the maintenance fence holds) but not the gate.
|
|
1903
2007
|
const adminGated = async (ctx, fn) => {
|
|
1904
|
-
if (!(await this.epAdminReach(
|
|
2008
|
+
if (!(await this.epAdminReach(ctx.subject.caller)))
|
|
1905
2009
|
throw new EpEnvelopeError("permission-denied", `${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)`);
|
|
1906
2010
|
return fn();
|
|
1907
2011
|
};
|
|
@@ -1914,10 +2018,10 @@ export class Manager {
|
|
|
1914
2018
|
};
|
|
1915
2019
|
return managerCommandDefs({
|
|
1916
2020
|
status: (ctx) => this.serveGated(ctx, () => this.managerStatusData()),
|
|
1917
|
-
ps: (ctx) => this.serveGated(ctx, async () => this.list(await this.psOwnerFilter(
|
|
2021
|
+
ps: (ctx) => this.serveGated(ctx, async () => this.list(await this.psOwnerFilter(ctx.subject.caller, false))),
|
|
1918
2022
|
inspect: (ctx) => this.serveGated(ctx, async () => {
|
|
1919
2023
|
const name = String(args(ctx).name ?? "").trim();
|
|
1920
|
-
const row = this.list(await this.psOwnerFilter(
|
|
2024
|
+
const row = this.list(await this.psOwnerFilter(ctx.subject.caller, false)).find((x) => x.name === name);
|
|
1921
2025
|
if (!row) {
|
|
1922
2026
|
// The live hit above stays entirely local. Only a miss widens into durable static state,
|
|
1923
2027
|
// where `not-found` is honest only after the slot read itself succeeds and returns absent.
|
|
@@ -1957,14 +2061,14 @@ export class Manager {
|
|
|
1957
2061
|
spawn: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opStart(args(ctx), callerOf(ctx), h))),
|
|
1958
2062
|
despawn: (ctx) => this.serveGated(ctx, async () => {
|
|
1959
2063
|
const a = targetAgent(ctx);
|
|
1960
|
-
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
|
|
2064
|
+
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
1961
2065
|
if (denied)
|
|
1962
2066
|
throw new EpEnvelopeError("permission-denied", denied);
|
|
1963
2067
|
return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true));
|
|
1964
2068
|
}),
|
|
1965
2069
|
attach: (ctx) => this.serveGated(ctx, async () => {
|
|
1966
2070
|
const a = targetAgent(ctx);
|
|
1967
|
-
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
|
|
2071
|
+
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
1968
2072
|
if (denied)
|
|
1969
2073
|
throw new EpEnvelopeError("permission-denied", denied);
|
|
1970
2074
|
return unwrap(await this.attachAuthorized(a, ctx.subject.caller));
|
|
@@ -1974,7 +2078,7 @@ export class Manager {
|
|
|
1974
2078
|
// place for one of them to quietly acquire a condition the other does not have.
|
|
1975
2079
|
input: (ctx) => this.serveGated(ctx, async () => {
|
|
1976
2080
|
const a = targetAgent(ctx);
|
|
1977
|
-
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
|
|
2081
|
+
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
1978
2082
|
if (denied)
|
|
1979
2083
|
throw new EpEnvelopeError("permission-denied", denied);
|
|
1980
2084
|
return this.inputAuthorized(a, args(ctx));
|
|
@@ -1984,7 +2088,7 @@ export class Manager {
|
|
|
1984
2088
|
// reason `input` is (a shared policy, not a shared body).
|
|
1985
2089
|
turn: (ctx) => this.serveGated(ctx, async () => {
|
|
1986
2090
|
const a = targetAgent(ctx);
|
|
1987
|
-
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
|
|
2091
|
+
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
1988
2092
|
if (denied)
|
|
1989
2093
|
throw new EpEnvelopeError("permission-denied", denied);
|
|
1990
2094
|
return this.serveTurnGoal(ctx, a);
|
|
@@ -2025,7 +2129,7 @@ export class Manager {
|
|
|
2025
2129
|
finalizeResume: (ctx) => adminGated(ctx, async () => unwrap(await this.opFinalizeResume(args(ctx)))),
|
|
2026
2130
|
// The workflow-run family (SPEC 14.3): the manager hosts the driver. Reach is the broker's
|
|
2027
2131
|
// (`run` capability / privileged instrument rows); the serve gate is the maintenance fence.
|
|
2028
|
-
runStart: (ctx) => this.serveGated(ctx, () => this.runHost().start(args(ctx))),
|
|
2132
|
+
runStart: (ctx) => this.serveGated(ctx, () => this.runHost().start(ctx, args(ctx))),
|
|
2029
2133
|
runResume: (ctx) => this.serveGated(ctx, () => this.runHost().resume(args(ctx))),
|
|
2030
2134
|
runAnswer: (ctx) => this.serveGated(ctx, () => this.runHost().answer(args(ctx), this.runAnswerer(ctx))),
|
|
2031
2135
|
runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx))),
|
|
@@ -2075,11 +2179,27 @@ export class Manager {
|
|
|
2075
2179
|
* instruments). With admin=true (any-mode) any named target is allowed (operator). Otherwise
|
|
2076
2180
|
* a named target is allowed if it's the caller's OWN child (`spawner == caller`) — and, on a
|
|
2077
2181
|
* user mesh, if it runs under the CALLER'S OWNER (owner-domain) or the caller's ledger row
|
|
2078
|
-
* holds `admin`, read fresh.
|
|
2079
|
-
*
|
|
2080
|
-
*
|
|
2081
|
-
*
|
|
2082
|
-
|
|
2182
|
+
* holds `admin`, read fresh. Local managers run the pure {@link authorizeNamedControl} policy
|
|
2183
|
+
* against their provider-backed ledger. Remote managers preserve own-child and owner-domain
|
|
2184
|
+
* decisions locally, then send only the residual cross-owner admin question through the registered
|
|
2185
|
+
* host callback with the full endpoint caller tuple. Error string when denied, `undefined` when
|
|
2186
|
+
* allowed; remote host-state faults throw rather than being collapsed into a denial. */
|
|
2187
|
+
async authorizeNamed(target, caller, admin, epCaller) {
|
|
2188
|
+
if (this.remoteAuthority && epCaller && !admin) {
|
|
2189
|
+
if (target.spawner === caller)
|
|
2190
|
+
return undefined;
|
|
2191
|
+
const principal = parsePrincipalKey(caller);
|
|
2192
|
+
if (principal && target.userOwner === principal.owner)
|
|
2193
|
+
return undefined;
|
|
2194
|
+
// Unlike the local pure-policy adapter below, host authority faults are not collapsed into an
|
|
2195
|
+
// ordinary denial. The registered remote manager must fail the operation closed and surface
|
|
2196
|
+
// unavailable/corrupt authoritative state rather than implying a healthy `authorized:false`.
|
|
2197
|
+
if (await this.epAdminReach(epCaller))
|
|
2198
|
+
return undefined;
|
|
2199
|
+
return (`not authorized: ${target.name} runs under another owner - your grant covers agents under your own owner; ` +
|
|
2200
|
+
`cross-owner stop/attach/input needs scope "admin" on your actor. Re-grant with "admin" ADDED to your current ` +
|
|
2201
|
+
`scope (the upsert replaces the list; see \`cotal actor list\`)`);
|
|
2202
|
+
}
|
|
2083
2203
|
return authorizeNamedControl({
|
|
2084
2204
|
target: { name: target.name, spawner: target.spawner, userOwner: target.userOwner },
|
|
2085
2205
|
caller,
|
|
@@ -2407,14 +2527,17 @@ export class Manager {
|
|
|
2407
2527
|
// static retirement the detached deprovision below drives) — the alias frees only when the
|
|
2408
2528
|
// gate+head terminal completes, exactly the user-mode discipline. The wire principal is the
|
|
2409
2529
|
// incarnation-unique nkey (F5-bind); owner is the dev owner.
|
|
2410
|
-
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: DEV_OWNER, actor: a.id, agentId: a.id, secretPaths: a.secretPaths, startedAt: Date.now() });
|
|
2530
|
+
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: DEV_OWNER, actor: a.id, agentId: a.id, secretPaths: a.secretPaths, runtime: a.handle?.reference, startedAt: Date.now() });
|
|
2411
2531
|
}
|
|
2412
2532
|
// Auth mode: tear down the departed agent's minted broker footprint + creds file (#159 B2). The
|
|
2413
2533
|
// process is already gone, so this must never block the slot free or throw into the caller — it runs
|
|
2414
2534
|
// detached, and a failure is logged loudly (never swallowed), not retried. The `agents` guard above
|
|
2415
2535
|
// makes this fire exactly once per agent across every free path (despawn / self-stop / reap / exit).
|
|
2536
|
+
// The departed agent's own custody reference rides the teardown: the exit that freed this slot
|
|
2537
|
+
// is usually the seat ending, but a stop that could not prove the process gone leaves one the
|
|
2538
|
+
// terminal must still address before it frees the alias.
|
|
2416
2539
|
if (!a.suppressCleanup && (this.maintenanceState === "active" || acceptedBeforeFence))
|
|
2417
|
-
this.trackDeprovision(a);
|
|
2540
|
+
this.trackDeprovision({ ...a, runtime: a.handle?.reference });
|
|
2418
2541
|
}
|
|
2419
2542
|
/** Tear down a departed agent's minted footprint (#159 B2, auth mode): its local-principal durables
|
|
2420
2543
|
* (`dm_local-<id>`, `dlv_local-<id>`), its read-ACL row, and its creds file — everything the spawn's
|
|
@@ -2429,7 +2552,7 @@ export class Manager {
|
|
|
2429
2552
|
* is the separate per-user-auth work, not this. Tearing down the durables + ACL row still shrinks the
|
|
2430
2553
|
* delivery surface a stale copy could use. */
|
|
2431
2554
|
async deprovision(a) {
|
|
2432
|
-
if (!this.auth)
|
|
2555
|
+
if (!this.auth && !this.remoteAuthority)
|
|
2433
2556
|
return; // open mesh mints no creds/durables — nothing to tear down
|
|
2434
2557
|
// SINGLE-FLIGHT per (name, lifecycleUid) (INT-2/C): join an in-flight teardown for this exact
|
|
2435
2558
|
// lifecycle rather than launching a second concurrent one whose delayed name-keyed revoke could
|
|
@@ -2447,6 +2570,20 @@ export class Manager {
|
|
|
2447
2570
|
}
|
|
2448
2571
|
/** The actual footprint teardown (wrapped by {@link deprovision}'s single-flight). */
|
|
2449
2572
|
async driveDeprovision(a) {
|
|
2573
|
+
if (this.remoteAuthority) {
|
|
2574
|
+
// A hosted composition owns grant revocation and resumable footprint release. It calls this
|
|
2575
|
+
// prerequisite before the terminal rail. The callback must preserve this UID and its pending
|
|
2576
|
+
// state until the host decides terminal retirement, never map deprovisioning to suspension.
|
|
2577
|
+
const target = parsePrincipalKey(a.id);
|
|
2578
|
+
if (!target)
|
|
2579
|
+
throw new Error(`hosted retirement cannot derive the managed principal ${a.id}`);
|
|
2580
|
+
await this.remoteAuthority.prepareAgentRetirement({
|
|
2581
|
+
target: { owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid },
|
|
2582
|
+
opId: retireOpId(a.lifecycleUid),
|
|
2583
|
+
});
|
|
2584
|
+
await this.requestRetirement(a);
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2450
2587
|
if (!this.auth)
|
|
2451
2588
|
return; // guaranteed by deprovision; re-checked for the deprovisionBroker narrowing
|
|
2452
2589
|
if (!this.userMode && !a.userOwner) {
|
|
@@ -2552,8 +2689,8 @@ export class Manager {
|
|
|
2552
2689
|
}
|
|
2553
2690
|
/** The rail round-trip for one retirement (wrapped by {@link requestRetirement}'s single-flight). */
|
|
2554
2691
|
async driveRetirement(a) {
|
|
2555
|
-
if (!this.auth)
|
|
2556
|
-
return;
|
|
2692
|
+
if (!this.auth && !this.remoteAuthority)
|
|
2693
|
+
return;
|
|
2557
2694
|
const held = this.retiring.get(a.name);
|
|
2558
2695
|
const target = parsePrincipalKey(a.id);
|
|
2559
2696
|
if (!target) {
|
|
@@ -2594,10 +2731,18 @@ export class Manager {
|
|
|
2594
2731
|
// mismatch in the owner half the moment a manager ran under a user-shaped identity. This is
|
|
2595
2732
|
// also the more honest attribution: the authority being exercised is "I am the registered
|
|
2596
2733
|
// serving instance", which is exactly what the gate records.
|
|
2597
|
-
const caller =
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2734
|
+
const caller = this.remoteAuthority
|
|
2735
|
+
? { owner: this.remoteAuthority.owner, actor: this.remoteAuthority.actors.serve, uid: this.managerLifecycleUid }
|
|
2736
|
+
: { owner: DEV_OWNER, actor: serveIdentity.id, uid: this.managerLifecycleUid };
|
|
2737
|
+
const requestIdentity = newIdentity();
|
|
2738
|
+
const retirementTarget = { owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid };
|
|
2739
|
+
const opId = retireOpId(a.lifecycleUid);
|
|
2740
|
+
const serveEpoch = this.serviceServe?.grant.epoch ?? 0;
|
|
2741
|
+
const creds = this.remoteAuthority
|
|
2742
|
+
? await this.remoteAuthority.mintRetirementRequester({ identity: requestIdentity, target: retirementTarget, opId, serveEpoch })
|
|
2743
|
+
: await mintCreds(this.auth, requestIdentity, "retirement-requester", {
|
|
2744
|
+
retirementRequester: { ...caller, target: retirementTarget },
|
|
2745
|
+
});
|
|
2601
2746
|
const nc = await this.dial({ authenticator: credsAuthenticator(new TextEncoder().encode(creds)), maxReconnectAttempts: 0 });
|
|
2602
2747
|
try {
|
|
2603
2748
|
// §13.2 nonce: >=128 bits of CSPRNG entropy, base64url (the `endpoint-invoke` idiom).
|
|
@@ -2625,30 +2770,12 @@ export class Manager {
|
|
|
2625
2770
|
// caller's own subject-derived principal. A superseded predecessor (same instanceId, OLD
|
|
2626
2771
|
// epoch after a restart) is still refused by the epoch comparison.
|
|
2627
2772
|
JSON.stringify({ id: requestId, op: "retireLifecycle", args: {
|
|
2628
|
-
opId
|
|
2629
|
-
serveEndpoint: MANAGER_ENDPOINT, serveInstanceId: this.managerInstanceId, serveEpoch
|
|
2773
|
+
opId,
|
|
2774
|
+
serveEndpoint: MANAGER_ENDPOINT, serveInstanceId: this.managerInstanceId, serveEpoch,
|
|
2630
2775
|
} }), 20_000);
|
|
2631
2776
|
const r = m;
|
|
2632
2777
|
if (r.ok) {
|
|
2633
|
-
|
|
2634
|
-
// lifecycle's - a late reply for a retired predecessor must never clear a successor's newer hold.
|
|
2635
|
-
const cur = this.retiring.get(a.name);
|
|
2636
|
-
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
2637
|
-
if (cur.standingAuthorityLive) {
|
|
2638
|
-
// INT-2: the auth-plane lifecycle retired, but the manager-side STANDING mint authority is
|
|
2639
|
-
// not yet revoked (a failed revoke). Freeing the name here would be a false terminal (a
|
|
2640
|
-
// copied token could still mint), so keep the hold with its revoke-failure copy; a retry
|
|
2641
|
-
// re-drives the full teardown (revoke included).
|
|
2642
|
-
console.error(`despawn ${a.name}: the auth-plane lifecycle retired, but the standing mint authority is not yet revoked; the name stays held. ${cur.lastError ?? ""}`);
|
|
2643
|
-
}
|
|
2644
|
-
else {
|
|
2645
|
-
this.retiring.delete(a.name);
|
|
2646
|
-
console.error(`despawn ${a.name}: the agent's retirement completed; the name is free for reuse`);
|
|
2647
|
-
}
|
|
2648
|
-
}
|
|
2649
|
-
else {
|
|
2650
|
-
console.error(`despawn ${a.name}: retirement confirmed for a prior lifecycle of "${a.name}"; the current hold is left intact`);
|
|
2651
|
-
}
|
|
2778
|
+
this.confirmRetirement(a);
|
|
2652
2779
|
}
|
|
2653
2780
|
else {
|
|
2654
2781
|
// The rail's refusal is already the operator copy (lease-loss/stale/foreign-op faces,
|
|
@@ -2669,6 +2796,30 @@ export class Manager {
|
|
|
2669
2796
|
console.error(`despawn ${a.name}: ${copy}`);
|
|
2670
2797
|
}
|
|
2671
2798
|
}
|
|
2799
|
+
/** Apply one terminal retirement answer. The predecessor coordinate is recorded only when the
|
|
2800
|
+
* answer clears this exact lifecycle's hold, never for an ABA-late answer or an incomplete hosted
|
|
2801
|
+
* teardown whose standing authority still lives. */
|
|
2802
|
+
confirmRetirement(a) {
|
|
2803
|
+
// CAS the hold clear (audit #1 ABA): free the alias ONLY if the current hold is still THIS
|
|
2804
|
+
// lifecycle's. A late reply for a retired predecessor must never clear a successor's newer hold.
|
|
2805
|
+
const cur = this.retiring.get(a.name);
|
|
2806
|
+
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
2807
|
+
if (cur.standingAuthorityLive) {
|
|
2808
|
+
// INT-2: the auth-plane lifecycle retired, but the manager-side STANDING mint authority is
|
|
2809
|
+
// not yet revoked. A copied token could still mint, so neither free the alias nor classify
|
|
2810
|
+
// its presence as a completed predecessor.
|
|
2811
|
+
console.error(`despawn ${a.name}: the auth-plane lifecycle retired, but the standing mint authority is not yet revoked; the name stays held. ${cur.lastError ?? ""}`);
|
|
2812
|
+
}
|
|
2813
|
+
else {
|
|
2814
|
+
this.confirmedRetiredPredecessors.set(a.name, { principal: a.id, lifecycleUid: a.lifecycleUid });
|
|
2815
|
+
this.retiring.delete(a.name);
|
|
2816
|
+
console.error(`despawn ${a.name}: the agent's retirement completed; the name is free for reuse`);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
else {
|
|
2820
|
+
console.error(`despawn ${a.name}: retirement confirmed for a prior lifecycle of "${a.name}"; the current hold is left intact`);
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2672
2823
|
/** The teardown's ASYNC BROKER PHASE: mint the ephemeral target-pinned deprovisioner cred and
|
|
2673
2824
|
* delete the agent's broker footprint (dm_/dlv_ durables + read-ACL row). Split from
|
|
2674
2825
|
* {@link deprovision} because it runs LAST in the ordered teardown chain — after the creds/secret
|
|
@@ -2831,7 +2982,12 @@ export class Manager {
|
|
|
2831
2982
|
const joinedAfter = this.ep.getRoster()
|
|
2832
2983
|
.filter((p) => p.card.id === wanted && p.lifecycleUid === a.lifecycleUid)
|
|
2833
2984
|
.reduce((max, p) => Math.max(max, p.ts), 0) + 1;
|
|
2834
|
-
|
|
2985
|
+
// Same ordering as the spawn path: the replacement seat's reference is recorded on the
|
|
2986
|
+
// still-active slot before its processes exist, so a crash here never strands it.
|
|
2987
|
+
const custody = this.reserveCustody();
|
|
2988
|
+
if (custody)
|
|
2989
|
+
await this.recordSlotCustody(a, custody);
|
|
2990
|
+
const handle = await this.spawnCustodied(a.name, spec, a.launch.cwd, custody);
|
|
2835
2991
|
replacement = handle;
|
|
2836
2992
|
restart.sessionStatePath = spec.sessionStatePath ?? restart.sessionStatePath;
|
|
2837
2993
|
if (continueSession !== undefined)
|
|
@@ -2867,6 +3023,7 @@ export class Manager {
|
|
|
2867
3023
|
replacement = undefined;
|
|
2868
3024
|
restart.opts = opts;
|
|
2869
3025
|
restart.recovering = false;
|
|
3026
|
+
await this.recordSlotRuntime(a);
|
|
2870
3027
|
if (continueSession !== undefined)
|
|
2871
3028
|
console.error(`! ${a.name}: recovered Pi session ${continueSession} after crash (${restart.crashes.length}/${limit})`);
|
|
2872
3029
|
else
|
|
@@ -3177,14 +3334,13 @@ export class Manager {
|
|
|
3177
3334
|
async psOwnerFilter(caller, admin) {
|
|
3178
3335
|
if (!this.userMode || admin)
|
|
3179
3336
|
return undefined;
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
return NO_OWNER_MATCHES;
|
|
3337
|
+
if (this.remoteAuthority)
|
|
3338
|
+
return (await this.epAdminReach(caller)) ? undefined : caller.owner;
|
|
3183
3339
|
try {
|
|
3184
3340
|
const scope = await resolveAuthProvider().actorScope({
|
|
3185
3341
|
dir: userAuthStateDir(this.workspaceRoot, this.space),
|
|
3186
|
-
owner:
|
|
3187
|
-
actor:
|
|
3342
|
+
owner: caller.owner,
|
|
3343
|
+
actor: caller.actor,
|
|
3188
3344
|
});
|
|
3189
3345
|
if (scope?.includes("admin"))
|
|
3190
3346
|
return undefined;
|
|
@@ -3192,7 +3348,7 @@ export class Manager {
|
|
|
3192
3348
|
catch {
|
|
3193
3349
|
/* unreadable ledger authorizes nothing extra: fall through to the own-owner bound */
|
|
3194
3350
|
}
|
|
3195
|
-
return
|
|
3351
|
+
return caller.owner;
|
|
3196
3352
|
}
|
|
3197
3353
|
/** Boot one resolved agent from a mesh-manifest launch spec, for `cotal spawn -f` onto a RUNNING
|
|
3198
3354
|
* manager. The request carries `{ runId, name }` — plus, for a deploy from another checkout or
|
|
@@ -3554,6 +3710,11 @@ export class Manager {
|
|
|
3554
3710
|
// AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
|
|
3555
3711
|
// `userOwner` for a user-mode spawn so that rollback runs the revoke+shred branch, not just the
|
|
3556
3712
|
// static durable teardown (the freelance found this window leaking the managed grant + files).
|
|
3713
|
+
// The RESERVED custody reference rides the rollback object: a spawn that launched the seat and
|
|
3714
|
+
// then threw (the activation CAS refuses a slot that is not this spawn's intent) reaches the
|
|
3715
|
+
// `finally` with the handle only in a local, so without this the terminal has nothing to reap
|
|
3716
|
+
// and retires over a live process. The slot row holds the same reference; this is the copy the
|
|
3717
|
+
// in-process rollback can actually read.
|
|
3557
3718
|
let provisioned;
|
|
3558
3719
|
try {
|
|
3559
3720
|
// A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
|
|
@@ -3563,6 +3724,11 @@ export class Manager {
|
|
|
3563
3724
|
// broker resource (dm_/dlv_/chathist_ durables, ACL row, memberships) and the teardown
|
|
3564
3725
|
// credential carry it, so a same-name successor's footprint is name-disjoint by construction.
|
|
3565
3726
|
const lifecycleUid = mintLifecycleUid();
|
|
3727
|
+
// Reserve the seat's custody reference NOW, before any durable row and long before the
|
|
3728
|
+
// launch. Minting it inside `runtime.spawn` (as it used to be) put the processes on disk
|
|
3729
|
+
// before anything recorded how to address them, so a crash in that window left a live seat
|
|
3730
|
+
// outside every manager and the successor retired the slot over it.
|
|
3731
|
+
const custody = this.reserveCustody();
|
|
3566
3732
|
// ACCEPT SEAM (P2 item 2 spawn-as-action): the incarnation identity is minted and NOTHING has
|
|
3567
3733
|
// been provisioned yet — the action serve path binds the goal + replies the acceptance HERE. A
|
|
3568
3734
|
// throw (bind conflict / duplicate goalId) aborts the spawn before provisioning: the catch below
|
|
@@ -3625,6 +3791,7 @@ export class Manager {
|
|
|
3625
3791
|
// spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
|
|
3626
3792
|
// subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
|
|
3627
3793
|
let credsPath;
|
|
3794
|
+
let issued;
|
|
3628
3795
|
let userLaunch;
|
|
3629
3796
|
let userOwner;
|
|
3630
3797
|
if (this.userMode) {
|
|
@@ -3645,7 +3812,7 @@ export class Manager {
|
|
|
3645
3812
|
}
|
|
3646
3813
|
userLaunch = prep.launch;
|
|
3647
3814
|
userOwner = prep.owner;
|
|
3648
|
-
provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, secretPaths: prep.files };
|
|
3815
|
+
provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, secretPaths: prep.files, ...(custody ? { runtime: custody } : {}) };
|
|
3649
3816
|
}
|
|
3650
3817
|
else if (this.auth) {
|
|
3651
3818
|
// Unit B (§13.1): reserve + activate this incarnation's DURABLE identity BEFORE any
|
|
@@ -3653,18 +3820,25 @@ export class Manager {
|
|
|
3653
3820
|
// then the SHARED core activation saga (reserve uid -> gate frozen -> head CAS -> reopen
|
|
3654
3821
|
// LAST) over the key-pinned executor. The wire AUTHORITY principal is the incarnation-
|
|
3655
3822
|
// unique nkey (F5-bind); the alias is protected by the name-keyed slot + freeSlot hold.
|
|
3656
|
-
await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias: name }, (t) => activateStaticLifecycle(t, { owner: DEV_OWNER, alias: name, actor: identity.id, lifecycleUid, managerInstance: this.managerLifecycleUid, ownerInstanceId: this.managerInstanceId }));
|
|
3823
|
+
await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias: name }, (t) => activateStaticLifecycle(t, { owner: DEV_OWNER, alias: name, actor: identity.id, lifecycleUid, managerInstance: this.managerLifecycleUid, ownerInstanceId: this.managerInstanceId, runtime: custody }));
|
|
3657
3824
|
// From here the DURABLE registration exists: arm the rollback BEFORE minting, so a throw
|
|
3658
3825
|
// between activation and provisioning still drives the exact-op static terminal (the
|
|
3659
3826
|
// finally's deprovision tolerates absent files; the broker teardown is idempotent).
|
|
3660
|
-
provisioned = { id: identity.id, name, lifecycleUid };
|
|
3827
|
+
provisioned = { id: identity.id, name, lifecycleUid, ...(custody ? { runtime: custody } : {}) };
|
|
3661
3828
|
// Pre-create the agent's bind-only chat (+ DM + role TASK) durables and mint its scoped creds
|
|
3662
3829
|
// — the shared onboarding step (provisionAgent). It runs on a short-lived PROVISIONER connection
|
|
3663
3830
|
// (NOT the supervisor's long-lived endpoint), so the DM/DLV consumer-create surface exists only
|
|
3664
3831
|
// for the provisioning window, never as a standing grant on the always-on daemon (residual 2).
|
|
3665
3832
|
// F5(b): the credential is BOUNDED (`expiresAt`) — the manager push-renews it ahead of expiry.
|
|
3666
3833
|
const exp = Math.floor(Date.now() / 1000) + MANAGED_STATIC_TTL_SEC;
|
|
3667
|
-
|
|
3834
|
+
// The credential is an ISSUANCE (SPEC 13.15): its caller rails carry a fresh generation
|
|
3835
|
+
// and its ceiling is recorded as evidence bound to this incarnation's ledger family. The
|
|
3836
|
+
// §13.1 ledger append (ledger BEFORE materialization) is the release's finalizer, so the
|
|
3837
|
+
// evidence activates only once the row exists, and the row exists only after the evidence
|
|
3838
|
+
// was staged. The agent learns the generation by reading the accepted row under its token.
|
|
3839
|
+
issued = { generation: mintGeneration(), acceptedToken: mintAcceptedToken() };
|
|
3840
|
+
const issuedAgent = { name, id: identity.id, lifecycleUid };
|
|
3841
|
+
const creds = await this.withIssuer((i) => this.withProvisioner((prov) => provisionAgent(prov, this.auth, identity, {
|
|
3668
3842
|
subscribe,
|
|
3669
3843
|
allowSubscribe,
|
|
3670
3844
|
allowPublish,
|
|
@@ -3672,15 +3846,9 @@ export class Manager {
|
|
|
3672
3846
|
capabilities,
|
|
3673
3847
|
lifecycleUid,
|
|
3674
3848
|
expiresAt: exp,
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
// it — a credential is never materialized before its ledger row exists.
|
|
3679
|
-
const credentialId = rawDigest(creds).replace("sha256:", "sha256-");
|
|
3680
|
-
await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias: name }, async (t) => {
|
|
3681
|
-
await recordSlotCredential(t, DEV_OWNER, name, lifecycleUid, credentialId);
|
|
3682
|
-
await appendStaticCredentialRow(t, { lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, identity.id).key, exp });
|
|
3683
|
-
});
|
|
3849
|
+
issued,
|
|
3850
|
+
issuance: this.issuanceSeam(i, "issue", issuedAgent, exp),
|
|
3851
|
+
})));
|
|
3684
3852
|
// Store first (the source of truth), then materialize: `buildLaunch` hands the CHILD this
|
|
3685
3853
|
// file path, so the cred must exist as a file regardless of the store behind the seam. The
|
|
3686
3854
|
// manager's ONE store (injected for hosted, workstation FS locally).
|
|
@@ -3690,7 +3858,7 @@ export class Manager {
|
|
|
3690
3858
|
credsPath = agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, name, lifecycleUid).creds;
|
|
3691
3859
|
await secrets.put(agentSecretKeyForFile(credsPath, this.space), creds);
|
|
3692
3860
|
await materializeSecretToFile(secrets, agentSecretKeyForFile(credsPath, this.space), credsPath);
|
|
3693
|
-
provisioned = { id: identity.id, name, lifecycleUid, secretPaths: { creds: credsPath } }; // footprint now exists — the finally rolls it back if the spawn throws
|
|
3861
|
+
provisioned = { id: identity.id, name, lifecycleUid, secretPaths: { creds: credsPath }, ...(custody ? { runtime: custody } : {}) }; // footprint now exists — the finally rolls it back if the spawn throws
|
|
3694
3862
|
}
|
|
3695
3863
|
// Personal MCP servers the operator opted to share with manager-spawned agents of this type
|
|
3696
3864
|
// (cotal config; default none → isolated, the memory-safe default this guards), narrowed by
|
|
@@ -3723,6 +3891,7 @@ export class Manager {
|
|
|
3723
3891
|
// chathist durables by this exact value (its creds pin the same names, so a mismatch fails
|
|
3724
3892
|
// at the broker, never silently).
|
|
3725
3893
|
lifecycleUid,
|
|
3894
|
+
acceptedToken: issued?.acceptedToken,
|
|
3726
3895
|
servers: this.servers,
|
|
3727
3896
|
configPath,
|
|
3728
3897
|
model,
|
|
@@ -3751,7 +3920,7 @@ export class Manager {
|
|
|
3751
3920
|
workspaceRoot: this.workspaceRoot,
|
|
3752
3921
|
};
|
|
3753
3922
|
const spec = connector.buildLaunch(launchOpts);
|
|
3754
|
-
const handle = this.
|
|
3923
|
+
const handle = await this.spawnCustodied(name, spec, cwd, custody);
|
|
3755
3924
|
hooks?.onLaunched?.(); // P2 item 2: the "launched" progress edge (process spawned, pre-presence)
|
|
3756
3925
|
const managed = {
|
|
3757
3926
|
name,
|
|
@@ -3763,6 +3932,7 @@ export class Manager {
|
|
|
3763
3932
|
// recorded truth teardown/preservation/health consume, never re-derived by name.
|
|
3764
3933
|
secretPaths: provisioned?.secretPaths,
|
|
3765
3934
|
...(userLaunch ? { userOwner } : { seed: identity.seed }),
|
|
3935
|
+
...(issued ? { issued } : {}),
|
|
3766
3936
|
spawner: spawner ?? this.ep.ref().id,
|
|
3767
3937
|
authorityParent: userLaunch && spawner && parsePrincipalKey(spawner) ? spawner : undefined,
|
|
3768
3938
|
startedAt: Date.now(),
|
|
@@ -3818,7 +3988,9 @@ export class Manager {
|
|
|
3818
3988
|
const slot = await readStaticSlot(t, DEV_OWNER, name);
|
|
3819
3989
|
if (slot === undefined || slot.row.lifecycleUid !== lifecycleUid || slot.row.phase !== "provisioning")
|
|
3820
3990
|
throw new Error(`the static slot for "${name}" is ${slot === undefined ? "absent" : `${slot.row.phase} at uid ${slot.row.lifecycleUid}`}, not this spawn's provisioning intent; refusing to take the slot`);
|
|
3821
|
-
|
|
3991
|
+
// The custody reference rides the SAME CAS as activation: a crash after this write leaves
|
|
3992
|
+
// an active slot a successor can reap by reference, never a live seat nobody addresses.
|
|
3993
|
+
await casStaticSlot(t, { ...slot.row, phase: "active", ...(handle.reference ? { runtime: handle.reference } : {}) }, slot.revision);
|
|
3822
3994
|
});
|
|
3823
3995
|
}
|
|
3824
3996
|
this.agents.set(name, managed);
|
|
@@ -3915,15 +4087,33 @@ export class Manager {
|
|
|
3915
4087
|
const seen = new Set();
|
|
3916
4088
|
const principals = new Set();
|
|
3917
4089
|
await this.ep.waitForPresenceSnapshot();
|
|
3918
|
-
const
|
|
3919
|
-
.filter((presence) => presence.status !== "offline")
|
|
3920
|
-
.map((presence) => presence.card.id));
|
|
4090
|
+
const liveRoster = this.ep.getRoster().filter((presence) => presence.status !== "offline");
|
|
3921
4091
|
if (this.agents.size + this.reserved.size + this.coolingCount() + inventory.agents.length > MAX_AGENTS)
|
|
3922
4092
|
return { ok: false, agents: [], error: `resume inventory would exceed manager capacity (${MAX_AGENTS})` };
|
|
3923
4093
|
for (const entry of inventory.agents) {
|
|
3924
4094
|
if (seen.has(entry.name))
|
|
3925
4095
|
return { ok: false, agents: [], error: `resume inventory contains duplicate agent name "${entry.name}"` };
|
|
3926
4096
|
seen.add(entry.name);
|
|
4097
|
+
// A manager-local retirement hold is stronger and more precise than presence. The stopped
|
|
4098
|
+
// predecessor can remain roster-live until its TTL/offline update lands, but that stale row
|
|
4099
|
+
// must not hide the durable teardown state the manager already owns. Refuse and re-drive the
|
|
4100
|
+
// exact held lifecycle before consulting generic principal liveness. A confirmed terminal has
|
|
4101
|
+
// already removed the hold, so fresh-lifecycle resume still reaches the fail-closed roster gate.
|
|
4102
|
+
const held = this.retiring.get(entry.name);
|
|
4103
|
+
if (held) {
|
|
4104
|
+
void this.deprovision({
|
|
4105
|
+
id: held.agentId,
|
|
4106
|
+
name: entry.name,
|
|
4107
|
+
lifecycleUid: held.lifecycleUid,
|
|
4108
|
+
userOwner: held.userOwner,
|
|
4109
|
+
secretPaths: held.secretPaths,
|
|
4110
|
+
}).catch(() => { });
|
|
4111
|
+
return {
|
|
4112
|
+
ok: false,
|
|
4113
|
+
agents: [],
|
|
4114
|
+
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`,
|
|
4115
|
+
};
|
|
4116
|
+
}
|
|
3927
4117
|
let principal;
|
|
3928
4118
|
try {
|
|
3929
4119
|
principal = entry.identity.mode === "user"
|
|
@@ -3936,7 +4126,17 @@ export class Manager {
|
|
|
3936
4126
|
if (principals.has(principal))
|
|
3937
4127
|
return { ok: false, agents: [], error: `resume inventory contains duplicate principal "${principal}"` };
|
|
3938
4128
|
principals.add(principal);
|
|
3939
|
-
|
|
4129
|
+
const confirmedPredecessor = this.confirmedRetiredPredecessors.get(entry.name);
|
|
4130
|
+
const principalIsLive = liveRoster.some((presence) => {
|
|
4131
|
+
if (presence.card.id !== principal)
|
|
4132
|
+
return false;
|
|
4133
|
+
return confirmedPredecessor === undefined ||
|
|
4134
|
+
confirmedPredecessor.principal !== principal ||
|
|
4135
|
+
confirmedPredecessor.lifecycleUid !== presence.lifecycleUid ||
|
|
4136
|
+
presence.card.name !== entry.name ||
|
|
4137
|
+
entry.identity.lifecycleUid === confirmedPredecessor.lifecycleUid;
|
|
4138
|
+
});
|
|
4139
|
+
if (principalIsLive)
|
|
3940
4140
|
return { ok: false, agents: [], error: `retained principal "${principal}" is already live and this runtime cannot authoritatively adopt it` };
|
|
3941
4141
|
if (this.agents.has(entry.name) || this.reserved.has(entry.name))
|
|
3942
4142
|
return { ok: false, agents: [], error: `retained agent "${entry.name}" is already managed or reserved` };
|
|
@@ -4088,15 +4288,23 @@ export class Manager {
|
|
|
4088
4288
|
const sentinelCreds = await secrets.get(agentSecretKeyForFile(recordedSentinel, this.space));
|
|
4089
4289
|
if (actorToken === undefined || sentinelCreds === undefined)
|
|
4090
4290
|
throw new Error("the retained actor token / sentinel credential is not in the secret store");
|
|
4091
|
-
const adopted =
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4291
|
+
const adopted = this.remoteAuthority
|
|
4292
|
+
? await this.remoteAuthority.validateRetainedAgent({
|
|
4293
|
+
owner: entry.identity.owner,
|
|
4294
|
+
actor: entry.identity.actor,
|
|
4295
|
+
lifecycleUid: entry.identity.lifecycleUid,
|
|
4296
|
+
actorToken,
|
|
4297
|
+
sentinelCreds,
|
|
4298
|
+
})
|
|
4299
|
+
: await provider.validateRetainedAgent({
|
|
4300
|
+
store: secrets,
|
|
4301
|
+
dir: userAuthStateDir(this.workspaceRoot, this.space),
|
|
4302
|
+
space: this.space,
|
|
4303
|
+
owner: entry.identity.owner,
|
|
4304
|
+
actor: entry.identity.actor,
|
|
4305
|
+
actorToken,
|
|
4306
|
+
sentinelCreds,
|
|
4307
|
+
});
|
|
4100
4308
|
if (adopted.owner !== entry.identity.owner || adopted.actor !== entry.identity.actor)
|
|
4101
4309
|
throw new Error(`auth provider returned a replacement principal; expected ${entry.identity.owner}.${entry.identity.actor}`);
|
|
4102
4310
|
// Bind the inventory's uid to the CURRENT authority row BEFORE any spawn: a corrupt or
|
|
@@ -4119,7 +4327,9 @@ export class Manager {
|
|
|
4119
4327
|
...process.execArgv,
|
|
4120
4328
|
process.argv[1],
|
|
4121
4329
|
provider.agentBearerCommand,
|
|
4122
|
-
|
|
4330
|
+
...(this.remoteAuthority
|
|
4331
|
+
? ["--exchange-url", this.remoteAuthority.agentBearerExchangeUrl]
|
|
4332
|
+
: ["--dir", userAuthStateDir(this.workspaceRoot, this.space)]),
|
|
4123
4333
|
"--space", this.space,
|
|
4124
4334
|
"--owner", entry.identity.owner,
|
|
4125
4335
|
"--actor", entry.identity.actor,
|
|
@@ -4251,6 +4461,7 @@ export class Manager {
|
|
|
4251
4461
|
// with no COTAL_LIFECYCLE_UID: static/user fail the connector auth gate and open self-mints a
|
|
4252
4462
|
// fresh uid that orphans the preserved durables and never matches the readiness fence.
|
|
4253
4463
|
lifecycleUid: entry.identity.lifecycleUid,
|
|
4464
|
+
acceptedToken: entry.identity.mode === "static" ? entry.identity.issued?.acceptedToken : undefined,
|
|
4254
4465
|
servers: this.servers,
|
|
4255
4466
|
configPath: entry.launch.source.configPath,
|
|
4256
4467
|
model: entry.launch.model,
|
|
@@ -4300,13 +4511,18 @@ export class Manager {
|
|
|
4300
4511
|
const readinessTimeoutMs = connector.readinessTimeoutMs ?? this.readinessTimeoutMs;
|
|
4301
4512
|
if (!Number.isSafeInteger(readinessTimeoutMs) || readinessTimeoutMs <= 0)
|
|
4302
4513
|
return { ok: false, error: `connector ${connector.name} declares invalid readinessTimeoutMs ${JSON.stringify(connector.readinessTimeoutMs)}; expected a positive safe integer` };
|
|
4303
|
-
const
|
|
4514
|
+
const resumeId = entry.identity.mode === "user" ? principalKey(entry.identity.owner, entry.identity.actor).key : entry.identity.id;
|
|
4515
|
+
const custody = this.reserveCustody();
|
|
4516
|
+
if (custody)
|
|
4517
|
+
await this.recordSlotCustody({ name: entry.name, id: resumeId, lifecycleUid: entry.identity.lifecycleUid }, custody);
|
|
4518
|
+
const handle = await this.spawnCustodied(entry.name, prepared.spec, entry.launch.cwd, custody);
|
|
4304
4519
|
const managed = {
|
|
4305
4520
|
name: entry.name,
|
|
4306
4521
|
role: entry.role,
|
|
4307
4522
|
agent: entry.launch.connector,
|
|
4308
|
-
id:
|
|
4523
|
+
id: resumeId,
|
|
4309
4524
|
seed: adoptedSeed,
|
|
4525
|
+
...(entry.identity.mode === "static" && entry.identity.issued ? { issued: entry.identity.issued } : {}),
|
|
4310
4526
|
// Recover the ORIGINAL incarnation uid the durables are keyed by (never a fresh mint on resume).
|
|
4311
4527
|
lifecycleUid: entry.identity.lifecycleUid,
|
|
4312
4528
|
// Adopt the INVENTORY's recorded family (possibly a pre-split name-keyed layout) — the
|
|
@@ -4345,6 +4561,7 @@ export class Manager {
|
|
|
4345
4561
|
this.agents.set(entry.name, managed);
|
|
4346
4562
|
if (this.resumeAttemptId)
|
|
4347
4563
|
this.resumedAgentNames.add(entry.name);
|
|
4564
|
+
await this.recordSlotRuntime(managed);
|
|
4348
4565
|
const readiness = await this.awaitReadiness(managed, readinessTimeoutMs);
|
|
4349
4566
|
if (!readiness.ok && !readiness.uncertain)
|
|
4350
4567
|
return { ok: false, error: readiness.detail };
|
|
@@ -4643,16 +4860,56 @@ export class Manager {
|
|
|
4643
4860
|
await nc.drain().catch(() => nc.close());
|
|
4644
4861
|
}
|
|
4645
4862
|
}
|
|
4646
|
-
/** Run one
|
|
4863
|
+
/** Run one SPEC 13.15 issuance operation over an ephemeral `issuer` connection: the evidence
|
|
4864
|
+
* stage/release of one mint, or the retirement walk of one lifecycle's issuances. The store
|
|
4865
|
+
* handle and the accepted-row KV live only for this window; the standing supervisor holds no
|
|
4866
|
+
* grant on either bucket. */
|
|
4867
|
+
async withIssuer(fn) {
|
|
4868
|
+
if (!this.auth)
|
|
4869
|
+
throw new Error("withIssuer: no space auth (an open mesh issues nothing)");
|
|
4870
|
+
const creds = await mintCreds(this.auth, newIdentity(), "issuer");
|
|
4871
|
+
const nc = await this.dial({ ...standaloneConnectOpts({ creds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 });
|
|
4872
|
+
try {
|
|
4873
|
+
const kvm = new Kvm(nc);
|
|
4874
|
+
const store = openIssuedStore(await kvm.open(issuedBucket(this.space)), await jetstreamManager(nc), this.space);
|
|
4875
|
+
return await fn({ store, accepted: await kvm.open(acceptedBucket(this.space)) });
|
|
4876
|
+
}
|
|
4877
|
+
finally {
|
|
4878
|
+
await nc.drain().catch(() => nc.close());
|
|
4879
|
+
}
|
|
4880
|
+
}
|
|
4881
|
+
/** The issuance seam of ONE static credential mint (SPEC 13.15): the evidence is bound to the
|
|
4882
|
+
* incarnation's own `cred.<uid>` ledger family, so the family's revocation (the static
|
|
4883
|
+
* terminal) retires every issuance that named it. The finalizer is the §13.1 ledger append
|
|
4884
|
+
* (slot credentialId + `cred.<uid>.<credId>` row); release runs it, then activates. */
|
|
4885
|
+
issuanceSeam(i, mode, a, exp) {
|
|
4886
|
+
return {
|
|
4887
|
+
mode,
|
|
4888
|
+
store: i.store,
|
|
4889
|
+
accepted: i.accepted,
|
|
4890
|
+
sources: mode === "issue" ? [{ space: this.space, bucket: epAuthBucket(this.space), key: `cred.${a.lifecycleUid}` }] : [],
|
|
4891
|
+
finalize: async (credentialId) => {
|
|
4892
|
+
await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
|
|
4893
|
+
await recordSlotCredential(t, DEV_OWNER, a.name, a.lifecycleUid, credentialId);
|
|
4894
|
+
await appendStaticCredentialRow(t, { lifecycleUid: a.lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, a.id).key, exp });
|
|
4895
|
+
});
|
|
4896
|
+
},
|
|
4897
|
+
};
|
|
4898
|
+
}
|
|
4899
|
+
/** Run one §13.1 endpoint-instance maintenance operation over an ephemeral,
|
|
4647
4900
|
* key-pinned `endpoint-serve-executor` connection: the credential's grants name exactly the
|
|
4648
4901
|
* manager instance's `epgate`/`epcred` keys plus its registration's two records keys, so the
|
|
4649
|
-
* gate CAS,
|
|
4650
|
-
*
|
|
4902
|
+
* gate CAS, mint fence, spec/governance writes, and clean deregistration ride a one-shot scoped
|
|
4903
|
+
* authority. The remote boot goal-index sweep instead uses the authenticated host-owned scan.
|
|
4904
|
+
* This is NEVER the manager's standing seed/supervisor connection. */
|
|
4651
4905
|
async withEndpointServeExecutor(fn) {
|
|
4652
4906
|
const identity = this.remoteAuthority?.identities.executor ?? newIdentity();
|
|
4653
4907
|
const creds = this.remoteAuthority?.executorCreds ?? (this.auth
|
|
4654
4908
|
? await mintCreds(this.auth, identity, "endpoint-serve-executor", {
|
|
4655
4909
|
endpointServeExecutor: { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId },
|
|
4910
|
+
...(this.endpointServeExecutorExpiresInSeconds !== undefined
|
|
4911
|
+
? { expiresInSeconds: this.endpointServeExecutorExpiresInSeconds }
|
|
4912
|
+
: {}),
|
|
4656
4913
|
})
|
|
4657
4914
|
: undefined);
|
|
4658
4915
|
if (!creds)
|
|
@@ -4671,8 +4928,8 @@ export class Manager {
|
|
|
4671
4928
|
* writes ride a bare one-shot connection (the broker enforces nothing on an open mesh; the
|
|
4672
4929
|
* ceremony still produces the real gate, epoch, and registration the serve rails run on). */
|
|
4673
4930
|
async withOpenServeConnection(fn) {
|
|
4674
|
-
if (this.auth)
|
|
4675
|
-
throw new Error("withOpenServeConnection: an
|
|
4931
|
+
if (this.auth || this.remoteAuthority)
|
|
4932
|
+
throw new Error("withOpenServeConnection: an authenticated mesh must use the scoped endpoint-serve executor");
|
|
4676
4933
|
const nc = await this.dial({ maxReconnectAttempts: 0 });
|
|
4677
4934
|
try {
|
|
4678
4935
|
const kvm = new Kvm(nc);
|
|
@@ -4867,7 +5124,23 @@ export class Manager {
|
|
|
4867
5124
|
// the takeover barrier revokes a deposed manager's ledger cred alongside its goal-writer. The
|
|
4868
5125
|
// per-session serving creds join the same family, each with its own fresh identity.
|
|
4869
5126
|
this.sessionLedgerIdentity = newIdentity();
|
|
4870
|
-
|
|
5127
|
+
// One registration operation for this boot. Fresh executors may replace a dead connection, but
|
|
5128
|
+
// they must resume THIS freeze rather than minting a new op that discards Phase-2 progress.
|
|
5129
|
+
const registrationOpId = mintLifecycleUid();
|
|
5130
|
+
const executorExpired = (e) => /closed connection/i.test(e?.message ?? String(e));
|
|
5131
|
+
const retryExpiredExecutor = async (label, fn) => {
|
|
5132
|
+
for (;;) {
|
|
5133
|
+
try {
|
|
5134
|
+
return await fn();
|
|
5135
|
+
}
|
|
5136
|
+
catch (e) {
|
|
5137
|
+
if (!executorExpired(e))
|
|
5138
|
+
throw e;
|
|
5139
|
+
console.error(`manager ${label} executor expired; retrying${label === "registration" ? ` operation ${registrationOpId}` : ""} with fresh scoped authority`);
|
|
5140
|
+
}
|
|
5141
|
+
}
|
|
5142
|
+
};
|
|
5143
|
+
const publishAndProvision = async ({ authKv, nc: execNc }) => {
|
|
4871
5144
|
// §13.7 contract-artifact publication (1c): every schema root + its closure manifest, plus
|
|
4872
5145
|
// the cluster document + ITS manifest, land in the EPC store BEFORE the registration that
|
|
4873
5146
|
// advertises their digests — so a caller can always fetch-verify-compile a registered
|
|
@@ -4886,14 +5159,9 @@ export class Manager {
|
|
|
4886
5159
|
// so the gate's principal binding is unchanged either way.
|
|
4887
5160
|
if ((await serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: iid }).observe()) === null)
|
|
4888
5161
|
await provisionEndpointGateOpen(authKv, { endpoint: MANAGER_ENDPOINT, instanceId: iid, principal: servePrincipal });
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
// holder-gone evidence BEFORE this incarnation freezes a new one. Auth only: the
|
|
4893
|
-
// CONNZ oracle rides delivery-admin, which an open mesh does not have.
|
|
4894
|
-
if (auth) {
|
|
4895
|
-
await this.healFrozenRegistrationGate(authKv, iid, auth, recordsKv);
|
|
4896
|
-
}
|
|
5162
|
+
};
|
|
5163
|
+
const completeRegistration = async ({ recordsKv, authKv, nc: execNc }) => {
|
|
5164
|
+
await publishAndProvision({ recordsKv, authKv, nc: execNc });
|
|
4897
5165
|
// P2 item 3 (slice 3a): on an AUTH mesh a RE-registration (restart of the persisted instanceId)
|
|
4898
5166
|
// must VERIFY-EVICT the superseded serve family BEFORE the epoch advances (§13.1 "old authority
|
|
4899
5167
|
// dies before new authority is visible"). Inject the SCOPED delivery-admin evictor; the OPEN
|
|
@@ -4902,12 +5170,18 @@ export class Manager {
|
|
|
4902
5170
|
// evictor THROWS naming the cure, so PHASE 2 fails closed with the delivery-daemon fix in the
|
|
4903
5171
|
// error text — a crash-restart never silently skips eviction (no-fallbacks).
|
|
4904
5172
|
const barrier = endpointRegistrationBarrier(authKv, this.space, {
|
|
4905
|
-
endpoint: MANAGER_ENDPOINT, instanceId: iid, opId:
|
|
5173
|
+
endpoint: MANAGER_ENDPOINT, instanceId: iid, opId: registrationOpId,
|
|
4906
5174
|
...(auth ? { evict: makeManagerEndpointEvictor({ space: this.space, servers: this.servers ?? DEFAULT_SERVER, auth, log: (line) => console.error(line) }) } : {}),
|
|
4907
5175
|
});
|
|
4908
5176
|
const spec = { endpoint: MANAGER_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
|
|
4909
5177
|
const { registrationRevision } = await registerServiceInstance(recordsKv, {
|
|
4910
5178
|
space: this.space, spec, instanceId: iid, registrant: { owner: DEV_OWNER }, authority, barrier, readClusterArtifact,
|
|
5179
|
+
// #1393: when a FOREIGN instance holds the endpoint governance slot, let core tell an
|
|
5180
|
+
// in-flight registration from one abandoned by a predecessor that died between its
|
|
5181
|
+
// slot-take and its spec publish. The read rides THIS executor's existing auth-bucket read
|
|
5182
|
+
// grant (no new grant, no new writer); core refuses on anything but a gate provably
|
|
5183
|
+
// reopened past the slot's stamp.
|
|
5184
|
+
observeHolderGeneration: (holderInstanceId) => readEndpointGateGeneration(authKv, { endpoint: MANAGER_ENDPOINT, instanceId: holderInstanceId }),
|
|
4911
5185
|
});
|
|
4912
5186
|
// processEpoch comes from the GATE (checklist 4: never derived from the uid string); the
|
|
4913
5187
|
// fence below is also the mint's §13.1 release CAS.
|
|
@@ -4960,7 +5234,32 @@ export class Manager {
|
|
|
4960
5234
|
const sessionLedgerCreds = auth ? await this.mintAndStageSessionLedger(authKv) : undefined;
|
|
4961
5235
|
return { grant, creds, goalWriterCreds, sessionLedgerCreds };
|
|
4962
5236
|
};
|
|
4963
|
-
|
|
5237
|
+
let registration;
|
|
5238
|
+
if (auth) {
|
|
5239
|
+
// Boot heal may consume an arbitrarily large prior family. End that executor window here and
|
|
5240
|
+
// mint fresh scoped authority for the new registration, rather than forcing heal + takeover
|
|
5241
|
+
// through one fixed-lifetime connection. A closed connection during heal retries the SAME
|
|
5242
|
+
// frozen predecessor op via reconcile's durable cursor; a closed connection during
|
|
5243
|
+
// registration retries THIS boot's operationId without heal, so Phase-2 progress stays bound.
|
|
5244
|
+
// #783/#871: a predecessor that died mid-barrier leaves this gate frozen under a
|
|
5245
|
+
// registration op. registerServiceInstance will then refuse SPEC 13.8 forever, even
|
|
5246
|
+
// when the freeze-holder is gone. Complete that SAME op (abort-reopen) on independent
|
|
5247
|
+
// holder-gone evidence BEFORE this incarnation freezes a new one. Auth only: the
|
|
5248
|
+
// CONNZ oracle rides delivery-admin, which an open mesh does not have.
|
|
5249
|
+
await retryExpiredExecutor("boot-heal", () => this.withEndpointServeExecutor(async ({ recordsKv, authKv, nc: execNc }) => {
|
|
5250
|
+
await publishAndProvision({ recordsKv, authKv, nc: execNc });
|
|
5251
|
+
if (auth) {
|
|
5252
|
+
await this.healFrozenRegistrationGate(authKv, iid, auth, recordsKv);
|
|
5253
|
+
}
|
|
5254
|
+
}));
|
|
5255
|
+
registration = await retryExpiredExecutor("registration", () => this.withEndpointServeExecutor(completeRegistration));
|
|
5256
|
+
}
|
|
5257
|
+
else {
|
|
5258
|
+
registration = await this.withOpenServeConnection(completeRegistration);
|
|
5259
|
+
}
|
|
5260
|
+
if (!registration)
|
|
5261
|
+
throw new Error("manager registration produced no serve authority");
|
|
5262
|
+
const { grant, creds, goalWriterCreds, sessionLedgerCreds } = registration;
|
|
4964
5263
|
this.goalWriterCreds = goalWriterCreds;
|
|
4965
5264
|
this.sessionLedgerCreds = sessionLedgerCreds;
|
|
4966
5265
|
// The serve connection presents the CURRENT credential on every (re)connect (the state object
|
|
@@ -5380,10 +5679,11 @@ export class Manager {
|
|
|
5380
5679
|
}
|
|
5381
5680
|
/** P2 item 2 must-5 Q-B — the boot reconcile: a fresh incarnation (a manager restart takes a NEW
|
|
5382
5681
|
* instanceId, so the in-memory acceptance map starts empty) inherits the endpoint's accepted-but-
|
|
5383
|
-
* unterminal goals from any predecessor.
|
|
5384
|
-
*
|
|
5385
|
-
*
|
|
5386
|
-
*
|
|
5682
|
+
* unterminal goals from any predecessor. The local signer enumerates with an ephemeral PROVISIONER.
|
|
5683
|
+
* A remote manager calls the authenticated host-owned scan, which keeps the sealed consumer and its
|
|
5684
|
+
* lifecycle authority on the host; the instance executor, goal-writer, and supervisor hold NO records
|
|
5685
|
+
* consumer grant. Settle each orphan so an accepted goal is NEVER dropped across a restart. Open mesh:
|
|
5686
|
+
* a bare connection (the broker enforces nothing). Runs ONCE at start, BEFORE spawn-as-action begins
|
|
5387
5687
|
* accepting (the `goalReconcileDone` gate), so it never races a live goal's acceptance. Never
|
|
5388
5688
|
* fatal — a reconcile failure is logged and the gate opens either way. */
|
|
5389
5689
|
async reconcileGoalIndex() {
|
|
@@ -5394,16 +5694,26 @@ export class Manager {
|
|
|
5394
5694
|
}
|
|
5395
5695
|
try {
|
|
5396
5696
|
let entries = [];
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5697
|
+
if (this.remoteAuthority) {
|
|
5698
|
+
entries = (await this.remoteAuthority.scanGoalIndex()).map((entry) => ({
|
|
5699
|
+
ref: { endpoint: entry.endpoint, caller: { owner: entry.owner, actor: entry.actor, uid: entry.uid }, goalId: entry.goalId },
|
|
5700
|
+
iid: entry.iid,
|
|
5701
|
+
...(entry.allocated !== undefined ? { allocated: entry.allocated } : {}),
|
|
5702
|
+
...(entry.note !== undefined ? { note: entry.note } : {}),
|
|
5703
|
+
}));
|
|
5404
5704
|
}
|
|
5405
|
-
|
|
5406
|
-
|
|
5705
|
+
else {
|
|
5706
|
+
const nc = this.auth
|
|
5707
|
+
? await this.dial({ ...standaloneConnectOpts({ creds: await mintCreds(this.auth, newIdentity(), "provisioner"), /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 })
|
|
5708
|
+
: await this.dial({ maxReconnectAttempts: 0 });
|
|
5709
|
+
try {
|
|
5710
|
+
const kvm = new Kvm(nc);
|
|
5711
|
+
await ensureAuthorityStores(await jetstreamManager(nc), kvm, this.space);
|
|
5712
|
+
entries = await listGoalIndex(await kvm.open(recordsBucket(this.space)), MANAGER_ENDPOINT);
|
|
5713
|
+
}
|
|
5714
|
+
finally {
|
|
5715
|
+
await nc.drain().catch(() => nc.close());
|
|
5716
|
+
}
|
|
5407
5717
|
}
|
|
5408
5718
|
// Single-manager item 2: EVERY inherited entry belongs to a DEAD predecessor (only one manager
|
|
5409
5719
|
// at a time), so all are reconciled. The `iid` field is the hook item-3's multi-instance sweep
|
|
@@ -6241,6 +6551,97 @@ export class Manager {
|
|
|
6241
6551
|
// the goal already terminalized (the readiness outcome won the settle race) - nothing to cancel.
|
|
6242
6552
|
}
|
|
6243
6553
|
}
|
|
6554
|
+
/** Re-record the custody reference of a managed agent's CURRENT handle on its active slot: a
|
|
6555
|
+
* same-lifecycle restart or a resume binds a new custody under the old uid, and the successor's
|
|
6556
|
+
* reap must address the live one. Static auth only; a runtime without durable custody records
|
|
6557
|
+
* nothing (there is nothing to reap by reference). Loud on failure, never fatal to the bind: the
|
|
6558
|
+
* handle is already live and a stale reference is refused by identity at reap time. */
|
|
6559
|
+
async recordSlotRuntime(a) {
|
|
6560
|
+
if (a.handle.reference === undefined)
|
|
6561
|
+
return;
|
|
6562
|
+
await this.recordSlotCustody(a, a.handle.reference);
|
|
6563
|
+
}
|
|
6564
|
+
/** Record one custody reference on an agent's ACTIVE slot. Split from {@link recordSlotRuntime}
|
|
6565
|
+
* so a restart or a resume can record the reference it RESERVED before spawning, while no
|
|
6566
|
+
* handle exists yet. Static auth only; a runtime without durable custody reserves nothing. */
|
|
6567
|
+
async recordSlotCustody(a, runtime) {
|
|
6568
|
+
if (!this.auth || this.userMode)
|
|
6569
|
+
return;
|
|
6570
|
+
try {
|
|
6571
|
+
await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
|
|
6572
|
+
const slot = await readStaticSlot(t, DEV_OWNER, a.name);
|
|
6573
|
+
if (slot === undefined || slot.row.lifecycleUid !== a.lifecycleUid || slot.row.phase !== "active")
|
|
6574
|
+
return;
|
|
6575
|
+
if (slot.row.runtime?.kind === runtime.kind && slot.row.runtime.id === runtime.id)
|
|
6576
|
+
return;
|
|
6577
|
+
await casStaticSlot(t, { ...slot.row, runtime }, slot.revision);
|
|
6578
|
+
});
|
|
6579
|
+
}
|
|
6580
|
+
catch (e) {
|
|
6581
|
+
console.error(`! ${a.name}: could not record the custody reference ${runtime.kind}:${runtime.id} on its static slot: ${e.message} - a successor reaps this seat only by an up-to-date reference`);
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
/** Mint the custody reference for a seat this manager is about to spawn, BEFORE the processes
|
|
6585
|
+
* exist. Absent for a runtime with no durable custody (there is nothing a successor could reap
|
|
6586
|
+
* by reference), which is the only case that still spawns unreserved. */
|
|
6587
|
+
reserveCustody() {
|
|
6588
|
+
return isCustodialRuntime(this.runtime) ? this.runtime.reserve() : undefined;
|
|
6589
|
+
}
|
|
6590
|
+
/** Spawn under a reserved reference and prove the runtime honoured it. A runtime that minted its
|
|
6591
|
+
* own id instead would leave every durable record addressing a seat that does not exist, so the
|
|
6592
|
+
* mismatch tears the new seat down and throws rather than returning an unaddressable handle. */
|
|
6593
|
+
async spawnCustodied(name, spec, cwd, reserved) {
|
|
6594
|
+
const handle = this.runtime.spawn(name, spec, cwd, reserved);
|
|
6595
|
+
if (reserved === undefined)
|
|
6596
|
+
return handle;
|
|
6597
|
+
const got = handle.reference;
|
|
6598
|
+
if (got?.kind === reserved.kind && got.id === reserved.id)
|
|
6599
|
+
return handle;
|
|
6600
|
+
// A live seat now exists under a reference no durable row names, and the rollback will reap the
|
|
6601
|
+
// RESERVED one, which addresses nothing. `handle.stop` cannot close this: it is fire-and-forget
|
|
6602
|
+
// over the seat socket and swallows its own errors, so it proves nothing about the process. Reap
|
|
6603
|
+
// the reference the runtime ACTUALLY used — that path verifies identity and proves the exit —
|
|
6604
|
+
// and carry what the proof said into the throw, so a mismatch never reports a disposal it did
|
|
6605
|
+
// not make.
|
|
6606
|
+
let disposal;
|
|
6607
|
+
if (got === undefined) {
|
|
6608
|
+
try {
|
|
6609
|
+
handle.stop({ graceful: false });
|
|
6610
|
+
}
|
|
6611
|
+
catch { /* reported below */ }
|
|
6612
|
+
disposal = "the handle carries no reference, so nothing could reap it by identity and only a best-effort stop was issued";
|
|
6613
|
+
}
|
|
6614
|
+
else {
|
|
6615
|
+
try {
|
|
6616
|
+
const evidence = await requireRuntimeReap(this.runtime, got);
|
|
6617
|
+
disposal = `the spawned seat was reaped (${evidence.outcome === "absent" ? "already forgotten by the runtime" : evidence.detail})`;
|
|
6618
|
+
}
|
|
6619
|
+
catch (e) {
|
|
6620
|
+
disposal = `the spawned seat could NOT be reaped: ${e.message}`;
|
|
6621
|
+
}
|
|
6622
|
+
}
|
|
6623
|
+
throw new Error(`runtime "${this.runtime.kind}" reserved custody ${reserved.kind}:${reserved.id} for "${name}" but spawned ` +
|
|
6624
|
+
`${got ? `${got.kind}:${got.id}` : "a handle with no reference"}; the recorded reference would address no seat, so ${disposal}`);
|
|
6625
|
+
}
|
|
6626
|
+
/** The terminal's process step: an orphan (no live handle in THIS process) is reaped by the custody
|
|
6627
|
+
* reference the slot recorded, before its footprint goes. A successor must never retire a
|
|
6628
|
+
* lifecycle and free its alias while the predecessor's seat process is still running outside
|
|
6629
|
+
* every manager. The runtime verifies the process identity against its own record and proves the
|
|
6630
|
+
* exit; a runtime that cannot reap refuses by name and the lifecycle stays terminalizing. A row
|
|
6631
|
+
* with no recorded reference has nothing this manager can address. */
|
|
6632
|
+
async reapOrphanSeat(a) {
|
|
6633
|
+
if (a.runtime === undefined) {
|
|
6634
|
+
// No reference reached this terminal. Under a custodying runtime that is either a row written
|
|
6635
|
+
// before the reservation existed, or a caller that dropped the reference on the way here.
|
|
6636
|
+
// Do not name the slot row: the row may well hold a reference this caller never read, and
|
|
6637
|
+
// a diagnostic that guesses which is which sends the reader the wrong way.
|
|
6638
|
+
if (isCustodialRuntime(this.runtime))
|
|
6639
|
+
console.error(`static retirement ${a.name}: no custody reference reached this terminal, though runtime "${this.runtime.kind}" custodies its seats; any seat it launched is not addressable from here`);
|
|
6640
|
+
return;
|
|
6641
|
+
}
|
|
6642
|
+
const evidence = await requireRuntimeReap(this.runtime, a.runtime);
|
|
6643
|
+
console.error(`static retirement ${a.name}: orphan seat process ${evidence.outcome === "absent" ? `already forgotten by runtime "${a.runtime.kind}" (${a.runtime.id})` : evidence.detail}`);
|
|
6644
|
+
}
|
|
6244
6645
|
/** The static F1 terminal for one departed incarnation (Unit B): delegates the gate/head CAS
|
|
6245
6646
|
* sequence to the shared core saga over the executor transport; the footprint teardown (creds
|
|
6246
6647
|
* file + broker durables/ACL) runs INSIDE the barrier as its cleanup step. On completion the
|
|
@@ -6263,6 +6664,13 @@ export class Manager {
|
|
|
6263
6664
|
log: (line) => console.error(`static retirement ${a.name}: ${line}`),
|
|
6264
6665
|
});
|
|
6265
6666
|
const cleanup = async () => {
|
|
6667
|
+
// SPEC 13.15: every issuance bound to this incarnation's ledger family is retired here,
|
|
6668
|
+
// inside the barrier and after the ledger rows were revoked (the source is frozen first,
|
|
6669
|
+
// then the index is walked). A retirement that lost is surfaced, never skipped: an active
|
|
6670
|
+
// attempt row on a retired lifecycle would authorize a run the credential no longer can.
|
|
6671
|
+
const retired = await this.withIssuer((i) => i.store.retireSource({ space: this.space, bucket: epAuthBucket(this.space), key: `cred.${a.lifecycleUid}` }));
|
|
6672
|
+
if (retired > 0)
|
|
6673
|
+
console.error(`static retirement ${a.name}: retired ${retired} issuance(s) bound to uid ${a.lifecycleUid}`);
|
|
6266
6674
|
const secrets = this.secrets;
|
|
6267
6675
|
const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
|
|
6268
6676
|
if (files.creds) {
|
|
@@ -6271,19 +6679,24 @@ export class Manager {
|
|
|
6271
6679
|
}
|
|
6272
6680
|
await this.deprovisionBroker(a);
|
|
6273
6681
|
};
|
|
6682
|
+
// The process goes before its footprint: reap the orphan seat by reference, then tear down.
|
|
6683
|
+
const reapThenCleanup = async () => {
|
|
6684
|
+
await this.reapOrphanSeat(a);
|
|
6685
|
+
await cleanup();
|
|
6686
|
+
};
|
|
6274
6687
|
try {
|
|
6275
6688
|
await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
|
|
6276
6689
|
const slot = await readStaticSlot(t, DEV_OWNER, a.name);
|
|
6277
6690
|
if (slot === undefined || slot.row.lifecycleUid !== a.lifecycleUid) {
|
|
6278
6691
|
// No durable registration for THIS incarnation: a pre-Unit-B spawn (or a slot already
|
|
6279
6692
|
// replaced by a successor — then this stale teardown must not touch the registry at all).
|
|
6280
|
-
await
|
|
6693
|
+
await reapThenCleanup();
|
|
6281
6694
|
return;
|
|
6282
6695
|
}
|
|
6283
6696
|
await runStaticTerminal(t, {
|
|
6284
6697
|
owner: DEV_OWNER, alias: a.name, actor: a.id, lifecycleUid: a.lifecycleUid, opId,
|
|
6285
6698
|
managerInstance: this.managerInstanceId, managerProcessUid: this.managerLifecycleUid,
|
|
6286
|
-
}, { cleanup, evict, log: (line) => console.error(`static retirement ${a.name}: ${line}`) });
|
|
6699
|
+
}, { cleanup: reapThenCleanup, evict, log: (line) => console.error(`static retirement ${a.name}: ${line}`) });
|
|
6287
6700
|
});
|
|
6288
6701
|
this.retiredPrincipals.add(principalKey(DEV_OWNER, a.id).key);
|
|
6289
6702
|
const cur = this.retiring.get(a.name);
|
|
@@ -6327,19 +6740,22 @@ export class Manager {
|
|
|
6327
6740
|
// The SAME permission scope the spawn minted (recorded on the managed row): allowSubscribe/
|
|
6328
6741
|
// allowPublish/role/capabilities are the JWT-shaping inputs; `subscribe` (the active read
|
|
6329
6742
|
// set) shapes durable membership only and is not a mint input.
|
|
6330
|
-
|
|
6743
|
+
if (!a.issued)
|
|
6744
|
+
throw new Error(`renewManagedStaticCred: ${a.name} carries no issuance; a static credential minted before SPEC 13.15 is not renewed under an unbound generation - respawn the agent`);
|
|
6745
|
+
// A RENEWAL keeps the generation (SPEC 13.15): the same JWT-shaping inputs produce the same
|
|
6746
|
+
// ceiling, which `confirm` verifies against the recorded evidence before the ledger append. A
|
|
6747
|
+
// changed ceiling is refused there: that is a fresh issuance on a new connection, never a renewal.
|
|
6748
|
+
const issued = a.issued;
|
|
6749
|
+
const creds = await this.withIssuer((i) => mintCreds(this.auth, { id: a.id, seed: a.seed }, "agent", {
|
|
6331
6750
|
allowSubscribe: a.launch.allowSubscribe,
|
|
6332
6751
|
allowPublish: a.launch.allowPublish,
|
|
6333
6752
|
role: a.role,
|
|
6334
6753
|
capabilities: a.launch.capabilities,
|
|
6335
6754
|
lifecycleUid: a.lifecycleUid,
|
|
6336
6755
|
expiresAt: exp,
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
await recordSlotCredential(t, DEV_OWNER, a.name, a.lifecycleUid, credentialId);
|
|
6341
|
-
await appendStaticCredentialRow(t, { lifecycleUid: a.lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, a.id).key, exp });
|
|
6342
|
-
});
|
|
6756
|
+
issued,
|
|
6757
|
+
issuance: this.issuanceSeam(i, "renew", a, exp),
|
|
6758
|
+
}));
|
|
6343
6759
|
const secrets = this.secrets;
|
|
6344
6760
|
const credsPath = a.secretPaths.creds;
|
|
6345
6761
|
await secrets.put(agentSecretKeyForFile(credsPath, this.space), creds);
|
|
@@ -6505,7 +6921,7 @@ export class Manager {
|
|
|
6505
6921
|
this.reconcilingAliases.add(row.alias);
|
|
6506
6922
|
console.error(`static reconcile terminal alias=${row.alias} phase=${row.phase} uid=${row.lifecycleUid} attempt=${item.attempts}/${item.maxAttempts}`);
|
|
6507
6923
|
try {
|
|
6508
|
-
await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid }, true);
|
|
6924
|
+
await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid, runtime: row.runtime }, true);
|
|
6509
6925
|
if (item.attempts === 1) {
|
|
6510
6926
|
this.staticReconcileItems.delete(key);
|
|
6511
6927
|
}
|
|
@@ -6799,12 +7215,14 @@ export class Manager {
|
|
|
6799
7215
|
/** Persist a peer-defined persona as config. After this, `start name` auto-discovers
|
|
6800
7216
|
* .cotal/agents/<name>.md and the connector applies its persona/model at spawn.
|
|
6801
7217
|
*
|
|
6802
|
-
* CONTENT vs POLICY (P6):
|
|
6803
|
-
*
|
|
6804
|
-
*
|
|
6805
|
-
*
|
|
6806
|
-
*
|
|
6807
|
-
*
|
|
7218
|
+
* CONTENT vs POLICY (P6): `capabilities` and `owner` still have no slot, so a peer cannot
|
|
7219
|
+
* self-grant spawn or claim ownership. Channel grants, role, and agent are content: they may
|
|
7220
|
+
* arrive as explicit arguments or as a leading frontmatter block inside `persona`. A prompt
|
|
7221
|
+
* that starts with `---` is parsed and merged (explicit args win) so the written file has one
|
|
7222
|
+
* frontmatter block; a malformed block is refused by name rather than wrapped. A fresh name is
|
|
7223
|
+
* created with owner = caller. Redefining an EXISTING file is allowed on the privileged tier
|
|
7224
|
+
* only if `file.owner == caller`, else admin is required. Fail-closed: an ownerless file
|
|
7225
|
+
* (legacy / operator-written) is admin-only. */
|
|
6808
7226
|
opDefinePersona(args, caller, admin) {
|
|
6809
7227
|
const name = String(args.name ?? "").trim();
|
|
6810
7228
|
if (!name)
|
|
@@ -6815,55 +7233,42 @@ export class Manager {
|
|
|
6815
7233
|
const persona = String(args.persona ?? "").trim();
|
|
6816
7234
|
if (!persona)
|
|
6817
7235
|
return { ok: false, error: "persona required" };
|
|
6818
|
-
const model = args.model ? String(args.model) : undefined;
|
|
7236
|
+
const model = args.model !== undefined && args.model !== null && String(args.model) !== "" ? String(args.model) : undefined;
|
|
6819
7237
|
const path = agentFilePath(this.workspaceRoot, name);
|
|
6820
|
-
let
|
|
7238
|
+
let existing;
|
|
6821
7239
|
if (existsSync(path)) {
|
|
6822
|
-
// Redefine: load, authorize by ownership, then overwrite ONLY content; preserve all policy.
|
|
6823
7240
|
try {
|
|
6824
|
-
|
|
7241
|
+
existing = loadAgentFile(path);
|
|
6825
7242
|
}
|
|
6826
7243
|
catch (e) {
|
|
6827
7244
|
return { ok: false, error: e.message };
|
|
6828
7245
|
}
|
|
6829
|
-
if (!admin &&
|
|
6830
|
-
const owner =
|
|
7246
|
+
if (!admin && existing.owner !== caller) {
|
|
7247
|
+
const owner = existing.owner ? `owned by ${existing.owner}` : "operator-owned (legacy file - no agent owner)";
|
|
6831
7248
|
return { ok: false, error: `not authorized to redefine ${name}: ${owner}; only its owner or an operator can` };
|
|
6832
7249
|
}
|
|
6833
|
-
// PATCH content: overwrite model only when provided, so a persona-only redefine can't wipe an existing model.
|
|
6834
|
-
if (model !== undefined)
|
|
6835
|
-
def.model = model;
|
|
6836
|
-
def.persona = persona;
|
|
6837
|
-
// A redefine cannot change scope, so a file that still has no channels keeps its marker. But
|
|
6838
|
-
// if an operator has since given it a real read set, the marker is a stale claim about a state
|
|
6839
|
-
// that no longer holds, and a marker that outlives its condition is worse than none: it tells
|
|
6840
|
-
// a census the scope was never chosen when someone chose it.
|
|
6841
|
-
//
|
|
6842
|
-
// The condition is the READ SET specifically, not any channel field. `allowSubscribe` alone is
|
|
6843
|
-
// a ceiling on what the agent may read, not the set it reads, so a persona given only that
|
|
6844
|
-
// still reads nothing and the marker still describes it correctly.
|
|
6845
|
-
if (def.meta?.scope_source === "wire-default" && def.subscribe?.length) {
|
|
6846
|
-
const { scope_source: _dropped, ...rest } = def.meta;
|
|
6847
|
-
def.meta = Object.keys(rest).length ? rest : undefined;
|
|
6848
|
-
}
|
|
6849
7250
|
}
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
7251
|
+
const strList = (v) => {
|
|
7252
|
+
if (v === undefined || v === null)
|
|
7253
|
+
return undefined;
|
|
7254
|
+
return (Array.isArray(v) ? v : [v]).map((x) => String(x));
|
|
7255
|
+
};
|
|
7256
|
+
let def;
|
|
7257
|
+
try {
|
|
7258
|
+
def = composeWirePersona({
|
|
7259
|
+
name,
|
|
7260
|
+
owner: caller,
|
|
7261
|
+
prompt: persona,
|
|
7262
|
+
model,
|
|
7263
|
+
role: args.role !== undefined && args.role !== null && String(args.role) !== "" ? String(args.role) : undefined,
|
|
7264
|
+
agent: args.agent !== undefined && args.agent !== null && String(args.agent) !== "" ? String(args.agent) : undefined,
|
|
7265
|
+
subscribe: strList(args.subscribe),
|
|
7266
|
+
allowSubscribe: strList(args.allowSubscribe),
|
|
7267
|
+
allowPublish: strList(args.allowPublish),
|
|
7268
|
+
}, existing);
|
|
7269
|
+
}
|
|
7270
|
+
catch (e) {
|
|
7271
|
+
return { ok: false, error: e.message };
|
|
6867
7272
|
}
|
|
6868
7273
|
try {
|
|
6869
7274
|
saveAgentFile(path, def);
|
|
@@ -6968,7 +7373,7 @@ export class Manager {
|
|
|
6968
7373
|
*
|
|
6969
7374
|
* `enter` defaults to true: a harness command typed but not submitted has not been delivered.
|
|
6970
7375
|
* Nothing is echoed back; the caller reads the resulting turns from the event plane. */
|
|
6971
|
-
inputAuthorized(a, args) {
|
|
7376
|
+
async inputAuthorized(a, args) {
|
|
6972
7377
|
if (this.agents.get(a.name) !== a)
|
|
6973
7378
|
throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry`);
|
|
6974
7379
|
if (a.handle.status() !== "running")
|
|
@@ -6980,8 +7385,17 @@ export class Manager {
|
|
|
6980
7385
|
// the only decision left is the carriage return. `!== false` and not `?? true`: an ABSENT enter
|
|
6981
7386
|
// and an explicit `true` must behave identically, and only `false` may suppress the return.
|
|
6982
7387
|
const data = `${String(args.text)}${args.enter !== false ? "\r" : ""}`;
|
|
6983
|
-
|
|
6984
|
-
|
|
7388
|
+
const intendedBytes = Buffer.byteLength(data, "utf8");
|
|
7389
|
+
let bytes;
|
|
7390
|
+
try {
|
|
7391
|
+
bytes = await write(data);
|
|
7392
|
+
}
|
|
7393
|
+
catch (error) {
|
|
7394
|
+
throw new EpEnvelopeError("unavailable", `input for seat "${a.name}" failed: ${error.message}`);
|
|
7395
|
+
}
|
|
7396
|
+
if (!Number.isSafeInteger(bytes) || bytes !== intendedBytes)
|
|
7397
|
+
throw new EpEnvelopeError("unavailable", `input for seat "${a.name}" failed: runtime accepted ${String(bytes)} of ${intendedBytes} bytes`);
|
|
7398
|
+
return { name: a.name, bytes };
|
|
6985
7399
|
}
|
|
6986
7400
|
/** The post-authorization attach effect (P2 item 6): mint the holder-bound §13.6 offer, redeem it
|
|
6987
7401
|
* through the ONE session plane (one-use CAS + presenter-equality), and stand up the PTY bridge —
|
|
@@ -7075,6 +7489,14 @@ export class Manager {
|
|
|
7075
7489
|
* owner-domain bound); undefined = unbounded. {@link NO_OWNER_MATCHES} matches nothing. */
|
|
7076
7490
|
list(ownerFilter) {
|
|
7077
7491
|
const roster = new Map(this.ep.getRoster().map((p) => [p.card.name, p]));
|
|
7492
|
+
// The roster is only evidence while THIS observer's presence watch is fresh. A stale view
|
|
7493
|
+
// (whole-bucket silence past TTL) or an unpopulated one (snapshot not yet replayed) cannot
|
|
7494
|
+
// support "offline" or "absent" for anyone: netcup 2026-09-09 rendered every live seat as
|
|
7495
|
+
// one of those for hours after the presence stream was recreated under a still-open watch.
|
|
7496
|
+
// Carry the view state on the row so the renderer can say "unknown" instead of a verdict.
|
|
7497
|
+
// An endpoint that reports no view (test doubles built on `getRoster` alone) is read as
|
|
7498
|
+
// `current`: that is exactly what every row meant before the field existed.
|
|
7499
|
+
const view = typeof this.ep.presenceView === "function" ? this.ep.presenceView() : { state: "current" };
|
|
7078
7500
|
return [...this.agents.values()].filter((a) => ownerFilter === undefined || a.userOwner === ownerFilter).map((a) => {
|
|
7079
7501
|
// USER MODE: a detached agent's bearer-refresh death is silent everywhere except here — its
|
|
7080
7502
|
// bearer command writes each attempt's outcome to the health file, and `ps` renders it
|
|
@@ -7096,6 +7518,9 @@ export class Manager {
|
|
|
7096
7518
|
status: a.handle.status(),
|
|
7097
7519
|
uptimeMs: Date.now() - a.startedAt,
|
|
7098
7520
|
mesh: roster.get(a.name)?.status ?? "absent",
|
|
7521
|
+
// `current` is the only state in which `mesh` is a verdict; the other two are the
|
|
7522
|
+
// observer's own condition and travel on the row (older CLIs ignore the field).
|
|
7523
|
+
meshView: view.state,
|
|
7099
7524
|
// The incarnation coordinate (SPEC 13.1) — with `id`, exactly what a v0.4 caller needs to
|
|
7100
7525
|
// build a targeted (`despawn`/`attach`) request against THIS incarnation.
|
|
7101
7526
|
lifecycleUid: a.lifecycleUid,
|