@cotal-ai/manager 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands.js +44 -6
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/manager-service-contract.d.ts +4 -0
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +4 -0
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +168 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +556 -97
- package/dist/manager.js.map +1 -1
- package/dist/remote-authority.d.ts +20 -1
- package/dist/remote-authority.d.ts.map +1 -1
- package/dist/remote-authority.js +109 -1
- package/dist/remote-authority.js.map +1 -1
- package/package.json +10 -10
package/dist/manager.js
CHANGED
|
@@ -4,7 +4,7 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
5
5
|
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
7
|
-
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, divergentSecretStoreNotice, parseDaemonStoreAnswer, 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";
|
|
7
|
+
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, 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
8
|
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
9
|
import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
|
|
10
10
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
@@ -109,7 +109,7 @@ const STARTUP_RECONCILING = "startup static lifecycle reconciliation";
|
|
|
109
109
|
* broker/eviction blip; the wider later spacing avoids hammering a persistently unavailable
|
|
110
110
|
* authority. The finite ~36s window is intentionally not a liveness inference: exhausting it
|
|
111
111
|
* leaves the durable alias held and requires a later manager start. */
|
|
112
|
-
const STATIC_RECONCILE_RETRY_DELAYS_MS = [1_000, 5_000, 30_000];
|
|
112
|
+
export const STATIC_RECONCILE_RETRY_DELAYS_MS = [1_000, 5_000, 30_000];
|
|
113
113
|
const STATIC_RECONCILE_MAX_ATTEMPTS = STATIC_RECONCILE_RETRY_DELAYS_MS.length + 1;
|
|
114
114
|
/** The STABLE retirement opId for one lifecycle (#29 piece 3): deterministic from the uid, so a
|
|
115
115
|
* despawn retry, a same-name-spawn nudge, and the auth service's boot resume all drive the SAME
|
|
@@ -505,8 +505,20 @@ export class Manager {
|
|
|
505
505
|
/** A renew is in flight. The tick must not start a second one: both would read the same cached
|
|
506
506
|
* revision, so whichever lands second CASes against a sequence the first already moved. */
|
|
507
507
|
leaseRenewInFlight = false;
|
|
508
|
+
/** The promise a currently in-flight `renewLease()` is running, so `stop()` can await it before
|
|
509
|
+
* releasing the key instead of racing it: a renew that lands after the cached revision was read
|
|
510
|
+
* for release would otherwise move the row out from under a same-cached-revision CAS delete. */
|
|
511
|
+
leaseRenewTask;
|
|
512
|
+
/** Set at the top of `stop()`. `renewLease` returns at once once this is set, and its `gone` arm
|
|
513
|
+
* does not re-acquire: a manager that is stopping never puts its own key back. */
|
|
514
|
+
leaseStopping = false;
|
|
508
515
|
/** The class-2 renewal owner's half-TTL schedule (D5 slice 5); armed only on auth meshes. */
|
|
509
516
|
credRenewTimer;
|
|
517
|
+
/** #2073: the `endpoint-serve` credential's OWN renewal, armed from its `[renewAt, exp)` window
|
|
518
|
+
* (`credsRenewalDelayMs`) rather than left to `credRenewTimer`'s boot-phased TTL/4 tick, which can
|
|
519
|
+
* miss the window by ordering. Re-armed at registration and after every successful re-mint;
|
|
520
|
+
* cleared in `stop()` beside `credRenewTimer`. */
|
|
521
|
+
endpointServeRenewalTimer;
|
|
510
522
|
/** #1634: does THIS manager own the delivery daemon's credential renewal? True only while the
|
|
511
523
|
* daemon reloads from this manager's store AND this manager holds the per-space renewal lease.
|
|
512
524
|
* Starts false: a manager that has not proved both must not remint. */
|
|
@@ -632,17 +644,42 @@ export class Manager {
|
|
|
632
644
|
* write it to. The operator reading the refusal is on the manager's mesh, never on the record's. A
|
|
633
645
|
* boolean parameter is how that happened, so the mode is read from `this` and a third door cannot
|
|
634
646
|
* pass the wrong one.
|
|
647
|
+
*
|
|
648
|
+
* The selector below reads the three-valued mesh mode (`open | static | user`), not a boolean
|
|
649
|
+
* derived from it (#567). `this.userMode` is `false` for BOTH static and open, so a boolean
|
|
650
|
+
* selector collapses those two and an open mesh was handed the static route (`cotal mint
|
|
651
|
+
* --provision`), which `mint` refuses outright on an open mesh. A boolean also has no third
|
|
652
|
+
* state to invert, so a mutation grid over `return this.userMode ? … : …` proves the one arm it
|
|
653
|
+
* has is load-bearing but is structurally blind to a missing arm: there is no mutant that turns
|
|
654
|
+
* "two arms" into "three arms". Switching on `meshMode` gives a fourth mesh mode nowhere to fall
|
|
655
|
+
* silently, since `switch` has no default arm to catch it.
|
|
635
656
|
*/
|
|
657
|
+
get meshMode() {
|
|
658
|
+
if (this.userMode)
|
|
659
|
+
return "user";
|
|
660
|
+
if (this.auth !== undefined)
|
|
661
|
+
return "static";
|
|
662
|
+
return "open";
|
|
663
|
+
}
|
|
636
664
|
readerRemedy(owner, channel) {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
665
|
+
switch (this.meshMode) {
|
|
666
|
+
case "user":
|
|
667
|
+
return (`\`cotal actor grant <reader> --owner ${owner} --scope '' --allow-subscribe '${channel}' ` +
|
|
668
|
+
`--allow-publish ''\`, with every field spelled out: \`actor grant\` is an upsert of the ` +
|
|
669
|
+
`WHOLE row and an omitted flag means the WIDE default (\`>\` read, \`>\` post, \`spawn,role:default\` ` +
|
|
670
|
+
`scope), not "leave it alone".`);
|
|
671
|
+
case "static":
|
|
672
|
+
return (`\`cotal mint <reader> --profile agent --allow-subscribe ${channel} --provision\`, where there ` +
|
|
673
|
+
`is no actor ledger for \`actor grant\` to write to. The AGENT profile, not the observer ` +
|
|
674
|
+
`one: \`mint\` reads --allow-subscribe only for that profile, so an observer mint is ` +
|
|
675
|
+
`refused outright and writes no creds file.`);
|
|
676
|
+
case "open":
|
|
677
|
+
return (`there is nothing to grant on an open mesh: it has no credentials and no ACLs, so any peer ` +
|
|
678
|
+
`may read \`${channel}\` by connecting bare and listing that channel (\`cotal join\` or an ` +
|
|
679
|
+
`endpoint whose channels include it); this refusal stands because the own-channel rule ` +
|
|
680
|
+
`applies to every mesh, and a spawn is not the place to hand out a read on another agent's ` +
|
|
681
|
+
`tool inputs and outputs.`);
|
|
682
|
+
}
|
|
646
683
|
}
|
|
647
684
|
start() {
|
|
648
685
|
if (this.startTask)
|
|
@@ -1048,6 +1085,50 @@ export class Manager {
|
|
|
1048
1085
|
}
|
|
1049
1086
|
await this.keepDaemonRenewalLease();
|
|
1050
1087
|
}
|
|
1088
|
+
/** #2073: arm the `endpoint-serve` credential's own renewal from its `[renewAt, exp)` window
|
|
1089
|
+
* instead of `credRenewTimer`'s boot-phased TTL/4 tick, which can miss that window depending on
|
|
1090
|
+
* when the timer happened to start relative to the credential's `iat`. Called after the initial
|
|
1091
|
+
* mint (`registerManagerService`) and after every successful re-mint in the serve branch below,
|
|
1092
|
+
* so the schedule always tracks the CURRENT credential. `credsRenewalDelayMs` is the same 75%
|
|
1093
|
+
* convention the endpoint's delivery renewal and the membership feed use (`@cotal-ai/core`); no
|
|
1094
|
+
* second copy of that arithmetic lives here. */
|
|
1095
|
+
scheduleServeRenewal(creds) {
|
|
1096
|
+
if (this.endpointServeRenewalTimer)
|
|
1097
|
+
clearTimeout(this.endpointServeRenewalTimer);
|
|
1098
|
+
const delay = Math.max(1_000, credsRenewalDelayMs(creds));
|
|
1099
|
+
const renewAt = new Date(Date.now() + delay).toISOString();
|
|
1100
|
+
this.endpointServeRenewalTimer = setTimeout(() => { void this.renewDaemonCreds(); }, delay);
|
|
1101
|
+
this.endpointServeRenewalTimer.unref?.();
|
|
1102
|
+
console.error(`manager endpoint-serve renewal scheduled at ${renewAt}`);
|
|
1103
|
+
}
|
|
1104
|
+
/** #2073: push a freshly re-minted credential onto its LIVE connection instead of waiting for a
|
|
1105
|
+
* reconnect the expiry itself would force two dials too late. `nc.reconnect()` re-evaluates the
|
|
1106
|
+
* authenticator, which reads the mutable `creds` holder on every attempt, so the new CONNECT
|
|
1107
|
+
* presents the fresh credential without re-registration. In-flight requests on the connection
|
|
1108
|
+
* fail once, on the reconnect. Best-effort, matching `CotalEndpoint.swapConnectionOntoFreshCreds`:
|
|
1109
|
+
* a connection that is already closed (e.g. mid re-dial after a fault) cannot reconnect, and that
|
|
1110
|
+
* failure is not this call's to report. */
|
|
1111
|
+
async pushRenewedCreds(nc) {
|
|
1112
|
+
await nc.reconnect().catch(() => { });
|
|
1113
|
+
}
|
|
1114
|
+
/** #2073: the shared bounded-retry shape for a closed standing connection: wait out each of
|
|
1115
|
+
* {@link STATIC_RECONCILE_RETRY_DELAYS_MS}, trying `attempt` after each. A stop landing while
|
|
1116
|
+
* this loop waits is not a fault this loop reports (the caller re-checks `leaseStopping`). */
|
|
1117
|
+
async boundedReconnect(label, attempt) {
|
|
1118
|
+
for (const delayMs of STATIC_RECONCILE_RETRY_DELAYS_MS) {
|
|
1119
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
1120
|
+
if (this.leaseStopping)
|
|
1121
|
+
return true;
|
|
1122
|
+
try {
|
|
1123
|
+
if (await attempt())
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
catch (e) {
|
|
1127
|
+
console.error(`! manager ${label} re-dial attempt failed: ${e.message}`);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1051
1132
|
/** One class-2 renewal pass (D5 slice 5): re-sign `.cotal/delivery.creds` + `.cotal/membership-rw.creds`
|
|
1052
1133
|
* for their existing nkeys, then request the delivery daemon's EXPLICIT `reloadCreds` adoption on the
|
|
1053
1134
|
* delivery-admin rail and persist the audit record (`.cotal/renewal.<spaceKey>.json`) that `cotal doctor auth`
|
|
@@ -1158,6 +1239,12 @@ export class Manager {
|
|
|
1158
1239
|
serveIssuance: serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId }),
|
|
1159
1240
|
endpointServe: s.grant,
|
|
1160
1241
|
}));
|
|
1242
|
+
// #2073: a superseded instance's re-mint fails the gate CAS above and lands in the
|
|
1243
|
+
// catch below, not here — this re-mint already re-checks the issuance gate through
|
|
1244
|
+
// the SAME executor a fresh registration would use, so there is no second epoch read
|
|
1245
|
+
// to add before re-binding after a re-dial.
|
|
1246
|
+
await this.pushRenewedCreds(s.nc);
|
|
1247
|
+
this.scheduleServeRenewal(s.creds);
|
|
1161
1248
|
}
|
|
1162
1249
|
}
|
|
1163
1250
|
catch (e) {
|
|
@@ -1176,6 +1263,7 @@ export class Manager {
|
|
|
1176
1263
|
const fresh = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageGoalWriter(authKv));
|
|
1177
1264
|
this.goalWriterCreds = fresh;
|
|
1178
1265
|
gw.creds = fresh;
|
|
1266
|
+
await this.pushRenewedCreds(gw.nc);
|
|
1179
1267
|
}
|
|
1180
1268
|
}
|
|
1181
1269
|
catch (e) {
|
|
@@ -1197,6 +1285,7 @@ export class Manager {
|
|
|
1197
1285
|
const fresh = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageSessionLedger(authKv));
|
|
1198
1286
|
this.sessionLedgerCreds = fresh;
|
|
1199
1287
|
sw.creds = fresh;
|
|
1288
|
+
await this.pushRenewedCreds(sw.nc);
|
|
1200
1289
|
}
|
|
1201
1290
|
}
|
|
1202
1291
|
catch (e) {
|
|
@@ -1717,6 +1806,7 @@ export class Manager {
|
|
|
1717
1806
|
throw new Error(`manager preservation shutdown incomplete: ${failures.join("; ")}`);
|
|
1718
1807
|
}
|
|
1719
1808
|
async stop(options = {}) {
|
|
1809
|
+
this.leaseStopping = true;
|
|
1720
1810
|
this.staticReconcileStopping = true;
|
|
1721
1811
|
const starting = this.startTask;
|
|
1722
1812
|
for (const item of this.staticReconcileItems.values()) {
|
|
@@ -1731,10 +1821,16 @@ export class Manager {
|
|
|
1731
1821
|
clearInterval(this.leaseTimer);
|
|
1732
1822
|
if (this.credRenewTimer)
|
|
1733
1823
|
clearInterval(this.credRenewTimer);
|
|
1824
|
+
if (this.endpointServeRenewalTimer)
|
|
1825
|
+
clearTimeout(this.endpointServeRenewalTimer);
|
|
1734
1826
|
if (this.daemonRenewalLeaseTimer)
|
|
1735
1827
|
clearInterval(this.daemonRenewalLeaseTimer);
|
|
1736
1828
|
if (this.sessionKeyRenewTimer)
|
|
1737
1829
|
clearInterval(this.sessionKeyRenewTimer);
|
|
1830
|
+
// A renew already in flight when the timer was cleared above must be awaited, not raced: it can
|
|
1831
|
+
// still move the key's revision after we clear the timer, and releasing against a cached
|
|
1832
|
+
// revision it has since moved is a CAS the broker correctly refuses, silently, forever.
|
|
1833
|
+
await this.leaseRenewTask?.catch(() => { });
|
|
1738
1834
|
let releaseFailures = [];
|
|
1739
1835
|
if (this.maintenanceState === "active" && !this.resumeRequired) {
|
|
1740
1836
|
if (options.withAgents)
|
|
@@ -1746,7 +1842,7 @@ export class Manager {
|
|
|
1746
1842
|
// A signal after a partial preservation must never fall back into destructive teardown.
|
|
1747
1843
|
await this.stopRetainedAgentsOnExit();
|
|
1748
1844
|
}
|
|
1749
|
-
await this.
|
|
1845
|
+
await this.releaseOwnManagerLease();
|
|
1750
1846
|
// #1634: hand the renewal lease back on a clean stop so a sibling picks it up on its next pass
|
|
1751
1847
|
// rather than waiting out the bucket TTL. A non-owner holds no revision and this is a no-op.
|
|
1752
1848
|
await this.ep.releaseDaemonRenewalLease();
|
|
@@ -1769,6 +1865,30 @@ export class Manager {
|
|
|
1769
1865
|
if (releaseFailures.length)
|
|
1770
1866
|
throw new Error(`manager shutdown could not release every detachable seat: ${releaseFailures.join("; ")}`);
|
|
1771
1867
|
}
|
|
1868
|
+
/** Release this instance's liveness lease key on a clean stop, at the broker's own revision
|
|
1869
|
+
* rather than a cached one that a renew still in flight moments ago may have already moved past.
|
|
1870
|
+
* Re-reads the row and releases only what is PROVABLY this process's (absent, or another pid's,
|
|
1871
|
+
* is left alone — the same test {@link reconcileLease} uses). Nothing here throws: a stop must
|
|
1872
|
+
* complete either way, and a row that could not be released is left for the bucket TTL, with one
|
|
1873
|
+
* line of warning so the cost (a same-root restart waiting out the TTL) is not silent. */
|
|
1874
|
+
async releaseOwnManagerLease() {
|
|
1875
|
+
try {
|
|
1876
|
+
const own = await this.ep.readOwnManagerLease(this.managerInstanceId);
|
|
1877
|
+
if (own !== undefined && own.info.pid === process.pid)
|
|
1878
|
+
await this.ep.releaseManagerLease(this.managerInstanceId, own.revision);
|
|
1879
|
+
}
|
|
1880
|
+
catch {
|
|
1881
|
+
// Best-effort, like the release it wraps: a broker failure here is recovered by the bucket TTL.
|
|
1882
|
+
}
|
|
1883
|
+
try {
|
|
1884
|
+
const stillOurs = await this.ep.readOwnManagerLease(this.managerInstanceId);
|
|
1885
|
+
if (stillOurs !== undefined && stillOurs.info.pid === process.pid)
|
|
1886
|
+
console.error(`! manager instance ${this.managerInstanceId} could not release its liveness lease (space "${this.space}"); a same-root restart inside the bucket TTL will be refused`);
|
|
1887
|
+
}
|
|
1888
|
+
catch {
|
|
1889
|
+
// Could not confirm either way; the warning is best-effort, not a verdict.
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1772
1892
|
/**
|
|
1773
1893
|
* REMOVE THIS INSTANCE'S SERVICE REGISTRATION on a clean stop (§13.5 deregistration).
|
|
1774
1894
|
*
|
|
@@ -1860,12 +1980,25 @@ export class Manager {
|
|
|
1860
1980
|
* on a live mesh turned a ten-second broker or KV outage into a manager outage that lasted until an
|
|
1861
1981
|
* operator noticed. */
|
|
1862
1982
|
async renewLease() {
|
|
1983
|
+
// A manager that is stopping never puts its own key back: a stop already fenced this loop, and
|
|
1984
|
+
// any tick or explicit call landing after that must be a no-op, not a fresh acquire attempt.
|
|
1985
|
+
if (this.leaseStopping)
|
|
1986
|
+
return;
|
|
1863
1987
|
// A renew that runs long must not be overlapped by the next tick: both would CAS against the same
|
|
1864
1988
|
// cached revision, so whichever lands second is refused over a sequence the first legitimately
|
|
1865
1989
|
// moved, a conflict this instance manufactured itself.
|
|
1866
1990
|
if (this.leaseRenewInFlight)
|
|
1867
1991
|
return;
|
|
1868
1992
|
this.leaseRenewInFlight = true;
|
|
1993
|
+
const task = this.renewLeaseAttempt();
|
|
1994
|
+
this.leaseRenewTask = task;
|
|
1995
|
+
return task;
|
|
1996
|
+
}
|
|
1997
|
+
/** The renew attempt itself, split out from {@link renewLease} only so `stop()` can hold the
|
|
1998
|
+
* promise a renew already in flight is running ({@link leaseRenewTask}) and await it before
|
|
1999
|
+
* releasing the key: releasing against a cached revision a live renew has since moved is a CAS
|
|
2000
|
+
* the broker correctly refuses, silently, forever. */
|
|
2001
|
+
async renewLeaseAttempt() {
|
|
1869
2002
|
try {
|
|
1870
2003
|
if (!this.leaseInfo || this.leaseRevision === undefined)
|
|
1871
2004
|
return;
|
|
@@ -1885,6 +2018,11 @@ export class Manager {
|
|
|
1885
2018
|
this.noteLease("held-unrenewed", `could not renew its liveness lease (${why}) but the key is still its own at revision ${verdict.revision}; serving, retrying`);
|
|
1886
2019
|
return;
|
|
1887
2020
|
case "gone": {
|
|
2021
|
+
// A stop that began while this renew was already in flight fences it here too: the
|
|
2022
|
+
// await above can cross the point where `stop()` set the flag, and a re-acquire past
|
|
2023
|
+
// that point would put a stopping process's key straight back.
|
|
2024
|
+
if (this.leaseStopping)
|
|
2025
|
+
return;
|
|
1888
2026
|
// The key being gone is a fact before the re-acquire is attempted, so a successful
|
|
1889
2027
|
// re-acquire is a change of state (gone to held) and prints, every time it happens.
|
|
1890
2028
|
const before = this.leaseState;
|
|
@@ -2328,7 +2466,12 @@ export class Manager {
|
|
|
2328
2466
|
resolveCwd: (ctx) => this.serveGated(ctx, () => this.resolveSpawnCwd(args(ctx).cwd)),
|
|
2329
2467
|
// P2 item 2: `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
|
|
2330
2468
|
// progress + terminal off-handler (no ~30s block). The blocking reply path is gone (pin 8).
|
|
2331
|
-
|
|
2469
|
+
// #373: arming the event plane (events: true, or the omission that arms it by default)
|
|
2470
|
+
// is operator reach on a user mesh — the spawn handler resolves the caller's admin tier
|
|
2471
|
+
// ONCE here (opLaunch takes the same signal as a parameter) and opStart's admission gate
|
|
2472
|
+
// decides on it BEFORE connector resolution or any grant mutation. Static meshes resolve
|
|
2473
|
+
// true and nothing changes.
|
|
2474
|
+
spawn: (ctx) => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route))),
|
|
2332
2475
|
despawn: (ctx) => this.serveGated(ctx, async () => {
|
|
2333
2476
|
const a = targetAgent(ctx);
|
|
2334
2477
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
@@ -2635,6 +2778,8 @@ export class Manager {
|
|
|
2635
2778
|
* dead auth plane. Every failure is returned as the refusal sentence, with the grant + files
|
|
2636
2779
|
* rolled back. */
|
|
2637
2780
|
async provisionUserAgent(name, opts) {
|
|
2781
|
+
if (this.remoteAuthority?.enrollManagedAgent)
|
|
2782
|
+
return this.enrollUserAgent(name, opts, this.remoteAuthority.enrollManagedAgent);
|
|
2638
2783
|
const spawnerPr = opts.spawner ? parsePrincipalKey(opts.spawner) : null;
|
|
2639
2784
|
const owner = opts.specOwner ?? (spawnerPr && spawnerPr.owner.startsWith("u_") ? spawnerPr.owner : undefined);
|
|
2640
2785
|
if (!owner)
|
|
@@ -2713,7 +2858,7 @@ export class Manager {
|
|
|
2713
2858
|
"--health-file", healthPath,
|
|
2714
2859
|
];
|
|
2715
2860
|
await execBearerPreflight(bearerCmd);
|
|
2716
|
-
return { owner, files, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
|
|
2861
|
+
return { owner, lifecycleUid: opts.lifecycleUid, files, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
|
|
2717
2862
|
}
|
|
2718
2863
|
catch (e) {
|
|
2719
2864
|
// Roll back everything this attempt materialized — a refused spawn must leave no standing
|
|
@@ -2730,6 +2875,111 @@ export class Manager {
|
|
|
2730
2875
|
return { error: `agent auth preflight failed for "${name}": ${e.message}` };
|
|
2731
2876
|
}
|
|
2732
2877
|
}
|
|
2878
|
+
/**
|
|
2879
|
+
* HOSTED user-mode spawn provisioning (#1972): the participant arm of host-owned enrollment.
|
|
2880
|
+
*
|
|
2881
|
+
* A registered participant holds no ledger writer, no JetStream provisioner, and no signing seed,
|
|
2882
|
+
* so every step the local arm above performs itself is the HOST's here. What stays local is the
|
|
2883
|
+
* one thing that must: the standing `actorToken`. This method generates it, writes it at 0600
|
|
2884
|
+
* BEFORE the request, and sends only its SHA-256 digest — so the plaintext never crosses the
|
|
2885
|
+
* network and a host compromise cannot replay an agent's exchange secret.
|
|
2886
|
+
*
|
|
2887
|
+
* The HOST picks the lifecycle UID. It alone can see the retirement tombstones that make a UID
|
|
2888
|
+
* permanently unusable (a retired uid is refused forever by the retirement frontier), so a
|
|
2889
|
+
* participant-chosen uid could aim a fresh grant at a dead incarnation. The uid the host returns
|
|
2890
|
+
* is the one the spawn records and the one the child's durables are keyed by.
|
|
2891
|
+
*
|
|
2892
|
+
* The bearer argv selects `--exchange-url`: the local `--dir` arm reads a state directory this
|
|
2893
|
+
* machine does not have, and a signerless participant must never appear to hold one.
|
|
2894
|
+
*/
|
|
2895
|
+
async enrollUserAgent(name, opts, enroll) {
|
|
2896
|
+
let provider;
|
|
2897
|
+
try {
|
|
2898
|
+
provider = resolveAuthProvider();
|
|
2899
|
+
}
|
|
2900
|
+
catch (e) {
|
|
2901
|
+
return { error: e.message };
|
|
2902
|
+
}
|
|
2903
|
+
const secrets = this.secrets;
|
|
2904
|
+
// The token the agent will present to `POST /exchange` for the rest of its life. Generated
|
|
2905
|
+
// here, hashed here, and never sent: the request carries `tokenHash` alone.
|
|
2906
|
+
const actorToken = randomBytes(32).toString("base64url");
|
|
2907
|
+
const tokenHash = createHash("sha256").update(actorToken, "utf8").digest("hex");
|
|
2908
|
+
// Provisional paths under the participant's OWN uid: the token has to be on disk before the
|
|
2909
|
+
// request (a host that granted against a digest whose plaintext was lost would leave an agent
|
|
2910
|
+
// that can never exchange), and the host's uid is not known until the request answers. The
|
|
2911
|
+
// family is re-keyed to the host's uid below.
|
|
2912
|
+
const staged = agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, name, opts.lifecycleUid);
|
|
2913
|
+
let files = staged;
|
|
2914
|
+
let material;
|
|
2915
|
+
try {
|
|
2916
|
+
await secrets.put(agentSecretKeyForFile(staged.actorToken, this.space), actorToken);
|
|
2917
|
+
await materializeSecretToFile(secrets, agentSecretKeyForFile(staged.actorToken, this.space), staged.actorToken);
|
|
2918
|
+
material = await enroll({
|
|
2919
|
+
target: {
|
|
2920
|
+
actor: name,
|
|
2921
|
+
tokenHash,
|
|
2922
|
+
...(opts.role !== undefined ? { role: opts.role } : {}),
|
|
2923
|
+
label: opts.label,
|
|
2924
|
+
...(opts.capabilities !== undefined ? { capabilities: opts.capabilities } : {}),
|
|
2925
|
+
...(opts.subscribe !== undefined ? { subscribe: opts.subscribe } : {}),
|
|
2926
|
+
allowSubscribe: opts.allowSubscribe,
|
|
2927
|
+
...(opts.allowPublish !== undefined ? { allowPublish: opts.allowPublish } : {}),
|
|
2928
|
+
},
|
|
2929
|
+
});
|
|
2930
|
+
if (material.actor !== name)
|
|
2931
|
+
throw new Error(`the host enrolled actor "${material.actor}", not the requested "${name}"`);
|
|
2932
|
+
const specOwner = opts.specOwner ?? (opts.spawner ? parsePrincipalKey(opts.spawner)?.owner : undefined);
|
|
2933
|
+
if (specOwner !== undefined && specOwner.startsWith("u_") && material.owner !== specOwner)
|
|
2934
|
+
throw new Error(`the host enrolled the agent under owner ${material.owner}, not the spawning owner ${specOwner}`);
|
|
2935
|
+
assertLifecycleToken(material.lifecycleUid, "host-enrolled managed agent lifecycleUid");
|
|
2936
|
+
// Re-key the family onto the HOST's uid (SPEC 13.1 name-disjointness on the FS): the child's
|
|
2937
|
+
// credential paths must embed the uid its broker footprint carries, never the provisional one.
|
|
2938
|
+
files = agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, name, material.lifecycleUid);
|
|
2939
|
+
if (files.actorToken !== staged.actorToken) {
|
|
2940
|
+
await secrets.put(agentSecretKeyForFile(files.actorToken, this.space), actorToken);
|
|
2941
|
+
await materializeSecretToFile(secrets, agentSecretKeyForFile(files.actorToken, this.space), files.actorToken);
|
|
2942
|
+
await secrets.delete(agentSecretKeyForFile(staged.actorToken, this.space)).catch(() => { });
|
|
2943
|
+
rmSync(staged.actorToken, { force: true });
|
|
2944
|
+
}
|
|
2945
|
+
await secrets.put(agentSecretKeyForFile(files.sentinelCreds, this.space), material.sentinelCreds);
|
|
2946
|
+
await materializeSecretToFile(secrets, agentSecretKeyForFile(files.sentinelCreds, this.space), files.sentinelCreds);
|
|
2947
|
+
rmSync(files.health, { force: true }); // a fresh start opens a fresh health window
|
|
2948
|
+
const bearerCmd = [
|
|
2949
|
+
process.execPath,
|
|
2950
|
+
...process.execArgv,
|
|
2951
|
+
process.argv[1],
|
|
2952
|
+
provider.agentBearerCommand,
|
|
2953
|
+
// The signerless arm: the pinned public exchange, never a local state dir.
|
|
2954
|
+
"--exchange-url", material.agentBearerExchangeUrl,
|
|
2955
|
+
"--space", this.space,
|
|
2956
|
+
"--owner", material.owner,
|
|
2957
|
+
"--actor", name,
|
|
2958
|
+
"--token-file", files.actorToken,
|
|
2959
|
+
"--health-file", files.health,
|
|
2960
|
+
];
|
|
2961
|
+
await execBearerPreflight(bearerCmd);
|
|
2962
|
+
return {
|
|
2963
|
+
owner: material.owner,
|
|
2964
|
+
lifecycleUid: material.lifecycleUid,
|
|
2965
|
+
files,
|
|
2966
|
+
launch: { owner: material.owner, actor: name, sentinelCredsPath: files.sentinelCreds, bearerCmd },
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
catch (e) {
|
|
2970
|
+
// Shred every local secret this attempt materialized. The HOST's rows are the host's to
|
|
2971
|
+
// reconcile: a participant holds no writer, so it must not pretend to roll back a grant or a
|
|
2972
|
+
// durable it never authored. A retry with the same requestId re-enters the same enrollment.
|
|
2973
|
+
for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files]) {
|
|
2974
|
+
await secrets.delete(agentSecretKeyForFile(family.actorToken, this.space)).catch(() => { });
|
|
2975
|
+
await secrets.delete(agentSecretKeyForFile(family.sentinelCreds, this.space)).catch(() => { });
|
|
2976
|
+
rmSync(family.actorToken, { force: true });
|
|
2977
|
+
rmSync(family.sentinelCreds, { force: true });
|
|
2978
|
+
rmSync(family.health, { force: true });
|
|
2979
|
+
}
|
|
2980
|
+
return { error: `agent auth preflight failed for "${name}": ${e.message}` };
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2733
2983
|
/** Drop a live agent's slot. When `floor` is set and the agent died young (lived less than
|
|
2734
2984
|
* MIN_LIFETIME), push a cooling stamp so the freed slot still counts toward the ceiling until it
|
|
2735
2985
|
* expires — flooring the RECYCLE, not the call, so both free paths (despawn + exit/reap) are
|
|
@@ -3374,9 +3624,14 @@ export class Manager {
|
|
|
3374
3624
|
* uncertain black hole. */
|
|
3375
3625
|
liveRosterNames() {
|
|
3376
3626
|
const live = new Set();
|
|
3377
|
-
for (const p of this.ep.getRoster())
|
|
3378
|
-
if (p.status
|
|
3379
|
-
|
|
3627
|
+
for (const p of this.ep.getRoster()) {
|
|
3628
|
+
if (p.status === "offline")
|
|
3629
|
+
continue;
|
|
3630
|
+
const predecessor = this.confirmedRetiredPredecessors.get(p.card.name);
|
|
3631
|
+
if (predecessor !== undefined && predecessor.principal === p.card.id && predecessor.lifecycleUid === p.lifecycleUid)
|
|
3632
|
+
continue;
|
|
3633
|
+
live.add(p.card.name);
|
|
3634
|
+
}
|
|
3380
3635
|
return live;
|
|
3381
3636
|
}
|
|
3382
3637
|
/** THE single name-liveness predicate both the hard-pinned collision refuse (M6, P2 item 2) and
|
|
@@ -3395,7 +3650,9 @@ export class Manager {
|
|
|
3395
3650
|
* choice at allocation, never an authority check (the broker still enforces): a stale
|
|
3396
3651
|
* still-live-looking row only costs a numbered suffix, and a missed freshly-joined occupant is
|
|
3397
3652
|
* still refused downstream exactly as before. Offline rows do NOT occupy — a properly retired
|
|
3398
|
-
* name stays reusable.
|
|
3653
|
+
* name stays reusable. A row this manager itself confirmed retired (its alias, principal, and
|
|
3654
|
+
* lifecycleUid all matching a {@link confirmedRetiredPredecessors} entry) does not occupy
|
|
3655
|
+
* either, even while its advisory presence record ages out. */
|
|
3399
3656
|
uniqueName(base) {
|
|
3400
3657
|
const live = this.liveRosterNames();
|
|
3401
3658
|
return firstFreeName(base, (n) => this.nameInUse(n, live));
|
|
@@ -3421,7 +3678,7 @@ export class Manager {
|
|
|
3421
3678
|
return false;
|
|
3422
3679
|
}
|
|
3423
3680
|
/** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
|
|
3424
|
-
opStart(args, caller, hooks, route = "inst") {
|
|
3681
|
+
opStart(args, caller, admin, hooks, route = "inst") {
|
|
3425
3682
|
// `resume`, when present, must be a non-empty session id. An empty/whitespace value is a
|
|
3426
3683
|
// malformed request, not an implicit "spawn fresh" (no fallbacks). The CLI surfaces reject it,
|
|
3427
3684
|
// but a raw control message could otherwise slip an empty value through and silently start fresh.
|
|
@@ -3468,6 +3725,31 @@ export class Manager {
|
|
|
3468
3725
|
catch (e) {
|
|
3469
3726
|
return Promise.resolve({ ok: false, error: e.message });
|
|
3470
3727
|
}
|
|
3728
|
+
// #373: the event-plane admission gate. `startAgent` resolves `eventsRequired || opts.events
|
|
3729
|
+
// !== false`, so omission ARMS the plane — a gate on the explicit bit alone is bypassed by
|
|
3730
|
+
// omitting it. The gate is on the DECISION, and it runs before connector resolution and
|
|
3731
|
+
// before `startAgent` (which mints credentials), so a refusal provisions nothing.
|
|
3732
|
+
const requestedEvents = typeof args.events === "boolean" ? args.events : undefined;
|
|
3733
|
+
let eventsNotice;
|
|
3734
|
+
let events = requestedEvents;
|
|
3735
|
+
if (!admin) {
|
|
3736
|
+
if (requestedEvents === true)
|
|
3737
|
+
return Promise.resolve({
|
|
3738
|
+
ok: false,
|
|
3739
|
+
error: `spawn is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2): events: arming the event plane needs the admin tier`,
|
|
3740
|
+
});
|
|
3741
|
+
if (this.eventsRequired)
|
|
3742
|
+
return Promise.resolve({
|
|
3743
|
+
ok: false,
|
|
3744
|
+
error: `spawn is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2): space "${this.space}" requires the event plane by registration policy, which cannot be met without the admin tier`,
|
|
3745
|
+
});
|
|
3746
|
+
// Omission arms by default in startAgent; serve it DISARMED and say so (never a silent
|
|
3747
|
+
// downgrade). An explicit `false` is already the opt-out — served as-is, no notice.
|
|
3748
|
+
if (requestedEvents === undefined) {
|
|
3749
|
+
events = false;
|
|
3750
|
+
eventsNotice = `event plane not armed: arming it on spawn needs the admin tier; the spawn was served with events: false`;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3471
3753
|
return this.startAgent({
|
|
3472
3754
|
name: String(args.name ?? "").trim(),
|
|
3473
3755
|
agent: args.agent ? String(args.agent) : undefined,
|
|
@@ -3479,7 +3761,8 @@ export class Manager {
|
|
|
3479
3761
|
variant: args.variant ? String(args.variant) : undefined,
|
|
3480
3762
|
launchOptions: args.launchOptions,
|
|
3481
3763
|
resume: args.resume ? String(args.resume) : undefined,
|
|
3482
|
-
events
|
|
3764
|
+
events,
|
|
3765
|
+
eventsNotice,
|
|
3483
3766
|
cwd: args.cwd ? String(args.cwd) : undefined,
|
|
3484
3767
|
prompt: args.prompt ? String(args.prompt) : undefined,
|
|
3485
3768
|
subscribe,
|
|
@@ -3490,33 +3773,40 @@ export class Manager {
|
|
|
3490
3773
|
route,
|
|
3491
3774
|
}, caller, hooks);
|
|
3492
3775
|
}
|
|
3493
|
-
/**
|
|
3494
|
-
*
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
throw new EpEnvelopeError("failed-precondition", `cwd does not resolve on this host: ${JSON.stringify(asked)} (${reason})`);
|
|
3499
|
-
};
|
|
3500
|
-
if (!isAbsolute(asked))
|
|
3501
|
-
refuse("expected an absolute path");
|
|
3502
|
-
let cwd = asked;
|
|
3776
|
+
/** The realpath + directory checks a placed spawn directory must pass on this manager's host,
|
|
3777
|
+
* shared by the served `resolve-cwd` command and the `spawn` admission check (#385 item 2): one
|
|
3778
|
+
* function, so the two doors never drift. Creates nothing and never substitutes the workspace root. */
|
|
3779
|
+
checkSpawnCwd(resolved) {
|
|
3780
|
+
let cwd = resolved;
|
|
3503
3781
|
try {
|
|
3504
|
-
cwd = realpathSync(
|
|
3782
|
+
cwd = realpathSync(resolved);
|
|
3505
3783
|
}
|
|
3506
3784
|
catch (error) {
|
|
3507
|
-
|
|
3785
|
+
return { refusal: `path cannot be resolved: ${error.message}` };
|
|
3508
3786
|
}
|
|
3509
3787
|
try {
|
|
3510
3788
|
if (!statSync(cwd).isDirectory())
|
|
3511
|
-
|
|
3789
|
+
return { refusal: "path is not a directory" };
|
|
3512
3790
|
}
|
|
3513
3791
|
catch (error) {
|
|
3514
|
-
|
|
3515
|
-
throw error;
|
|
3516
|
-
refuse(error.message);
|
|
3792
|
+
return { refusal: error.message };
|
|
3517
3793
|
}
|
|
3518
3794
|
return { cwd, host: hostname() };
|
|
3519
3795
|
}
|
|
3796
|
+
/** Resolve a placed spawn directory on this manager's host. The manager serves any existing
|
|
3797
|
+
* absolute directory it can resolve. It creates nothing and never substitutes the workspace root. */
|
|
3798
|
+
resolveSpawnCwd(value) {
|
|
3799
|
+
const asked = typeof value === "string" ? value : String(value ?? "");
|
|
3800
|
+
const refuse = (reason) => {
|
|
3801
|
+
throw new EpEnvelopeError("failed-precondition", `cwd does not resolve on this host: ${JSON.stringify(asked)} (${reason})`);
|
|
3802
|
+
};
|
|
3803
|
+
if (!isAbsolute(asked))
|
|
3804
|
+
refuse("expected an absolute path");
|
|
3805
|
+
const result = this.checkSpawnCwd(asked);
|
|
3806
|
+
if ("refusal" in result)
|
|
3807
|
+
return refuse(result.refusal);
|
|
3808
|
+
return result;
|
|
3809
|
+
}
|
|
3520
3810
|
/** Resolve a connector by agent type. Library composition (installedExtensions off) → a registry
|
|
3521
3811
|
* hit, exactly as before (the composition root imported what it wants). The published binary gates
|
|
3522
3812
|
* on MANIFEST membership FIRST — so a live `cotal ext remove` is honored even though the registry
|
|
@@ -3868,6 +4158,20 @@ export class Manager {
|
|
|
3868
4158
|
if (this.userMode)
|
|
3869
4159
|
return { ok: false, error: "supervise is a restart policy this host cannot enforce: a user-mode seat has no static slot to keep the incarnation owned across a process death" };
|
|
3870
4160
|
}
|
|
4161
|
+
// A placed spawn's cwd is checked on this host BEFORE anything is minted or reserved: an absolute
|
|
4162
|
+
// path this manager cannot realpath, or one that is not a directory, is refused here with the
|
|
4163
|
+
// asked path, the reason and this host, so a misplaced spawn in a multi-manager space is
|
|
4164
|
+
// diagnosable at the moment it happens (#385 item 2) instead of failing inside the pty launch.
|
|
4165
|
+
// The resolved realpath is threaded to the launch cwd below (`resolvedCwd`) so it is computed once.
|
|
4166
|
+
let resolvedCwd;
|
|
4167
|
+
if (typeof opts.cwd === "string" && opts.cwd !== "") {
|
|
4168
|
+
const asked = opts.cwd;
|
|
4169
|
+
const target = resolve(this.workspaceRoot, asked);
|
|
4170
|
+
const checked = this.checkSpawnCwd(target);
|
|
4171
|
+
if ("refusal" in checked)
|
|
4172
|
+
return { ok: false, error: `spawn: cwd does not resolve on this host (${hostname()}): ${JSON.stringify(asked)} (${checked.refusal}); the seat was not launched` };
|
|
4173
|
+
resolvedCwd = checked.cwd;
|
|
4174
|
+
}
|
|
3871
4175
|
// Resolve the launch profile: IDENTITY (free-form `name:`) + role + read/post ACL + capabilities
|
|
3872
4176
|
// + model/variant. Either from a fully-resolved manifest launch object (`opts.resolved`, whose `config`
|
|
3873
4177
|
// is a materialized transient persona — the file is NOT the access authority), or from the
|
|
@@ -3975,6 +4279,8 @@ export class Manager {
|
|
|
3975
4279
|
}
|
|
3976
4280
|
if (variant && !connector.supportsModelVariant)
|
|
3977
4281
|
return { ok: false, error: `${agent} connector does not support model variants (variant)` };
|
|
4282
|
+
if (prompt !== undefined && !connector.supportsPrompt)
|
|
4283
|
+
return { ok: false, error: `${agent} connector does not support an initial prompt (prompt)` };
|
|
3978
4284
|
// #4 A4 (panel): the roster the allocation consults must reflect the initial presence snapshot,
|
|
3979
4285
|
// or a spawn immediately after manager boot races an already-live unmanaged peer and re-opens the
|
|
3980
4286
|
// very collision black-hole this closes. Await the snapshot (bounded internally, fail-safe on an
|
|
@@ -4057,7 +4363,12 @@ export class Manager {
|
|
|
4057
4363
|
// The incarnation's lifecycle UID (SPEC 13.1), minted ONCE per spawn: every lifecycle-keyed
|
|
4058
4364
|
// broker resource (dm_/dlv_/chathist_ durables, ACL row, memberships) and the teardown
|
|
4059
4365
|
// credential carry it, so a same-name successor's footprint is name-disjoint by construction.
|
|
4060
|
-
|
|
4366
|
+
//
|
|
4367
|
+
// On the HOSTED enrollment arm (#1972) this value is PROVISIONAL. The host selects the
|
|
4368
|
+
// authoritative uid, because it alone reads the retirement tombstones that make a uid
|
|
4369
|
+
// permanently unusable, and the enrollment below rebinds this binding to what the host
|
|
4370
|
+
// returned before anything durable is keyed by it.
|
|
4371
|
+
let lifecycleUid = mintLifecycleUid();
|
|
4061
4372
|
// Reserve the seat's custody reference NOW, before any durable row and long before the
|
|
4062
4373
|
// launch. Minting it inside `runtime.spawn` (as it used to be) put the processes on disk
|
|
4063
4374
|
// before anything recorded how to address them, so a crash in that window left a live seat
|
|
@@ -4144,6 +4455,12 @@ export class Manager {
|
|
|
4144
4455
|
this.reserved.delete(name);
|
|
4145
4456
|
return { ok: false, error: prep.error };
|
|
4146
4457
|
}
|
|
4458
|
+
// THE HOST'S UID WINS (#1972). On the local arm this is the value passed in, unchanged. On
|
|
4459
|
+
// the hosted enrollment arm the host selected it, and from here every lifecycle-keyed thing
|
|
4460
|
+
// this spawn records — the slot, the launch, the managed row, the teardown credential — must
|
|
4461
|
+
// carry that value and not the provisional one, or the manager would address a footprint the
|
|
4462
|
+
// host never created.
|
|
4463
|
+
lifecycleUid = prep.lifecycleUid;
|
|
4147
4464
|
userLaunch = prep.launch;
|
|
4148
4465
|
userOwner = prep.owner;
|
|
4149
4466
|
provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, secretPaths: prep.files, ...(custody ? { runtime: custody } : {}) };
|
|
@@ -4206,7 +4523,9 @@ export class Manager {
|
|
|
4206
4523
|
// Per-agent cwd overrides the manager's shared workspace root, so agents can be rooted at
|
|
4207
4524
|
// arbitrary folders/repos. A relative path resolves against the workspace root; omitted → the
|
|
4208
4525
|
// agent shares the workspace root (the prior, unchanged behavior).
|
|
4209
|
-
|
|
4526
|
+
// The realpath the admission check above already resolved (#385 item 2) — never re-resolved here,
|
|
4527
|
+
// so the admission refusal and the launch cwd can never disagree on the same request.
|
|
4528
|
+
const cwd = resolvedCwd ?? this.workspaceRoot;
|
|
4210
4529
|
const configSha256 = this.fileDigest(configPath);
|
|
4211
4530
|
const manifestPath = opts.launchRef
|
|
4212
4531
|
? join(this.workspaceRoot, ".cotal", "run", `${opts.launchRef.runId}.json`)
|
|
@@ -4289,6 +4608,9 @@ export class Manager {
|
|
|
4289
4608
|
cwd,
|
|
4290
4609
|
model,
|
|
4291
4610
|
variant,
|
|
4611
|
+
// The kickoff prompt is part of the launch form: a stale-restart re-submits it, so the
|
|
4612
|
+
// recorded launch must carry it for the restart gate (and preserve) to see it.
|
|
4613
|
+
prompt,
|
|
4292
4614
|
subscribe,
|
|
4293
4615
|
allowSubscribe,
|
|
4294
4616
|
allowPublish,
|
|
@@ -4384,7 +4706,7 @@ export class Manager {
|
|
|
4384
4706
|
// canonicalJson (undefined never coerces to null, SPEC 13.6), so a role-less spawn would
|
|
4385
4707
|
// otherwise fail its succeeded terminal. The CLI/connector already render an absent role as
|
|
4386
4708
|
// "no role", so dropping the key preserves the reply (P2 item 2, surfaced by readiness:live).
|
|
4387
|
-
const okData = { name, agent, id: managed.id, mode: handle.kind, lifecycleUid, ...(role !== undefined ? { role } : {}) };
|
|
4709
|
+
const okData = { name, agent, id: managed.id, mode: handle.kind, lifecycleUid, ...(role !== undefined ? { role } : {}), ...(opts.eventsNotice !== undefined ? { eventsNotice: opts.eventsNotice } : {}) };
|
|
4388
4710
|
await hooks?.onOutcome?.({ kind: "succeeded", data: okData });
|
|
4389
4711
|
return { ok: true, data: okData };
|
|
4390
4712
|
}
|
|
@@ -4735,6 +5057,8 @@ export class Manager {
|
|
|
4735
5057
|
return { ok: false, error: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found` };
|
|
4736
5058
|
if (entry.launch.variant && !connector.supportsModelVariant)
|
|
4737
5059
|
return { ok: false, error: `${connector.name} connector does not support model variants (variant)` };
|
|
5060
|
+
if (entry.launch.prompt !== undefined && !connector.supportsPrompt)
|
|
5061
|
+
return { ok: false, error: `${connector.name} connector does not support an initial prompt (prompt)` };
|
|
4738
5062
|
let retainedSession;
|
|
4739
5063
|
try {
|
|
4740
5064
|
retainedSession = this.retainedSessionId(entry, connector);
|
|
@@ -5361,6 +5685,104 @@ export class Manager {
|
|
|
5361
5685
|
}
|
|
5362
5686
|
console.error(`✓ boot self-heal: ${MANAGER_ENDPOINT}/${instanceId} registration gate reopened at generation ${report.reopenedAtGeneration} (processEpoch ${report.after.processEpoch}, registrationRevision ${report.after.registrationRevision}; freeze-holder gone, sweepComplete=true). Continuing the normal takeover.`);
|
|
5363
5687
|
}
|
|
5688
|
+
/** #2073: dial the manager's ONE control-plane connection and serve its typed command surface on
|
|
5689
|
+
* it, exactly the shape `registerManagerService` used inline before this lane. Shared by the
|
|
5690
|
+
* initial registration AND the closed-connection recovery path ({@link onServeConnectionClosed}),
|
|
5691
|
+
* so there is one place that dials and serves this endpoint. Attaches {@link
|
|
5692
|
+
* onServeConnectionClosed} as the close handler in both cases: a closed serve connection is
|
|
5693
|
+
* always a fault this manager acts on, never a bare log line. */
|
|
5694
|
+
async dialServeConnection(state) {
|
|
5695
|
+
const enc = new TextEncoder();
|
|
5696
|
+
const nc = await this.dial({
|
|
5697
|
+
// Open mesh: a bare serve connection (no credential exists; the broker enforces nothing).
|
|
5698
|
+
...(state.creds !== undefined ? { authenticator: (nonce) => credsAuthenticator(enc.encode(state.creds))(nonce) } : {}),
|
|
5699
|
+
inboxPrefix: `_INBOX_${state.identity.id}`,
|
|
5700
|
+
maxReconnectAttempts: -1,
|
|
5701
|
+
});
|
|
5702
|
+
nc.closed().then((err) => { void this.onServeConnectionClosed(err ?? undefined); });
|
|
5703
|
+
let handle;
|
|
5704
|
+
try {
|
|
5705
|
+
// The 1b typed surface + the derived `describe`. The descriptor stays PUBLIC in static
|
|
5706
|
+
// mode: the broker grant (who holds each command's request-publish row) is the
|
|
5707
|
+
// load-bearing authority tier, and a static single-operator mesh leaks nothing by listing
|
|
5708
|
+
// command names; the trusted per-caller `view(caller)` scoping joins the user-mode
|
|
5709
|
+
// registration follow-up (where actorScope is the trusted source). Every ordinary handler
|
|
5710
|
+
// runs the SHARED admission chokepoint ({@link serveGated}).
|
|
5711
|
+
handle = serveEndpoint(nc, this.space, state.grant, this.managerServiceDefs(), { public: true }, {
|
|
5712
|
+
// The FRESH target resolver (§13.3) for the targeted commands (`despawn`/`attach`): the
|
|
5713
|
+
// manager's live managed set IS the current-mapping authority for its own agents (the
|
|
5714
|
+
// durable slot rows mirror it). Static mode carries no mapping-revision dimension, so
|
|
5715
|
+
// the revision is the constant 0 — a caller that pins a revision pins 0.
|
|
5716
|
+
resolveTarget: (t) => {
|
|
5717
|
+
if (t.owner === DEV_OWNER) {
|
|
5718
|
+
for (const a of this.agents.values())
|
|
5719
|
+
if (!a.userOwner && a.id === t.actor)
|
|
5720
|
+
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5721
|
+
return undefined;
|
|
5722
|
+
}
|
|
5723
|
+
const key = principalKey(t.owner, t.actor).key;
|
|
5724
|
+
for (const a of this.agents.values())
|
|
5725
|
+
if (a.userOwner && a.id === key)
|
|
5726
|
+
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5727
|
+
return undefined;
|
|
5728
|
+
},
|
|
5729
|
+
...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
|
|
5730
|
+
});
|
|
5731
|
+
}
|
|
5732
|
+
catch (e) {
|
|
5733
|
+
await nc.drain().catch(() => nc.close());
|
|
5734
|
+
throw e;
|
|
5735
|
+
}
|
|
5736
|
+
return { nc, handle };
|
|
5737
|
+
}
|
|
5738
|
+
/** #2073: re-mint the SAME `endpoint-serve` identity through the SAME scoped one-shot executor and
|
|
5739
|
+
* §13.1 mint fence `renewDaemonCreds`'s serve branch uses — never a second copy of that mint. Used
|
|
5740
|
+
* by both the periodic renewal pass and the closed-connection recovery path below, so a
|
|
5741
|
+
* superseded instance's re-mint fails the SAME gate CAS in both places and lands in the exit
|
|
5742
|
+
* path rather than needing a second epoch check. */
|
|
5743
|
+
async remintServeCreds(s) {
|
|
5744
|
+
const authRef = this.auth;
|
|
5745
|
+
return this.withEndpointServeExecutor(({ authKv }) => mintCreds(authRef, s.identity, "endpoint-serve", {
|
|
5746
|
+
serveIssuance: serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId }),
|
|
5747
|
+
endpointServe: s.grant,
|
|
5748
|
+
}));
|
|
5749
|
+
}
|
|
5750
|
+
/** #2073: a closed serve connection is a FAULT the manager acts on, not a log line: this is the
|
|
5751
|
+
* control plane a report showed staying dead for hours while the process kept its liveness
|
|
5752
|
+
* lease and answered nothing (#2073). `stop()` sets `leaseStopping` BEFORE it tears down this
|
|
5753
|
+
* connection, so a close landing from OUR OWN graceful teardown returns here at once. Any other
|
|
5754
|
+
* close re-dials with the current credential (a transport blip needs nothing new), re-mints
|
|
5755
|
+
* through the existing serve branch when the credential itself has expired, and re-serves on the
|
|
5756
|
+
* new connection — bounded by {@link STATIC_RECONCILE_RETRY_DELAYS_MS}. Exhausting every attempt
|
|
5757
|
+
* releases this process's lease and exits loud (without `withAgents`, so seats stay up, the path
|
|
5758
|
+
* `supervise`'s signal handler already uses) so a restart can serve: a manager that holds the
|
|
5759
|
+
* lease and answers nothing is the worst of the three states this issue named, and holding the
|
|
5760
|
+
* connection open under unbounded reconnects (the transport-drop case) never reaches this method
|
|
5761
|
+
* at all — `nc.closed()` does not resolve until the client itself gives up. */
|
|
5762
|
+
async onServeConnectionClosed(err) {
|
|
5763
|
+
if (this.leaseStopping)
|
|
5764
|
+
return;
|
|
5765
|
+
const s = this.serviceServe;
|
|
5766
|
+
console.error(`! manager service endpoint connection closed: ${err?.message ?? "unknown reason"}`);
|
|
5767
|
+
const recovered = await this.boundedReconnect("service endpoint", async () => {
|
|
5768
|
+
if (!s)
|
|
5769
|
+
return false;
|
|
5770
|
+
if (inspectCredHealth(s.creds ?? "").state !== "healthy" && s.creds !== undefined) {
|
|
5771
|
+
s.creds = await this.remintServeCreds(s);
|
|
5772
|
+
this.scheduleServeRenewal(s.creds);
|
|
5773
|
+
}
|
|
5774
|
+
const { nc, handle } = await this.dialServeConnection(s);
|
|
5775
|
+
s.nc = nc;
|
|
5776
|
+
s.handle = handle;
|
|
5777
|
+
console.error(`manager service endpoint re-dialed: ${MANAGER_ENDPOINT}/${this.managerInstanceId} (epoch ${s.grant.epoch})`);
|
|
5778
|
+
return true;
|
|
5779
|
+
});
|
|
5780
|
+
if (this.leaseStopping || recovered)
|
|
5781
|
+
return;
|
|
5782
|
+
console.error(`! manager service endpoint could not be restored for ${this.space}/${MANAGER_ENDPOINT}/${this.managerInstanceId}: releasing the lease and exiting`);
|
|
5783
|
+
await this.stop();
|
|
5784
|
+
process.exit(1);
|
|
5785
|
+
}
|
|
5364
5786
|
/** P2 item 1: register the manager as an ordinary v0.4 `service` endpoint and serve its typed
|
|
5365
5787
|
* command surface on the ep rails - since 1d the manager's ONLY control door. On an AUTH mesh
|
|
5366
5788
|
* the whole credential path is the SAME one an ordinary endpoint traverses (the enforcement
|
|
@@ -5610,50 +6032,16 @@ export class Manager {
|
|
|
5610
6032
|
// without re-registration. Reconnects stay unbounded: the serve rails are this instance's
|
|
5611
6033
|
// registered surface for its whole incarnation.
|
|
5612
6034
|
const state = { handle: undefined, nc: undefined, identity: serveIdentity, grant, creds };
|
|
5613
|
-
const
|
|
5614
|
-
const nc = await this.dial({
|
|
5615
|
-
// Open mesh: a bare serve connection (no credential exists; the broker enforces nothing).
|
|
5616
|
-
...(creds !== undefined ? { authenticator: (nonce) => credsAuthenticator(enc.encode(state.creds))(nonce) } : {}),
|
|
5617
|
-
inboxPrefix: `_INBOX_${serveIdentity.id}`,
|
|
5618
|
-
maxReconnectAttempts: -1,
|
|
5619
|
-
});
|
|
5620
|
-
nc.closed().then((err) => { if (err)
|
|
5621
|
-
console.error(`! manager service endpoint connection closed: ${err.message}`); });
|
|
5622
|
-
try {
|
|
5623
|
-
// The 1b typed surface + the derived `describe`. The descriptor stays PUBLIC in static
|
|
5624
|
-
// mode: the broker grant (who holds each command's request-publish row) is the
|
|
5625
|
-
// load-bearing authority tier, and a static single-operator mesh leaks nothing by listing
|
|
5626
|
-
// command names; the trusted per-caller `view(caller)` scoping joins the user-mode
|
|
5627
|
-
// registration follow-up (where actorScope is the trusted source). Every ordinary handler
|
|
5628
|
-
// runs the SHARED admission chokepoint ({@link serveGated}).
|
|
5629
|
-
state.handle = serveEndpoint(nc, this.space, grant, this.managerServiceDefs(), { public: true }, {
|
|
5630
|
-
// The FRESH target resolver (§13.3) for the targeted commands (`despawn`/`attach`): the
|
|
5631
|
-
// manager's live managed set IS the current-mapping authority for its own agents (the
|
|
5632
|
-
// durable slot rows mirror it). Static mode carries no mapping-revision dimension, so
|
|
5633
|
-
// the revision is the constant 0 — a caller that pins a revision pins 0.
|
|
5634
|
-
resolveTarget: (t) => {
|
|
5635
|
-
if (t.owner === DEV_OWNER) {
|
|
5636
|
-
for (const a of this.agents.values())
|
|
5637
|
-
if (!a.userOwner && a.id === t.actor)
|
|
5638
|
-
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5639
|
-
return undefined;
|
|
5640
|
-
}
|
|
5641
|
-
const key = principalKey(t.owner, t.actor).key;
|
|
5642
|
-
for (const a of this.agents.values())
|
|
5643
|
-
if (a.userOwner && a.id === key)
|
|
5644
|
-
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5645
|
-
return undefined;
|
|
5646
|
-
},
|
|
5647
|
-
...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
|
|
5648
|
-
});
|
|
5649
|
-
}
|
|
5650
|
-
catch (e) {
|
|
5651
|
-
await nc.drain().catch(() => nc.close());
|
|
5652
|
-
throw e;
|
|
5653
|
-
}
|
|
6035
|
+
const { nc, handle } = await this.dialServeConnection(state);
|
|
5654
6036
|
state.nc = nc;
|
|
6037
|
+
state.handle = handle;
|
|
5655
6038
|
this.serviceServe = state;
|
|
5656
6039
|
console.error(`manager service endpoint registered: ${MANAGER_ENDPOINT}/${iid} (epoch ${grant.epoch}, registrationRevision ${grant.registrationRevision})`);
|
|
6040
|
+
// #2073: arm the serve credential's own renewal now, from ITS window, instead of leaving it to
|
|
6041
|
+
// `credRenewTimer`'s boot-phased TTL/4 tick. Open mesh mints no credential (`creds` is
|
|
6042
|
+
// undefined) and has nothing to schedule.
|
|
6043
|
+
if (state.creds !== undefined)
|
|
6044
|
+
this.scheduleServeRenewal(state.creds);
|
|
5657
6045
|
}
|
|
5658
6046
|
/** P2 item 2 must-5 (b): mint the standing `goal-writer` credential and STAGE it into this
|
|
5659
6047
|
* instance's §13.1 revocation family (`epcred.<e>.<iid>`), over the passed executor's `authKv`
|
|
@@ -5701,19 +6089,18 @@ export class Manager {
|
|
|
5701
6089
|
* renewal is adopted without reconnecting the whole endpoint); the ActionContext bonds its
|
|
5702
6090
|
* KV + JS + JSM to this one connection and space (SPEC 13.4), so a composition mixup cannot splice
|
|
5703
6091
|
* goal state across brokers. */
|
|
5704
|
-
|
|
5705
|
-
|
|
6092
|
+
/** #2073: dial the goal-writer's ONE standing connection and rebuild everything derived from it
|
|
6093
|
+
* (the fence-resolving action context, the issuance-gate reader) — shared by the initial start
|
|
6094
|
+
* and the closed-connection recovery ({@link onGoalWriterConnectionClosed}), so a redial never
|
|
6095
|
+
* leaves the context bound to a dead socket. */
|
|
6096
|
+
async dialGoalWriter(gw) {
|
|
5706
6097
|
const enc = new TextEncoder();
|
|
5707
|
-
// The mutable holder captured by the authenticator (mirrors the serve connection): a half-TTL
|
|
5708
|
-
// renewal updates `gw.creds` and the next (re)connect presents the refreshed credential.
|
|
5709
|
-
const gw = { nc: undefined, ctx: undefined, creds: (this.auth || this.remoteAuthority) ? this.goalWriterCreds : undefined, identity };
|
|
5710
6098
|
const nc = await this.dial({
|
|
5711
6099
|
...((this.auth || this.remoteAuthority) ? { authenticator: (nonce) => credsAuthenticator(enc.encode(gw.creds))(nonce) } : {}),
|
|
5712
|
-
inboxPrefix: `_INBOX_${identity.id}`,
|
|
6100
|
+
inboxPrefix: `_INBOX_${gw.identity.id}`,
|
|
5713
6101
|
maxReconnectAttempts: -1,
|
|
5714
6102
|
});
|
|
5715
|
-
nc.closed().then((err) => {
|
|
5716
|
-
console.error(`! manager goal-writer connection closed: ${err.message}`); });
|
|
6103
|
+
nc.closed().then((err) => { void this.onGoalWriterConnectionClosed(err ?? undefined); });
|
|
5717
6104
|
gw.nc = nc;
|
|
5718
6105
|
// The (i) fence resolver (SPEC 13.6 P2 item 3): resolve an executing instance's CURRENT gate
|
|
5719
6106
|
// epoch. This manager reconciles only ITS OWN goals (security pin 4), so it resolves its own
|
|
@@ -5739,6 +6126,40 @@ export class Manager {
|
|
|
5739
6126
|
// rather than closing it. An AUTH mesh gets durable closure from the §13.1 barrier (revoke +
|
|
5740
6127
|
// cluster-verified eviction BEFORE the epoch advance); an open mesh gets none.
|
|
5741
6128
|
gw.gate = serveIssuanceGateKv(await new Kvm(nc).open(epAuthBucket(this.space)), this.space, { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId });
|
|
6129
|
+
}
|
|
6130
|
+
/** #2073: the goal-writer rail's closed-connection fault, the same bounded shape the serve rail
|
|
6131
|
+
* uses ({@link onServeConnectionClosed}) through the same {@link boundedReconnect} helper:
|
|
6132
|
+
* re-dial with the current credential (a transport blip needs nothing new), re-mint through the
|
|
6133
|
+
* existing goal-writer renewal branch when the credential itself has expired, then re-attach.
|
|
6134
|
+
* Unlike the serve rail this never exits the process on exhaustion (brief step 4: only the serve
|
|
6135
|
+
* rail is the control plane the report lost) — it logs and leaves the credential to the next
|
|
6136
|
+
* scheduled renewal pass. */
|
|
6137
|
+
async onGoalWriterConnectionClosed(err) {
|
|
6138
|
+
if (this.leaseStopping)
|
|
6139
|
+
return;
|
|
6140
|
+
console.error(`! manager goal-writer connection closed: ${err?.message ?? "unknown reason"}`);
|
|
6141
|
+
const gw = this.goalWriter;
|
|
6142
|
+
const recovered = await this.boundedReconnect("goal-writer", async () => {
|
|
6143
|
+
if (!gw)
|
|
6144
|
+
return false;
|
|
6145
|
+
if (gw.creds !== undefined && inspectCredHealth(gw.creds).state !== "healthy") {
|
|
6146
|
+
gw.creds = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageGoalWriter(authKv));
|
|
6147
|
+
this.goalWriterCreds = gw.creds;
|
|
6148
|
+
}
|
|
6149
|
+
await this.dialGoalWriter(gw);
|
|
6150
|
+
console.error("manager goal-writer re-dialed");
|
|
6151
|
+
return true;
|
|
6152
|
+
});
|
|
6153
|
+
if (this.leaseStopping || recovered)
|
|
6154
|
+
return;
|
|
6155
|
+
console.error("! manager goal-writer could not be re-dialed; leaving the credential to the next renewal pass");
|
|
6156
|
+
}
|
|
6157
|
+
async startGoalWriter() {
|
|
6158
|
+
const identity = (this.auth || this.remoteAuthority) ? this.goalWriterIdentity : newIdentity();
|
|
6159
|
+
// The mutable holder captured by the authenticator (mirrors the serve connection): a half-TTL
|
|
6160
|
+
// renewal updates `gw.creds` and the next (re)connect presents the refreshed credential.
|
|
6161
|
+
const gw = { nc: undefined, ctx: undefined, creds: (this.auth || this.remoteAuthority) ? this.goalWriterCreds : undefined, identity };
|
|
6162
|
+
await this.dialGoalWriter(gw);
|
|
5742
6163
|
this.goalWriter = gw;
|
|
5743
6164
|
console.error(`manager goal-writer standing (endpoint ${MANAGER_ENDPOINT}, ${(this.auth || this.remoteAuthority) ? "scoped cred, §13.1 family-staged" : "open/bare"})`);
|
|
5744
6165
|
}
|
|
@@ -5806,20 +6227,55 @@ export class Manager {
|
|
|
5806
6227
|
* holder never verifies the signature (it presents the grant back over the rail; the broker's
|
|
5807
6228
|
* per-session caller cred is the holder's real fence). The plane's ledger lives in the DEDICATED
|
|
5808
6229
|
* sessions bucket (createEndpointStreams provisioned it at registration). */
|
|
5809
|
-
|
|
5810
|
-
|
|
6230
|
+
/** #2073: dial the session-ledger's ONE standing connection — shared by the initial start and the
|
|
6231
|
+
* closed-connection recovery ({@link onSessionLedgerConnectionClosed}), the same shape {@link
|
|
6232
|
+
* dialGoalWriter} uses for its rail. The KV/JetStream clients the session plane opened from the
|
|
6233
|
+
* connection at start stay bound to it for this incarnation's life; a redial refreshes the
|
|
6234
|
+
* standing authenticator connection the credential renewal presents onto (mirroring `sw.creds`),
|
|
6235
|
+
* which is what a closed-connection fault on this rail needs restored. */
|
|
6236
|
+
async dialSessionLedgerConnection(sw, identity) {
|
|
5811
6237
|
const enc = new TextEncoder();
|
|
5812
|
-
// The mutable holder captured by the authenticator (mirrors the goal-writer): a half-TTL renewal
|
|
5813
|
-
// updates `sw.creds` and the next (re)connect presents the refreshed credential.
|
|
5814
|
-
const sw = { nc: undefined, creds: (this.auth || this.remoteAuthority) ? this.sessionLedgerCreds : undefined };
|
|
5815
6238
|
const nc = await this.dial({
|
|
5816
6239
|
...((this.auth || this.remoteAuthority) ? { authenticator: (nonce) => credsAuthenticator(enc.encode(sw.creds))(nonce) } : {}),
|
|
5817
6240
|
inboxPrefix: `_INBOX_${identity.id}`,
|
|
5818
6241
|
maxReconnectAttempts: -1,
|
|
5819
6242
|
});
|
|
5820
|
-
nc.closed().then((err) => {
|
|
5821
|
-
console.error(`! manager session-ledger connection closed: ${err.message}`); });
|
|
6243
|
+
nc.closed().then((err) => { void this.onSessionLedgerConnectionClosed(err ?? undefined); });
|
|
5822
6244
|
sw.nc = nc;
|
|
6245
|
+
}
|
|
6246
|
+
/** #2073: the session-ledger rail's closed-connection fault, the same bounded shape {@link
|
|
6247
|
+
* onGoalWriterConnectionClosed} uses: re-dial with the current credential, re-mint through the
|
|
6248
|
+
* existing session-ledger renewal branch when the credential itself has expired, then re-attach.
|
|
6249
|
+
* Exhaustion logs and leaves the credential to the next renewal pass; it never exits the process
|
|
6250
|
+
* (brief step 4: only the serve rail is the control plane the report lost). */
|
|
6251
|
+
async onSessionLedgerConnectionClosed(err) {
|
|
6252
|
+
if (this.leaseStopping)
|
|
6253
|
+
return;
|
|
6254
|
+
console.error(`! manager session-ledger connection closed: ${err?.message ?? "unknown reason"}`);
|
|
6255
|
+
const sw = this.sessionLedgerConn;
|
|
6256
|
+
const identity = this.sessionLedgerIdentity;
|
|
6257
|
+
const recovered = await this.boundedReconnect("session-ledger", async () => {
|
|
6258
|
+
if (!sw || !identity)
|
|
6259
|
+
return false;
|
|
6260
|
+
if (sw.creds !== undefined && inspectCredHealth(sw.creds).state !== "healthy") {
|
|
6261
|
+
sw.creds = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageSessionLedger(authKv));
|
|
6262
|
+
this.sessionLedgerCreds = sw.creds;
|
|
6263
|
+
}
|
|
6264
|
+
await this.dialSessionLedgerConnection(sw, identity);
|
|
6265
|
+
console.error("manager session-ledger re-dialed");
|
|
6266
|
+
return true;
|
|
6267
|
+
});
|
|
6268
|
+
if (this.leaseStopping || recovered)
|
|
6269
|
+
return;
|
|
6270
|
+
console.error("! manager session-ledger could not be re-dialed; leaving the credential to the next renewal pass");
|
|
6271
|
+
}
|
|
6272
|
+
async startSessionPlane() {
|
|
6273
|
+
const identity = (this.auth || this.remoteAuthority) ? this.sessionLedgerIdentity : newIdentity();
|
|
6274
|
+
// The mutable holder captured by the authenticator (mirrors the goal-writer): a half-TTL renewal
|
|
6275
|
+
// updates `sw.creds` and the next (re)connect presents the refreshed credential.
|
|
6276
|
+
const sw = { nc: undefined, creds: (this.auth || this.remoteAuthority) ? this.sessionLedgerCreds : undefined };
|
|
6277
|
+
await this.dialSessionLedgerConnection(sw, identity);
|
|
6278
|
+
const nc = sw.nc;
|
|
5823
6279
|
const serveEpoch = this.serviceServe?.grant.epoch;
|
|
5824
6280
|
if (serveEpoch === undefined)
|
|
5825
6281
|
throw new Error("the manager session plane needs the serve grant epoch; registerManagerService must run first");
|
|
@@ -7052,10 +7508,13 @@ export class Manager {
|
|
|
7052
7508
|
managerInstance: this.managerInstanceId, managerProcessUid: this.managerLifecycleUid,
|
|
7053
7509
|
}, { cleanup: reapThenCleanup, evict, log: (line) => console.error(`static retirement ${a.name}: ${line}`) });
|
|
7054
7510
|
});
|
|
7055
|
-
|
|
7511
|
+
const principal = principalKey(DEV_OWNER, a.id).key;
|
|
7512
|
+
this.retiredPrincipals.add(principal);
|
|
7056
7513
|
const cur = this.retiring.get(a.name);
|
|
7057
|
-
if (cur && cur.lifecycleUid === a.lifecycleUid)
|
|
7514
|
+
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
7058
7515
|
this.retiring.delete(a.name); // ABA-guarded hold clear
|
|
7516
|
+
this.confirmedRetiredPredecessors.set(a.name, { principal, lifecycleUid: a.lifecycleUid });
|
|
7517
|
+
}
|
|
7059
7518
|
}
|
|
7060
7519
|
catch (e) {
|
|
7061
7520
|
const h = this.retiring.get(a.name);
|