@cotal-ai/manager 0.50.1 → 0.52.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.d.ts +3 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +40 -14
- package/dist/commands.js.map +1 -1
- package/dist/launch.d.ts +1 -0
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +2 -0
- package/dist/launch.js.map +1 -1
- package/dist/manager-service-contract.d.ts +16 -2
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +79 -12
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +56 -6
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +154 -30
- package/dist/manager.js.map +1 -1
- package/dist/resume.d.ts.map +1 -1
- package/dist/resume.js +4 -0
- package/dist/resume.js.map +1 -1
- package/dist/run-hosting.d.ts +2 -2
- package/dist/run-hosting.d.ts.map +1 -1
- package/dist/run-hosting.js +28 -7
- package/dist/run-hosting.js.map +1 -1
- package/package.json +10 -10
package/dist/manager.js
CHANGED
|
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
5
|
-
import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
-
import { join, dirname, resolve } from "node:path";
|
|
5
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
|
+
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
7
7
|
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, divergentSecretStoreNotice, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
|
|
8
8
|
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
9
|
import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
|
|
@@ -147,9 +147,10 @@ function sameStrings(a, b) {
|
|
|
147
147
|
return JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
|
|
148
148
|
}
|
|
149
149
|
/** Operator-facing phrasing per cause. Kept beside the union so adding a member without a sentence
|
|
150
|
-
* is a type error rather than a blank in the log.
|
|
150
|
+
* is a type error rather than a blank in the log. The stop family's sentences carry the principal
|
|
151
|
+
* from the cause, so they render through {@link STOP_CAUSE_TEXT} under the same exhaustive-Record
|
|
152
|
+
* discipline: a new stop kind without a renderer is a type error too. */
|
|
151
153
|
const FREE_SLOT_CAUSE_TEXT = {
|
|
152
|
-
"stopped": "this manager stopped it (despawn or shutdown)",
|
|
153
154
|
"process-exit": "its own process exited and this manager did not stop it",
|
|
154
155
|
"pi-crash-loop": "this manager retired it after a Pi crash loop",
|
|
155
156
|
"pi-recovery-failed": "this manager retired it after Pi session recovery failed",
|
|
@@ -158,6 +159,21 @@ const FREE_SLOT_CAUSE_TEXT = {
|
|
|
158
159
|
"session-bind-failed": "this manager stopped it: its host session could not be bound at launch",
|
|
159
160
|
"resume-session-rebind-failed": "this manager stopped it: its host session could not be rebound on resume",
|
|
160
161
|
};
|
|
162
|
+
/** The stop family's sentences — the same job {@link FREE_SLOT_CAUSE_TEXT} does for the string
|
|
163
|
+
* causes, beside it so both stay the single place a cause becomes a sentence. A requested stop's
|
|
164
|
+
* sentence embeds the authenticated principal and a recursive reap's embeds the parent that
|
|
165
|
+
* left, so they cannot sit in a static Record keyed by kind; this exhaustive switch is their
|
|
166
|
+
* single home, and the `satisfies never` default keeps the discipline: a new stop kind without a
|
|
167
|
+
* sentence is a type error rather than a blank in the log. */
|
|
168
|
+
function stopCauseText(cause) {
|
|
169
|
+
switch (cause.kind) {
|
|
170
|
+
case "stopped-requested": return `this manager stopped it at ${cause.requester}'s request`;
|
|
171
|
+
case "stopped-self": return "it stopped itself (self-stop)";
|
|
172
|
+
case "stopped-reap": return `this manager stopped it because its parent ${cause.parent} left (recursive reap)`;
|
|
173
|
+
case "stopped-shutdown": return "this manager stopped it on manager shutdown";
|
|
174
|
+
default: return cause;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
161
177
|
function parseTurnNote(raw) {
|
|
162
178
|
let o;
|
|
163
179
|
try {
|
|
@@ -284,6 +300,7 @@ export class Manager {
|
|
|
284
300
|
wsPort;
|
|
285
301
|
name;
|
|
286
302
|
workspaceRoot;
|
|
303
|
+
eventsRequired;
|
|
287
304
|
/** P2 item 6: the operator-set global live-session ceiling (see {@link ManagerOptions.maxSessions}). */
|
|
288
305
|
maxSessions;
|
|
289
306
|
/** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
|
|
@@ -535,6 +552,7 @@ export class Manager {
|
|
|
535
552
|
this.servers = opts.servers;
|
|
536
553
|
this.name = opts.name ?? "manager";
|
|
537
554
|
this.workspaceRoot = opts.workspaceRoot ?? findCotalRoot();
|
|
555
|
+
this.eventsRequired = opts.eventsRequired === true;
|
|
538
556
|
this.maxSessions = opts.maxSessions;
|
|
539
557
|
this.remoteAuthority = opts.remoteAuthority;
|
|
540
558
|
if (opts.remoteAuthority)
|
|
@@ -927,7 +945,7 @@ export class Manager {
|
|
|
927
945
|
}
|
|
928
946
|
/** One class-2 renewal pass (D5 slice 5): re-sign `.cotal/delivery.creds` + `.cotal/membership-rw.creds`
|
|
929
947
|
* for their existing nkeys, then request the delivery daemon's EXPLICIT `reloadCreds` adoption on the
|
|
930
|
-
* delivery-admin rail and persist the audit record (`.cotal/renewal
|
|
948
|
+
* delivery-admin rail and persist the audit record (`.cotal/renewal.<spaceKey>.json`) that `cotal doctor auth`
|
|
931
949
|
* renders — so "file re-signed" and "daemon adopted" are distinguishable states. A missing daemon
|
|
932
950
|
* (no responder) is recorded honestly: each daemon's own 75% renewal timer remains the adoption backstop.
|
|
933
951
|
* Never throws — renewal failure must be LOUD (log + record), not fatal to the supervisor. */
|
|
@@ -985,7 +1003,7 @@ export class Manager {
|
|
|
985
1003
|
// A non-owner (#1634) re-signed nothing, so it writes no record: an empty one renders in
|
|
986
1004
|
// `doctor auth` as a pass that never ran.
|
|
987
1005
|
if (this.daemonRenewalOwner)
|
|
988
|
-
writeRenewalRecord(this.workspaceRoot, { ts: new Date().toISOString(), owner: "manager", results, adoption });
|
|
1006
|
+
writeRenewalRecord(this.workspaceRoot, this.space, { ts: new Date().toISOString(), owner: "manager", results, adoption });
|
|
989
1007
|
this.warnOnSystemCredExpiry();
|
|
990
1008
|
// F5(b) (Unit B): the MANAGER is the renewal owner for its managed-static agent creds —
|
|
991
1009
|
// supervisor-side PUSH remint for recorded LIVE slots (the child JWT is never proof of
|
|
@@ -1480,6 +1498,9 @@ export class Manager {
|
|
|
1480
1498
|
shareTools: a.launch.shareTools,
|
|
1481
1499
|
forkSource: a.launch.forkSource,
|
|
1482
1500
|
sessionId: a.restart?.armed ? this.readManagedSession(a) : a.launch.sessionId,
|
|
1501
|
+
// Additive and only when the connector supplied one. A seat with no pointer records no
|
|
1502
|
+
// field, which is what keeps an older inventory and a fresh one the same document shape.
|
|
1503
|
+
...(a.restart?.sessionStatePath ? { sessionStatePath: a.restart.sessionStatePath } : {}),
|
|
1483
1504
|
unresolvedLaunchOptionKeys: a.launch.unresolvedLaunchOptionKeys,
|
|
1484
1505
|
},
|
|
1485
1506
|
dependencies,
|
|
@@ -1504,6 +1525,11 @@ export class Manager {
|
|
|
1504
1525
|
this.agents.delete(a.name);
|
|
1505
1526
|
this.stopHandle(a, false);
|
|
1506
1527
|
}
|
|
1528
|
+
// The slot delete above bypasses freeSlot, which is where the reap line lives — so say it here,
|
|
1529
|
+
// once per seat, with the one stop cause that names this path: the shutdown itself. Without
|
|
1530
|
+
// this a `--with-agents` stop left every seat unaccounted for in the log (see #1423).
|
|
1531
|
+
for (const a of managed)
|
|
1532
|
+
this.logSeatReaped(a, { kind: "stopped-shutdown" });
|
|
1507
1533
|
// A runtime stop request is not exit proof. Do not release the manager lease or registration
|
|
1508
1534
|
// while any seat may still hold this instance's broker rails: that creates the exact orphan
|
|
1509
1535
|
// window where a successor sees a dead manager but live predecessor authority. Every shipped
|
|
@@ -2070,6 +2096,34 @@ export class Manager {
|
|
|
2070
2096
|
return a.name;
|
|
2071
2097
|
return caller;
|
|
2072
2098
|
}
|
|
2099
|
+
/** A baseline seat may answer only the pause whose relay is pending on that exact incarnation.
|
|
2100
|
+
* Explicit `run` holders and unmanaged operator instruments keep the run capability's existing
|
|
2101
|
+
* answer reach. The relay payload is manager-held state written by the runtime, never request
|
|
2102
|
+
* input, and its token is checked against the open checkpoint before any answer is filed. */
|
|
2103
|
+
authorizeRunAnswer(ctx, args, open) {
|
|
2104
|
+
const caller = principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
|
|
2105
|
+
const agent = [...this.agents.values()].find((a) => this.managedPrincipal(a) === caller && a.lifecycleUid === ctx.subject.caller.uid);
|
|
2106
|
+
if (agent === undefined || agent.launch.capabilities?.includes("run"))
|
|
2107
|
+
return;
|
|
2108
|
+
const pending = [...this.pendingTurns.values()].find((p) => p.seat.owner === ctx.subject.caller.owner
|
|
2109
|
+
&& p.seat.actor === ctx.subject.caller.actor
|
|
2110
|
+
&& p.seat.uid === ctx.subject.caller.uid);
|
|
2111
|
+
if (pending === undefined)
|
|
2112
|
+
throw new EpEnvelopeError("permission-denied", `run-answer is allowed to this baseline seat only for a pending ask or escalation addressed to its own incarnation; none is pending for ${agent.name}`);
|
|
2113
|
+
let payload;
|
|
2114
|
+
try {
|
|
2115
|
+
payload = JSON.parse(pending.payload);
|
|
2116
|
+
}
|
|
2117
|
+
catch {
|
|
2118
|
+
throw new EpEnvelopeError("permission-denied", `turn "${pending.goalId}" carries no readable ask or escalation authorization; run-answer is refused`);
|
|
2119
|
+
}
|
|
2120
|
+
const p = payload;
|
|
2121
|
+
const addressedToken = typeof p.ask?.token === "string" ? p.ask.token
|
|
2122
|
+
: p.checkpoint?.escalatedTo === agent.name && typeof p.checkpoint?.token === "string" ? p.checkpoint.token
|
|
2123
|
+
: undefined;
|
|
2124
|
+
if (p.run !== args.runId || p.step !== args.stepKey || addressedToken !== open.token)
|
|
2125
|
+
throw new EpEnvelopeError("permission-denied", `run-answer is allowed to ${agent.name} only for the open pause named by its pending ask or escalation relay; ${args.runId} ${args.stepKey} is not that pause`);
|
|
2126
|
+
}
|
|
2073
2127
|
managerServiceDefs() {
|
|
2074
2128
|
const args = (ctx) => (ctx.request.args ?? {});
|
|
2075
2129
|
const callerOf = (ctx) => principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
|
|
@@ -2139,15 +2193,16 @@ export class Manager {
|
|
|
2139
2193
|
const data = unwrap(await this.opModels(args(ctx)));
|
|
2140
2194
|
return { catalogs: Array.isArray(data) ? data : [data] };
|
|
2141
2195
|
}),
|
|
2196
|
+
resolveCwd: (ctx) => this.serveGated(ctx, () => this.resolveSpawnCwd(args(ctx).cwd)),
|
|
2142
2197
|
// P2 item 2: `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
|
|
2143
2198
|
// progress + terminal off-handler (no ~30s block). The blocking reply path is gone (pin 8).
|
|
2144
|
-
spawn: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opStart(args(ctx), callerOf(ctx), h))),
|
|
2199
|
+
spawn: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opStart(args(ctx), callerOf(ctx), h, ctx.subject.route))),
|
|
2145
2200
|
despawn: (ctx) => this.serveGated(ctx, async () => {
|
|
2146
2201
|
const a = targetAgent(ctx);
|
|
2147
2202
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
2148
2203
|
if (denied)
|
|
2149
2204
|
throw new EpEnvelopeError("permission-denied", denied);
|
|
2150
|
-
return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true));
|
|
2205
|
+
return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true, callerOf(ctx)));
|
|
2151
2206
|
}),
|
|
2152
2207
|
attach: (ctx) => this.serveGated(ctx, async () => {
|
|
2153
2208
|
const a = targetAgent(ctx);
|
|
@@ -2206,7 +2261,7 @@ export class Manager {
|
|
|
2206
2261
|
// P2 item 2 (ruling 3): manifest `launch` is an ACTION through the SAME chokepoint as spawn -
|
|
2207
2262
|
// the manifest resolve + owner-equality authz run in opLaunch's accept path, then the goal
|
|
2208
2263
|
// drives progress + terminal. The acceptance floor is the allocated identity + goal coords.
|
|
2209
|
-
launch: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opLaunch(args(ctx), callerOf(ctx), false, h))),
|
|
2264
|
+
launch: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opLaunch(args(ctx), callerOf(ctx), false, h, ctx.subject.route))),
|
|
2210
2265
|
resumePreserved: (ctx) => adminGated(ctx, async () => unwrap(await this.opResumePreserved(args(ctx)))),
|
|
2211
2266
|
commitResume: (ctx) => adminGated(ctx, async () => unwrap(await this.opCommitResume(args(ctx)))),
|
|
2212
2267
|
finalizeResume: (ctx) => adminGated(ctx, async () => unwrap(await this.opFinalizeResume(args(ctx)))),
|
|
@@ -2214,7 +2269,10 @@ export class Manager {
|
|
|
2214
2269
|
// (`run` capability / privileged instrument rows); the serve gate is the maintenance fence.
|
|
2215
2270
|
runStart: (ctx) => this.serveGated(ctx, () => this.runHost().start(ctx, args(ctx))),
|
|
2216
2271
|
runResume: (ctx) => this.serveGated(ctx, () => this.runHost().resume(args(ctx))),
|
|
2217
|
-
runAnswer: (ctx) => this.serveGated(ctx, () =>
|
|
2272
|
+
runAnswer: (ctx) => this.serveGated(ctx, () => {
|
|
2273
|
+
const input = args(ctx);
|
|
2274
|
+
return this.runHost().answer(input, this.runAnswerer(ctx), (open) => this.authorizeRunAnswer(ctx, input, open));
|
|
2275
|
+
}),
|
|
2218
2276
|
runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx))),
|
|
2219
2277
|
runPs: (ctx) => this.serveGated(ctx, () => this.runHost().list(args(ctx))),
|
|
2220
2278
|
preparePreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("preparePreservation", args(ctx)))),
|
|
@@ -2358,7 +2416,7 @@ export class Manager {
|
|
|
2358
2416
|
return { ok: false, error: `self-stop: caller ${callerId} is not a managed agent` };
|
|
2359
2417
|
const graceful = args.graceful !== false;
|
|
2360
2418
|
this.stopHandle(target, graceful);
|
|
2361
|
-
this.trackStoppedHandle(target, true);
|
|
2419
|
+
this.trackStoppedHandle(target, true, { kind: "stopped-self" });
|
|
2362
2420
|
return { ok: true, data: { name: target.name, stopped: true, graceful } };
|
|
2363
2421
|
}
|
|
2364
2422
|
// Plane-3 durable join/leave/list ops moved OFF the manager onto the server-side delivery daemon's
|
|
@@ -2392,7 +2450,9 @@ export class Manager {
|
|
|
2392
2450
|
}
|
|
2393
2451
|
}
|
|
2394
2452
|
/** Keep an accepted stop inside the lifecycle drain until the runtime proves the child is gone,
|
|
2395
|
-
* so a maintenance prepare can never fence ahead of a child that is still dying.
|
|
2453
|
+
* so a maintenance prepare can never fence ahead of a child that is still dying. The `cause` is
|
|
2454
|
+
* the stop-family cause the door already knows (which door, and who asked); it rides every
|
|
2455
|
+
* freeSlot below so the reap line can say it.
|
|
2396
2456
|
*
|
|
2397
2457
|
* An operator-accepted stop frees its slot at once: `stop` replying ✓ means `ps` no longer lists
|
|
2398
2458
|
* the agent. That cannot omit a still-live child from a cut, because runPreparation drains the
|
|
@@ -2400,7 +2460,7 @@ export class Manager {
|
|
|
2400
2460
|
* slot lingering. A recursive reap (`requireAuthoritativeExit`) instead keeps the slot until the
|
|
2401
2461
|
* wait proves exit: nobody asked for those children to be gone, so they stay managed until the
|
|
2402
2462
|
* runtime says otherwise, and a runtime that cannot prove exit records an unverified stop. */
|
|
2403
|
-
trackStoppedHandle(a, floor, requireAuthoritativeExit = false) {
|
|
2463
|
+
trackStoppedHandle(a, floor, cause, requireAuthoritativeExit = false) {
|
|
2404
2464
|
if (!a.handle.waitForExit) {
|
|
2405
2465
|
// Preserve ordinary external-runtime stop behavior, but retain enough evidence for a later
|
|
2406
2466
|
// maintenance prepare to fail if that runtime still cannot prove the surface disappeared.
|
|
@@ -2415,14 +2475,14 @@ export class Manager {
|
|
|
2415
2475
|
});
|
|
2416
2476
|
if (requireAuthoritativeExit)
|
|
2417
2477
|
return;
|
|
2418
|
-
this.freeSlot(a, floor,
|
|
2478
|
+
this.freeSlot(a, floor, cause, true);
|
|
2419
2479
|
return;
|
|
2420
2480
|
}
|
|
2421
2481
|
if (!requireAuthoritativeExit)
|
|
2422
|
-
this.freeSlot(a, floor,
|
|
2482
|
+
this.freeSlot(a, floor, cause, true);
|
|
2423
2483
|
this.lifecycleInFlight++;
|
|
2424
2484
|
void this.awaitHandleExit(a.handle)
|
|
2425
|
-
.then(() => this.freeSlot(a, floor,
|
|
2485
|
+
.then(() => this.freeSlot(a, floor, cause, true)) // no-op once an accepted stop already freed it
|
|
2426
2486
|
.catch((e) => {
|
|
2427
2487
|
this.unverifiedStops.push({
|
|
2428
2488
|
name: a.name,
|
|
@@ -2559,6 +2619,7 @@ export class Manager {
|
|
|
2559
2619
|
* that could not provide it.
|
|
2560
2620
|
*/
|
|
2561
2621
|
logSeatReaped(a, cause) {
|
|
2622
|
+
const causeText = typeof cause === "string" ? FREE_SLOT_CAUSE_TEXT[cause] : stopCauseText(cause);
|
|
2562
2623
|
let detail;
|
|
2563
2624
|
try {
|
|
2564
2625
|
const info = a.handle.exitInfo?.();
|
|
@@ -2571,7 +2632,7 @@ export class Manager {
|
|
|
2571
2632
|
// log line (or the free path it sits on) down with it.
|
|
2572
2633
|
detail = `exit detail unreadable from runtime "${a.handle.kind}": ${e.message}`;
|
|
2573
2634
|
}
|
|
2574
|
-
console.error(`seat reaped: ${a.name} (${a.id}, uid ${a.lifecycleUid}${a.handle.pid !== undefined ? `, pid ${a.handle.pid}` : ""}) - ${
|
|
2635
|
+
console.error(`seat reaped: ${a.name} (${a.id}, uid ${a.lifecycleUid}${a.handle.pid !== undefined ? `, pid ${a.handle.pid}` : ""}) - ${causeText}; ${detail}`);
|
|
2575
2636
|
}
|
|
2576
2637
|
freeSlot(a, floor, cause, acceptedBeforeFence = false) {
|
|
2577
2638
|
if (this.agents.get(a.name) !== a)
|
|
@@ -2934,7 +2995,7 @@ export class Manager {
|
|
|
2934
2995
|
continue;
|
|
2935
2996
|
this.reapChildrenOf(this.managedPrincipal(child));
|
|
2936
2997
|
this.stopHandle(child, false);
|
|
2937
|
-
this.trackStoppedHandle(child, true, true);
|
|
2998
|
+
this.trackStoppedHandle(child, true, { kind: "stopped-reap", parent: parentId }, true);
|
|
2938
2999
|
}
|
|
2939
3000
|
}
|
|
2940
3001
|
readSessionStatePath(path) {
|
|
@@ -3227,7 +3288,7 @@ export class Manager {
|
|
|
3227
3288
|
return false;
|
|
3228
3289
|
}
|
|
3229
3290
|
/** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
|
|
3230
|
-
opStart(args, caller, hooks) {
|
|
3291
|
+
opStart(args, caller, hooks, route = "inst") {
|
|
3231
3292
|
// `resume`, when present, must be a non-empty session id. An empty/whitespace value is a
|
|
3232
3293
|
// malformed request, not an implicit "spawn fresh" (no fallbacks). The CLI surfaces reject it,
|
|
3233
3294
|
// but a raw control message could otherwise slip an empty value through and silently start fresh.
|
|
@@ -3293,8 +3354,36 @@ export class Manager {
|
|
|
3293
3354
|
allowPublish,
|
|
3294
3355
|
shareTools: args.shareTools !== undefined ? String(args.shareTools) : undefined,
|
|
3295
3356
|
...(supervise !== undefined ? { supervise } : {}),
|
|
3357
|
+
route,
|
|
3296
3358
|
}, caller, hooks);
|
|
3297
3359
|
}
|
|
3360
|
+
/** Resolve a placed spawn directory on this manager's host. The manager serves any existing
|
|
3361
|
+
* absolute directory it can resolve. It creates nothing and never substitutes the workspace root. */
|
|
3362
|
+
resolveSpawnCwd(value) {
|
|
3363
|
+
const asked = typeof value === "string" ? value : String(value ?? "");
|
|
3364
|
+
const refuse = (reason) => {
|
|
3365
|
+
throw new EpEnvelopeError("failed-precondition", `cwd does not resolve on this host: ${JSON.stringify(asked)} (${reason})`);
|
|
3366
|
+
};
|
|
3367
|
+
if (!isAbsolute(asked))
|
|
3368
|
+
refuse("expected an absolute path");
|
|
3369
|
+
let cwd = asked;
|
|
3370
|
+
try {
|
|
3371
|
+
cwd = realpathSync(asked);
|
|
3372
|
+
}
|
|
3373
|
+
catch (error) {
|
|
3374
|
+
refuse(`path cannot be resolved: ${error.message}`);
|
|
3375
|
+
}
|
|
3376
|
+
try {
|
|
3377
|
+
if (!statSync(cwd).isDirectory())
|
|
3378
|
+
refuse("path is not a directory");
|
|
3379
|
+
}
|
|
3380
|
+
catch (error) {
|
|
3381
|
+
if (error instanceof EpEnvelopeError)
|
|
3382
|
+
throw error;
|
|
3383
|
+
refuse(error.message);
|
|
3384
|
+
}
|
|
3385
|
+
return { cwd, host: hostname() };
|
|
3386
|
+
}
|
|
3298
3387
|
/** Resolve a connector by agent type. Library composition (installedExtensions off) → a registry
|
|
3299
3388
|
* hit, exactly as before (the composition root imported what it wants). The published binary gates
|
|
3300
3389
|
* on MANIFEST membership FIRST — so a live `cotal ext remove` is honored even though the registry
|
|
@@ -3328,6 +3417,7 @@ export class Manager {
|
|
|
3328
3417
|
const reason = error.message;
|
|
3329
3418
|
console.error(`! manager boot: connector inventory unavailable - ${reason}`);
|
|
3330
3419
|
this.connectorStatuses = [{ agent: "(inventory)", state: "unavailable", binaries: {}, reason }];
|
|
3420
|
+
console.error("! manager boot: no connector available - spawn and launch stay on this instance rail only so a sibling that can launch them can take the class queue");
|
|
3331
3421
|
return;
|
|
3332
3422
|
}
|
|
3333
3423
|
}
|
|
@@ -3355,6 +3445,27 @@ export class Manager {
|
|
|
3355
3445
|
}
|
|
3356
3446
|
}
|
|
3357
3447
|
this.connectorStatuses = rows.sort((a, b) => a.agent.localeCompare(b.agent));
|
|
3448
|
+
if (!this.classSpawnEnabled())
|
|
3449
|
+
console.error("! manager boot: no connector available - spawn and launch stay on this instance rail only so a sibling that can launch them can take the class queue");
|
|
3450
|
+
}
|
|
3451
|
+
/** True when at least one declared connector is available. Empty inventory (or a failed
|
|
3452
|
+
* inventory load) skips the class `one` subscribe for spawn/launch so this member cannot
|
|
3453
|
+
* consume an unpinned request a sibling could serve. */
|
|
3454
|
+
classSpawnEnabled() {
|
|
3455
|
+
return this.connectorStatuses.some((row) => row.state === "available");
|
|
3456
|
+
}
|
|
3457
|
+
omitClassCommands() {
|
|
3458
|
+
return this.classSpawnEnabled() ? undefined : ["spawn", "launch"];
|
|
3459
|
+
}
|
|
3460
|
+
/** Named harness-unavailable refusal. On the class rail a partial inventory must not look
|
|
3461
|
+
* like the space cannot launch: name `--on` so the caller can pin a sibling. The standing
|
|
3462
|
+
* serve credential cannot enumerate sibling `svc.*.status` inventories, so the residual
|
|
3463
|
+
* is the flag, not a list of capable instance ids. Instance-pinned calls keep the local
|
|
3464
|
+
* reason only. */
|
|
3465
|
+
harnessUnavailableError(reason, route) {
|
|
3466
|
+
if (route !== "one")
|
|
3467
|
+
return reason;
|
|
3468
|
+
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)`;
|
|
3358
3469
|
}
|
|
3359
3470
|
/** Return connector-provided model catalogs for selector UIs. Optional by connector: a host with no
|
|
3360
3471
|
* local model-list API reports `supported:false` rather than blocking the manager. A connector that
|
|
@@ -3444,7 +3555,7 @@ export class Manager {
|
|
|
3444
3555
|
* (collision-numbered) name + nkey id creds are filed under, plus the manifest `requested` name,
|
|
3445
3556
|
* `runId`, and resolved `hash`. USER mesh: a privileged-tier launch is owner-equality-authorized
|
|
3446
3557
|
* (spec owner === caller owner) before any side effect; the admin tier keeps operator behavior. */
|
|
3447
|
-
async opLaunch(args, caller, admin, hooks) {
|
|
3558
|
+
async opLaunch(args, caller, admin, hooks, route = "inst") {
|
|
3448
3559
|
const runId = String(args.runId ?? "").trim();
|
|
3449
3560
|
const name = String(args.name ?? "").trim();
|
|
3450
3561
|
if (!runId || !name)
|
|
@@ -3496,7 +3607,7 @@ export class Manager {
|
|
|
3496
3607
|
catch (e) {
|
|
3497
3608
|
return { ok: false, error: e.message };
|
|
3498
3609
|
}
|
|
3499
|
-
const reply = await this.startAgent(launchAgentToStartOpts(la, configPath, spec.owner, runId), caller, hooks);
|
|
3610
|
+
const reply = await this.startAgent({ ...launchAgentToStartOpts(la, configPath, spec.owner, runId), route }, caller, hooks);
|
|
3500
3611
|
if (reply.ok)
|
|
3501
3612
|
// `data.name` stays the spawned (numbered) identity — what creds are filed under and the ledger
|
|
3502
3613
|
// keys on; `requested`/`runId`/`hash` give the CLI the manifest name + drift hash for the ledger.
|
|
@@ -3599,14 +3710,15 @@ export class Manager {
|
|
|
3599
3710
|
// fails here with a clear name, not obscurely at process spawn. No fallback. All synchronous, so
|
|
3600
3711
|
// the reserve gate stays atomic. (The connector itself was resolved up top, before the capacity gate.)
|
|
3601
3712
|
const bootStatus = this.connectorStatuses.find((row) => row.agent === agent);
|
|
3713
|
+
const route = opts.route ?? "inst";
|
|
3602
3714
|
if (bootStatus?.state === "unavailable")
|
|
3603
|
-
return { ok: false, error: bootStatus.reason };
|
|
3715
|
+
return { ok: false, error: this.harnessUnavailableError(bootStatus.reason ?? `${agent} harness is unavailable`, route) };
|
|
3604
3716
|
// A connector registered after boot has no inventory row. Keep the existing pre-mint backstop
|
|
3605
3717
|
// for that dynamic library-composition case; ordinary installed connectors were checked at boot.
|
|
3606
3718
|
if (!bootStatus) {
|
|
3607
3719
|
const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
|
|
3608
3720
|
if (missing.length)
|
|
3609
|
-
return { ok: false, error: `${agent} harness needs ${missing.join(", ")} on PATH - not found
|
|
3721
|
+
return { ok: false, error: this.harnessUnavailableError(`${agent} harness needs ${missing.join(", ")} on PATH - not found`, route) };
|
|
3610
3722
|
}
|
|
3611
3723
|
// Resume is a connector capability: reject an unsupported resume HERE, before the reserve/mint, so
|
|
3612
3724
|
// it can never provision creds + durables and then throw at buildLaunch (mint-then-orphan). Same
|
|
@@ -3759,19 +3871,25 @@ export class Manager {
|
|
|
3759
3871
|
name = this.uniqueName(identityName);
|
|
3760
3872
|
}
|
|
3761
3873
|
this.reserved.add(name);
|
|
3762
|
-
// The AG-UI event plane
|
|
3874
|
+
// The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
|
|
3763
3875
|
// anything is minted: a connector that cannot
|
|
3764
3876
|
// emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
|
|
3765
3877
|
// The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
|
|
3766
3878
|
// the principal's owner is resolved further down, so deriving it from anything in scope here
|
|
3767
3879
|
// would mean guessing at the identity the child will actually connect as. It is added at the
|
|
3768
3880
|
// accept seam below, where the allocated triple exists.
|
|
3769
|
-
|
|
3881
|
+
if (this.eventsRequired && opts.events === false) {
|
|
3882
|
+
this.reserved.delete(name);
|
|
3883
|
+
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` };
|
|
3884
|
+
}
|
|
3885
|
+
const events = this.eventsRequired || opts.events !== false;
|
|
3770
3886
|
if (events && !connector.eventChannel) {
|
|
3771
3887
|
// Release the just-reserved name on this fail-fast path. A leaked reserve is silent: it costs
|
|
3772
3888
|
// the next spawn of this persona its un-suffixed name and nothing reports why.
|
|
3773
3889
|
this.reserved.delete(name);
|
|
3774
|
-
return { ok: false, error:
|
|
3890
|
+
return { ok: false, error: this.eventsRequired
|
|
3891
|
+
? `space "${this.space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
|
|
3892
|
+
: `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` };
|
|
3775
3893
|
}
|
|
3776
3894
|
// F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
|
|
3777
3895
|
// a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
|
|
@@ -3995,6 +4113,7 @@ export class Manager {
|
|
|
3995
4113
|
allowPublish,
|
|
3996
4114
|
capabilities,
|
|
3997
4115
|
events,
|
|
4116
|
+
eventsRequired: this.eventsRequired,
|
|
3998
4117
|
mcpServers,
|
|
3999
4118
|
envAllow,
|
|
4000
4119
|
resolvedBinaries: bootStatus?.binaries,
|
|
@@ -4899,13 +5018,15 @@ export class Manager {
|
|
|
4899
5018
|
const denied = await this.authorizeNamed(a, caller, admin);
|
|
4900
5019
|
if (denied)
|
|
4901
5020
|
return { ok: false, error: denied };
|
|
4902
|
-
return this.despawnAuthorized(a, graceful, !admin);
|
|
5021
|
+
return this.despawnAuthorized(a, graceful, !admin, caller);
|
|
4903
5022
|
}
|
|
4904
5023
|
/** The post-authorization terminal effect (both doors). `trackNonAdmin` mirrors the ctl door's
|
|
4905
|
-
* `trackStoppedHandle(a, !admin)` disposition.
|
|
4906
|
-
|
|
5024
|
+
* `trackStoppedHandle(a, !admin)` disposition. The reap line names `caller` — the AUTHENTICATED
|
|
5025
|
+
* wire principal the door already resolved — so an operator reading the log can tell one peer's
|
|
5026
|
+
* despawn from another's. */
|
|
5027
|
+
despawnAuthorized(a, graceful, trackNonAdmin, caller) {
|
|
4907
5028
|
this.stopHandle(a, graceful);
|
|
4908
|
-
this.trackStoppedHandle(a, trackNonAdmin);
|
|
5029
|
+
this.trackStoppedHandle(a, trackNonAdmin, { kind: "stopped-requested", requester: caller });
|
|
4909
5030
|
void this.cancelAgentGoal(a.name, graceful ? "graceful" : "terminate"); // M4: cancel a live spawn goal
|
|
4910
5031
|
return { ok: true, data: { name: a.name, stopped: true, graceful } };
|
|
4911
5032
|
}
|
|
@@ -5036,6 +5157,7 @@ export class Manager {
|
|
|
5036
5157
|
agentCount: this.agents.size,
|
|
5037
5158
|
uptimeMs: Date.now() - this.startedAtMs,
|
|
5038
5159
|
connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
|
|
5160
|
+
classSpawn: this.classSpawnEnabled(),
|
|
5039
5161
|
staticReconciliation: this.staticReconciliationStatus(),
|
|
5040
5162
|
};
|
|
5041
5163
|
}
|
|
@@ -5145,6 +5267,7 @@ export class Manager {
|
|
|
5145
5267
|
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5146
5268
|
return undefined;
|
|
5147
5269
|
},
|
|
5270
|
+
...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
|
|
5148
5271
|
});
|
|
5149
5272
|
}
|
|
5150
5273
|
catch (e) {
|
|
@@ -5384,6 +5507,7 @@ export class Manager {
|
|
|
5384
5507
|
return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
|
|
5385
5508
|
return undefined;
|
|
5386
5509
|
},
|
|
5510
|
+
...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
|
|
5387
5511
|
});
|
|
5388
5512
|
}
|
|
5389
5513
|
catch (e) {
|