@cotal-ai/manager 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/commands.d.ts +19 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +13 -6
- package/dist/commands.js.map +1 -1
- package/dist/endpoint-evict.d.ts +5 -3
- package/dist/endpoint-evict.d.ts.map +1 -1
- package/dist/endpoint-evict.js +10 -5
- package/dist/endpoint-evict.js.map +1 -1
- package/dist/manager-service-contract.d.ts +24 -2
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +81 -7
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +29 -2
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +275 -63
- package/dist/manager.js.map +1 -1
- package/dist/reconcile-gate.d.ts +10 -0
- package/dist/reconcile-gate.d.ts.map +1 -1
- package/dist/reconcile-gate.js +60 -7
- package/dist/reconcile-gate.js.map +1 -1
- package/dist/static-lifecycle.d.ts +28 -7
- package/dist/static-lifecycle.d.ts.map +1 -1
- package/dist/static-lifecycle.js +114 -15
- package/dist/static-lifecycle.js.map +1 -1
- package/package.json +9 -5
package/dist/manager.js
CHANGED
|
@@ -4,11 +4,11 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { connect, credsAuthenticator } from "@nats-io/transport-node";
|
|
5
5
|
import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
|
|
6
6
|
import { join, dirname, resolve } from "node:path";
|
|
7
|
-
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, loadAgentFile, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
|
|
8
|
-
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType,
|
|
7
|
+
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
|
|
8
|
+
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, saveManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
9
|
import { createRuntime, } from "./runtime/index.js";
|
|
10
10
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
11
|
-
import { makeManagerEndpointEvictor } from "./endpoint-evict.js";
|
|
11
|
+
import { makeManagerEndpointEvictionEvidence, makeManagerEndpointEvictor } from "./endpoint-evict.js";
|
|
12
12
|
import { makeManagerHolderLivenessProbe } from "./holder-liveness.js";
|
|
13
13
|
import { GateReconcileRefused, reconcileEndpointGate } from "./reconcile-gate.js";
|
|
14
14
|
import { launchSpecForRun, materializePersona, launchAgentToStartOpts, parseLaunchSpec, persistLaunchSpec } from "./launch.js";
|
|
@@ -28,7 +28,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
|
|
|
28
28
|
// P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
|
|
29
29
|
// endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
|
|
30
30
|
// the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
|
|
31
|
-
import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, registerServiceInstance, deregisterServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, } from "@cotal-ai/core";
|
|
31
|
+
import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, registerServiceInstance, deregisterServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, renderLifecycleBlocked, } from "@cotal-ai/core";
|
|
32
32
|
import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
|
|
33
33
|
import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, } from "./static-lifecycle.js";
|
|
34
34
|
/** Concurrency ceiling — the manager refuses to hold more than this many live + in-flight +
|
|
@@ -210,6 +210,8 @@ export class Manager {
|
|
|
210
210
|
/** See {@link ManagerOptions.installedExtensions}. */
|
|
211
211
|
installedExtensions;
|
|
212
212
|
runtime;
|
|
213
|
+
/** Internal test seam. Production leaves this undefined and uses the scoped delivery-admin evictor. */
|
|
214
|
+
staticLifecycleEvict;
|
|
213
215
|
preserveStopTimeoutMs;
|
|
214
216
|
agents = new Map();
|
|
215
217
|
/** Names whose spawn is in flight (reserved synchronously before the provision await) — counted
|
|
@@ -312,6 +314,9 @@ export class Manager {
|
|
|
312
314
|
agentGoals = new Map();
|
|
313
315
|
/** Process start, for the served `status` uptime. */
|
|
314
316
|
startedAtMs = Date.now();
|
|
317
|
+
/** Connector harness paths resolved ONCE at boot. Missing binaries do not stop unrelated manager
|
|
318
|
+
* work, but they are named before the manager reports ready and remain visible in manager status. */
|
|
319
|
+
connectorStatuses = [];
|
|
315
320
|
/** Static aliases whose startup exact-op terminal is still in flight. Scoped per alias: control
|
|
316
321
|
* serves during the sweep, but no caller can reuse or attach an alias the sweep still owns. */
|
|
317
322
|
reconcilingAliases = new Set();
|
|
@@ -453,6 +458,7 @@ export class Manager {
|
|
|
453
458
|
`refused outright and writes no creds file.`;
|
|
454
459
|
}
|
|
455
460
|
async start() {
|
|
461
|
+
await this.inspectConnectorsAtBoot();
|
|
456
462
|
await this.attach.start();
|
|
457
463
|
// In auth mode the manager is just another user in the space's account — it mints
|
|
458
464
|
// itself creds from the same signing key it uses for the agents it spawns. The signer comes
|
|
@@ -542,9 +548,11 @@ export class Manager {
|
|
|
542
548
|
watchChannels: false,
|
|
543
549
|
card: { id, ...(this.remoteAuthority ? { owner: this.remoteAuthority.owner, actor: this.remoteAuthority.actors.supervisor } : {}), name: this.name, role: "manager", kind: "endpoint" },
|
|
544
550
|
});
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
|
|
551
|
+
// Raw failures ride error; conditions the endpoint is already surviving ride warning. Both
|
|
552
|
+
// matter to a supervisor operator, especially a failed standing-credential renewal.
|
|
553
|
+
const reportEndpoint = (e) => console.error(`! manager endpoint: ${e.message}`);
|
|
554
|
+
this.ep.on("error", reportEndpoint);
|
|
555
|
+
this.ep.on("warning", reportEndpoint);
|
|
548
556
|
await this.ep.start();
|
|
549
557
|
await this.ep.setActivity(`supervisor (${this.runtime.kind})`);
|
|
550
558
|
// Per-instance liveness lease (P2 item 3 — the old per-space singleton is DEMOTED per D9). Acquire
|
|
@@ -645,9 +653,9 @@ export class Manager {
|
|
|
645
653
|
// re-signed) so its reply proves it adopted THIS generation, not merely re-read some file.
|
|
646
654
|
const expected = {};
|
|
647
655
|
for (const r of resigned) {
|
|
648
|
-
if (r.file ===
|
|
656
|
+
if (r.file === DELIVERY_CREDS_KIND && r.fingerprint)
|
|
649
657
|
expected.delivery = r.fingerprint;
|
|
650
|
-
else if (r.file ===
|
|
658
|
+
else if (r.file === MEMBERSHIP_RW_CREDS_KIND && r.fingerprint)
|
|
651
659
|
expected.membership = r.fingerprint;
|
|
652
660
|
}
|
|
653
661
|
try {
|
|
@@ -686,7 +694,7 @@ export class Manager {
|
|
|
686
694
|
if (a.userOwner || a.terminalizing || !a.seed || !a.secretPaths?.creds)
|
|
687
695
|
continue;
|
|
688
696
|
try {
|
|
689
|
-
const stored = await this.secrets.get(agentSecretKeyForFile(a.secretPaths.creds));
|
|
697
|
+
const stored = await this.secrets.get(agentSecretKeyForFile(a.secretPaths.creds, this.space));
|
|
690
698
|
if (stored === undefined)
|
|
691
699
|
continue; // no materialized cred (never minted here) - nothing to renew
|
|
692
700
|
const health = inspectCredHealth(stored);
|
|
@@ -783,11 +791,18 @@ export class Manager {
|
|
|
783
791
|
* attempt it. An absent file is the unprovisioned space, reported by the daemon that needs it. */
|
|
784
792
|
warnOnSystemCredExpiry() {
|
|
785
793
|
for (const file of SYSTEM_CREDS_FILES) {
|
|
786
|
-
const path = join(this.workspaceRoot, ".cotal", file);
|
|
787
|
-
if (!existsSync(path))
|
|
788
|
-
continue;
|
|
789
794
|
let health;
|
|
790
795
|
try {
|
|
796
|
+
// The pair is PER-SPACE as of P7, so the location comes from the resolver and never from a
|
|
797
|
+
// path built here (§2 rule 1). Building `.cotal/<kind>` instead would not warn on a wrong
|
|
798
|
+
// file — it would silently warn on NOTHING, for every provisioned root, forever, which is
|
|
799
|
+
// exactly the signal #338 added this for. The resolver may also refuse (§2 rules 3, 4); that
|
|
800
|
+
// refusal is caught with the unreadable case for the reason on the tin — the daemon that
|
|
801
|
+
// NEEDS the pair resolves it through the same function and reports it loudly there, so a
|
|
802
|
+
// diagnostic pass has nothing to add by crashing renewal.
|
|
803
|
+
const path = join(this.workspaceRoot, ".cotal", spaceMaterialKey(file, this.space, { injected: false, root: this.workspaceRoot }));
|
|
804
|
+
if (!existsSync(path))
|
|
805
|
+
continue;
|
|
791
806
|
health = inspectCredHealth(readFileSync(path, "utf8"));
|
|
792
807
|
}
|
|
793
808
|
catch {
|
|
@@ -1172,14 +1187,29 @@ export class Manager {
|
|
|
1172
1187
|
* touches NEITHER the lease NOR the endpoints — the caller owns those. */
|
|
1173
1188
|
async teardownManagedAgents() {
|
|
1174
1189
|
const managed = [...this.agents.values()];
|
|
1190
|
+
const failures = [];
|
|
1175
1191
|
for (const a of managed) {
|
|
1176
1192
|
// Free the slot + hard-stop each; `stopHandle` is best-effort (never throws — see it), so one bad
|
|
1177
1193
|
// stop can't strand the rest, and every snapshot entry is deprovisioned below regardless.
|
|
1178
1194
|
this.agents.delete(a.name);
|
|
1179
1195
|
this.stopHandle(a, false);
|
|
1180
1196
|
}
|
|
1197
|
+
// A runtime stop request is not exit proof. Do not release the manager lease or registration
|
|
1198
|
+
// while any seat may still hold this instance's broker rails: that creates the exact orphan
|
|
1199
|
+
// window where a successor sees a dead manager but live predecessor authority. Every shipped
|
|
1200
|
+
// runtime now supplies waitForExit; absence or timeout is a failed shutdown, never a clean one.
|
|
1201
|
+
await Promise.all(managed.map(async (a) => {
|
|
1202
|
+
try {
|
|
1203
|
+
await this.awaitHandleExit(a.handle);
|
|
1204
|
+
}
|
|
1205
|
+
catch (e) {
|
|
1206
|
+
failures.push(`${a.name}: ${e.message}`);
|
|
1207
|
+
}
|
|
1208
|
+
}));
|
|
1181
1209
|
// Deprovision EVERY snapshot entry regardless of whether its stop failed (allSettled + a loud log).
|
|
1182
1210
|
await Promise.allSettled(managed.filter((a) => !a.suppressCleanup).map((a) => this.deprovision(a).catch((e) => console.error(`deprovision ${a.name} (${a.id}) on shutdown: ${e.message}`))));
|
|
1211
|
+
if (failures.length)
|
|
1212
|
+
throw new Error(`manager shutdown could not prove every seat exited: ${failures.join("; ")}`);
|
|
1183
1213
|
}
|
|
1184
1214
|
async stopRetainedAgentsOnExit() {
|
|
1185
1215
|
const managed = [...this.agents.values()];
|
|
@@ -1709,6 +1739,17 @@ export class Manager {
|
|
|
1709
1739
|
}),
|
|
1710
1740
|
stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
|
|
1711
1741
|
definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
|
|
1742
|
+
listPersonas: (ctx) => this.serveGated(ctx, () => unwrap(this.opListPersonas(callerOf(ctx), false))),
|
|
1743
|
+
showPersona: (ctx) => this.serveGated(ctx, () => {
|
|
1744
|
+
const r = this.opShowPersona(args(ctx), callerOf(ctx), false);
|
|
1745
|
+
if (!r.ok) {
|
|
1746
|
+
const msg = r.error ?? "not found";
|
|
1747
|
+
if (msg.startsWith("no persona"))
|
|
1748
|
+
throw new EpEnvelopeError("not-found", msg);
|
|
1749
|
+
throw new EpEnvelopeError("failed-precondition", msg);
|
|
1750
|
+
}
|
|
1751
|
+
return r.data;
|
|
1752
|
+
}),
|
|
1712
1753
|
purge: (ctx) => this.serveGated(ctx, () => adminGated(ctx, async () => unwrap(await this.opPurge(args(ctx), callerOf(ctx))))),
|
|
1713
1754
|
// launch is OWNER-EQUALITY on the ep door for every caller (freelance HIGH #2): the deploy
|
|
1714
1755
|
// path is the only launch consumer and its spec stamps the CALLER's own owner, so
|
|
@@ -1961,7 +2002,7 @@ export class Manager {
|
|
|
1961
2002
|
const secrets = this.secrets;
|
|
1962
2003
|
// LIFECYCLE-KEYED family (SPEC 13.1 name-disjointness on the FS): this incarnation's files
|
|
1963
2004
|
// embed its uid, so no teardown addressed to another incarnation can ever reach them.
|
|
1964
|
-
const files = agentLifecycleSecretFilePaths(this.workspaceRoot, name, opts.lifecycleUid);
|
|
2005
|
+
const files = agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, name, opts.lifecycleUid);
|
|
1965
2006
|
const { actorToken: tokenPath, sentinelCreds: sentinelPath, health: healthPath } = files;
|
|
1966
2007
|
try {
|
|
1967
2008
|
// The GRANT first — it is the envelope-rule enforcement point (a delegation must sit within
|
|
@@ -1992,10 +2033,10 @@ export class Manager {
|
|
|
1992
2033
|
// The store holds the source of truth; the bearer re-exec (`--token-file`) and the launch's
|
|
1993
2034
|
// sentinel handoff read FILES, so materialize both at the canonical paths (under the local
|
|
1994
2035
|
// FS composition, a byte-identical rewrite of the keys' own locations).
|
|
1995
|
-
await secrets.put(agentSecretKeyForFile(tokenPath), grant.actorToken);
|
|
1996
|
-
await secrets.put(agentSecretKeyForFile(sentinelPath), grant.sentinelCreds);
|
|
1997
|
-
await materializeSecretToFile(secrets, agentSecretKeyForFile(tokenPath), tokenPath);
|
|
1998
|
-
await materializeSecretToFile(secrets, agentSecretKeyForFile(sentinelPath), sentinelPath);
|
|
2036
|
+
await secrets.put(agentSecretKeyForFile(tokenPath, this.space), grant.actorToken);
|
|
2037
|
+
await secrets.put(agentSecretKeyForFile(sentinelPath, this.space), grant.sentinelCreds);
|
|
2038
|
+
await materializeSecretToFile(secrets, agentSecretKeyForFile(tokenPath, this.space), tokenPath);
|
|
2039
|
+
await materializeSecretToFile(secrets, agentSecretKeyForFile(sentinelPath, this.space), sentinelPath);
|
|
1999
2040
|
rmSync(healthPath, { force: true }); // a fresh start opens a fresh health window
|
|
2000
2041
|
const bearerCmd = [
|
|
2001
2042
|
// The manager's own invocation prefix (node + loader flags + the cotal entry) — the agent
|
|
@@ -2022,8 +2063,8 @@ export class Manager {
|
|
|
2022
2063
|
// may respawn the moment it reads the refusal, and a detached teardown would race (and
|
|
2023
2064
|
// delete) that fresh spawn's just-provisioned durables.
|
|
2024
2065
|
await provider.revokeAgent({ dir, owner, actor: name }).catch(() => { });
|
|
2025
|
-
await secrets.delete(agentSecretKeyForFile(tokenPath)).catch(() => { });
|
|
2026
|
-
await secrets.delete(agentSecretKeyForFile(sentinelPath)).catch(() => { });
|
|
2066
|
+
await secrets.delete(agentSecretKeyForFile(tokenPath, this.space)).catch(() => { });
|
|
2067
|
+
await secrets.delete(agentSecretKeyForFile(sentinelPath, this.space)).catch(() => { });
|
|
2027
2068
|
rmSync(tokenPath, { force: true });
|
|
2028
2069
|
rmSync(sentinelPath, { force: true });
|
|
2029
2070
|
rmSync(healthPath, { force: true });
|
|
@@ -2162,9 +2203,9 @@ export class Manager {
|
|
|
2162
2203
|
// standing `cotal mint` cred, a seeded workstation cred, a pre-split leftover): deleting an
|
|
2163
2204
|
// unowned same-name file is the exact successor-clobber this ownership discipline removes.
|
|
2164
2205
|
const secrets = this.secrets;
|
|
2165
|
-
const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, a.name, a.lifecycleUid);
|
|
2206
|
+
const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
|
|
2166
2207
|
if (files.creds) {
|
|
2167
|
-
await secrets.delete(agentSecretKeyForFile(files.creds));
|
|
2208
|
+
await secrets.delete(agentSecretKeyForFile(files.creds, this.space));
|
|
2168
2209
|
rmSync(files.creds, { force: true });
|
|
2169
2210
|
}
|
|
2170
2211
|
if (a.userOwner) {
|
|
@@ -2173,9 +2214,9 @@ export class Manager {
|
|
|
2173
2214
|
// denied) and shred the secret/sentinel/health files. A copied actor token dies here; a
|
|
2174
2215
|
// still-LIVE connection ends at its bearer-bound JWT expiry (≤ the agent TTL).
|
|
2175
2216
|
if (files.actorToken)
|
|
2176
|
-
await secrets.delete(agentSecretKeyForFile(files.actorToken));
|
|
2217
|
+
await secrets.delete(agentSecretKeyForFile(files.actorToken, this.space));
|
|
2177
2218
|
if (files.sentinelCreds)
|
|
2178
|
-
await secrets.delete(agentSecretKeyForFile(files.sentinelCreds));
|
|
2219
|
+
await secrets.delete(agentSecretKeyForFile(files.sentinelCreds, this.space));
|
|
2179
2220
|
for (const f of [files.actorToken, files.sentinelCreds, files.health])
|
|
2180
2221
|
if (f)
|
|
2181
2222
|
rmSync(f, { force: true });
|
|
@@ -2714,6 +2755,48 @@ export class Manager {
|
|
|
2714
2755
|
connectorNames() {
|
|
2715
2756
|
return this.installedExtensions ? manifestExtensionNames("connector") : registry.all("connector").map((c) => c.name);
|
|
2716
2757
|
}
|
|
2758
|
+
/** Resolve every installed/registered connector's declared harness binaries before the manager
|
|
2759
|
+
* reports ready. A missing harness degrades only that connector, never unrelated lifecycle work:
|
|
2760
|
+
* boot continues, but prints a named refusal and records it on both manager status surfaces. */
|
|
2761
|
+
async inspectConnectorsAtBoot() {
|
|
2762
|
+
const rows = [];
|
|
2763
|
+
let declared;
|
|
2764
|
+
if (this.installedExtensions) {
|
|
2765
|
+
try {
|
|
2766
|
+
declared = loadExtensionsManifest().extensions.flatMap((ext) => extensionConnectors(ext));
|
|
2767
|
+
}
|
|
2768
|
+
catch (error) {
|
|
2769
|
+
const reason = error.message;
|
|
2770
|
+
console.error(`! manager boot: connector inventory unavailable - ${reason}`);
|
|
2771
|
+
this.connectorStatuses = [{ agent: "(inventory)", state: "unavailable", binaries: {}, reason }];
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
else {
|
|
2776
|
+
declared = registry.all("connector").map((connector) => ({ name: connector.name, requires: connector.requires ?? [] }));
|
|
2777
|
+
}
|
|
2778
|
+
for (const connector of declared) {
|
|
2779
|
+
const name = connector.name;
|
|
2780
|
+
const binaries = {};
|
|
2781
|
+
const missing = [];
|
|
2782
|
+
for (const bin of connector.requires) {
|
|
2783
|
+
const path = resolveOnPath(bin);
|
|
2784
|
+
if (path)
|
|
2785
|
+
binaries[bin] = path;
|
|
2786
|
+
else
|
|
2787
|
+
missing.push(bin);
|
|
2788
|
+
}
|
|
2789
|
+
if (missing.length) {
|
|
2790
|
+
const reason = `${name} harness needs ${missing.join(", ")} on PATH - not found`;
|
|
2791
|
+
rows.push({ agent: name, state: "unavailable", binaries, reason });
|
|
2792
|
+
console.error(`! manager boot: connector ${name} unavailable - ${reason}`);
|
|
2793
|
+
}
|
|
2794
|
+
else {
|
|
2795
|
+
rows.push({ agent: name, state: "available", binaries });
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
this.connectorStatuses = rows.sort((a, b) => a.agent.localeCompare(b.agent));
|
|
2799
|
+
}
|
|
2717
2800
|
/** Return connector-provided model catalogs for selector UIs. Optional by connector: a host with no
|
|
2718
2801
|
* local model-list API reports `supported:false` rather than blocking the manager. A connector that
|
|
2719
2802
|
* fails to import shows an `error:` row (from manifest enumeration) and never blocks the others. */
|
|
@@ -2957,9 +3040,16 @@ export class Manager {
|
|
|
2957
3040
|
// Harness preflight before reserving a slot or minting — a missing `claude`/`opencode` binary
|
|
2958
3041
|
// fails here with a clear name, not obscurely at process spawn. No fallback. All synchronous, so
|
|
2959
3042
|
// the reserve gate stays atomic. (The connector itself was resolved up top, before the capacity gate.)
|
|
2960
|
-
const
|
|
2961
|
-
if (
|
|
2962
|
-
return { ok: false, error:
|
|
3043
|
+
const bootStatus = this.connectorStatuses.find((row) => row.agent === agent);
|
|
3044
|
+
if (bootStatus?.state === "unavailable")
|
|
3045
|
+
return { ok: false, error: bootStatus.reason };
|
|
3046
|
+
// A connector registered after boot has no inventory row. Keep the existing pre-mint backstop
|
|
3047
|
+
// for that dynamic library-composition case; ordinary installed connectors were checked at boot.
|
|
3048
|
+
if (!bootStatus) {
|
|
3049
|
+
const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
|
|
3050
|
+
if (missing.length)
|
|
3051
|
+
return { ok: false, error: `${agent} harness needs ${missing.join(", ")} on PATH - not found` };
|
|
3052
|
+
}
|
|
2963
3053
|
// Resume is a connector capability: reject an unsupported resume HERE, before the reserve/mint, so
|
|
2964
3054
|
// it can never provision creds + durables and then throw at buildLaunch (mint-then-orphan). Same
|
|
2965
3055
|
// reject-before-side-effects window as the harness preflight above; buildLaunch stays the backstop.
|
|
@@ -3033,6 +3123,29 @@ export class Manager {
|
|
|
3033
3123
|
const idErr = this.nameError(identityName);
|
|
3034
3124
|
if (idErr)
|
|
3035
3125
|
return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${configPath}: ${idErr}` };
|
|
3126
|
+
// #966: a seat labelled `manager` that cannot spawn is a worker wearing a label, and the label
|
|
3127
|
+
// is what other agents ROUTE on (role is the anycast address). The gap is silent today because
|
|
3128
|
+
// the mismatch is only discovered minutes in, when the seat first reaches for a worker. The
|
|
3129
|
+
// persona write path takes content only (role/capabilities are POLICY, P6), so a peer-defined
|
|
3130
|
+
// persona can never fix this by declaring the capability — the only honest outcomes are "the
|
|
3131
|
+
// persona grants spawn" or "say so now". Refuse at accept, before any reserve/mint, naming the
|
|
3132
|
+
// exact remediation for each author: an operator edits the file to add the grant; a peer that
|
|
3133
|
+
// defined the persona over the wire must ask one, because capabilities cannot be declared over
|
|
3134
|
+
// the wire by design. The check keys on the EFFECTIVE role (a spawn-time role: override wins
|
|
3135
|
+
// over the file, mirroring the resolution above), not the file's, so the label other agents
|
|
3136
|
+
// will actually see is the label that is held to account. `manager` is the label that MEANS
|
|
3137
|
+
// spawn-holding: naming it literally keeps this the narrow guard for the observed harm, not a
|
|
3138
|
+
// vocabulary any caller can trigger with a synonym (roles are free-form and mostly
|
|
3139
|
+
// non-manager: this workspace's own catalog carries worker/reviewer/orchestrator seats with no
|
|
3140
|
+
// spawn grant, all legitimate).
|
|
3141
|
+
if (role === "manager" && !capabilities?.includes("spawn"))
|
|
3142
|
+
return {
|
|
3143
|
+
ok: false,
|
|
3144
|
+
error: `persona "${ref}" would join with role "manager" but no spawn capability: it presents as a manager ` +
|
|
3145
|
+
`while being unable to seat workers, and it would discover that only on first use. ` +
|
|
3146
|
+
`An operator adds \`capabilities: [spawn]\` to ${configPath}; a peer-defined persona ` +
|
|
3147
|
+
`(cotal_persona) cannot declare capabilities by design — ask an operator to grant it.`,
|
|
3148
|
+
};
|
|
3036
3149
|
// The alias-reuse gate (#29 piece 3): a name whose previous agent is still retiring REFUSES
|
|
3037
3150
|
// legibly (never a silent suffix), and the refusal re-drives the FULL durable teardown so
|
|
3038
3151
|
// "retry the spawn" is also the nudge. It routes through `deprovision` (not `requestRetirement`
|
|
@@ -3044,10 +3157,8 @@ export class Manager {
|
|
|
3044
3157
|
const held = this.retiring.get(identityName);
|
|
3045
3158
|
if (held !== undefined) {
|
|
3046
3159
|
void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner, secretPaths: held.secretPaths }).catch(() => { });
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
error: `the name "${identityName}" is reserved pending retirement: its previous agent's despawn started that lifecycle's teardown (footprint + standing-authority revoke + auth-side retirement), and the name frees only when all of it completes${held.lastError !== undefined ? ` (last attempt: ${held.lastError})` : ""}. NEXT: wait a moment and retry this spawn (retrying re-drives the whole teardown), or pick another name.`,
|
|
3050
|
-
};
|
|
3160
|
+
const err = lifecycleBlocked("failed-precondition", `the name "${identityName}" is reserved pending retirement: its previous agent's despawn started that lifecycle's teardown (footprint + standing-authority revoke + auth-side retirement), and the name frees only when all of it completes${held.lastError !== undefined ? ` (last attempt: ${held.lastError})` : ""}. NEXT: wait a moment and retry this spawn (retrying re-drives the whole teardown), or pick another name.`, { blockedOp: "retirement", headState: "retiring", opId: held.opId, remedy: "retry" });
|
|
3161
|
+
return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
|
|
3051
3162
|
}
|
|
3052
3163
|
if (variant && !connector.supportsModelVariant)
|
|
3053
3164
|
return { ok: false, error: `${agent} connector does not support model variants (variant)` };
|
|
@@ -3247,9 +3358,9 @@ export class Manager {
|
|
|
3247
3358
|
const secrets = this.secrets;
|
|
3248
3359
|
// LIFECYCLE-KEYED (SPEC 13.1 on the FS): the incarnation's cred file embeds its uid, so a
|
|
3249
3360
|
// replayed/stale teardown can never address a same-name successor's credential.
|
|
3250
|
-
credsPath = agentLifecycleSecretFilePaths(this.workspaceRoot, name, lifecycleUid).creds;
|
|
3251
|
-
await secrets.put(agentSecretKeyForFile(credsPath), creds);
|
|
3252
|
-
await materializeSecretToFile(secrets, agentSecretKeyForFile(credsPath), credsPath);
|
|
3361
|
+
credsPath = agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, name, lifecycleUid).creds;
|
|
3362
|
+
await secrets.put(agentSecretKeyForFile(credsPath, this.space), creds);
|
|
3363
|
+
await materializeSecretToFile(secrets, agentSecretKeyForFile(credsPath, this.space), credsPath);
|
|
3253
3364
|
provisioned = { id: identity.id, name, lifecycleUid, secretPaths: { creds: credsPath } }; // footprint now exists — the finally rolls it back if the spawn throws
|
|
3254
3365
|
}
|
|
3255
3366
|
// Personal MCP servers the operator opted to share with manager-spawned agents of this type
|
|
@@ -3305,6 +3416,7 @@ export class Manager {
|
|
|
3305
3416
|
events,
|
|
3306
3417
|
mcpServers,
|
|
3307
3418
|
envAllow,
|
|
3419
|
+
resolvedBinaries: bootStatus?.binaries,
|
|
3308
3420
|
// So a connector that keeps per-agent local state can root it at the workspace, not the
|
|
3309
3421
|
// (possibly per-agent) launch cwd below. The cwd itself rides runtime.spawn, not the launch.
|
|
3310
3422
|
workspaceRoot: this.workspaceRoot,
|
|
@@ -3428,6 +3540,10 @@ export class Manager {
|
|
|
3428
3540
|
catch (e) {
|
|
3429
3541
|
// Failure after reserve (provision / launch threw): the slot was never live, so no cold-start
|
|
3430
3542
|
// was paid — the reserved rollback (finally) is enough, no cooling stamp.
|
|
3543
|
+
// A lifecycle-blocked envelope already named the barrier; keep its details on the ControlReply
|
|
3544
|
+
// so follow/CLI/cotal_spawn do not collapse it to a generic string (#873).
|
|
3545
|
+
if (e instanceof EpEnvelopeError)
|
|
3546
|
+
return { ok: false, error: renderLifecycleBlocked(e.message, e), ...(e.details ? { details: e.details } : {}) };
|
|
3431
3547
|
return { ok: false, error: e.message };
|
|
3432
3548
|
}
|
|
3433
3549
|
finally {
|
|
@@ -3570,8 +3686,8 @@ export class Manager {
|
|
|
3570
3686
|
// layout) or the name-keyed one (a pre-split inventory being carried across the upgrade).
|
|
3571
3687
|
// Anything else is a foreign path and refused exactly as before.
|
|
3572
3688
|
const candidates = [
|
|
3573
|
-
resolve(agentLifecycleSecretFilePaths(this.workspaceRoot, entry.name, entry.identity.lifecycleUid).creds),
|
|
3574
|
-
resolve(agentSecretFilePaths(this.workspaceRoot, entry.name).creds),
|
|
3689
|
+
resolve(agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, entry.name, entry.identity.lifecycleUid).creds),
|
|
3690
|
+
resolve(agentSecretFilePaths(this.workspaceRoot, this.space, entry.name).creds),
|
|
3575
3691
|
];
|
|
3576
3692
|
const expected = resolve(entry.identity.credential.path);
|
|
3577
3693
|
if (!candidates.includes(expected))
|
|
@@ -3584,7 +3700,7 @@ export class Manager {
|
|
|
3584
3700
|
const st = lstatSync(expected);
|
|
3585
3701
|
if (!st.isFile() || st.isSymbolicLink())
|
|
3586
3702
|
throw new Error("not a regular non-symlink file");
|
|
3587
|
-
const stored = await this.secrets.get(agentSecretKeyForFile(expected));
|
|
3703
|
+
const stored = await this.secrets.get(agentSecretKeyForFile(expected, this.space));
|
|
3588
3704
|
if (stored === undefined)
|
|
3589
3705
|
throw new Error("the credential is not in the secret store");
|
|
3590
3706
|
credentialText = stored;
|
|
@@ -3613,17 +3729,20 @@ export class Manager {
|
|
|
3613
3729
|
// the atomic family closes both: `health` is pinned by PATH EQUALITY (never by file existence,
|
|
3614
3730
|
// so a transiently-absent health file still validates), and the store reads below key off the
|
|
3615
3731
|
// RECORDED path, so a foreign path can neither pass the pin nor address a different row.
|
|
3616
|
-
|
|
3617
|
-
|
|
3732
|
+
// Both triples are derived under THIS manager's space, and the key builder is given
|
|
3733
|
+
// `this.space` rather than reading the segment out of the recorded path: an inventory row is
|
|
3734
|
+
// caller data, and a recorded path may not choose which tenant's material is addressed.
|
|
3735
|
+
const lifecycleFiles = agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, entry.name, entry.identity.lifecycleUid);
|
|
3736
|
+
const legacyFiles = agentSecretFilePaths(this.workspaceRoot, this.space, entry.name);
|
|
3618
3737
|
const recordedToken = resolve(entry.identity.actorToken.path);
|
|
3619
3738
|
const recordedSentinel = resolve(entry.identity.sentinelCredential.path);
|
|
3620
3739
|
const recordedHealth = resolve(entry.identity.health.path);
|
|
3621
3740
|
const matchesFamily = (f) => recordedToken === resolve(f.actorToken) && recordedSentinel === resolve(f.sentinelCreds) && recordedHealth === resolve(f.health);
|
|
3622
3741
|
if (!matchesFamily(lifecycleFiles) && !matchesFamily(legacyFiles))
|
|
3623
|
-
throw new Error(`retained identity references for ${entry.name} are not one manager-owned secret family: all of actor-token, sentinel, and health must be the lifecycle-<uid> triple or the legacy name-keyed triple under ${agentCredsDir(this.workspaceRoot)} (no mixed families, no foreign health path)`);
|
|
3742
|
+
throw new Error(`retained identity references for ${entry.name} are not one manager-owned secret family: all of actor-token, sentinel, and health must be the lifecycle-<uid> triple or the legacy name-keyed triple under ${agentCredsDir(this.workspaceRoot, this.space)} (no mixed families, no foreign health path)`);
|
|
3624
3743
|
const secrets = this.secrets;
|
|
3625
|
-
const actorToken = await secrets.get(agentSecretKeyForFile(recordedToken));
|
|
3626
|
-
const sentinelCreds = await secrets.get(agentSecretKeyForFile(recordedSentinel));
|
|
3744
|
+
const actorToken = await secrets.get(agentSecretKeyForFile(recordedToken, this.space));
|
|
3745
|
+
const sentinelCreds = await secrets.get(agentSecretKeyForFile(recordedSentinel, this.space));
|
|
3627
3746
|
if (actorToken === undefined || sentinelCreds === undefined)
|
|
3628
3747
|
throw new Error("the retained actor token / sentinel credential is not in the secret store");
|
|
3629
3748
|
const adopted = await provider.validateRetainedAgent({
|
|
@@ -3829,7 +3948,7 @@ export class Manager {
|
|
|
3829
3948
|
// preserve/resume — without it the adopted cred would die loud at its TTL with no remint.
|
|
3830
3949
|
let adoptedSeed;
|
|
3831
3950
|
if (entry.identity.mode === "static") {
|
|
3832
|
-
const stored = await this.secrets.get(agentSecretKeyForFile(resolve(entry.identity.credential.path)));
|
|
3951
|
+
const stored = await this.secrets.get(agentSecretKeyForFile(resolve(entry.identity.credential.path), this.space));
|
|
3833
3952
|
adoptedSeed = stored === undefined ? undefined : /-----BEGIN USER NKEY SEED-----\s*([A-Z0-9]+)\s*-----END USER NKEY SEED-----/.exec(stored)?.[1];
|
|
3834
3953
|
if (adoptedSeed === undefined)
|
|
3835
3954
|
console.error(`! resume ${entry.name}: the adopted credential carries no readable nkey seed - the manager cannot renew it (it dies loud at its exp)`);
|
|
@@ -4212,6 +4331,7 @@ export class Manager {
|
|
|
4212
4331
|
runtime: this.runtime.kind,
|
|
4213
4332
|
agentCount: this.agents.size,
|
|
4214
4333
|
uptimeMs: Date.now() - this.startedAtMs,
|
|
4334
|
+
connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
|
|
4215
4335
|
};
|
|
4216
4336
|
}
|
|
4217
4337
|
/** Complete a crashed predecessor's FROZEN registration gate before this incarnation starts a
|
|
@@ -4429,7 +4549,12 @@ export class Manager {
|
|
|
4429
4549
|
// one). Key-pinned to this instance's own status key on the SAME executor.
|
|
4430
4550
|
await writeServiceStatus(recordsKv, {
|
|
4431
4551
|
endpoint: MANAGER_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
|
|
4432
|
-
status: {
|
|
4552
|
+
status: {
|
|
4553
|
+
state: SERVICE_READY,
|
|
4554
|
+
epoch: observed.processEpoch,
|
|
4555
|
+
observedSpecRevision: registrationRevision,
|
|
4556
|
+
connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
|
|
4557
|
+
},
|
|
4433
4558
|
readProcessEpoch: async () => {
|
|
4434
4559
|
const g = await fence.observe();
|
|
4435
4560
|
if (g === null)
|
|
@@ -5138,13 +5263,13 @@ export class Manager {
|
|
|
5138
5263
|
// exists that early — the goal spec does not carry it and the terminal does not exist yet.
|
|
5139
5264
|
// A same-goalId attempt that loses the bind while the winner is still in flight can then
|
|
5140
5265
|
// serve what the winner actually allocated instead of inventing an empty identity.
|
|
5141
|
-
const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name, actor: agentTriple.actor, uid: agentTriple.uid });
|
|
5266
|
+
const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name, actor: agentTriple.actor, uid: agentTriple.uid, readinessDeadlineMs: readinessTimeoutMs });
|
|
5142
5267
|
// A create loss is an idempotent retry ONLY for the same incarnation. A FOREIGN iid means a
|
|
5143
5268
|
// sibling instance accepted this goalId (the live vector is a client retry over ANYCAST, not
|
|
5144
5269
|
// a journal consumer): this attempt provisions nothing and answers with the winner's floor,
|
|
5145
5270
|
// or refuses if that instance never persisted one.
|
|
5146
5271
|
if (!idx.recorded && idx.existing.iid !== executor.lifecycleUid) {
|
|
5147
|
-
acceptance = this.acceptanceFromIndex(idx.existing, goalId, fingerprint, executor);
|
|
5272
|
+
acceptance = await this.acceptanceFromIndex(idx.existing, ref, goalId, fingerprint, executor);
|
|
5148
5273
|
resolveAccept(acceptance);
|
|
5149
5274
|
// No claim needed here: `ownsGoal` is still false, so the commit path refuses this attempt.
|
|
5150
5275
|
throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted by instance "${idx.existing.iid}"; that instance's acceptance is served and this attempt provisions nothing (SPEC 13.6)`);
|
|
@@ -5180,7 +5305,7 @@ export class Manager {
|
|
|
5180
5305
|
acceptedAt,
|
|
5181
5306
|
readinessDeadlineMs: readinessTimeoutMs,
|
|
5182
5307
|
});
|
|
5183
|
-
acceptance = { name, owner: agentTriple.owner, actor: agentTriple.actor, uid: agentTriple.uid, goalId, fingerprint, executor };
|
|
5308
|
+
acceptance = { name, owner: agentTriple.owner, actor: agentTriple.actor, uid: agentTriple.uid, goalId, fingerprint, readinessDeadlineMs: readinessTimeoutMs, executor };
|
|
5184
5309
|
this.goalAcceptances.set(goalId, acceptance);
|
|
5185
5310
|
this.agentGoals.set(name, ref); // M4: a despawn of this name mid-goal drives the cancel path
|
|
5186
5311
|
resolveAccept(acceptance);
|
|
@@ -5197,7 +5322,8 @@ export class Manager {
|
|
|
5197
5322
|
bg.then((reply) => {
|
|
5198
5323
|
// Refused BEFORE onAccepted (M6 hard-pin collision, capacity, persona-not-found) — no goal bound.
|
|
5199
5324
|
if (acceptance === undefined) {
|
|
5200
|
-
|
|
5325
|
+
const details = reply.details;
|
|
5326
|
+
rejectAccept(new EpEnvelopeError("failed-precondition", reply.error ?? "spawn refused at accept", details));
|
|
5201
5327
|
return;
|
|
5202
5328
|
}
|
|
5203
5329
|
// H1: `run` CATCHES its own body (a throw in buildLaunch/runtime.spawn) and RESOLVES `{ok:false}`
|
|
@@ -5206,7 +5332,7 @@ export class Manager {
|
|
|
5206
5332
|
// the caller follows epe to a terminal that never comes, and the reconcile index that would
|
|
5207
5333
|
// settle it is only swept at BOOT, so a manager that stays up never converges it.
|
|
5208
5334
|
if (reply.ok === false && !terminalEntered)
|
|
5209
|
-
return onOutcome({ kind: "failed", data: { error: reply.error ?? "spawn failed after accept" } });
|
|
5335
|
+
return onOutcome({ kind: "failed", data: { error: reply.error ?? "spawn failed after accept", ...(reply.details ? { details: reply.details } : {}) } });
|
|
5210
5336
|
}).catch((e) => {
|
|
5211
5337
|
if (acceptance === undefined) {
|
|
5212
5338
|
rejectAccept(e);
|
|
@@ -5214,7 +5340,7 @@ export class Manager {
|
|
|
5214
5340
|
}
|
|
5215
5341
|
// Same obligation for a genuine rejection (one that escaped `run`'s own catch).
|
|
5216
5342
|
if (!terminalEntered)
|
|
5217
|
-
return onOutcome({ kind: "failed", data: { error: e?.message ?? String(e) } });
|
|
5343
|
+
return onOutcome({ kind: "failed", data: { error: e?.message ?? String(e), ...(e instanceof EpEnvelopeError && e.details ? { details: e.details } : {}) } });
|
|
5218
5344
|
console.error(`! spawn-as-action async body for ${goalId}: ${e?.message ?? String(e)}`);
|
|
5219
5345
|
}).catch((e) => console.error(`! goal terminal fallback for ${goalId}: ${e?.message ?? String(e)}`));
|
|
5220
5346
|
return acceptP;
|
|
@@ -5222,10 +5348,13 @@ export class Manager {
|
|
|
5222
5348
|
/** H2: an acceptance served from a WINNER'S durable acceptance floor (the goal-index entry it
|
|
5223
5349
|
* wrote before its own ack). Refuses `unavailable` rather than inventing one — see
|
|
5224
5350
|
* {@link cachedSpawnAcceptance} for why an empty identity is never an acceptable answer. */
|
|
5225
|
-
acceptanceFromIndex(entry, goalId, fingerprint, executor) {
|
|
5351
|
+
async acceptanceFromIndex(entry, ref, goalId, fingerprint, executor) {
|
|
5226
5352
|
if (entry.allocated === undefined)
|
|
5227
5353
|
throw new EpEnvelopeError("unavailable", `goal "${goalId}" was accepted by instance "${entry.iid}", which persisted no acceptance floor; its allocated identity is not readable from here (SPEC 13.6)`);
|
|
5228
|
-
|
|
5354
|
+
const readinessDeadlineMs = entry.allocated.readinessDeadlineMs ?? (await readGoalSpec(this.goalWriter.ctx, ref))?.value.readinessDeadlineMs;
|
|
5355
|
+
if (readinessDeadlineMs === undefined)
|
|
5356
|
+
throw new EpEnvelopeError("unavailable", `goal "${goalId}" is accepted but its readiness deadline is not readable; a synchronous follower cannot bound its wait honestly (SPEC 13.6)`);
|
|
5357
|
+
return { name: entry.allocated.name, owner: DEV_OWNER, actor: entry.allocated.actor, uid: entry.allocated.uid, goalId, fingerprint, readinessDeadlineMs, executor };
|
|
5229
5358
|
}
|
|
5230
5359
|
/** Reconstruct a cached acceptance for a same-goalId retry NOT in the live map (a prior incarnation
|
|
5231
5360
|
* accepted it, or a concurrent winner whose map write this reader has not yet observed).
|
|
@@ -5241,13 +5370,15 @@ export class Manager {
|
|
|
5241
5370
|
async cachedSpawnAcceptance(ref, goalId, fingerprint, executor) {
|
|
5242
5371
|
const entry = await readGoalIndex(this.goalWriter.ctx, ref);
|
|
5243
5372
|
if (entry?.allocated !== undefined)
|
|
5244
|
-
return this.acceptanceFromIndex(entry, goalId, fingerprint, executor);
|
|
5373
|
+
return this.acceptanceFromIndex(entry, ref, goalId, fingerprint, executor);
|
|
5245
5374
|
// The index is CLEARED at terminal, so a settled goal legitimately has no entry: fall back to
|
|
5246
5375
|
// the terminal's data, which carries the same identity for exactly that case.
|
|
5247
5376
|
const result = await readGoalResult(this.goalWriter.ctx, ref);
|
|
5248
5377
|
const d = (result?.data ?? {});
|
|
5249
|
-
|
|
5250
|
-
|
|
5378
|
+
const spec = await readGoalSpec(this.goalWriter.ctx, ref);
|
|
5379
|
+
const readinessDeadlineMs = spec?.value.readinessDeadlineMs;
|
|
5380
|
+
if (typeof d.name === "string" && d.name.length > 0 && typeof d.id === "string" && d.id.length > 0 && typeof d.lifecycleUid === "string" && d.lifecycleUid.length > 0 && readinessDeadlineMs !== undefined)
|
|
5381
|
+
return { name: d.name, owner: DEV_OWNER, actor: d.id, uid: d.lifecycleUid, goalId, fingerprint, readinessDeadlineMs, executor };
|
|
5251
5382
|
throw new EpEnvelopeError("unavailable", `goal "${goalId}" is already accepted but its allocated identity is not readable (no acceptance floor, and no terminal carrying one); retry (SPEC 13.6)`);
|
|
5252
5383
|
}
|
|
5253
5384
|
/** M4 (settle race): a despawn MID-GOAL drives the goal's cancel terminal - transition to
|
|
@@ -5289,11 +5420,17 @@ export class Manager {
|
|
|
5289
5420
|
if (acceptedRenewal)
|
|
5290
5421
|
await acceptedRenewal.catch(() => { });
|
|
5291
5422
|
const opId = retireOpId(a.lifecycleUid);
|
|
5423
|
+
const evict = this.staticLifecycleEvict ?? makeManagerEndpointEvictionEvidence({
|
|
5424
|
+
space: this.space,
|
|
5425
|
+
servers: this.servers ?? DEFAULT_SERVER,
|
|
5426
|
+
auth: this.auth,
|
|
5427
|
+
log: (line) => console.error(`static retirement ${a.name}: ${line}`),
|
|
5428
|
+
});
|
|
5292
5429
|
const cleanup = async () => {
|
|
5293
5430
|
const secrets = this.secrets;
|
|
5294
|
-
const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, a.name, a.lifecycleUid);
|
|
5431
|
+
const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
|
|
5295
5432
|
if (files.creds) {
|
|
5296
|
-
await secrets.delete(agentSecretKeyForFile(files.creds));
|
|
5433
|
+
await secrets.delete(agentSecretKeyForFile(files.creds, this.space));
|
|
5297
5434
|
rmSync(files.creds, { force: true });
|
|
5298
5435
|
}
|
|
5299
5436
|
await this.deprovisionBroker(a);
|
|
@@ -5307,7 +5444,10 @@ export class Manager {
|
|
|
5307
5444
|
await cleanup();
|
|
5308
5445
|
return;
|
|
5309
5446
|
}
|
|
5310
|
-
await runStaticTerminal(t, {
|
|
5447
|
+
await runStaticTerminal(t, {
|
|
5448
|
+
owner: DEV_OWNER, alias: a.name, actor: a.id, lifecycleUid: a.lifecycleUid, opId,
|
|
5449
|
+
managerInstance: this.managerInstanceId, managerProcessUid: this.managerLifecycleUid,
|
|
5450
|
+
}, { cleanup, evict, log: (line) => console.error(`static retirement ${a.name}: ${line}`) });
|
|
5311
5451
|
});
|
|
5312
5452
|
this.retiredPrincipals.add(principalKey(DEV_OWNER, a.id).key);
|
|
5313
5453
|
const cur = this.retiring.get(a.name);
|
|
@@ -5364,15 +5504,15 @@ export class Manager {
|
|
|
5364
5504
|
});
|
|
5365
5505
|
const secrets = this.secrets;
|
|
5366
5506
|
const credsPath = a.secretPaths.creds;
|
|
5367
|
-
await secrets.put(agentSecretKeyForFile(credsPath), creds);
|
|
5368
|
-
await materializeSecretToFile(secrets, agentSecretKeyForFile(credsPath), credsPath);
|
|
5507
|
+
await secrets.put(agentSecretKeyForFile(credsPath, this.space), creds);
|
|
5508
|
+
await materializeSecretToFile(secrets, agentSecretKeyForFile(credsPath, this.space), credsPath);
|
|
5369
5509
|
console.error(`managed cred renewal ${a.name}: re-signed for the same identity (exp +${MANAGED_STATIC_TTL_SEC}s); the agent endpoint's source re-read adopts it`);
|
|
5370
5510
|
}
|
|
5371
5511
|
/** The Unit B reconciliation (F3 "no active orphan"): ensure the authority stores, then sweep
|
|
5372
5512
|
* every durable slot row and act by the TOTAL resume table — `provisioning`/`terminalizing`
|
|
5373
5513
|
* re-drive the exact-op terminal; an `active` row survives ONLY when a LIVE managed agent this
|
|
5374
|
-
* process owns backs it at the same uid (`adopted`), else
|
|
5375
|
-
*
|
|
5514
|
+
* manager process owns backs it at the same uid (`adopted`), else no managed owner claimed it and
|
|
5515
|
+
* its broker authority is contained and terminalized; `retired` rows seed the F5 refusal index. Two call sites: the BOOT sweep
|
|
5376
5516
|
* (`postAdoption=false`, under the lease before control serving) DEFERS active-non-adopted
|
|
5377
5517
|
* slots while a resume is still pending (adoption runs after it); the POST-ADOPTION sweep
|
|
5378
5518
|
* (`postAdoption=true`, inside finalizeResume while `resumeRequired` still fences ordinary
|
|
@@ -5598,6 +5738,78 @@ export class Manager {
|
|
|
5598
5738
|
}
|
|
5599
5739
|
return { ok: true, data: { name, path } };
|
|
5600
5740
|
}
|
|
5741
|
+
/** Whether this caller may READ a catalog card's details (role/model/description/body). Same
|
|
5742
|
+
* ownership rule as redefine: owner == caller, or admin. Ownerless / operator-written files are
|
|
5743
|
+
* admin-only. Names of parseable files still appear on the list so a definer can see a taken
|
|
5744
|
+
* spawn name; details stay off the wire unless this is true. */
|
|
5745
|
+
canReadPersona(owner, caller, admin) {
|
|
5746
|
+
return personaCatalogReadable(owner, caller, admin);
|
|
5747
|
+
}
|
|
5748
|
+
/** Mesh-side catalog list (#402). Every parseable name is returned so a definer can see a taken
|
|
5749
|
+
* spawn name. Role / model / description / owner ride only when the caller could redefine the
|
|
5750
|
+
* file (same ownership as `definePersona`); other cards are name-only, so a peer cannot read a
|
|
5751
|
+
* prompt it does not own. Unparseable files are omitted for non-admin (no existence leak of a
|
|
5752
|
+
* broken card). */
|
|
5753
|
+
opListPersonas(caller, admin) {
|
|
5754
|
+
const personas = [];
|
|
5755
|
+
for (const e of listPersonaCatalog(this.workspaceRoot)) {
|
|
5756
|
+
if (e.error) {
|
|
5757
|
+
if (!admin)
|
|
5758
|
+
continue;
|
|
5759
|
+
personas.push({ name: e.name, error: "unparseable" });
|
|
5760
|
+
continue;
|
|
5761
|
+
}
|
|
5762
|
+
const def = e.def;
|
|
5763
|
+
if (!this.canReadPersona(def.owner, caller, admin)) {
|
|
5764
|
+
personas.push({ name: e.name });
|
|
5765
|
+
continue;
|
|
5766
|
+
}
|
|
5767
|
+
const description = personaCatalogDescription(def);
|
|
5768
|
+
personas.push({
|
|
5769
|
+
name: e.name,
|
|
5770
|
+
...(def.role ? { role: def.role } : {}),
|
|
5771
|
+
...(def.model ? { model: def.model } : {}),
|
|
5772
|
+
...(description ? { description } : {}),
|
|
5773
|
+
...(def.owner ? { owner: def.owner } : {}),
|
|
5774
|
+
});
|
|
5775
|
+
}
|
|
5776
|
+
return { ok: true, data: { personas } };
|
|
5777
|
+
}
|
|
5778
|
+
/** Mesh-side catalog show (#402). Same ownership as list. Unknown, unauthorized, or unparseable
|
|
5779
|
+
* names are not-found (no existence or parser-diagnostic leak). The body is included so a peer can
|
|
5780
|
+
* inspect a prompt it owns before spawn; policy fields stay off the wire. */
|
|
5781
|
+
opShowPersona(args, caller, admin) {
|
|
5782
|
+
const name = String(args.name ?? "").trim();
|
|
5783
|
+
if (!name)
|
|
5784
|
+
return { ok: false, error: "name required" };
|
|
5785
|
+
const nameErr = this.nameError(name);
|
|
5786
|
+
if (nameErr)
|
|
5787
|
+
return { ok: false, error: nameErr };
|
|
5788
|
+
const path = agentFilePath(this.workspaceRoot, name);
|
|
5789
|
+
if (!existsSync(path))
|
|
5790
|
+
return { ok: false, error: `no persona "${name}"` };
|
|
5791
|
+
let def;
|
|
5792
|
+
try {
|
|
5793
|
+
def = loadAgentFile(path);
|
|
5794
|
+
}
|
|
5795
|
+
catch {
|
|
5796
|
+
return { ok: false, error: `no persona "${name}"` };
|
|
5797
|
+
}
|
|
5798
|
+
if (!this.canReadPersona(def.owner, caller, admin))
|
|
5799
|
+
return { ok: false, error: `no persona "${name}"` };
|
|
5800
|
+
const description = personaCatalogDescription(def);
|
|
5801
|
+
return {
|
|
5802
|
+
ok: true,
|
|
5803
|
+
data: {
|
|
5804
|
+
name,
|
|
5805
|
+
...(def.role ? { role: def.role } : {}),
|
|
5806
|
+
...(def.model ? { model: def.model } : {}),
|
|
5807
|
+
...(description ? { description } : {}),
|
|
5808
|
+
...(def.owner ? { owner: def.owner } : {}),
|
|
5809
|
+
...(def.persona ? { persona: def.persona } : {}),
|
|
5810
|
+
},
|
|
5811
|
+
};
|
|
5812
|
+
}
|
|
5601
5813
|
/** The post-authorization `input` effect (C3): type `text` into the seat's terminal as if a human
|
|
5602
5814
|
* had. The single call an external UI needs to deliver a harness command (`/compact`, `/clear`,
|
|
5603
5815
|
* `/model`) without holding a terminal open on the caller's side.
|
|
@@ -5734,7 +5946,7 @@ export class Manager {
|
|
|
5734
5946
|
// FAIL-CLOSED: a failed record is the failure + repair sentence; a missing/malformed or
|
|
5735
5947
|
// stale record on a live agent is auth-unknown/auth-stale, NEVER silently healthy.
|
|
5736
5948
|
const health = a.userOwner
|
|
5737
|
-
? agentAuthState(a.secretPaths?.health ?? agentLifecycleSecretFilePaths(this.workspaceRoot, a.name, a.lifecycleUid).health)
|
|
5949
|
+
? agentAuthState(a.secretPaths?.health ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid).health)
|
|
5738
5950
|
: undefined;
|
|
5739
5951
|
return {
|
|
5740
5952
|
name: a.name,
|