@cotal-ai/manager 0.67.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands.js +11 -41
- package/dist/commands.js.map +1 -1
- package/dist/endpoint-evict.d.ts +10 -1
- package/dist/endpoint-evict.d.ts.map +1 -1
- package/dist/endpoint-evict.js +31 -51
- package/dist/endpoint-evict.js.map +1 -1
- package/dist/holder-liveness.d.ts.map +1 -1
- package/dist/holder-liveness.js +11 -48
- package/dist/holder-liveness.js.map +1 -1
- package/dist/manager-service-contract.d.ts +5 -1
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +7 -3
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +19 -11
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +185 -151
- package/dist/manager.js.map +1 -1
- package/dist/remote-authority.d.ts +14 -1
- package/dist/remote-authority.d.ts.map +1 -1
- package/dist/remote-authority.js +50 -12
- package/dist/remote-authority.js.map +1 -1
- package/dist/remote-register.d.ts +1 -1
- package/dist/remote-register.d.ts.map +1 -1
- package/dist/remote-register.js +1 -9
- package/dist/remote-register.js.map +1 -1
- package/dist/resume.d.ts.map +1 -1
- package/dist/resume.js +24 -7
- package/dist/resume.js.map +1 -1
- package/dist/roster.d.ts.map +1 -1
- package/dist/roster.js +3 -9
- package/dist/roster.js.map +1 -1
- package/dist/run-hosting.d.ts +2 -1
- package/dist/run-hosting.d.ts.map +1 -1
- package/dist/run-hosting.js +8 -6
- package/dist/run-hosting.js.map +1 -1
- package/dist/static-lifecycle.d.ts +9 -5
- package/dist/static-lifecycle.d.ts.map +1 -1
- package/dist/static-lifecycle.js +18 -13
- package/dist/static-lifecycle.js.map +1 -1
- package/package.json +12 -12
package/dist/manager.js
CHANGED
|
@@ -4,8 +4,8 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
5
5
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
7
|
-
import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey,
|
|
8
|
-
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimManagerSiblingIdentities, connectorInstallHint,
|
|
7
|
+
import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, bearerCommandFailure, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
|
|
8
|
+
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveAgentType, 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";
|
|
11
11
|
import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers } from "./endpoint-evict.js";
|
|
@@ -32,7 +32,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
|
|
|
32
32
|
// the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
|
|
33
33
|
import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
|
|
34
34
|
import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
|
|
35
|
-
import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
|
|
35
|
+
import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
|
|
36
36
|
/** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
|
|
37
37
|
* before living this long leaves a cooling stamp that still counts toward the ceiling until it
|
|
38
38
|
* expires — so churn (spawn↔despawn or spawn↔fast-exit) can't outrun the concurrency bound. */
|
|
@@ -137,14 +137,25 @@ function rejectionText(e) {
|
|
|
137
137
|
return "an unreadable rejection";
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
|
+
/** Run one cleanup step, recording its failure in `failed` as `<what>: <reason>`. A step that threw
|
|
141
|
+
* would otherwise skip every step after it, and a swallowed one would leave nothing to report. */
|
|
142
|
+
async function attemptCleanup(failed, what, step) {
|
|
143
|
+
try {
|
|
144
|
+
await step();
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
failed.push(`${what}: ${rejectionText(err)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
140
150
|
/** Run the agent's bearer argv once, pre-launch — the end-to-end auth preflight (state dir, daemon,
|
|
141
151
|
* ledger row, secret). Its stderr is the provider command's operator-exact sentence; surface it
|
|
142
152
|
* verbatim as the spawn refusal. */
|
|
143
153
|
function execBearerPreflight(argv) {
|
|
154
|
+
const timeout = 30_000;
|
|
144
155
|
return new Promise((res, rej) => {
|
|
145
|
-
execFile(argv[0], argv.slice(1), { timeout
|
|
156
|
+
execFile(argv[0], argv.slice(1), { timeout, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
|
|
146
157
|
if (err)
|
|
147
|
-
return rej(
|
|
158
|
+
return rej(bearerCommandFailure(err, stderr, timeout));
|
|
148
159
|
res();
|
|
149
160
|
});
|
|
150
161
|
});
|
|
@@ -282,7 +293,8 @@ export class Manager {
|
|
|
282
293
|
/** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
|
|
283
294
|
* See {@link ManagerOptions.secretStore}. */
|
|
284
295
|
secrets;
|
|
285
|
-
/** Named identity of {@link secrets}: the same coordinate the delivery daemon must reload from.
|
|
296
|
+
/** Named identity of {@link secrets}: the same coordinate the delivery daemon must reload from.
|
|
297
|
+
* Set at construction for an injected store; the workspace store is named on first challenge. */
|
|
286
298
|
secretStoreIdentity;
|
|
287
299
|
/** See {@link ManagerOptions.installedExtensions}. */
|
|
288
300
|
installedExtensions;
|
|
@@ -593,9 +605,8 @@ export class Manager {
|
|
|
593
605
|
if (opts.remoteAuthority)
|
|
594
606
|
this.managerLifecycleUid = opts.remoteAuthority.lifecycleUid;
|
|
595
607
|
this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
: { kind: "fs", root: resolve(this.workspaceRoot) };
|
|
608
|
+
if (opts.secretStore)
|
|
609
|
+
this.secretStoreIdentity = injectedManagerStoreIdentity(opts.secretStore);
|
|
599
610
|
this.installedExtensions = opts.installedExtensions ?? false;
|
|
600
611
|
this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
|
|
601
612
|
if (opts.pooled && isCustodialRuntime(this.runtime))
|
|
@@ -1129,6 +1140,9 @@ export class Manager {
|
|
|
1129
1140
|
`this space's delivery lease (${holder}), so its SecretStore is not the store the daemon ` +
|
|
1130
1141
|
`reloads from - nothing reminted`);
|
|
1131
1142
|
const daemon = answer.identity;
|
|
1143
|
+
// The workspace store records its id the first time it is named, so it is named here, where
|
|
1144
|
+
// only an auth manager reaches, rather than in a constructor every open-mode manager runs.
|
|
1145
|
+
this.secretStoreIdentity ??= parseSecretStoreIdentity(this.secrets.identity);
|
|
1132
1146
|
if (!sameSecretStoreIdentity(this.secretStoreIdentity, daemon)) {
|
|
1133
1147
|
console.error(`! ${divergentSecretStoreNotice(this.secretStoreIdentity, daemon)}`);
|
|
1134
1148
|
return "divergent";
|
|
@@ -1676,7 +1690,7 @@ export class Manager {
|
|
|
1676
1690
|
// actually saw.
|
|
1677
1691
|
const backfillFloor = await this.chatFrontierForPreservation();
|
|
1678
1692
|
const inventory = this.preservationInventory ?? {
|
|
1679
|
-
version: "cotal-manager-resume/
|
|
1693
|
+
version: "cotal-manager-resume/v2",
|
|
1680
1694
|
space: this.space,
|
|
1681
1695
|
createdAt: new Date().toISOString(),
|
|
1682
1696
|
agents: [...this.agents.values()].map((a) => this.resumeEntry(a, backfillFloor)),
|
|
@@ -2139,17 +2153,17 @@ export class Manager {
|
|
|
2139
2153
|
await step("release renewal lease", () => this.ep.releaseDaemonRenewalLease());
|
|
2140
2154
|
}
|
|
2141
2155
|
// Capture BEFORE the serve loop is torn down: `stopServiceServe` clears the state, and what is
|
|
2142
|
-
// being asked here is "did this process
|
|
2156
|
+
// being asked here is "which registration did this process write", which only the state
|
|
2143
2157
|
// before teardown can answer. Deregistration runs AFTER the serve loop has drained, so no
|
|
2144
2158
|
// in-flight command can write a status back onto the record it just removed.
|
|
2145
|
-
const
|
|
2159
|
+
const registrationRevision = this.serviceServe?.grant.registrationRevision;
|
|
2146
2160
|
await step("stop service serve", () => this.stopServiceServe());
|
|
2147
2161
|
// The drives AFTER the serve loop (no new `run-start` can land) and BEFORE deregistration: a
|
|
2148
2162
|
// released run's status write is the last thing this incarnation says about it.
|
|
2149
2163
|
await step("stop run hosting", () => this.runHosting?.stop());
|
|
2150
2164
|
this.runHosting = undefined;
|
|
2151
|
-
if (
|
|
2152
|
-
await step("deregister service", () => this.deregisterServiceOnStop());
|
|
2165
|
+
if (registrationRevision !== undefined && !seatFailure)
|
|
2166
|
+
await step("deregister service", () => this.deregisterServiceOnStop(registrationRevision));
|
|
2153
2167
|
await step("stop goal writer", () => this.stopGoalWriter());
|
|
2154
2168
|
await step("stop session plane", () => this.stopSessionPlane());
|
|
2155
2169
|
await step("stop endpoint", () => this.ep.stop());
|
|
@@ -2195,8 +2209,8 @@ export class Manager {
|
|
|
2195
2209
|
* deadline — on every `cotal ps`, `stop` and `attach` in that space, for good. A manager that is
|
|
2196
2210
|
* shutting down is the one participant that KNOWS it is going away, so it says so.
|
|
2197
2211
|
*
|
|
2198
|
-
* The delete is
|
|
2199
|
-
* persists the SAME instanceId and has already re-registered loses nothing to a slow stop here.
|
|
2212
|
+
* The delete is pinned to the `registrationRevision` this process registered at, so a successor
|
|
2213
|
+
* that persists the SAME instanceId and has already re-registered loses nothing to a slow stop here.
|
|
2200
2214
|
*
|
|
2201
2215
|
* Best-effort and LOUD, matching every other teardown step: a broker that is already gone must not
|
|
2202
2216
|
* turn a stop into a failure, but a registration that survives a stop is the exact defect this
|
|
@@ -2204,11 +2218,12 @@ export class Manager {
|
|
|
2204
2218
|
* rather than swallowed. It is not fatal because a crash leaves the same state and the operator
|
|
2205
2219
|
* verb handles both.
|
|
2206
2220
|
*/
|
|
2207
|
-
async deregisterServiceOnStop() {
|
|
2221
|
+
async deregisterServiceOnStop(registrationRevision) {
|
|
2208
2222
|
const iid = this.managerInstanceId;
|
|
2209
2223
|
const dereg = ({ recordsKv, authKv }) => deregisterServiceInstance(recordsKv, {
|
|
2210
2224
|
endpoint: MANAGER_ENDPOINT,
|
|
2211
2225
|
instanceId: iid,
|
|
2226
|
+
registrationRevision,
|
|
2212
2227
|
observeGeneration: async () => {
|
|
2213
2228
|
const key = epgateKey(MANAGER_ENDPOINT, iid);
|
|
2214
2229
|
const entry = await authKv.get(key);
|
|
@@ -2224,7 +2239,7 @@ export class Manager {
|
|
|
2224
2239
|
if (outcome.removed)
|
|
2225
2240
|
console.error(`✓ deregistered manager instance ${iid} from the ${MANAGER_ENDPOINT} service registry (spec revision ${outcome.specRevision})`);
|
|
2226
2241
|
else if (outcome.reason === "superseded")
|
|
2227
|
-
console.error(`! manager instance ${iid} was not deregistered:
|
|
2242
|
+
console.error(`! manager instance ${iid} was not deregistered: another incarnation has registered it since this process did - leaving it alone`);
|
|
2228
2243
|
else if (outcome.reason === "registration-in-flight")
|
|
2229
2244
|
console.error(`! manager instance ${iid} was not deregistered: a registration is still in flight (governance slot held at the live gate generation) - leaving it alone`);
|
|
2230
2245
|
// `absent` is silent: there was nothing registered to remove, which is not news at shutdown.
|
|
@@ -3239,15 +3254,14 @@ export class Manager {
|
|
|
3239
3254
|
// Roll back everything this attempt materialized — a refused spawn must leave no standing
|
|
3240
3255
|
// secret, no ledger row, no durable footprint — and AWAIT the broker teardown: the caller
|
|
3241
3256
|
// may respawn the moment it reads the refusal, and a detached teardown would race (and
|
|
3242
|
-
// delete) that fresh spawn's just-provisioned durables.
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
return { error: `agent auth preflight failed for "${name}": ${e.message}` };
|
|
3257
|
+
// delete) that fresh spawn's just-provisioned durables. deprovision shreds the secret family
|
|
3258
|
+
// and revokes the grant, but only logs a failed revoke, so the revoke is attempted here first.
|
|
3259
|
+
// Each failure joins the refusal, so the caller learns what this spawn left behind.
|
|
3260
|
+
const failed = [];
|
|
3261
|
+
await attemptCleanup(failed, "revoke agent grant", () => provider.revokeAgent({ dir, owner, actor: name }));
|
|
3262
|
+
await attemptCleanup(failed, "deprovision", () => this.deprovision({ id: principalKey(owner, name).key, name, lifecycleUid: opts.lifecycleUid, userOwner: owner, secretPaths: files }));
|
|
3263
|
+
const leftover = failed.length ? `; cleanup failed: ${failed.join("; ")}` : "";
|
|
3264
|
+
return { error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}` };
|
|
3251
3265
|
}
|
|
3252
3266
|
}
|
|
3253
3267
|
/**
|
|
@@ -3316,7 +3330,7 @@ export class Manager {
|
|
|
3316
3330
|
if (files.actorToken !== staged.actorToken) {
|
|
3317
3331
|
await secrets.put(agentSecretKeyForFile(files.actorToken, this.space), actorToken);
|
|
3318
3332
|
await materializeSecretToFile(secrets, agentSecretKeyForFile(files.actorToken, this.space), files.actorToken);
|
|
3319
|
-
await secrets.delete(agentSecretKeyForFile(staged.actorToken, this.space))
|
|
3333
|
+
await secrets.delete(agentSecretKeyForFile(staged.actorToken, this.space));
|
|
3320
3334
|
rmSync(staged.actorToken, { force: true });
|
|
3321
3335
|
}
|
|
3322
3336
|
await secrets.put(agentSecretKeyForFile(files.sentinelCreds, this.space), material.sentinelCreds);
|
|
@@ -3351,19 +3365,11 @@ export class Manager {
|
|
|
3351
3365
|
// A failed removal joins the refusal instead of throwing, so every removal is attempted and
|
|
3352
3366
|
// the refusal still reaches the caller.
|
|
3353
3367
|
const unshredded = [];
|
|
3354
|
-
const shred = async (what, remove) => {
|
|
3355
|
-
try {
|
|
3356
|
-
await remove();
|
|
3357
|
-
}
|
|
3358
|
-
catch (err) {
|
|
3359
|
-
unshredded.push(`${what}: ${rejectionText(err)}`);
|
|
3360
|
-
}
|
|
3361
|
-
};
|
|
3362
3368
|
for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files]) {
|
|
3363
3369
|
for (const path of [family.actorToken, family.sentinelCreds])
|
|
3364
|
-
await
|
|
3370
|
+
await attemptCleanup(unshredded, `secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
|
|
3365
3371
|
for (const path of [family.actorToken, family.sentinelCreds, family.health])
|
|
3366
|
-
await
|
|
3372
|
+
await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
|
|
3367
3373
|
}
|
|
3368
3374
|
const leftover = unshredded.length ? `; cleanup failed: ${unshredded.join("; ")}` : "";
|
|
3369
3375
|
return { error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}` };
|
|
@@ -3616,22 +3622,25 @@ export class Manager {
|
|
|
3616
3622
|
// unowned same-name file is the exact successor-clobber this ownership discipline removes.
|
|
3617
3623
|
const secrets = this.secrets;
|
|
3618
3624
|
const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
|
|
3625
|
+
// Every removal is attempted, and a failed one is thrown only after the broker teardown below,
|
|
3626
|
+
// so a file fault cannot strand the agent's durables and ACL row on the broker.
|
|
3627
|
+
const unshredded = [];
|
|
3619
3628
|
if (files.creds) {
|
|
3620
|
-
|
|
3621
|
-
|
|
3629
|
+
const creds = files.creds;
|
|
3630
|
+
await attemptCleanup(unshredded, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
|
|
3631
|
+
await attemptCleanup(unshredded, `file ${creds}`, () => rmSync(creds, { force: true }));
|
|
3622
3632
|
}
|
|
3623
3633
|
if (a.userOwner) {
|
|
3624
3634
|
// USER MODE: this teardown IS revocation, not just footprint reduction — the ledger row is
|
|
3625
3635
|
// the agent's standing mint authority, so delete it (next exchange refused, next connect
|
|
3626
3636
|
// denied) and shred the secret/sentinel/health files. A copied actor token dies here; a
|
|
3627
3637
|
// still-LIVE connection ends at its bearer-bound JWT expiry (≤ the agent TTL).
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
rmSync(f, { force: true });
|
|
3638
|
+
for (const path of [files.actorToken, files.sentinelCreds])
|
|
3639
|
+
if (path)
|
|
3640
|
+
await attemptCleanup(unshredded, `secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
|
|
3641
|
+
for (const path of [files.actorToken, files.sentinelCreds, files.health])
|
|
3642
|
+
if (path)
|
|
3643
|
+
await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
|
|
3635
3644
|
// The ledger row IS the agent's STANDING mint authority (a different store from the auth-plane
|
|
3636
3645
|
// cred ledger the rail retirement covers): while it lives, a copied actor token can still mint a
|
|
3637
3646
|
// fresh connect credential. So a FAILED revoke must NOT be swallowed into a clean terminal (INT-2):
|
|
@@ -3659,6 +3668,7 @@ export class Manager {
|
|
|
3659
3668
|
console.error(`revoke agent grant ${a.name}: ${e.message}`);
|
|
3660
3669
|
}
|
|
3661
3670
|
}
|
|
3671
|
+
const shredFailure = unshredded.length ? `could not shred "${a.name}": ${unshredded.join("; ")}` : undefined;
|
|
3662
3672
|
try {
|
|
3663
3673
|
const res = await this.deprovisionBroker(a);
|
|
3664
3674
|
const h = this.retiring.get(a.name);
|
|
@@ -3672,8 +3682,12 @@ export class Manager {
|
|
|
3672
3682
|
if (e instanceof DeprovisionError)
|
|
3673
3683
|
h.lastResources = e.accounting;
|
|
3674
3684
|
}
|
|
3685
|
+
if (shredFailure)
|
|
3686
|
+
throw new Error(`${rejectionText(e)}; ${shredFailure}`, { cause: e });
|
|
3675
3687
|
throw e;
|
|
3676
3688
|
}
|
|
3689
|
+
if (shredFailure)
|
|
3690
|
+
throw new Error(shredFailure);
|
|
3677
3691
|
// #29 piece 3: after the footprint teardown, ask the AUTH plane to RETIRE the lifecycle over
|
|
3678
3692
|
// the auth endpoint rail. The rail re-checks the SERVE-ISSUANCE GATE at serve time (not the
|
|
3679
3693
|
// space-manager lease - that check was replaced in 02794b2f) and refuses unless the registration
|
|
@@ -4265,7 +4279,7 @@ export class Manager {
|
|
|
4265
4279
|
nameInUse(name, live = this.liveRosterNames()) {
|
|
4266
4280
|
return this.agents.has(name) || this.reserved.has(name) || this.retiring.has(name) || live.has(name);
|
|
4267
4281
|
}
|
|
4268
|
-
/** First free name in the series `base`, `
|
|
4282
|
+
/** First free name in the series `base`, `base_2`, `base_3`, … — checked against live slots,
|
|
4269
4283
|
* in-flight (reserved) slots, names held pending retirement, AND the live mesh roster. The
|
|
4270
4284
|
* roster check covers occupants this manager does not manage (a foreground `cotal spawn`, a
|
|
4271
4285
|
* connector session, another manager's agent): allocating their name would mint a sibling the
|
|
@@ -4346,11 +4360,12 @@ export class Manager {
|
|
|
4346
4360
|
throw new Error(`${flag}: expected an array of strings`);
|
|
4347
4361
|
return v;
|
|
4348
4362
|
};
|
|
4349
|
-
let subscribe, allowSubscribe, allowPublish;
|
|
4363
|
+
let subscribe, allowSubscribe, allowPublish, shareTools;
|
|
4350
4364
|
try {
|
|
4351
4365
|
subscribe = strList(args.subscribe, "subscribe");
|
|
4352
4366
|
allowSubscribe = strList(args.allowSubscribe, "allowSubscribe");
|
|
4353
4367
|
allowPublish = strList(args.allowPublish, "allowPublish");
|
|
4368
|
+
shareTools = strList(args.shareTools, "shareTools");
|
|
4354
4369
|
}
|
|
4355
4370
|
catch (e) {
|
|
4356
4371
|
return Promise.resolve({ ok: false, error: e.message });
|
|
@@ -4400,7 +4415,7 @@ export class Manager {
|
|
|
4400
4415
|
subscribe,
|
|
4401
4416
|
allowSubscribe,
|
|
4402
4417
|
allowPublish,
|
|
4403
|
-
shareTools
|
|
4418
|
+
shareTools,
|
|
4404
4419
|
...(supervise !== undefined ? { supervise } : {}),
|
|
4405
4420
|
route,
|
|
4406
4421
|
}, caller, hooks);
|
|
@@ -4522,6 +4537,31 @@ export class Manager {
|
|
|
4522
4537
|
return reason;
|
|
4523
4538
|
return `${reason}. This manager cannot launch that harness. The space may have other managers; pin one with --on <instance> (the whole id, as ps prints it)`;
|
|
4524
4539
|
}
|
|
4540
|
+
/** The harness binaries a managed launch runs, as boot resolved them, or the refusal naming what is
|
|
4541
|
+
* missing. Spawn and resume both take it, so a seat launches from the same files whichever path
|
|
4542
|
+
* starts it. A connector registered after boot has no inventory row and is checked on PATH now. */
|
|
4543
|
+
launchHarness(connector) {
|
|
4544
|
+
const row = this.connectorStatuses.find((status) => status.agent === connector.name);
|
|
4545
|
+
if (row?.state === "unavailable")
|
|
4546
|
+
return { refusal: row.reason ?? `${connector.name} harness is unavailable` };
|
|
4547
|
+
if (row)
|
|
4548
|
+
return { binaries: row.binaries };
|
|
4549
|
+
const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
|
|
4550
|
+
if (missing.length)
|
|
4551
|
+
return { refusal: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found` };
|
|
4552
|
+
return {};
|
|
4553
|
+
}
|
|
4554
|
+
/** The refusal for a launch choice the connector cannot honour. Spawn and resume both ask before
|
|
4555
|
+
* any reserve or mint, so a rule changed here holds on both paths. */
|
|
4556
|
+
capabilityRefusal(connector, launch) {
|
|
4557
|
+
if (launch.variant && !connector.supportsModelVariant)
|
|
4558
|
+
return `${connector.name} connector does not support model variants (variant)`;
|
|
4559
|
+
if (launch.prompt !== undefined && !connector.supportsPrompt)
|
|
4560
|
+
return `${connector.name} connector does not support an initial prompt (prompt)`;
|
|
4561
|
+
if (launch.exact && (!connector.supportsSessionReopen || !connector.supportsSessionContinuation))
|
|
4562
|
+
return `${connector.name} connector does not support exact session continuity (continuity: exact)`;
|
|
4563
|
+
return undefined;
|
|
4564
|
+
}
|
|
4525
4565
|
/** Return connector-provided model catalogs for selector UIs. Optional by connector: a host with no
|
|
4526
4566
|
* local model-list API reports `supported:false` rather than blocking the manager. A connector that
|
|
4527
4567
|
* fails to import shows an `error:` row (from manifest enumeration) and never blocks the others. */
|
|
@@ -4687,6 +4727,9 @@ export class Manager {
|
|
|
4687
4727
|
}
|
|
4688
4728
|
}
|
|
4689
4729
|
async startAgentActive(opts, spawner, hooks) {
|
|
4730
|
+
// The caller keeps its array, so the selection is copied before the first await: a later write
|
|
4731
|
+
// to it cannot change what this launch shares or what preservation retains.
|
|
4732
|
+
const shareTools = opts.shareTools && [...opts.shareTools];
|
|
4690
4733
|
if (opts.delegatedIntent) {
|
|
4691
4734
|
if (!this.remoteAuthority?.executeDelegatedUserIntent)
|
|
4692
4735
|
return { ok: false, error: `"${opts.name}" names a delegated user intent, and this manager has no host execution for one` };
|
|
@@ -4743,10 +4786,7 @@ export class Manager {
|
|
|
4743
4786
|
return { ok: false, error: e.message };
|
|
4744
4787
|
}
|
|
4745
4788
|
}
|
|
4746
|
-
|
|
4747
|
-
// this manager's COTAL_DEFAULT_AGENT > DEFAULT_CONNECTOR. Both environment values are defaults,
|
|
4748
|
-
// never overrides: neither may beat a deliberate per-persona pin.
|
|
4749
|
-
const agent = opts.agent ?? def?.agent ?? opts.defaultAgent ?? defaultAgentType(DEFAULT_CONNECTOR);
|
|
4789
|
+
const agent = resolveAgentType({ flag: opts.agent, pin: def?.agent, callerDefault: opts.defaultAgent });
|
|
4750
4790
|
// Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
|
|
4751
4791
|
// `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
|
|
4752
4792
|
// below so that span stays fully SYNCHRONOUS and atomic. On the manifest binary this also honors a
|
|
@@ -4772,17 +4812,9 @@ export class Manager {
|
|
|
4772
4812
|
// Harness preflight before reserving a slot or minting — a missing `claude`/`opencode` binary
|
|
4773
4813
|
// fails here with a clear name, not obscurely at process spawn. No fallback. All synchronous, so
|
|
4774
4814
|
// the reserve gate stays atomic. (The connector itself was resolved up top, before the capacity gate.)
|
|
4775
|
-
const
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
return { ok: false, error: this.harnessUnavailableError(bootStatus.reason ?? `${agent} harness is unavailable`, route) };
|
|
4779
|
-
// A connector registered after boot has no inventory row. Keep the existing pre-mint backstop
|
|
4780
|
-
// for that dynamic library-composition case; ordinary installed connectors were checked at boot.
|
|
4781
|
-
if (!bootStatus) {
|
|
4782
|
-
const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
|
|
4783
|
-
if (missing.length)
|
|
4784
|
-
return { ok: false, error: this.harnessUnavailableError(`${agent} harness needs ${missing.join(", ")} on PATH - not found`, route) };
|
|
4785
|
-
}
|
|
4815
|
+
const harness = this.launchHarness(connector);
|
|
4816
|
+
if ("refusal" in harness)
|
|
4817
|
+
return { ok: false, error: this.harnessUnavailableError(harness.refusal, opts.route ?? "inst") };
|
|
4786
4818
|
// Resume is a connector capability: reject an unsupported resume HERE, before the reserve/mint, so
|
|
4787
4819
|
// it can never provision creds + durables and then throw at buildLaunch (mint-then-orphan). Same
|
|
4788
4820
|
// reject-before-side-effects window as the harness preflight above; buildLaunch stays the backstop.
|
|
@@ -4835,7 +4867,7 @@ export class Manager {
|
|
|
4835
4867
|
return { ok: false, error: `${delegatedBy}, so it cannot reopen a session held on this host (continuity: exact)` };
|
|
4836
4868
|
if (typeof opts.cwd === "string" && opts.cwd !== "")
|
|
4837
4869
|
return { ok: false, error: `${delegatedBy}, so it cannot run in a directory on this host's filesystem (cwd); the seat runs in its runtime resource's own directory` };
|
|
4838
|
-
const shared = Object.keys(connectorServers(loadCotalConfig(this.workspaceRoot), agent,
|
|
4870
|
+
const shared = Object.keys(connectorServers(loadCotalConfig(this.workspaceRoot), agent, shareTools));
|
|
4839
4871
|
if (shared.length)
|
|
4840
4872
|
return { ok: false, error: `${delegatedBy}, so it cannot share MCP servers that run on this host (${shared.join(", ")}); pass --share-tools none` };
|
|
4841
4873
|
}
|
|
@@ -4856,7 +4888,7 @@ export class Manager {
|
|
|
4856
4888
|
// Resolve the launch profile: IDENTITY (free-form `name:`) + role + read/post ACL + capabilities
|
|
4857
4889
|
// + model/variant. Either from a fully-resolved manifest launch object (`opts.resolved`, whose `config`
|
|
4858
4890
|
// is a materialized transient persona — the file is NOT the access authority), or from the
|
|
4859
|
-
// persona file. The number rides the IDENTITY (socrates →
|
|
4891
|
+
// persona file. The number rides the IDENTITY (socrates → socrates_2), not the file ref — a
|
|
4860
4892
|
// redelivered identical spawn yields a fresh numbered agent (MAX_AGENTS bounds the blast radius).
|
|
4861
4893
|
let identityName;
|
|
4862
4894
|
let role;
|
|
@@ -4871,13 +4903,13 @@ export class Manager {
|
|
|
4871
4903
|
if (opts.resolved) {
|
|
4872
4904
|
// A manifest launch is the access + identity authority: imperative overrides arriving
|
|
4873
4905
|
// alongside `resolved` are a caller contract error, not something to merge (no fallbacks).
|
|
4874
|
-
if (opts.subscribe || opts.allowSubscribe || opts.allowPublish || opts.prompt ||
|
|
4906
|
+
if (opts.subscribe || opts.allowSubscribe || opts.allowPublish || opts.prompt || shareTools || opts.identity)
|
|
4875
4907
|
return { ok: false, error: "a manifest launch (resolved) rejects imperative overrides (identity/subscribe/allow*/prompt/shareTools)" };
|
|
4876
4908
|
const r = opts.resolved;
|
|
4877
4909
|
identityName = r.name;
|
|
4878
4910
|
role = opts.role ?? r.role;
|
|
4879
4911
|
subscribe = r.subscribe;
|
|
4880
|
-
allowSubscribe = r.allowSubscribe
|
|
4912
|
+
allowSubscribe = r.allowSubscribe;
|
|
4881
4913
|
allowPublish = r.allowPublish;
|
|
4882
4914
|
capabilities = r.capabilities;
|
|
4883
4915
|
model = opts.model ?? r.model;
|
|
@@ -4899,9 +4931,7 @@ export class Manager {
|
|
|
4899
4931
|
// paths of the merged grammar can't diverge. One source feeds BOTH the minted creds and the
|
|
4900
4932
|
// connector env below.
|
|
4901
4933
|
subscribe = opts.subscribe ?? def.subscribe;
|
|
4902
|
-
|
|
4903
|
-
// boundary); runtime durable joins are re-authorized against the committed ACL by the daemon.
|
|
4904
|
-
allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? subscribe ?? [];
|
|
4934
|
+
allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? [];
|
|
4905
4935
|
allowPublish = opts.allowPublish ?? def.allowPublish;
|
|
4906
4936
|
capabilities = def.capabilities;
|
|
4907
4937
|
// #651: fold the persona's model into the launch record, mirroring the variant line below
|
|
@@ -4912,6 +4942,14 @@ export class Manager {
|
|
|
4912
4942
|
variant = opts.variant ?? def.variant;
|
|
4913
4943
|
launchOptions = mergeLaunchOptions(def.launchOptions, opts.launchOptions);
|
|
4914
4944
|
}
|
|
4945
|
+
// Resolved once for both branches, because this one list is granted, minted, recorded on the
|
|
4946
|
+
// launch and forwarded to the session, and each of those must read the list the provisioner does.
|
|
4947
|
+
try {
|
|
4948
|
+
allowSubscribe = resolveReadAcl(subscribe ?? [], allowSubscribe);
|
|
4949
|
+
}
|
|
4950
|
+
catch (e) {
|
|
4951
|
+
return { ok: false, error: opts.resolved ? `launch agent: ${e.message}` : `persona ${configPath}: ${e.message}` };
|
|
4952
|
+
}
|
|
4915
4953
|
// #651: an empty or whitespace-only model string is not a pin. Coerce it to undefined here, at
|
|
4916
4954
|
// the single point every path (persona, manifest, imperative) has resolved `model`, so it
|
|
4917
4955
|
// serializes ABSENT rather than present-but-empty (`"model": ""`), which a key-presence consumer
|
|
@@ -4979,18 +5017,15 @@ export class Manager {
|
|
|
4979
5017
|
const err = lifecycleBlocked("failed-precondition", `the name "${identityName}" is reserved pending retirement: its previous agent's despawn started that lifecycle's teardown (footprint + standing-authority revoke + auth-side retirement), and the name frees only when all of it completes${held.lastError !== undefined ? ` (last attempt: ${held.lastError})` : ""}. NEXT: wait a moment and retry this spawn (retrying re-drives the whole teardown), or pick another name.`, { blockedOp: "retirement", opId: held.opId, remedy: "retry" });
|
|
4980
5018
|
return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
|
|
4981
5019
|
}
|
|
4982
|
-
if (variant && !connector.supportsModelVariant)
|
|
4983
|
-
return { ok: false, error: `${agent} connector does not support model variants (variant)` };
|
|
4984
|
-
if (prompt !== undefined && !connector.supportsPrompt)
|
|
4985
|
-
return { ok: false, error: `${agent} connector does not support an initial prompt (prompt)` };
|
|
4986
5020
|
// A manifest `continuity: exact` agent reopens the session this manager last bound to its
|
|
4987
5021
|
// declared name. Refused before any reserve or mint when the connector cannot reopen an exact
|
|
4988
5022
|
// session or the recorded assignment does not match this declaration.
|
|
4989
5023
|
const exact = opts.resolved?.continuity === "exact";
|
|
5024
|
+
const unsupported = this.capabilityRefusal(connector, { variant, prompt, exact });
|
|
5025
|
+
if (unsupported)
|
|
5026
|
+
return { ok: false, error: unsupported };
|
|
4990
5027
|
let reopenSession;
|
|
4991
5028
|
if (exact) {
|
|
4992
|
-
if (!connector.supportsSessionReopen || !connector.supportsSessionContinuation)
|
|
4993
|
-
return { ok: false, error: `${agent} connector does not support exact session continuity (continuity: exact)` };
|
|
4994
5029
|
try {
|
|
4995
5030
|
reopenSession = readContinuityAssignment(this.workspaceRoot, { space: this.space, name: identityName, connector: agent, cwd: resolvedCwd ?? this.workspaceRoot });
|
|
4996
5031
|
}
|
|
@@ -5002,6 +5037,30 @@ export class Manager {
|
|
|
5002
5037
|
const unportable = this.runtime.spawnDelegated ? Object.keys(launchOptions ?? {}).filter((k) => typeof launchOptions[k] !== "string") : [];
|
|
5003
5038
|
if (unportable.length)
|
|
5004
5039
|
return { ok: false, error: `runtime "${this.runtime.kind}" starts seats outside this host, so it cannot pass non-string launch options (${unportable.join(", ")})` };
|
|
5040
|
+
// The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
|
|
5041
|
+
// anything is minted: a connector that cannot
|
|
5042
|
+
// emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
|
|
5043
|
+
// The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
|
|
5044
|
+
// the principal's owner is resolved further down, so deriving it from anything in scope here
|
|
5045
|
+
// would mean guessing at the identity the child will actually connect as. It is added at the
|
|
5046
|
+
// accept seam below, where the allocated triple exists.
|
|
5047
|
+
if (this.eventsRequired && opts.events === false)
|
|
5048
|
+
return { ok: false, error: `space "${this.space}" requires the event plane by registration policy; --no-events (events: false on the start op) is not allowed` };
|
|
5049
|
+
const events = this.eventsRequired || opts.events !== false;
|
|
5050
|
+
if (events && !connector.eventChannel)
|
|
5051
|
+
return { ok: false, error: this.eventsRequired
|
|
5052
|
+
? `space "${this.space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
|
|
5053
|
+
: `connector "${connector.name}" does not publish an AG-UI event plane; pass --no-events (events: false on the start op) to launch it without one` };
|
|
5054
|
+
// F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
|
|
5055
|
+
// a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
|
|
5056
|
+
// but-uncompleted endpoint obligation could execute AFTER its uid is declared retired. The
|
|
5057
|
+
// refusal sits at spawn-accept, before any provisioning, over the same records a persona or
|
|
5058
|
+
// manifest self-claim would ride in on — capabilities cannot slip past it into the grant path.
|
|
5059
|
+
if (this.auth && !this.userMode) {
|
|
5060
|
+
const claims = [opts, (opts.resolved ?? {})];
|
|
5061
|
+
if (claims.some((c) => c.endpointCapabilities !== undefined))
|
|
5062
|
+
return { ok: false, error: "a static managed spawn refuses endpointCapabilities (Unit B F2): the static lifecycle terminal carries no obligation-drain/frontier steps, so endpoint-rail grants are not containable in static mode" };
|
|
5063
|
+
}
|
|
5005
5064
|
// #4 A4 (panel): the roster the allocation consults must reflect the initial presence snapshot,
|
|
5006
5065
|
// or a spawn immediately after manager boot races an already-live unmanaged peer and re-opens the
|
|
5007
5066
|
// very collision black-hole this closes. Await the snapshot (bounded internally, fail-safe on an
|
|
@@ -5011,10 +5070,10 @@ export class Manager {
|
|
|
5011
5070
|
await this.ep.waitForPresenceSnapshot();
|
|
5012
5071
|
// M6 (P2 item 2 spawn-as-action): a HARD-PINNED name — an imperative `--name`/identity override
|
|
5013
5072
|
// or a manifest-declared name (opts.resolved) — that collides with a LIVE/provisioning/reserved
|
|
5014
|
-
// incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent
|
|
5073
|
+
// incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent `_2`
|
|
5015
5074
|
// suffix (so an address-by-triple caller's pinned name can't be re-pointed). A PERSONA-DERIVED
|
|
5016
5075
|
// base name (no pin) keeps uniqueName's collision numbering, so multi-peer `spawn reviewer` twice
|
|
5017
|
-
// still yields reviewer +
|
|
5076
|
+
// still yields reviewer + reviewer_2. The retiring-hold refuse (~2472) is orthogonal and already fired.
|
|
5018
5077
|
const hardPinned = opts.identity !== undefined || opts.resolved !== undefined;
|
|
5019
5078
|
let name;
|
|
5020
5079
|
if (hardPinned) {
|
|
@@ -5030,42 +5089,6 @@ export class Manager {
|
|
|
5030
5089
|
else {
|
|
5031
5090
|
name = this.uniqueName(identityName);
|
|
5032
5091
|
}
|
|
5033
|
-
this.reserved.add(name);
|
|
5034
|
-
// The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
|
|
5035
|
-
// anything is minted: a connector that cannot
|
|
5036
|
-
// emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
|
|
5037
|
-
// The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
|
|
5038
|
-
// the principal's owner is resolved further down, so deriving it from anything in scope here
|
|
5039
|
-
// would mean guessing at the identity the child will actually connect as. It is added at the
|
|
5040
|
-
// accept seam below, where the allocated triple exists.
|
|
5041
|
-
if (this.eventsRequired && opts.events === false) {
|
|
5042
|
-
this.reserved.delete(name);
|
|
5043
|
-
return { ok: false, error: `space "${this.space}" requires the event plane by registration policy; --no-events (events: false on the start op) is not allowed` };
|
|
5044
|
-
}
|
|
5045
|
-
const events = this.eventsRequired || opts.events !== false;
|
|
5046
|
-
if (events && !connector.eventChannel) {
|
|
5047
|
-
// Release the just-reserved name on this fail-fast path. A leaked reserve is silent: it costs
|
|
5048
|
-
// the next spawn of this persona its un-suffixed name and nothing reports why.
|
|
5049
|
-
this.reserved.delete(name);
|
|
5050
|
-
return { ok: false, error: this.eventsRequired
|
|
5051
|
-
? `space "${this.space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
|
|
5052
|
-
: `connector "${connector.name}" does not publish an AG-UI event plane; pass --no-events (events: false on the start op) to launch it without one` };
|
|
5053
|
-
}
|
|
5054
|
-
// F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
|
|
5055
|
-
// a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
|
|
5056
|
-
// but-uncompleted endpoint obligation could execute AFTER its uid is declared retired. The
|
|
5057
|
-
// refusal sits at spawn-accept, before any provisioning, over the same records a persona or
|
|
5058
|
-
// manifest self-claim would ride in on — capabilities cannot slip past it into the grant path.
|
|
5059
|
-
if (this.auth && !this.userMode) {
|
|
5060
|
-
const claims = [opts, (opts.resolved ?? {})];
|
|
5061
|
-
if (claims.some((c) => c.endpointCapabilities !== undefined)) {
|
|
5062
|
-
this.reserved.delete(name);
|
|
5063
|
-
return { ok: false, error: "a static managed spawn refuses endpointCapabilities (Unit B F2): the static lifecycle terminal carries no obligation-drain/frontier steps, so endpoint-rail grants are not containable in static mode" };
|
|
5064
|
-
}
|
|
5065
|
-
}
|
|
5066
|
-
// Set once the agent's creds + durables are minted; cleared the moment a live slot takes ownership
|
|
5067
|
-
// (`agents.set`, after which freeSlot deprovisions on exit). If it survives to `finally`, the spawn
|
|
5068
|
-
// threw AFTER minting (buildLaunch / runtime.spawn) — tear the orphan down so no footprint leaks (#159 B).
|
|
5069
5092
|
// Set once the agent's footprint (durables + creds, or the user-mode grant + secret files)
|
|
5070
5093
|
// exists; cleared when a live slot takes ownership. If it survives to `finally`, the spawn threw
|
|
5071
5094
|
// AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
|
|
@@ -5079,6 +5102,9 @@ export class Manager {
|
|
|
5079
5102
|
let provisioned;
|
|
5080
5103
|
// A carried resume's seat-private home, removed by the finally unless a live seat took it.
|
|
5081
5104
|
let seatHome;
|
|
5105
|
+
// Taken right before the try so its finally is the only release; a refusal belongs above the
|
|
5106
|
+
// allocation, where there is no name to give back.
|
|
5107
|
+
this.reserved.add(name);
|
|
5082
5108
|
try {
|
|
5083
5109
|
// A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
|
|
5084
5110
|
// COTAL_ID); the seed is retained to mint matching creds later.
|
|
@@ -5184,10 +5210,8 @@ export class Manager {
|
|
|
5184
5210
|
}
|
|
5185
5211
|
: undefined,
|
|
5186
5212
|
});
|
|
5187
|
-
if ("error" in prep)
|
|
5188
|
-
this.reserved.delete(name);
|
|
5213
|
+
if ("error" in prep)
|
|
5189
5214
|
return { ok: false, error: prep.error };
|
|
5190
|
-
}
|
|
5191
5215
|
// THE HOST'S UID WINS (#1972). On the local arm this is the value passed in, unchanged. On
|
|
5192
5216
|
// the hosted enrollment arm the host selected it, and from here every lifecycle-keyed thing
|
|
5193
5217
|
// this spawn records — the slot, the launch, the managed row, the teardown credential — must
|
|
@@ -5249,7 +5273,7 @@ export class Manager {
|
|
|
5249
5273
|
// (cotal config; default none → isolated, the memory-safe default this guards), narrowed by
|
|
5250
5274
|
// an optional --share-tools selection (absent → all declared, the pre-merge behavior).
|
|
5251
5275
|
const cotalConfig = loadCotalConfig(this.workspaceRoot);
|
|
5252
|
-
const mcpServers = connectorServers(cotalConfig, agent,
|
|
5276
|
+
const mcpServers = connectorServers(cotalConfig, agent, shareTools);
|
|
5253
5277
|
// The operator's spawn-env policy travels the same route: absent means no extras (the OS
|
|
5254
5278
|
// allow-list + operator knobs + connector-declared inputs), present means those names too.
|
|
5255
5279
|
// A connector never reads the config itself.
|
|
@@ -5304,10 +5328,11 @@ export class Manager {
|
|
|
5304
5328
|
eventsRequired: this.eventsRequired,
|
|
5305
5329
|
mcpServers,
|
|
5306
5330
|
envAllow,
|
|
5307
|
-
resolvedBinaries:
|
|
5331
|
+
resolvedBinaries: harness.binaries,
|
|
5308
5332
|
// So a connector that keeps per-agent local state can root it at the workspace, not the
|
|
5309
|
-
// (possibly per-agent) launch cwd
|
|
5333
|
+
// (possibly per-agent) launch cwd.
|
|
5310
5334
|
workspaceRoot: this.workspaceRoot,
|
|
5335
|
+
cwd,
|
|
5311
5336
|
};
|
|
5312
5337
|
// A delegated seat gets no local launch at all: no launch-material file, control token or
|
|
5313
5338
|
// manager path is produced for it, only the handoff its runtime writes into the child.
|
|
@@ -5375,7 +5400,7 @@ export class Manager {
|
|
|
5375
5400
|
allowPublish,
|
|
5376
5401
|
capabilities,
|
|
5377
5402
|
events,
|
|
5378
|
-
shareTools
|
|
5403
|
+
shareTools,
|
|
5379
5404
|
forkSource: opts.resume,
|
|
5380
5405
|
...(opts.resume !== undefined && spec?.resumeRecordPath ? { resumeRecordPath: spec.resumeRecordPath } : {}),
|
|
5381
5406
|
...(carried ? { carried: { transcriptSha256: carried.sha256, host: carried.sourceHost, transferredAt: carried.stagedAt } } : {}),
|
|
@@ -5508,10 +5533,17 @@ export class Manager {
|
|
|
5508
5533
|
// Failure after reserve (provision / launch threw): the slot was never live, so no cold-start
|
|
5509
5534
|
// was paid — the reserved rollback (finally) is enough, no cooling stamp.
|
|
5510
5535
|
// A lifecycle-blocked envelope already named the barrier; keep its details on the ControlReply
|
|
5511
|
-
// so follow/CLI/cotal_spawn do not collapse it to a generic string (#873).
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5536
|
+
// so follow/CLI/cotal_spawn do not collapse it to a generic string (#873). An extension can throw
|
|
5537
|
+
// any value, and an envelope's `message` is only typed as a string, so the refusal text always
|
|
5538
|
+
// comes from rejectionText; telling an envelope apart is guarded because a thrown Proxy can
|
|
5539
|
+
// throw from `instanceof` or from its own fields.
|
|
5540
|
+
const error = rejectionText(e);
|
|
5541
|
+
try {
|
|
5542
|
+
if (e instanceof EpEnvelopeError)
|
|
5543
|
+
return { ok: false, error: renderLifecycleBlocked(error, e), ...(e.details ? { details: e.details } : {}) };
|
|
5544
|
+
}
|
|
5545
|
+
catch { /* not a readable envelope */ }
|
|
5546
|
+
return { ok: false, error };
|
|
5515
5547
|
}
|
|
5516
5548
|
finally {
|
|
5517
5549
|
this.reserved.delete(name);
|
|
@@ -5541,19 +5573,22 @@ export class Manager {
|
|
|
5541
5573
|
const batchReservations = [];
|
|
5542
5574
|
const prepared = new Map();
|
|
5543
5575
|
try {
|
|
5544
|
-
if (inventory.version !== "cotal-manager-resume/
|
|
5576
|
+
if (inventory.version !== "cotal-manager-resume/v2")
|
|
5545
5577
|
return { ok: false, agents: [], error: `unsupported manager resume inventory version ${String(inventory.version)}` };
|
|
5546
5578
|
if (inventory.space !== this.space)
|
|
5547
5579
|
return { ok: false, agents: [], error: `resume inventory belongs to space "${inventory.space}", not "${this.space}"` };
|
|
5580
|
+
// The caller keeps its inventory, so each selection is copied before the first await: a later
|
|
5581
|
+
// write to it cannot change what a resumed seat shares or what the next preservation retains.
|
|
5582
|
+
const entries = inventory.agents.map((entry) => ({ ...entry, launch: { ...entry.launch, shareTools: entry.launch.shareTools && [...entry.launch.shareTools] } }));
|
|
5548
5583
|
const seen = new Set();
|
|
5549
5584
|
const principals = new Set();
|
|
5550
5585
|
const orphans = [];
|
|
5551
5586
|
await this.ep.waitForPresenceSnapshot();
|
|
5552
5587
|
const liveRoster = this.ep.getRoster().filter((presence) => presence.status !== "offline");
|
|
5553
5588
|
const occupancy = this.occupancy();
|
|
5554
|
-
if (occupancy.used +
|
|
5555
|
-
return { ok: false, agents: [], error: `resume inventory of ${
|
|
5556
|
-
for (const entry of
|
|
5589
|
+
if (occupancy.used + entries.length > MAX_AGENTS)
|
|
5590
|
+
return { ok: false, agents: [], error: `resume inventory of ${entries.length} would exceed manager capacity (${this.occupancyText(occupancy)})` };
|
|
5591
|
+
for (const entry of entries) {
|
|
5557
5592
|
if (seen.has(entry.name))
|
|
5558
5593
|
return { ok: false, agents: [], error: `resume inventory contains duplicate agent name "${entry.name}"` };
|
|
5559
5594
|
seen.add(entry.name);
|
|
@@ -5610,12 +5645,12 @@ export class Manager {
|
|
|
5610
5645
|
if (this.agents.has(entry.name) || this.reserved.has(entry.name))
|
|
5611
5646
|
return { ok: false, agents: [], error: `retained agent "${entry.name}" is already managed or reserved` };
|
|
5612
5647
|
}
|
|
5613
|
-
for (const entry of
|
|
5648
|
+
for (const entry of entries) {
|
|
5614
5649
|
this.reserved.add(entry.name);
|
|
5615
5650
|
batchReservations.push(entry.name);
|
|
5616
5651
|
}
|
|
5617
5652
|
const preflight = [];
|
|
5618
|
-
for (const entry of
|
|
5653
|
+
for (const entry of entries) {
|
|
5619
5654
|
const reply = await this.resumePreservedAgent(entry, true, true, prepared);
|
|
5620
5655
|
preflight.push({ name: entry.name, reply });
|
|
5621
5656
|
}
|
|
@@ -5634,12 +5669,12 @@ export class Manager {
|
|
|
5634
5669
|
return { ok: false, agents: [], error: refusal };
|
|
5635
5670
|
}
|
|
5636
5671
|
const agents = [];
|
|
5637
|
-
for (let i = 0; i <
|
|
5638
|
-
const entry =
|
|
5672
|
+
for (let i = 0; i < entries.length; i++) {
|
|
5673
|
+
const entry = entries[i];
|
|
5639
5674
|
const reply = await this.resumePreservedAgent(entry, false, true, prepared);
|
|
5640
5675
|
agents.push({ name: entry.name, reply });
|
|
5641
5676
|
if (!reply.ok) {
|
|
5642
|
-
for (const skipped of
|
|
5677
|
+
for (const skipped of entries.slice(i + 1))
|
|
5643
5678
|
agents.push({ name: skipped.name, reply: { ok: false, error: `not launched because ${entry.name} failed` } });
|
|
5644
5679
|
return { ok: false, agents, error: reply.error };
|
|
5645
5680
|
}
|
|
@@ -5849,7 +5884,7 @@ export class Manager {
|
|
|
5849
5884
|
const slot = await this.readStaticReconcileSlot(entry.name);
|
|
5850
5885
|
if (slot?.phase !== "active" || slot.actor !== entry.identity.id || slot.lifecycleUid !== entry.identity.lifecycleUid)
|
|
5851
5886
|
return undefined;
|
|
5852
|
-
if (slot
|
|
5887
|
+
if (ownedBySibling(slot, this.managerInstanceId))
|
|
5853
5888
|
return undefined;
|
|
5854
5889
|
return slot.runtime?.kind === this.runtime.kind ? slot.runtime : undefined;
|
|
5855
5890
|
}
|
|
@@ -5924,13 +5959,9 @@ export class Manager {
|
|
|
5924
5959
|
catch (e) {
|
|
5925
5960
|
return { ok: false, error: e.message };
|
|
5926
5961
|
}
|
|
5927
|
-
const
|
|
5928
|
-
if (
|
|
5929
|
-
return { ok: false, error:
|
|
5930
|
-
if (entry.launch.variant && !connector.supportsModelVariant)
|
|
5931
|
-
return { ok: false, error: `${connector.name} connector does not support model variants (variant)` };
|
|
5932
|
-
if (entry.launch.prompt !== undefined && !connector.supportsPrompt)
|
|
5933
|
-
return { ok: false, error: `${connector.name} connector does not support an initial prompt (prompt)` };
|
|
5962
|
+
const harness = this.launchHarness(connector);
|
|
5963
|
+
if ("refusal" in harness)
|
|
5964
|
+
return { ok: false, error: harness.refusal };
|
|
5934
5965
|
let retainedSession;
|
|
5935
5966
|
try {
|
|
5936
5967
|
retainedSession = this.retainedSessionId(entry, connector);
|
|
@@ -5962,10 +5993,6 @@ export class Manager {
|
|
|
5962
5993
|
return { ok: false, error: `retained manifest agent ${launchSource.requested} is missing or its hash changed; refusing same-principal resume` };
|
|
5963
5994
|
launchOptions = spec.launchOptions;
|
|
5964
5995
|
exact = spec.continuity === "exact";
|
|
5965
|
-
// An exact seat reopens its retained session or fails, like its manifest launch, so the
|
|
5966
|
-
// connector must honor reopenSession before the batch starts any child.
|
|
5967
|
-
if (exact && (!connector.supportsSessionReopen || !connector.supportsSessionContinuation))
|
|
5968
|
-
return { ok: false, error: `${connector.name} connector does not support exact session continuity (continuity: exact)` };
|
|
5969
5996
|
}
|
|
5970
5997
|
else {
|
|
5971
5998
|
try {
|
|
@@ -5975,6 +6002,11 @@ export class Manager {
|
|
|
5975
6002
|
return { ok: false, error: e.message };
|
|
5976
6003
|
}
|
|
5977
6004
|
}
|
|
6005
|
+
// Asked once `exact` is known: an exact seat reopens its retained session or fails, like its
|
|
6006
|
+
// manifest launch, so the connector must honor reopenSession before the batch starts any child.
|
|
6007
|
+
const unsupported = this.capabilityRefusal(connector, { variant: entry.launch.variant, prompt: entry.launch.prompt, exact });
|
|
6008
|
+
if (unsupported)
|
|
6009
|
+
return { ok: false, error: unsupported };
|
|
5978
6010
|
let authority;
|
|
5979
6011
|
try {
|
|
5980
6012
|
authority = await this.validateRetainedAuthority(entry);
|
|
@@ -5984,7 +6016,7 @@ export class Manager {
|
|
|
5984
6016
|
}
|
|
5985
6017
|
try {
|
|
5986
6018
|
const resumeConfig = loadCotalConfig(this.workspaceRoot);
|
|
5987
|
-
const mcpServers = connectorServers(resumeConfig, entry.launch.connector,
|
|
6019
|
+
const mcpServers = connectorServers(resumeConfig, entry.launch.connector, entry.launch.shareTools);
|
|
5988
6020
|
const envAllow = spawnEnvAllow(resumeConfig);
|
|
5989
6021
|
const launchOpts = {
|
|
5990
6022
|
space: this.space,
|
|
@@ -6016,7 +6048,9 @@ export class Manager {
|
|
|
6016
6048
|
events: entry.launch.events,
|
|
6017
6049
|
mcpServers,
|
|
6018
6050
|
envAllow,
|
|
6051
|
+
resolvedBinaries: harness.binaries,
|
|
6019
6052
|
workspaceRoot: this.workspaceRoot,
|
|
6053
|
+
cwd: entry.launch.cwd,
|
|
6020
6054
|
};
|
|
6021
6055
|
const spec = connector.buildLaunch(launchOpts);
|
|
6022
6056
|
spec.env = { ...spec.env, COTAL_MANAGER_INSTANCE: this.managerInstanceId };
|
|
@@ -8674,7 +8708,7 @@ export class Manager {
|
|
|
8674
8708
|
console.error(`✓ static reconcile recovered alias=${item.alias} phase=retired uid=${item.lifecycleUid} attempts=${item.attempts}/${item.maxAttempts}`);
|
|
8675
8709
|
return;
|
|
8676
8710
|
}
|
|
8677
|
-
if (row
|
|
8711
|
+
if (ownedBySibling(row, this.managerInstanceId)) {
|
|
8678
8712
|
item.disposition = "refused";
|
|
8679
8713
|
item.remedy = "the owning manager must reconcile or an operator must perform an explicit CAS takeover";
|
|
8680
8714
|
item.lastError = `the durable slot belongs to manager instance ${row.ownerInstanceId}, not ${this.managerInstanceId}`;
|
|
@@ -8867,7 +8901,7 @@ export class Manager {
|
|
|
8867
8901
|
// hazard, now cross-instance). A legacy row (pre-3b-2, no owner recorded) predates multi-manager,
|
|
8868
8902
|
// so this manager is its legitimate single-manager-past successor and reconciles it. An orphaned
|
|
8869
8903
|
// sibling row is reclaimed only by an explicit operator CAS takeover (ruling 1), never here.
|
|
8870
|
-
if (row
|
|
8904
|
+
if (ownedBySibling(row, this.managerInstanceId)) {
|
|
8871
8905
|
preservedForeign++;
|
|
8872
8906
|
continue;
|
|
8873
8907
|
}
|