@cotal-ai/manager 0.55.0 → 0.56.1
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 +15 -0
- package/dist/commands.js.map +1 -1
- package/dist/manager.d.ts +114 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +372 -93
- package/dist/manager.js.map +1 -1
- package/dist/resume.d.ts.map +1 -1
- package/dist/resume.js +4 -0
- package/dist/resume.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, assertLifecycleToken, 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
|
|
@@ -514,6 +514,11 @@ export class Manager {
|
|
|
514
514
|
leaseStopping = false;
|
|
515
515
|
/** The class-2 renewal owner's half-TTL schedule (D5 slice 5); armed only on auth meshes. */
|
|
516
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;
|
|
517
522
|
/** #1634: does THIS manager own the delivery daemon's credential renewal? True only while the
|
|
518
523
|
* daemon reloads from this manager's store AND this manager holds the per-space renewal lease.
|
|
519
524
|
* Starts false: a manager that has not proved both must not remint. */
|
|
@@ -639,17 +644,64 @@ export class Manager {
|
|
|
639
644
|
* write it to. The operator reading the refusal is on the manager's mesh, never on the record's. A
|
|
640
645
|
* boolean parameter is how that happened, so the mode is read from `this` and a third door cannot
|
|
641
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.
|
|
642
656
|
*/
|
|
657
|
+
get meshMode() {
|
|
658
|
+
if (this.userMode)
|
|
659
|
+
return "user";
|
|
660
|
+
if (this.auth !== undefined)
|
|
661
|
+
return "static";
|
|
662
|
+
return "open";
|
|
663
|
+
}
|
|
664
|
+
/** The CHAT stream frontier at a preservation cut (issue #545), read through whichever local
|
|
665
|
+
* authority this mesh mode holds: open mode's own endpoint already has `$JS.API.STREAM.INFO`
|
|
666
|
+
* on the CHAT stream ({@link supervisorPermissions} grants it there); static/user mode's own
|
|
667
|
+
* supervisor credential does NOT (it holds that grant only on the manager/presence/delivery
|
|
668
|
+
* buckets), so those modes borrow the same ephemeral `provisioner` profile the manager already
|
|
669
|
+
* mints per spawn, which DOES hold it under space auth. A remote-authority manager (`this.auth`
|
|
670
|
+
* undefined in static/user mode) has no local space authority to mint that provisioner with, so
|
|
671
|
+
* it prints one line and returns no floor — the inventory then carries none and resumed seats
|
|
672
|
+
* behave exactly as before this fix, not a silent partial degrade. */
|
|
673
|
+
async chatFrontierForPreservation() {
|
|
674
|
+
switch (this.meshMode) {
|
|
675
|
+
case "open":
|
|
676
|
+
return this.ep.chatFrontier();
|
|
677
|
+
case "static":
|
|
678
|
+
case "user":
|
|
679
|
+
if (!this.auth) {
|
|
680
|
+
console.error("! preservation: this manager holds no local space authority to read the chat frontier; retained seats replay their channel windows on resume");
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
return this.withProvisioner((prov) => prov.chatFrontier());
|
|
684
|
+
}
|
|
685
|
+
}
|
|
643
686
|
readerRemedy(owner, channel) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
687
|
+
switch (this.meshMode) {
|
|
688
|
+
case "user":
|
|
689
|
+
return (`\`cotal actor grant <reader> --owner ${owner} --scope '' --allow-subscribe '${channel}' ` +
|
|
690
|
+
`--allow-publish ''\`, with every field spelled out: \`actor grant\` is an upsert of the ` +
|
|
691
|
+
`WHOLE row and an omitted flag means the WIDE default (\`>\` read, \`>\` post, \`spawn,role:default\` ` +
|
|
692
|
+
`scope), not "leave it alone".`);
|
|
693
|
+
case "static":
|
|
694
|
+
return (`\`cotal mint <reader> --profile agent --allow-subscribe ${channel} --provision\`, where there ` +
|
|
695
|
+
`is no actor ledger for \`actor grant\` to write to. The AGENT profile, not the observer ` +
|
|
696
|
+
`one: \`mint\` reads --allow-subscribe only for that profile, so an observer mint is ` +
|
|
697
|
+
`refused outright and writes no creds file.`);
|
|
698
|
+
case "open":
|
|
699
|
+
return (`there is nothing to grant on an open mesh: it has no credentials and no ACLs, so any peer ` +
|
|
700
|
+
`may read \`${channel}\` by connecting bare and listing that channel (\`cotal join\` or an ` +
|
|
701
|
+
`endpoint whose channels include it); this refusal stands because the own-channel rule ` +
|
|
702
|
+
`applies to every mesh, and a spawn is not the place to hand out a read on another agent's ` +
|
|
703
|
+
`tool inputs and outputs.`);
|
|
704
|
+
}
|
|
653
705
|
}
|
|
654
706
|
start() {
|
|
655
707
|
if (this.startTask)
|
|
@@ -1055,6 +1107,50 @@ export class Manager {
|
|
|
1055
1107
|
}
|
|
1056
1108
|
await this.keepDaemonRenewalLease();
|
|
1057
1109
|
}
|
|
1110
|
+
/** #2073: arm the `endpoint-serve` credential's own renewal from its `[renewAt, exp)` window
|
|
1111
|
+
* instead of `credRenewTimer`'s boot-phased TTL/4 tick, which can miss that window depending on
|
|
1112
|
+
* when the timer happened to start relative to the credential's `iat`. Called after the initial
|
|
1113
|
+
* mint (`registerManagerService`) and after every successful re-mint in the serve branch below,
|
|
1114
|
+
* so the schedule always tracks the CURRENT credential. `credsRenewalDelayMs` is the same 75%
|
|
1115
|
+
* convention the endpoint's delivery renewal and the membership feed use (`@cotal-ai/core`); no
|
|
1116
|
+
* second copy of that arithmetic lives here. */
|
|
1117
|
+
scheduleServeRenewal(creds) {
|
|
1118
|
+
if (this.endpointServeRenewalTimer)
|
|
1119
|
+
clearTimeout(this.endpointServeRenewalTimer);
|
|
1120
|
+
const delay = Math.max(1_000, credsRenewalDelayMs(creds));
|
|
1121
|
+
const renewAt = new Date(Date.now() + delay).toISOString();
|
|
1122
|
+
this.endpointServeRenewalTimer = setTimeout(() => { void this.renewDaemonCreds(); }, delay);
|
|
1123
|
+
this.endpointServeRenewalTimer.unref?.();
|
|
1124
|
+
console.error(`manager endpoint-serve renewal scheduled at ${renewAt}`);
|
|
1125
|
+
}
|
|
1126
|
+
/** #2073: push a freshly re-minted credential onto its LIVE connection instead of waiting for a
|
|
1127
|
+
* reconnect the expiry itself would force two dials too late. `nc.reconnect()` re-evaluates the
|
|
1128
|
+
* authenticator, which reads the mutable `creds` holder on every attempt, so the new CONNECT
|
|
1129
|
+
* presents the fresh credential without re-registration. In-flight requests on the connection
|
|
1130
|
+
* fail once, on the reconnect. Best-effort, matching `CotalEndpoint.swapConnectionOntoFreshCreds`:
|
|
1131
|
+
* a connection that is already closed (e.g. mid re-dial after a fault) cannot reconnect, and that
|
|
1132
|
+
* failure is not this call's to report. */
|
|
1133
|
+
async pushRenewedCreds(nc) {
|
|
1134
|
+
await nc.reconnect().catch(() => { });
|
|
1135
|
+
}
|
|
1136
|
+
/** #2073: the shared bounded-retry shape for a closed standing connection: wait out each of
|
|
1137
|
+
* {@link STATIC_RECONCILE_RETRY_DELAYS_MS}, trying `attempt` after each. A stop landing while
|
|
1138
|
+
* this loop waits is not a fault this loop reports (the caller re-checks `leaseStopping`). */
|
|
1139
|
+
async boundedReconnect(label, attempt) {
|
|
1140
|
+
for (const delayMs of STATIC_RECONCILE_RETRY_DELAYS_MS) {
|
|
1141
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
1142
|
+
if (this.leaseStopping)
|
|
1143
|
+
return true;
|
|
1144
|
+
try {
|
|
1145
|
+
if (await attempt())
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
catch (e) {
|
|
1149
|
+
console.error(`! manager ${label} re-dial attempt failed: ${e.message}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return false;
|
|
1153
|
+
}
|
|
1058
1154
|
/** One class-2 renewal pass (D5 slice 5): re-sign `.cotal/delivery.creds` + `.cotal/membership-rw.creds`
|
|
1059
1155
|
* for their existing nkeys, then request the delivery daemon's EXPLICIT `reloadCreds` adoption on the
|
|
1060
1156
|
* delivery-admin rail and persist the audit record (`.cotal/renewal.<spaceKey>.json`) that `cotal doctor auth`
|
|
@@ -1165,6 +1261,12 @@ export class Manager {
|
|
|
1165
1261
|
serveIssuance: serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId }),
|
|
1166
1262
|
endpointServe: s.grant,
|
|
1167
1263
|
}));
|
|
1264
|
+
// #2073: a superseded instance's re-mint fails the gate CAS above and lands in the
|
|
1265
|
+
// catch below, not here — this re-mint already re-checks the issuance gate through
|
|
1266
|
+
// the SAME executor a fresh registration would use, so there is no second epoch read
|
|
1267
|
+
// to add before re-binding after a re-dial.
|
|
1268
|
+
await this.pushRenewedCreds(s.nc);
|
|
1269
|
+
this.scheduleServeRenewal(s.creds);
|
|
1168
1270
|
}
|
|
1169
1271
|
}
|
|
1170
1272
|
catch (e) {
|
|
@@ -1183,6 +1285,7 @@ export class Manager {
|
|
|
1183
1285
|
const fresh = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageGoalWriter(authKv));
|
|
1184
1286
|
this.goalWriterCreds = fresh;
|
|
1185
1287
|
gw.creds = fresh;
|
|
1288
|
+
await this.pushRenewedCreds(gw.nc);
|
|
1186
1289
|
}
|
|
1187
1290
|
}
|
|
1188
1291
|
catch (e) {
|
|
@@ -1204,6 +1307,7 @@ export class Manager {
|
|
|
1204
1307
|
const fresh = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageSessionLedger(authKv));
|
|
1205
1308
|
this.sessionLedgerCreds = fresh;
|
|
1206
1309
|
sw.creds = fresh;
|
|
1310
|
+
await this.pushRenewedCreds(sw.nc);
|
|
1207
1311
|
}
|
|
1208
1312
|
}
|
|
1209
1313
|
catch (e) {
|
|
@@ -1353,11 +1457,17 @@ export class Manager {
|
|
|
1353
1457
|
async runPreparation(attemptId, generation) {
|
|
1354
1458
|
await this.awaitLifecycleDrain();
|
|
1355
1459
|
this.assertPreservationGeneration(attemptId, generation);
|
|
1460
|
+
// Read the chat frontier BEFORE any child stops (issue #545): a message posted between this
|
|
1461
|
+
// read and the child's stop is delivered live now and again as `historical` on resume, a
|
|
1462
|
+
// duplicate bounded to the cut window that the seat's own id dedup on `durable` channels
|
|
1463
|
+
// absorbs, whereas a read taken after the stops would instead skip messages the seat never
|
|
1464
|
+
// actually saw.
|
|
1465
|
+
const backfillFloor = await this.chatFrontierForPreservation();
|
|
1356
1466
|
const inventory = this.preservationInventory ?? {
|
|
1357
1467
|
version: "cotal-manager-resume/v1",
|
|
1358
1468
|
space: this.space,
|
|
1359
1469
|
createdAt: new Date().toISOString(),
|
|
1360
|
-
agents: [...this.agents.values()].map((a) => this.resumeEntry(a)),
|
|
1470
|
+
agents: [...this.agents.values()].map((a) => this.resumeEntry(a, backfillFloor)),
|
|
1361
1471
|
};
|
|
1362
1472
|
const failures = [];
|
|
1363
1473
|
for (const entry of inventory.agents) {
|
|
@@ -1573,7 +1683,7 @@ export class Manager {
|
|
|
1573
1683
|
return "";
|
|
1574
1684
|
}
|
|
1575
1685
|
}
|
|
1576
|
-
resumeEntry(a) {
|
|
1686
|
+
resumeEntry(a, backfillFloor) {
|
|
1577
1687
|
const principal = a.userOwner
|
|
1578
1688
|
? parsePrincipalKey(a.id)
|
|
1579
1689
|
: { owner: DEV_OWNER, actor: a.id };
|
|
@@ -1636,6 +1746,7 @@ export class Manager {
|
|
|
1636
1746
|
spawner: a.spawner,
|
|
1637
1747
|
authorityParent: a.authorityParent,
|
|
1638
1748
|
startedAt: new Date(a.startedAt).toISOString(),
|
|
1749
|
+
...(backfillFloor !== undefined ? { backfillFloor } : {}),
|
|
1639
1750
|
};
|
|
1640
1751
|
}
|
|
1641
1752
|
/** Tear down every managed agent's footprint on an explicit destructive {@link stop}. A manager exit is
|
|
@@ -1739,6 +1850,8 @@ export class Manager {
|
|
|
1739
1850
|
clearInterval(this.leaseTimer);
|
|
1740
1851
|
if (this.credRenewTimer)
|
|
1741
1852
|
clearInterval(this.credRenewTimer);
|
|
1853
|
+
if (this.endpointServeRenewalTimer)
|
|
1854
|
+
clearTimeout(this.endpointServeRenewalTimer);
|
|
1742
1855
|
if (this.daemonRenewalLeaseTimer)
|
|
1743
1856
|
clearInterval(this.daemonRenewalLeaseTimer);
|
|
1744
1857
|
if (this.sessionKeyRenewTimer)
|
|
@@ -3540,9 +3653,14 @@ export class Manager {
|
|
|
3540
3653
|
* uncertain black hole. */
|
|
3541
3654
|
liveRosterNames() {
|
|
3542
3655
|
const live = new Set();
|
|
3543
|
-
for (const p of this.ep.getRoster())
|
|
3544
|
-
if (p.status
|
|
3545
|
-
|
|
3656
|
+
for (const p of this.ep.getRoster()) {
|
|
3657
|
+
if (p.status === "offline")
|
|
3658
|
+
continue;
|
|
3659
|
+
const predecessor = this.confirmedRetiredPredecessors.get(p.card.name);
|
|
3660
|
+
if (predecessor !== undefined && predecessor.principal === p.card.id && predecessor.lifecycleUid === p.lifecycleUid)
|
|
3661
|
+
continue;
|
|
3662
|
+
live.add(p.card.name);
|
|
3663
|
+
}
|
|
3546
3664
|
return live;
|
|
3547
3665
|
}
|
|
3548
3666
|
/** THE single name-liveness predicate both the hard-pinned collision refuse (M6, P2 item 2) and
|
|
@@ -3561,7 +3679,9 @@ export class Manager {
|
|
|
3561
3679
|
* choice at allocation, never an authority check (the broker still enforces): a stale
|
|
3562
3680
|
* still-live-looking row only costs a numbered suffix, and a missed freshly-joined occupant is
|
|
3563
3681
|
* still refused downstream exactly as before. Offline rows do NOT occupy — a properly retired
|
|
3564
|
-
* name stays reusable.
|
|
3682
|
+
* name stays reusable. A row this manager itself confirmed retired (its alias, principal, and
|
|
3683
|
+
* lifecycleUid all matching a {@link confirmedRetiredPredecessors} entry) does not occupy
|
|
3684
|
+
* either, even while its advisory presence record ages out. */
|
|
3565
3685
|
uniqueName(base) {
|
|
3566
3686
|
const live = this.liveRosterNames();
|
|
3567
3687
|
return firstFreeName(base, (n) => this.nameInUse(n, live));
|
|
@@ -3682,33 +3802,40 @@ export class Manager {
|
|
|
3682
3802
|
route,
|
|
3683
3803
|
}, caller, hooks);
|
|
3684
3804
|
}
|
|
3685
|
-
/**
|
|
3686
|
-
*
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
throw new EpEnvelopeError("failed-precondition", `cwd does not resolve on this host: ${JSON.stringify(asked)} (${reason})`);
|
|
3691
|
-
};
|
|
3692
|
-
if (!isAbsolute(asked))
|
|
3693
|
-
refuse("expected an absolute path");
|
|
3694
|
-
let cwd = asked;
|
|
3805
|
+
/** The realpath + directory checks a placed spawn directory must pass on this manager's host,
|
|
3806
|
+
* shared by the served `resolve-cwd` command and the `spawn` admission check (#385 item 2): one
|
|
3807
|
+
* function, so the two doors never drift. Creates nothing and never substitutes the workspace root. */
|
|
3808
|
+
checkSpawnCwd(resolved) {
|
|
3809
|
+
let cwd = resolved;
|
|
3695
3810
|
try {
|
|
3696
|
-
cwd = realpathSync(
|
|
3811
|
+
cwd = realpathSync(resolved);
|
|
3697
3812
|
}
|
|
3698
3813
|
catch (error) {
|
|
3699
|
-
|
|
3814
|
+
return { refusal: `path cannot be resolved: ${error.message}` };
|
|
3700
3815
|
}
|
|
3701
3816
|
try {
|
|
3702
3817
|
if (!statSync(cwd).isDirectory())
|
|
3703
|
-
|
|
3818
|
+
return { refusal: "path is not a directory" };
|
|
3704
3819
|
}
|
|
3705
3820
|
catch (error) {
|
|
3706
|
-
|
|
3707
|
-
throw error;
|
|
3708
|
-
refuse(error.message);
|
|
3821
|
+
return { refusal: error.message };
|
|
3709
3822
|
}
|
|
3710
3823
|
return { cwd, host: hostname() };
|
|
3711
3824
|
}
|
|
3825
|
+
/** Resolve a placed spawn directory on this manager's host. The manager serves any existing
|
|
3826
|
+
* absolute directory it can resolve. It creates nothing and never substitutes the workspace root. */
|
|
3827
|
+
resolveSpawnCwd(value) {
|
|
3828
|
+
const asked = typeof value === "string" ? value : String(value ?? "");
|
|
3829
|
+
const refuse = (reason) => {
|
|
3830
|
+
throw new EpEnvelopeError("failed-precondition", `cwd does not resolve on this host: ${JSON.stringify(asked)} (${reason})`);
|
|
3831
|
+
};
|
|
3832
|
+
if (!isAbsolute(asked))
|
|
3833
|
+
refuse("expected an absolute path");
|
|
3834
|
+
const result = this.checkSpawnCwd(asked);
|
|
3835
|
+
if ("refusal" in result)
|
|
3836
|
+
return refuse(result.refusal);
|
|
3837
|
+
return result;
|
|
3838
|
+
}
|
|
3712
3839
|
/** Resolve a connector by agent type. Library composition (installedExtensions off) → a registry
|
|
3713
3840
|
* hit, exactly as before (the composition root imported what it wants). The published binary gates
|
|
3714
3841
|
* on MANIFEST membership FIRST — so a live `cotal ext remove` is honored even though the registry
|
|
@@ -4060,6 +4187,20 @@ export class Manager {
|
|
|
4060
4187
|
if (this.userMode)
|
|
4061
4188
|
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" };
|
|
4062
4189
|
}
|
|
4190
|
+
// A placed spawn's cwd is checked on this host BEFORE anything is minted or reserved: an absolute
|
|
4191
|
+
// path this manager cannot realpath, or one that is not a directory, is refused here with the
|
|
4192
|
+
// asked path, the reason and this host, so a misplaced spawn in a multi-manager space is
|
|
4193
|
+
// diagnosable at the moment it happens (#385 item 2) instead of failing inside the pty launch.
|
|
4194
|
+
// The resolved realpath is threaded to the launch cwd below (`resolvedCwd`) so it is computed once.
|
|
4195
|
+
let resolvedCwd;
|
|
4196
|
+
if (typeof opts.cwd === "string" && opts.cwd !== "") {
|
|
4197
|
+
const asked = opts.cwd;
|
|
4198
|
+
const target = resolve(this.workspaceRoot, asked);
|
|
4199
|
+
const checked = this.checkSpawnCwd(target);
|
|
4200
|
+
if ("refusal" in checked)
|
|
4201
|
+
return { ok: false, error: `spawn: cwd does not resolve on this host (${hostname()}): ${JSON.stringify(asked)} (${checked.refusal}); the seat was not launched` };
|
|
4202
|
+
resolvedCwd = checked.cwd;
|
|
4203
|
+
}
|
|
4063
4204
|
// Resolve the launch profile: IDENTITY (free-form `name:`) + role + read/post ACL + capabilities
|
|
4064
4205
|
// + model/variant. Either from a fully-resolved manifest launch object (`opts.resolved`, whose `config`
|
|
4065
4206
|
// is a materialized transient persona — the file is NOT the access authority), or from the
|
|
@@ -4411,7 +4552,9 @@ export class Manager {
|
|
|
4411
4552
|
// Per-agent cwd overrides the manager's shared workspace root, so agents can be rooted at
|
|
4412
4553
|
// arbitrary folders/repos. A relative path resolves against the workspace root; omitted → the
|
|
4413
4554
|
// agent shares the workspace root (the prior, unchanged behavior).
|
|
4414
|
-
|
|
4555
|
+
// The realpath the admission check above already resolved (#385 item 2) — never re-resolved here,
|
|
4556
|
+
// so the admission refusal and the launch cwd can never disagree on the same request.
|
|
4557
|
+
const cwd = resolvedCwd ?? this.workspaceRoot;
|
|
4415
4558
|
const configSha256 = this.fileDigest(configPath);
|
|
4416
4559
|
const manifestPath = opts.launchRef
|
|
4417
4560
|
? join(this.workspaceRoot, ".cotal", "run", `${opts.launchRef.runId}.json`)
|
|
@@ -4490,7 +4633,7 @@ export class Manager {
|
|
|
4490
4633
|
configSha256,
|
|
4491
4634
|
manifestSha256,
|
|
4492
4635
|
}
|
|
4493
|
-
: { kind: "persona", ref, configPath, configSha256 },
|
|
4636
|
+
: { kind: "persona", ref: opts.config ? identityName : ref, configPath, configSha256 },
|
|
4494
4637
|
cwd,
|
|
4495
4638
|
model,
|
|
4496
4639
|
variant,
|
|
@@ -5007,6 +5150,7 @@ export class Manager {
|
|
|
5007
5150
|
// with no COTAL_LIFECYCLE_UID: static/user fail the connector auth gate and open self-mints a
|
|
5008
5151
|
// fresh uid that orphans the preserved durables and never matches the readiness fence.
|
|
5009
5152
|
lifecycleUid: entry.identity.lifecycleUid,
|
|
5153
|
+
backfillFloor: entry.backfillFloor,
|
|
5010
5154
|
acceptedToken: entry.identity.mode === "static" ? entry.identity.issued?.acceptedToken : undefined,
|
|
5011
5155
|
servers: this.servers,
|
|
5012
5156
|
configPath: entry.launch.source.configPath,
|
|
@@ -5571,6 +5715,104 @@ export class Manager {
|
|
|
5571
5715
|
}
|
|
5572
5716
|
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.`);
|
|
5573
5717
|
}
|
|
5718
|
+
/** #2073: dial the manager's ONE control-plane connection and serve its typed command surface on
|
|
5719
|
+
* it, exactly the shape `registerManagerService` used inline before this lane. Shared by the
|
|
5720
|
+
* initial registration AND the closed-connection recovery path ({@link onServeConnectionClosed}),
|
|
5721
|
+
* so there is one place that dials and serves this endpoint. Attaches {@link
|
|
5722
|
+
* onServeConnectionClosed} as the close handler in both cases: a closed serve connection is
|
|
5723
|
+
* always a fault this manager acts on, never a bare log line. */
|
|
5724
|
+
async dialServeConnection(state) {
|
|
5725
|
+
const enc = new TextEncoder();
|
|
5726
|
+
const nc = await this.dial({
|
|
5727
|
+
// Open mesh: a bare serve connection (no credential exists; the broker enforces nothing).
|
|
5728
|
+
...(state.creds !== undefined ? { authenticator: (nonce) => credsAuthenticator(enc.encode(state.creds))(nonce) } : {}),
|
|
5729
|
+
inboxPrefix: `_INBOX_${state.identity.id}`,
|
|
5730
|
+
maxReconnectAttempts: -1,
|
|
5731
|
+
});
|
|
5732
|
+
nc.closed().then((err) => { void this.onServeConnectionClosed(err ?? undefined); });
|
|
5733
|
+
let handle;
|
|
5734
|
+
try {
|
|
5735
|
+
// The 1b typed surface + the derived `describe`. The descriptor stays PUBLIC in static
|
|
5736
|
+
// mode: the broker grant (who holds each command's request-publish row) is the
|
|
5737
|
+
// load-bearing authority tier, and a static single-operator mesh leaks nothing by listing
|
|
5738
|
+
// command names; the trusted per-caller `view(caller)` scoping joins the user-mode
|
|
5739
|
+
// registration follow-up (where actorScope is the trusted source). Every ordinary handler
|
|
5740
|
+
// runs the SHARED admission chokepoint ({@link serveGated}).
|
|
5741
|
+
handle = serveEndpoint(nc, this.space, state.grant, this.managerServiceDefs(), { public: true }, {
|
|
5742
|
+
// The FRESH target resolver (§13.3) for the targeted commands (`despawn`/`attach`): the
|
|
5743
|
+
// manager's live managed set IS the current-mapping authority for its own agents (the
|
|
5744
|
+
// durable slot rows mirror it). Static mode carries no mapping-revision dimension, so
|
|
5745
|
+
// the revision is the constant 0 — a caller that pins a revision pins 0.
|
|
5746
|
+
resolveTarget: (t) => {
|
|
5747
|
+
if (t.owner === DEV_OWNER) {
|
|
5748
|
+
for (const a of this.agents.values())
|
|
5749
|
+
if (!a.userOwner && a.id === t.actor)
|
|
5750
|
+
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5751
|
+
return undefined;
|
|
5752
|
+
}
|
|
5753
|
+
const key = principalKey(t.owner, t.actor).key;
|
|
5754
|
+
for (const a of this.agents.values())
|
|
5755
|
+
if (a.userOwner && a.id === key)
|
|
5756
|
+
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5757
|
+
return undefined;
|
|
5758
|
+
},
|
|
5759
|
+
...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
|
|
5760
|
+
});
|
|
5761
|
+
}
|
|
5762
|
+
catch (e) {
|
|
5763
|
+
await nc.drain().catch(() => nc.close());
|
|
5764
|
+
throw e;
|
|
5765
|
+
}
|
|
5766
|
+
return { nc, handle };
|
|
5767
|
+
}
|
|
5768
|
+
/** #2073: re-mint the SAME `endpoint-serve` identity through the SAME scoped one-shot executor and
|
|
5769
|
+
* §13.1 mint fence `renewDaemonCreds`'s serve branch uses — never a second copy of that mint. Used
|
|
5770
|
+
* by both the periodic renewal pass and the closed-connection recovery path below, so a
|
|
5771
|
+
* superseded instance's re-mint fails the SAME gate CAS in both places and lands in the exit
|
|
5772
|
+
* path rather than needing a second epoch check. */
|
|
5773
|
+
async remintServeCreds(s) {
|
|
5774
|
+
const authRef = this.auth;
|
|
5775
|
+
return this.withEndpointServeExecutor(({ authKv }) => mintCreds(authRef, s.identity, "endpoint-serve", {
|
|
5776
|
+
serveIssuance: serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId }),
|
|
5777
|
+
endpointServe: s.grant,
|
|
5778
|
+
}));
|
|
5779
|
+
}
|
|
5780
|
+
/** #2073: a closed serve connection is a FAULT the manager acts on, not a log line: this is the
|
|
5781
|
+
* control plane a report showed staying dead for hours while the process kept its liveness
|
|
5782
|
+
* lease and answered nothing (#2073). `stop()` sets `leaseStopping` BEFORE it tears down this
|
|
5783
|
+
* connection, so a close landing from OUR OWN graceful teardown returns here at once. Any other
|
|
5784
|
+
* close re-dials with the current credential (a transport blip needs nothing new), re-mints
|
|
5785
|
+
* through the existing serve branch when the credential itself has expired, and re-serves on the
|
|
5786
|
+
* new connection — bounded by {@link STATIC_RECONCILE_RETRY_DELAYS_MS}. Exhausting every attempt
|
|
5787
|
+
* releases this process's lease and exits loud (without `withAgents`, so seats stay up, the path
|
|
5788
|
+
* `supervise`'s signal handler already uses) so a restart can serve: a manager that holds the
|
|
5789
|
+
* lease and answers nothing is the worst of the three states this issue named, and holding the
|
|
5790
|
+
* connection open under unbounded reconnects (the transport-drop case) never reaches this method
|
|
5791
|
+
* at all — `nc.closed()` does not resolve until the client itself gives up. */
|
|
5792
|
+
async onServeConnectionClosed(err) {
|
|
5793
|
+
if (this.leaseStopping)
|
|
5794
|
+
return;
|
|
5795
|
+
const s = this.serviceServe;
|
|
5796
|
+
console.error(`! manager service endpoint connection closed: ${err?.message ?? "unknown reason"}`);
|
|
5797
|
+
const recovered = await this.boundedReconnect("service endpoint", async () => {
|
|
5798
|
+
if (!s)
|
|
5799
|
+
return false;
|
|
5800
|
+
if (inspectCredHealth(s.creds ?? "").state !== "healthy" && s.creds !== undefined) {
|
|
5801
|
+
s.creds = await this.remintServeCreds(s);
|
|
5802
|
+
this.scheduleServeRenewal(s.creds);
|
|
5803
|
+
}
|
|
5804
|
+
const { nc, handle } = await this.dialServeConnection(s);
|
|
5805
|
+
s.nc = nc;
|
|
5806
|
+
s.handle = handle;
|
|
5807
|
+
console.error(`manager service endpoint re-dialed: ${MANAGER_ENDPOINT}/${this.managerInstanceId} (epoch ${s.grant.epoch})`);
|
|
5808
|
+
return true;
|
|
5809
|
+
});
|
|
5810
|
+
if (this.leaseStopping || recovered)
|
|
5811
|
+
return;
|
|
5812
|
+
console.error(`! manager service endpoint could not be restored for ${this.space}/${MANAGER_ENDPOINT}/${this.managerInstanceId}: releasing the lease and exiting`);
|
|
5813
|
+
await this.stop();
|
|
5814
|
+
process.exit(1);
|
|
5815
|
+
}
|
|
5574
5816
|
/** P2 item 1: register the manager as an ordinary v0.4 `service` endpoint and serve its typed
|
|
5575
5817
|
* command surface on the ep rails - since 1d the manager's ONLY control door. On an AUTH mesh
|
|
5576
5818
|
* the whole credential path is the SAME one an ordinary endpoint traverses (the enforcement
|
|
@@ -5820,50 +6062,16 @@ export class Manager {
|
|
|
5820
6062
|
// without re-registration. Reconnects stay unbounded: the serve rails are this instance's
|
|
5821
6063
|
// registered surface for its whole incarnation.
|
|
5822
6064
|
const state = { handle: undefined, nc: undefined, identity: serveIdentity, grant, creds };
|
|
5823
|
-
const
|
|
5824
|
-
const nc = await this.dial({
|
|
5825
|
-
// Open mesh: a bare serve connection (no credential exists; the broker enforces nothing).
|
|
5826
|
-
...(creds !== undefined ? { authenticator: (nonce) => credsAuthenticator(enc.encode(state.creds))(nonce) } : {}),
|
|
5827
|
-
inboxPrefix: `_INBOX_${serveIdentity.id}`,
|
|
5828
|
-
maxReconnectAttempts: -1,
|
|
5829
|
-
});
|
|
5830
|
-
nc.closed().then((err) => { if (err)
|
|
5831
|
-
console.error(`! manager service endpoint connection closed: ${err.message}`); });
|
|
5832
|
-
try {
|
|
5833
|
-
// The 1b typed surface + the derived `describe`. The descriptor stays PUBLIC in static
|
|
5834
|
-
// mode: the broker grant (who holds each command's request-publish row) is the
|
|
5835
|
-
// load-bearing authority tier, and a static single-operator mesh leaks nothing by listing
|
|
5836
|
-
// command names; the trusted per-caller `view(caller)` scoping joins the user-mode
|
|
5837
|
-
// registration follow-up (where actorScope is the trusted source). Every ordinary handler
|
|
5838
|
-
// runs the SHARED admission chokepoint ({@link serveGated}).
|
|
5839
|
-
state.handle = serveEndpoint(nc, this.space, grant, this.managerServiceDefs(), { public: true }, {
|
|
5840
|
-
// The FRESH target resolver (§13.3) for the targeted commands (`despawn`/`attach`): the
|
|
5841
|
-
// manager's live managed set IS the current-mapping authority for its own agents (the
|
|
5842
|
-
// durable slot rows mirror it). Static mode carries no mapping-revision dimension, so
|
|
5843
|
-
// the revision is the constant 0 — a caller that pins a revision pins 0.
|
|
5844
|
-
resolveTarget: (t) => {
|
|
5845
|
-
if (t.owner === DEV_OWNER) {
|
|
5846
|
-
for (const a of this.agents.values())
|
|
5847
|
-
if (!a.userOwner && a.id === t.actor)
|
|
5848
|
-
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5849
|
-
return undefined;
|
|
5850
|
-
}
|
|
5851
|
-
const key = principalKey(t.owner, t.actor).key;
|
|
5852
|
-
for (const a of this.agents.values())
|
|
5853
|
-
if (a.userOwner && a.id === key)
|
|
5854
|
-
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5855
|
-
return undefined;
|
|
5856
|
-
},
|
|
5857
|
-
...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
|
|
5858
|
-
});
|
|
5859
|
-
}
|
|
5860
|
-
catch (e) {
|
|
5861
|
-
await nc.drain().catch(() => nc.close());
|
|
5862
|
-
throw e;
|
|
5863
|
-
}
|
|
6065
|
+
const { nc, handle } = await this.dialServeConnection(state);
|
|
5864
6066
|
state.nc = nc;
|
|
6067
|
+
state.handle = handle;
|
|
5865
6068
|
this.serviceServe = state;
|
|
5866
6069
|
console.error(`manager service endpoint registered: ${MANAGER_ENDPOINT}/${iid} (epoch ${grant.epoch}, registrationRevision ${grant.registrationRevision})`);
|
|
6070
|
+
// #2073: arm the serve credential's own renewal now, from ITS window, instead of leaving it to
|
|
6071
|
+
// `credRenewTimer`'s boot-phased TTL/4 tick. Open mesh mints no credential (`creds` is
|
|
6072
|
+
// undefined) and has nothing to schedule.
|
|
6073
|
+
if (state.creds !== undefined)
|
|
6074
|
+
this.scheduleServeRenewal(state.creds);
|
|
5867
6075
|
}
|
|
5868
6076
|
/** P2 item 2 must-5 (b): mint the standing `goal-writer` credential and STAGE it into this
|
|
5869
6077
|
* instance's §13.1 revocation family (`epcred.<e>.<iid>`), over the passed executor's `authKv`
|
|
@@ -5911,19 +6119,18 @@ export class Manager {
|
|
|
5911
6119
|
* renewal is adopted without reconnecting the whole endpoint); the ActionContext bonds its
|
|
5912
6120
|
* KV + JS + JSM to this one connection and space (SPEC 13.4), so a composition mixup cannot splice
|
|
5913
6121
|
* goal state across brokers. */
|
|
5914
|
-
|
|
5915
|
-
|
|
6122
|
+
/** #2073: dial the goal-writer's ONE standing connection and rebuild everything derived from it
|
|
6123
|
+
* (the fence-resolving action context, the issuance-gate reader) — shared by the initial start
|
|
6124
|
+
* and the closed-connection recovery ({@link onGoalWriterConnectionClosed}), so a redial never
|
|
6125
|
+
* leaves the context bound to a dead socket. */
|
|
6126
|
+
async dialGoalWriter(gw) {
|
|
5916
6127
|
const enc = new TextEncoder();
|
|
5917
|
-
// The mutable holder captured by the authenticator (mirrors the serve connection): a half-TTL
|
|
5918
|
-
// renewal updates `gw.creds` and the next (re)connect presents the refreshed credential.
|
|
5919
|
-
const gw = { nc: undefined, ctx: undefined, creds: (this.auth || this.remoteAuthority) ? this.goalWriterCreds : undefined, identity };
|
|
5920
6128
|
const nc = await this.dial({
|
|
5921
6129
|
...((this.auth || this.remoteAuthority) ? { authenticator: (nonce) => credsAuthenticator(enc.encode(gw.creds))(nonce) } : {}),
|
|
5922
|
-
inboxPrefix: `_INBOX_${identity.id}`,
|
|
6130
|
+
inboxPrefix: `_INBOX_${gw.identity.id}`,
|
|
5923
6131
|
maxReconnectAttempts: -1,
|
|
5924
6132
|
});
|
|
5925
|
-
nc.closed().then((err) => {
|
|
5926
|
-
console.error(`! manager goal-writer connection closed: ${err.message}`); });
|
|
6133
|
+
nc.closed().then((err) => { void this.onGoalWriterConnectionClosed(err ?? undefined); });
|
|
5927
6134
|
gw.nc = nc;
|
|
5928
6135
|
// The (i) fence resolver (SPEC 13.6 P2 item 3): resolve an executing instance's CURRENT gate
|
|
5929
6136
|
// epoch. This manager reconciles only ITS OWN goals (security pin 4), so it resolves its own
|
|
@@ -5949,6 +6156,40 @@ export class Manager {
|
|
|
5949
6156
|
// rather than closing it. An AUTH mesh gets durable closure from the §13.1 barrier (revoke +
|
|
5950
6157
|
// cluster-verified eviction BEFORE the epoch advance); an open mesh gets none.
|
|
5951
6158
|
gw.gate = serveIssuanceGateKv(await new Kvm(nc).open(epAuthBucket(this.space)), this.space, { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId });
|
|
6159
|
+
}
|
|
6160
|
+
/** #2073: the goal-writer rail's closed-connection fault, the same bounded shape the serve rail
|
|
6161
|
+
* uses ({@link onServeConnectionClosed}) through the same {@link boundedReconnect} helper:
|
|
6162
|
+
* re-dial with the current credential (a transport blip needs nothing new), re-mint through the
|
|
6163
|
+
* existing goal-writer renewal branch when the credential itself has expired, then re-attach.
|
|
6164
|
+
* Unlike the serve rail this never exits the process on exhaustion (brief step 4: only the serve
|
|
6165
|
+
* rail is the control plane the report lost) — it logs and leaves the credential to the next
|
|
6166
|
+
* scheduled renewal pass. */
|
|
6167
|
+
async onGoalWriterConnectionClosed(err) {
|
|
6168
|
+
if (this.leaseStopping)
|
|
6169
|
+
return;
|
|
6170
|
+
console.error(`! manager goal-writer connection closed: ${err?.message ?? "unknown reason"}`);
|
|
6171
|
+
const gw = this.goalWriter;
|
|
6172
|
+
const recovered = await this.boundedReconnect("goal-writer", async () => {
|
|
6173
|
+
if (!gw)
|
|
6174
|
+
return false;
|
|
6175
|
+
if (gw.creds !== undefined && inspectCredHealth(gw.creds).state !== "healthy") {
|
|
6176
|
+
gw.creds = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageGoalWriter(authKv));
|
|
6177
|
+
this.goalWriterCreds = gw.creds;
|
|
6178
|
+
}
|
|
6179
|
+
await this.dialGoalWriter(gw);
|
|
6180
|
+
console.error("manager goal-writer re-dialed");
|
|
6181
|
+
return true;
|
|
6182
|
+
});
|
|
6183
|
+
if (this.leaseStopping || recovered)
|
|
6184
|
+
return;
|
|
6185
|
+
console.error("! manager goal-writer could not be re-dialed; leaving the credential to the next renewal pass");
|
|
6186
|
+
}
|
|
6187
|
+
async startGoalWriter() {
|
|
6188
|
+
const identity = (this.auth || this.remoteAuthority) ? this.goalWriterIdentity : newIdentity();
|
|
6189
|
+
// The mutable holder captured by the authenticator (mirrors the serve connection): a half-TTL
|
|
6190
|
+
// renewal updates `gw.creds` and the next (re)connect presents the refreshed credential.
|
|
6191
|
+
const gw = { nc: undefined, ctx: undefined, creds: (this.auth || this.remoteAuthority) ? this.goalWriterCreds : undefined, identity };
|
|
6192
|
+
await this.dialGoalWriter(gw);
|
|
5952
6193
|
this.goalWriter = gw;
|
|
5953
6194
|
console.error(`manager goal-writer standing (endpoint ${MANAGER_ENDPOINT}, ${(this.auth || this.remoteAuthority) ? "scoped cred, §13.1 family-staged" : "open/bare"})`);
|
|
5954
6195
|
}
|
|
@@ -6016,20 +6257,55 @@ export class Manager {
|
|
|
6016
6257
|
* holder never verifies the signature (it presents the grant back over the rail; the broker's
|
|
6017
6258
|
* per-session caller cred is the holder's real fence). The plane's ledger lives in the DEDICATED
|
|
6018
6259
|
* sessions bucket (createEndpointStreams provisioned it at registration). */
|
|
6019
|
-
|
|
6020
|
-
|
|
6260
|
+
/** #2073: dial the session-ledger's ONE standing connection — shared by the initial start and the
|
|
6261
|
+
* closed-connection recovery ({@link onSessionLedgerConnectionClosed}), the same shape {@link
|
|
6262
|
+
* dialGoalWriter} uses for its rail. The KV/JetStream clients the session plane opened from the
|
|
6263
|
+
* connection at start stay bound to it for this incarnation's life; a redial refreshes the
|
|
6264
|
+
* standing authenticator connection the credential renewal presents onto (mirroring `sw.creds`),
|
|
6265
|
+
* which is what a closed-connection fault on this rail needs restored. */
|
|
6266
|
+
async dialSessionLedgerConnection(sw, identity) {
|
|
6021
6267
|
const enc = new TextEncoder();
|
|
6022
|
-
// The mutable holder captured by the authenticator (mirrors the goal-writer): a half-TTL renewal
|
|
6023
|
-
// updates `sw.creds` and the next (re)connect presents the refreshed credential.
|
|
6024
|
-
const sw = { nc: undefined, creds: (this.auth || this.remoteAuthority) ? this.sessionLedgerCreds : undefined };
|
|
6025
6268
|
const nc = await this.dial({
|
|
6026
6269
|
...((this.auth || this.remoteAuthority) ? { authenticator: (nonce) => credsAuthenticator(enc.encode(sw.creds))(nonce) } : {}),
|
|
6027
6270
|
inboxPrefix: `_INBOX_${identity.id}`,
|
|
6028
6271
|
maxReconnectAttempts: -1,
|
|
6029
6272
|
});
|
|
6030
|
-
nc.closed().then((err) => {
|
|
6031
|
-
console.error(`! manager session-ledger connection closed: ${err.message}`); });
|
|
6273
|
+
nc.closed().then((err) => { void this.onSessionLedgerConnectionClosed(err ?? undefined); });
|
|
6032
6274
|
sw.nc = nc;
|
|
6275
|
+
}
|
|
6276
|
+
/** #2073: the session-ledger rail's closed-connection fault, the same bounded shape {@link
|
|
6277
|
+
* onGoalWriterConnectionClosed} uses: re-dial with the current credential, re-mint through the
|
|
6278
|
+
* existing session-ledger renewal branch when the credential itself has expired, then re-attach.
|
|
6279
|
+
* Exhaustion logs and leaves the credential to the next renewal pass; it never exits the process
|
|
6280
|
+
* (brief step 4: only the serve rail is the control plane the report lost). */
|
|
6281
|
+
async onSessionLedgerConnectionClosed(err) {
|
|
6282
|
+
if (this.leaseStopping)
|
|
6283
|
+
return;
|
|
6284
|
+
console.error(`! manager session-ledger connection closed: ${err?.message ?? "unknown reason"}`);
|
|
6285
|
+
const sw = this.sessionLedgerConn;
|
|
6286
|
+
const identity = this.sessionLedgerIdentity;
|
|
6287
|
+
const recovered = await this.boundedReconnect("session-ledger", async () => {
|
|
6288
|
+
if (!sw || !identity)
|
|
6289
|
+
return false;
|
|
6290
|
+
if (sw.creds !== undefined && inspectCredHealth(sw.creds).state !== "healthy") {
|
|
6291
|
+
sw.creds = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageSessionLedger(authKv));
|
|
6292
|
+
this.sessionLedgerCreds = sw.creds;
|
|
6293
|
+
}
|
|
6294
|
+
await this.dialSessionLedgerConnection(sw, identity);
|
|
6295
|
+
console.error("manager session-ledger re-dialed");
|
|
6296
|
+
return true;
|
|
6297
|
+
});
|
|
6298
|
+
if (this.leaseStopping || recovered)
|
|
6299
|
+
return;
|
|
6300
|
+
console.error("! manager session-ledger could not be re-dialed; leaving the credential to the next renewal pass");
|
|
6301
|
+
}
|
|
6302
|
+
async startSessionPlane() {
|
|
6303
|
+
const identity = (this.auth || this.remoteAuthority) ? this.sessionLedgerIdentity : newIdentity();
|
|
6304
|
+
// The mutable holder captured by the authenticator (mirrors the goal-writer): a half-TTL renewal
|
|
6305
|
+
// updates `sw.creds` and the next (re)connect presents the refreshed credential.
|
|
6306
|
+
const sw = { nc: undefined, creds: (this.auth || this.remoteAuthority) ? this.sessionLedgerCreds : undefined };
|
|
6307
|
+
await this.dialSessionLedgerConnection(sw, identity);
|
|
6308
|
+
const nc = sw.nc;
|
|
6033
6309
|
const serveEpoch = this.serviceServe?.grant.epoch;
|
|
6034
6310
|
if (serveEpoch === undefined)
|
|
6035
6311
|
throw new Error("the manager session plane needs the serve grant epoch; registerManagerService must run first");
|
|
@@ -7262,10 +7538,13 @@ export class Manager {
|
|
|
7262
7538
|
managerInstance: this.managerInstanceId, managerProcessUid: this.managerLifecycleUid,
|
|
7263
7539
|
}, { cleanup: reapThenCleanup, evict, log: (line) => console.error(`static retirement ${a.name}: ${line}`) });
|
|
7264
7540
|
});
|
|
7265
|
-
|
|
7541
|
+
const principal = principalKey(DEV_OWNER, a.id).key;
|
|
7542
|
+
this.retiredPrincipals.add(principal);
|
|
7266
7543
|
const cur = this.retiring.get(a.name);
|
|
7267
|
-
if (cur && cur.lifecycleUid === a.lifecycleUid)
|
|
7544
|
+
if (cur && cur.lifecycleUid === a.lifecycleUid) {
|
|
7268
7545
|
this.retiring.delete(a.name); // ABA-guarded hold clear
|
|
7546
|
+
this.confirmedRetiredPredecessors.set(a.name, { principal, lifecycleUid: a.lifecycleUid });
|
|
7547
|
+
}
|
|
7269
7548
|
}
|
|
7270
7549
|
catch (e) {
|
|
7271
7550
|
const h = this.retiring.get(a.name);
|