@cotal-ai/manager 0.60.0 → 0.62.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 +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/endpoint-evict.d.ts +23 -0
- package/dist/endpoint-evict.d.ts.map +1 -1
- package/dist/endpoint-evict.js +90 -24
- package/dist/endpoint-evict.js.map +1 -1
- package/dist/holder-liveness.d.ts +3 -0
- package/dist/holder-liveness.d.ts.map +1 -1
- package/dist/holder-liveness.js +33 -23
- package/dist/holder-liveness.js.map +1 -1
- package/dist/manager.d.ts +62 -9
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +299 -51
- package/dist/manager.js.map +1 -1
- package/dist/roster.d.ts +2 -1
- package/dist/roster.d.ts.map +1 -1
- package/dist/roster.js +12 -2
- package/dist/roster.js.map +1 -1
- package/dist/static-lifecycle.d.ts +3 -0
- package/dist/static-lifecycle.d.ts.map +1 -1
- package/dist/static-lifecycle.js +8 -5
- package/dist/static-lifecycle.js.map +1 -1
- package/package.json +12 -12
package/dist/manager.js
CHANGED
|
@@ -4,11 +4,11 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
5
5
|
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
7
|
-
import { CotalEndpoint, 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, 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, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
|
|
7
|
+
import { CotalEndpoint, 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, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
|
|
8
8
|
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
9
|
import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
|
|
10
10
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
11
|
-
import { makeManagerEndpointEvictionEvidence, makeManagerEndpointEvictor, makeManagerEndpointHolderEvictor } from "./endpoint-evict.js";
|
|
11
|
+
import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointEvictor, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers } 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, readContinuityAssignment, writeContinuityAssignment } from "./launch.js";
|
|
@@ -70,6 +70,9 @@ const DELIVERY_ADMIN_RELOAD_TIMEOUT_MS = 15_000;
|
|
|
70
70
|
/** A hard preservation stop should settle quickly. The manager still waits and reports a partial
|
|
71
71
|
* cut rather than pretending a child is gone. Held in ManagerOptions so fake runtimes can shorten it. */
|
|
72
72
|
const PRESERVE_STOP_TIMEOUT_MS = 10_000;
|
|
73
|
+
/** How long a resume waits for a reaped orphan's principal to leave presence. Presence rows age
|
|
74
|
+
* out after their 6s TTL, so three TTLs covers a killed seat that never published its leave. */
|
|
75
|
+
const ORPHAN_PRESENCE_CLEAR_MS = 20_000;
|
|
73
76
|
/** How much of an `input` text is written to a seat's pty at a time. Comfortably under the 4096-byte
|
|
74
77
|
* kernel input buffer, so a slice is never re-split at a boundary the manager did not choose: the
|
|
75
78
|
* tail of an oversized write stays queued and swallows the submit key that follows it (issue #1649).
|
|
@@ -122,6 +125,17 @@ function retireOpId(lifecycleUid) {
|
|
|
122
125
|
* dash) — what {@link Manager.psOwnerFilter} returns for an unparseable caller so a malformed
|
|
123
126
|
* principal fail-closes to an empty `ps` instead of an unbounded one. */
|
|
124
127
|
const NO_OWNER_MATCHES = "-no-owner-";
|
|
128
|
+
/** A caught value's text for a refusal. A host-supplied store or callback may reject with any value,
|
|
129
|
+
* including one whose `message` is a Symbol or whose `message` getter or `toString` throws, and the
|
|
130
|
+
* refusal must still be returned, so the coercion to text runs inside the guard. */
|
|
131
|
+
function rejectionText(e) {
|
|
132
|
+
try {
|
|
133
|
+
return String(e?.message ?? e);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return "an unreadable rejection";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
125
139
|
/** Run the agent's bearer argv once, pre-launch — the end-to-end auth preflight (state dir, daemon,
|
|
126
140
|
* ledger row, secret). Its stderr is the provider command's operator-exact sentence; surface it
|
|
127
141
|
* verbatim as the spawn refusal. */
|
|
@@ -237,6 +251,14 @@ function isAbsentDeliveryAdmin(msg) {
|
|
|
237
251
|
return false;
|
|
238
252
|
return /no responders|\b503\b/i.test(msg);
|
|
239
253
|
}
|
|
254
|
+
/** The store challenge went UNANSWERED while a delivery daemon exists: the rail timed out, or it had
|
|
255
|
+
* no responder while the lease row names a holder. Both are what a daemon that is starting or
|
|
256
|
+
* re-acquiring its lease looks like, so manager boot waits them out (#871); a renewal pass still
|
|
257
|
+
* refuses on the first one. A reply that does not decode was answered and is never this. */
|
|
258
|
+
class DeliveryAdminUnanswered extends Error {
|
|
259
|
+
}
|
|
260
|
+
/** How long one store challenge waits for the delivery daemon's answer. */
|
|
261
|
+
const STORE_CHALLENGE_TIMEOUT_MS = 5_000;
|
|
240
262
|
/**
|
|
241
263
|
* The agent supervisor: a long-lived mesh node that owns agent process lifecycle.
|
|
242
264
|
* It serves control requests on the "manager" service and spawns/kills agents
|
|
@@ -275,6 +297,11 @@ export class Manager {
|
|
|
275
297
|
* the launch settles, so no other spawn can take it, but the ceiling counts that seat through
|
|
276
298
|
* `agents` and, if it frees young, its cooling stamp, never through the reservation (#906). */
|
|
277
299
|
reservedLive = new Set();
|
|
300
|
+
/** Every numbered name {@link uniqueName} has issued. A suffix carries nothing but arrival order,
|
|
301
|
+
* so reissuing a freed one would give a later principal the name an earlier one posted under, and
|
|
302
|
+
* a reader of that name could not tell the two apart (#382). Never pruned in-process: one string
|
|
303
|
+
* per numbered spawn. */
|
|
304
|
+
issuedNumberedNames = new Set();
|
|
278
305
|
/** Expiry stamps (`startedAt + MIN_LIFETIME`) for slots that freed while still young — a
|
|
279
306
|
* count-only, lazily-pruned recycle floor (P4c). Pruned + summed into the ceiling gate. */
|
|
280
307
|
cooling = [];
|
|
@@ -437,6 +464,10 @@ export class Manager {
|
|
|
437
464
|
/** The complete boot task. stop() joins it after fencing static reconciliation, so a registration
|
|
438
465
|
* already past an earlier shutdown check cannot finish after stop() returns. */
|
|
439
466
|
startTask;
|
|
467
|
+
/** The one stop this manager runs, with the agent policy it was asked for. A stop takes seats out
|
|
468
|
+
* of `agents` before they have exited, so a second teardown would find none and report success
|
|
469
|
+
* while the first still waits on a live seat. */
|
|
470
|
+
stopTask;
|
|
440
471
|
/** Held as an instance field so broker-owning smokes can compress the schedule without changing
|
|
441
472
|
* production semantics. It is never a fence expiry: the durable non-retired row stays authoritative. */
|
|
442
473
|
staticReconcileRetryDelaysMs = STATIC_RECONCILE_RETRY_DELAYS_MS;
|
|
@@ -474,7 +505,8 @@ export class Manager {
|
|
|
474
505
|
* for release would otherwise move the row out from under a same-cached-revision CAS delete. */
|
|
475
506
|
leaseRenewTask;
|
|
476
507
|
/** Set at the top of `stop()`. `renewLease` returns at once once this is set, and its `gone` arm
|
|
477
|
-
* does not re-acquire: a manager that is stopping never puts its own key back.
|
|
508
|
+
* does not re-acquire: a manager that is stopping never puts its own key back. It also closes the
|
|
509
|
+
* lifecycle fence ({@link beginLifecycle}), so no spawn can launch a seat the stop never sees. */
|
|
478
510
|
leaseStopping = false;
|
|
479
511
|
/** The class-2 renewal owner's half-TTL schedule (D5 slice 5); armed only on auth meshes. */
|
|
480
512
|
credRenewTimer;
|
|
@@ -585,9 +617,12 @@ export class Manager {
|
|
|
585
617
|
get runtimeKind() {
|
|
586
618
|
return this.runtime.kind;
|
|
587
619
|
}
|
|
588
|
-
/**
|
|
589
|
-
|
|
590
|
-
|
|
620
|
+
/** What a default process stop does with this manager's seats. `release` drops manager-local
|
|
621
|
+
* custody and leaves every seat running. `stop` means this runtime keeps its PTYs inside the
|
|
622
|
+
* manager process, so those seats close with it: the stop stops and deprovisions them, and still
|
|
623
|
+
* releases every seat that can outlive it. */
|
|
624
|
+
get spareSeats() {
|
|
625
|
+
return this.runtime.kind !== "pty" || this.runtime.supportsRelease === true ? "release" : "stop";
|
|
591
626
|
}
|
|
592
627
|
/** Reattach this manager's runtime to a durable handle. Refuses by name when adopt is absent. */
|
|
593
628
|
adoptRuntimeHandle(reference) {
|
|
@@ -890,8 +925,10 @@ export class Manager {
|
|
|
890
925
|
// `reloadCreds` adoption on the delivery-admin rail, and persist the audit record doctor renders.
|
|
891
926
|
if (this.auth) {
|
|
892
927
|
// #1634: a space may be served by several managers, so ownership of the daemon's credential
|
|
893
|
-
// renewal is decided here rather than by being the only manager able to start.
|
|
894
|
-
|
|
928
|
+
// renewal is decided here rather than by being the only manager able to start. #871: a daemon
|
|
929
|
+
// that is still binding or re-acquiring its lease leaves this challenge unanswered for a few
|
|
930
|
+
// seconds, so boot waits that out instead of exiting over a daemon about to answer.
|
|
931
|
+
await this.claimDaemonRenewalOwnership(DELIVERY_ADMIN_BOOT_WAIT_MS);
|
|
895
932
|
// ARMED BEFORE the first remint, not after. That first `renewDaemonCreds()` re-signs through
|
|
896
933
|
// the SecretStore, and a slow or contended store makes the one pass outlast the lease's
|
|
897
934
|
// MANAGER_LEASE_TTL_MS. Arming afterwards leaves the whole first remint unprotected: the lease
|
|
@@ -1025,16 +1062,17 @@ export class Manager {
|
|
|
1025
1062
|
* daemon"), and only a row that is absent or holderless settles "absent", the state start()
|
|
1026
1063
|
* treats as not-yet-bound. An unreadable row (a failed read, or a key that exists but does not
|
|
1027
1064
|
* parse) is a genuine unknown and refuses fail-closed, like a hung rail. */
|
|
1028
|
-
async daemonStoreRelation() {
|
|
1065
|
+
async daemonStoreRelation(timeoutMs) {
|
|
1029
1066
|
let reply;
|
|
1030
1067
|
try {
|
|
1031
|
-
reply = await this.ep.requestDeliveryAdmin("reloadStoreIdentity", {},
|
|
1068
|
+
reply = await this.ep.requestDeliveryAdmin("reloadStoreIdentity", {}, timeoutMs);
|
|
1032
1069
|
}
|
|
1033
1070
|
catch (e) {
|
|
1034
1071
|
const msg = e.message;
|
|
1035
1072
|
if (isAbsentDeliveryAdmin(msg))
|
|
1036
|
-
return this.absentByLeaseRow();
|
|
1037
|
-
|
|
1073
|
+
return this.absentByLeaseRow(isUnansweredDeliveryAdmin(e));
|
|
1074
|
+
const failure = `could not challenge the delivery daemon's SecretStore: ${msg}`;
|
|
1075
|
+
throw isUnansweredDeliveryAdmin(e) ? new DeliveryAdminUnanswered(failure) : new Error(failure);
|
|
1038
1076
|
}
|
|
1039
1077
|
if (!reply.ok)
|
|
1040
1078
|
throw new Error(reply.error ?? "delivery daemon refused to name the SecretStore it reloads from");
|
|
@@ -1089,7 +1127,7 @@ export class Manager {
|
|
|
1089
1127
|
*
|
|
1090
1128
|
* `deliveryLeaseHolder` swallows its read failures into `undefined`, which would fold case 2
|
|
1091
1129
|
* into case 3 — so this reads the entry directly and lets the read's own failure refuse. */
|
|
1092
|
-
async absentByLeaseRow() {
|
|
1130
|
+
async absentByLeaseRow(unanswered) {
|
|
1093
1131
|
let entry;
|
|
1094
1132
|
try {
|
|
1095
1133
|
entry = await this.ep.readDeliveryLeaseEntry(Manager.DELIVERY_SHARD);
|
|
@@ -1099,9 +1137,11 @@ export class Manager {
|
|
|
1099
1137
|
`(${e.message}) - absence is undetermined, nothing reminted`);
|
|
1100
1138
|
}
|
|
1101
1139
|
const holder = entry?.info.holder;
|
|
1102
|
-
if (holder !== undefined && holder !== "")
|
|
1103
|
-
|
|
1104
|
-
`(${holder}) - absence cannot be read off the rail alone, and nothing reminted
|
|
1140
|
+
if (holder !== undefined && holder !== "") {
|
|
1141
|
+
const refusal = `the delivery-admin rail reported no responder while the delivery lease names a holder ` +
|
|
1142
|
+
`(${holder}) - absence cannot be read off the rail alone, and nothing reminted`;
|
|
1143
|
+
throw unanswered ? new DeliveryAdminUnanswered(refusal) : new Error(refusal);
|
|
1144
|
+
}
|
|
1105
1145
|
return "absent";
|
|
1106
1146
|
}
|
|
1107
1147
|
/** Keep this manager's renewal lease alive between credential passes (#1634). The manager bucket
|
|
@@ -1135,7 +1175,7 @@ export class Manager {
|
|
|
1135
1175
|
* request, which is the #773 refusal the challenge exists to prevent.
|
|
1136
1176
|
*
|
|
1137
1177
|
* A manager failing either test serves the space normally and skips only the daemon remint. */
|
|
1138
|
-
async claimDaemonRenewalOwnership() {
|
|
1178
|
+
async claimDaemonRenewalOwnership(waitMs = 0) {
|
|
1139
1179
|
// EVERY refusal outcome drops a held lease, not only "divergent". The refusal paths THROW
|
|
1140
1180
|
// (#1694: a non-holder answer, an unbindable answerer, an unreadable or holder-naming row on a
|
|
1141
1181
|
// no-responder rail), and a thrown refusal caught by renewDaemonCreds would otherwise leave a
|
|
@@ -1144,7 +1184,9 @@ export class Manager {
|
|
|
1144
1184
|
// Deciding here, inside the claim, is what keeps the release on the refusal path itself.
|
|
1145
1185
|
let relation;
|
|
1146
1186
|
try {
|
|
1147
|
-
|
|
1187
|
+
// Only the read-only challenge is retried, so a wait that ends mid-attempt abandons a read,
|
|
1188
|
+
// never the lease work below.
|
|
1189
|
+
relation = await untilDeliveryAdminAnswers(waitMs, (requestMs) => this.daemonStoreRelation(requestMs(STORE_CHALLENGE_TIMEOUT_MS)), (reason, delayMs) => console.error(`! delivery-admin rail did not answer the store challenge (${reason}); retrying in ${delayMs / 1000}s`), (e) => e instanceof DeliveryAdminUnanswered);
|
|
1148
1190
|
}
|
|
1149
1191
|
catch (e) {
|
|
1150
1192
|
if (this.daemonRenewalOwner)
|
|
@@ -1522,9 +1564,9 @@ export class Manager {
|
|
|
1522
1564
|
}
|
|
1523
1565
|
}
|
|
1524
1566
|
/** Admit one lifecycle/control operation while active. The synchronous increment is the fence:
|
|
1525
|
-
* preserveState
|
|
1567
|
+
* preserveState and stop close it before their first await, so work is either counted or rejected. */
|
|
1526
1568
|
beginLifecycle(resumeOperation = false) {
|
|
1527
|
-
if (this.maintenanceState !== "active" || (this.resumeRequired && !resumeOperation))
|
|
1569
|
+
if (this.leaseStopping || this.maintenanceState !== "active" || (this.resumeRequired && !resumeOperation))
|
|
1528
1570
|
return undefined;
|
|
1529
1571
|
this.lifecycleInFlight++;
|
|
1530
1572
|
let released = false;
|
|
@@ -1558,6 +1600,8 @@ export class Manager {
|
|
|
1558
1600
|
await new Promise((resolve) => this.lifecycleDrainWaiters.push(resolve));
|
|
1559
1601
|
}
|
|
1560
1602
|
maintenanceError() {
|
|
1603
|
+
if (this.leaseStopping)
|
|
1604
|
+
return "manager is stopping; new lifecycle/control work is fenced";
|
|
1561
1605
|
if (this.resumeRequired)
|
|
1562
1606
|
return `manager is waiting for resume attempt ${this.resumeAttemptId}; ordinary lifecycle/control work is fenced`;
|
|
1563
1607
|
return `manager is in ${this.maintenanceState} mode; new lifecycle/control work is fenced`;
|
|
@@ -1895,15 +1939,15 @@ export class Manager {
|
|
|
1895
1939
|
...(backfillFloor !== undefined ? { backfillFloor } : {}),
|
|
1896
1940
|
};
|
|
1897
1941
|
}
|
|
1898
|
-
/** Tear down every managed agent's footprint on an explicit destructive {@link stop}
|
|
1942
|
+
/** Tear down every managed agent's footprint on an explicit destructive {@link stop}, or only the
|
|
1943
|
+
* in-process seats a default stop cannot detach. A manager exit is
|
|
1899
1944
|
* a mass agent-exit, and without this its agents' footprints (creds files + `dm_`/`dlv_` durables + ACL
|
|
1900
1945
|
* rows) would orphan exactly as the per-agent exit path prevents. Hard-stop each child (an exit has no
|
|
1901
1946
|
* time for the graceful grace window) and AWAIT its deprovision — bounded per agent (`withTimeout`) and
|
|
1902
1947
|
* best-effort (`allSettled` + a loud log), so one slow/failed teardown can neither hang nor abort exit.
|
|
1903
1948
|
* The creds file is dropped even if the broker teardown fails (see {@link deprovision}). Deliberately
|
|
1904
1949
|
* touches NEITHER the lease NOR the endpoints — the caller owns those. */
|
|
1905
|
-
async teardownManagedAgents() {
|
|
1906
|
-
const managed = [...this.agents.values()];
|
|
1950
|
+
async teardownManagedAgents(managed = [...this.agents.values()]) {
|
|
1907
1951
|
const failures = [];
|
|
1908
1952
|
for (const a of managed) {
|
|
1909
1953
|
// Free the slot + hard-stop each; `stopHandle` is best-effort (never throws — see it), so one bad
|
|
@@ -1934,16 +1978,11 @@ export class Manager {
|
|
|
1934
1978
|
if (failures.length)
|
|
1935
1979
|
throw new Error(`manager shutdown could not prove every seat exited: ${failures.join("; ")}`);
|
|
1936
1980
|
}
|
|
1937
|
-
/** Drop
|
|
1938
|
-
*
|
|
1981
|
+
/** Drop this manager's local custody of every seat that can outlive it. A pty seat whose handle
|
|
1982
|
+
* cannot be released lives in this process and closes with it, so it is left for {@link stop} to
|
|
1983
|
+
* stop instead. */
|
|
1939
1984
|
releaseManagedAgents() {
|
|
1940
|
-
const managed = [...this.agents.values()];
|
|
1941
|
-
const blocked = managed.filter((a) => a.handle.kind === "pty" && typeof a.handle.release !== "function");
|
|
1942
|
-
if (blocked.length) {
|
|
1943
|
-
return [
|
|
1944
|
-
`manager shutdown cannot detach ${blocked.map((a) => `${a.name} (${a.handle.kind})`).join(", ")}: runtime handle does not support release`,
|
|
1945
|
-
];
|
|
1946
|
-
}
|
|
1985
|
+
const managed = [...this.agents.values()].filter((a) => !(a.handle.kind === "pty" && typeof a.handle.release !== "function"));
|
|
1947
1986
|
const failures = [];
|
|
1948
1987
|
for (const a of managed) {
|
|
1949
1988
|
try {
|
|
@@ -1981,7 +2020,16 @@ export class Manager {
|
|
|
1981
2020
|
if (failures.length)
|
|
1982
2021
|
throw new Error(`manager preservation shutdown incomplete: ${failures.join("; ")}`);
|
|
1983
2022
|
}
|
|
1984
|
-
|
|
2023
|
+
/** Every call joins the first stop and settles with it. A call that asks for a different
|
|
2024
|
+
* `withAgents` is refused, since the running stop has already applied its own policy. */
|
|
2025
|
+
stop(options = {}) {
|
|
2026
|
+
const withAgents = options.withAgents === true;
|
|
2027
|
+
this.stopTask ??= { withAgents, done: this.stopOnce(withAgents) };
|
|
2028
|
+
if (this.stopTask.withAgents === withAgents)
|
|
2029
|
+
return this.stopTask.done;
|
|
2030
|
+
return Promise.reject(new Error(`manager is already stopping ${this.stopTask.withAgents ? "with" : "without"} its agents`));
|
|
2031
|
+
}
|
|
2032
|
+
async stopOnce(withAgents) {
|
|
1985
2033
|
this.leaseStopping = true;
|
|
1986
2034
|
this.staticReconcileStopping = true;
|
|
1987
2035
|
const starting = this.startTask;
|
|
@@ -2007,14 +2055,22 @@ export class Manager {
|
|
|
2007
2055
|
// still move the key's revision after we clear the timer, and releasing against a cached
|
|
2008
2056
|
// revision it has since moved is a CAS the broker correctly refuses, silently, forever.
|
|
2009
2057
|
await this.leaseRenewTask?.catch(() => { });
|
|
2058
|
+
// A spawn admitted before the fence closed may still be launching. Its seat must be in the
|
|
2059
|
+
// snapshot below, or it would outlive a stop that reported success.
|
|
2060
|
+
await this.awaitLifecycleDrain();
|
|
2010
2061
|
let releaseFailures = [];
|
|
2011
2062
|
let seatFailure;
|
|
2012
2063
|
try {
|
|
2013
2064
|
if (this.maintenanceState === "active" && !this.resumeRequired) {
|
|
2014
|
-
if (
|
|
2065
|
+
if (withAgents)
|
|
2015
2066
|
await this.teardownManagedAgents();
|
|
2016
|
-
else
|
|
2067
|
+
else {
|
|
2068
|
+
// An in-process seat cannot be detached from the process that is exiting, so the default
|
|
2069
|
+
// stop stops and deprovisions it as `withAgents` does instead of orphaning its footprint.
|
|
2070
|
+
const inProcess = [...this.agents.values()].filter((a) => a.handle.kind === "pty" && typeof a.handle.release !== "function");
|
|
2017
2071
|
releaseFailures = this.releaseManagedAgents();
|
|
2072
|
+
await this.teardownManagedAgents(inProcess);
|
|
2073
|
+
}
|
|
2018
2074
|
}
|
|
2019
2075
|
else {
|
|
2020
2076
|
// A signal after a partial preservation must never fall back into destructive teardown.
|
|
@@ -2654,6 +2710,13 @@ export class Manager {
|
|
|
2654
2710
|
// A retired row affirms that no agent exists now, so it keeps the old `not-found`
|
|
2655
2711
|
// result. The changed refusal is reserved for durable NONTERMINAL state that contradicts
|
|
2656
2712
|
// the live-map miss and needs operator attention.
|
|
2713
|
+
//
|
|
2714
|
+
// A sibling's nonterminal row is not a contradiction: the name belongs to another
|
|
2715
|
+
// manager instance in this space. In a multi-manager space the class queue hands this
|
|
2716
|
+
// read to either instance, so a plain `not-found` here read the same as a name that
|
|
2717
|
+
// exists nowhere and a caller had no way to tell the two apart (#443).
|
|
2718
|
+
if (observed?.ownerInstanceId !== undefined)
|
|
2719
|
+
throw new EpEnvelopeError("failed-precondition", `the durable slot for "${name}" belongs to manager instance ${observed.ownerInstanceId}, not ${this.managerInstanceId} which answered; only that instance can act on it ${renderStaticSlotObservation(observed)}`, [observed]);
|
|
2657
2720
|
if (observed !== undefined && observed.slotPhase !== "retired")
|
|
2658
2721
|
throw new EpEnvelopeError("failed-precondition", `no live agent "${name}"; durable state contradicts absence ${renderStaticSlotObservation(observed)}`, [observed]);
|
|
2659
2722
|
}
|
|
@@ -3018,6 +3081,19 @@ export class Manager {
|
|
|
3018
3081
|
* dead auth plane. Every failure is returned as the refusal sentence, with the grant + files
|
|
3019
3082
|
* rolled back. */
|
|
3020
3083
|
async provisionUserAgent(name, opts) {
|
|
3084
|
+
if (opts.delegatedIntent) {
|
|
3085
|
+
// startAgentActive refused a delegated launch on a manager without this callback.
|
|
3086
|
+
const execute = this.remoteAuthority.executeDelegatedUserIntent;
|
|
3087
|
+
const { intentId, owner } = opts.delegatedIntent;
|
|
3088
|
+
// The hosted arm with the intent's execution in place of the holder's own enrollment: the
|
|
3089
|
+
// spawning owner is the intent's, so its existing owner check refuses any other.
|
|
3090
|
+
return this.enrollUserAgent(name, { ...opts, specOwner: owner }, async ({ target }) => {
|
|
3091
|
+
const result = await execute({ intentId, operation: "launch", target: { actor: target.actor, tokenHash: target.tokenHash } });
|
|
3092
|
+
if (result.operation !== "launch" || result.intentId !== intentId)
|
|
3093
|
+
throw new Error(`the host answered delegated intent ${intentId} with another execution`);
|
|
3094
|
+
return result.material;
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3021
3097
|
if (this.remoteAuthority?.enrollManagedAgent)
|
|
3022
3098
|
return this.enrollUserAgent(name, opts, this.remoteAuthority.enrollManagedAgent);
|
|
3023
3099
|
const spawnerPr = opts.spawner ? parsePrincipalKey(opts.spawner) : null;
|
|
@@ -3176,6 +3252,8 @@ export class Manager {
|
|
|
3176
3252
|
// Re-key the family onto the HOST's uid (SPEC 13.1 name-disjointness on the FS): the child's
|
|
3177
3253
|
// credential paths must embed the uid its broker footprint carries, never the provisional one.
|
|
3178
3254
|
files = agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, name, material.lifecycleUid);
|
|
3255
|
+
// From here the host holds a grant at that uid.
|
|
3256
|
+
opts.onEnrolled?.({ owner: material.owner, lifecycleUid: material.lifecycleUid, files });
|
|
3179
3257
|
if (files.actorToken !== staged.actorToken) {
|
|
3180
3258
|
await secrets.put(agentSecretKeyForFile(files.actorToken, this.space), actorToken);
|
|
3181
3259
|
await materializeSecretToFile(secrets, agentSecretKeyForFile(files.actorToken, this.space), files.actorToken);
|
|
@@ -3210,14 +3288,25 @@ export class Manager {
|
|
|
3210
3288
|
// Shred every local secret this attempt materialized. The HOST's rows are the host's to
|
|
3211
3289
|
// reconcile: a participant holds no writer, so it must not pretend to roll back a grant or a
|
|
3212
3290
|
// durable it never authored. A retry with the same requestId re-enters the same enrollment.
|
|
3291
|
+
// A failed removal joins the refusal instead of throwing, so every removal is attempted and
|
|
3292
|
+
// the refusal still reaches the caller.
|
|
3293
|
+
const unshredded = [];
|
|
3294
|
+
const shred = async (what, remove) => {
|
|
3295
|
+
try {
|
|
3296
|
+
await remove();
|
|
3297
|
+
}
|
|
3298
|
+
catch (err) {
|
|
3299
|
+
unshredded.push(`${what}: ${rejectionText(err)}`);
|
|
3300
|
+
}
|
|
3301
|
+
};
|
|
3213
3302
|
for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files]) {
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
rmSync(family.health, { force: true });
|
|
3303
|
+
for (const path of [family.actorToken, family.sentinelCreds])
|
|
3304
|
+
await shred(`secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
|
|
3305
|
+
for (const path of [family.actorToken, family.sentinelCreds, family.health])
|
|
3306
|
+
await shred(`file ${path}`, () => rmSync(path, { force: true }));
|
|
3219
3307
|
}
|
|
3220
|
-
|
|
3308
|
+
const leftover = unshredded.length ? `; cleanup failed: ${unshredded.join("; ")}` : "";
|
|
3309
|
+
return { error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}` };
|
|
3221
3310
|
}
|
|
3222
3311
|
}
|
|
3223
3312
|
/**
|
|
@@ -3325,7 +3414,7 @@ export class Manager {
|
|
|
3325
3414
|
if (this.userMode) {
|
|
3326
3415
|
const p = parsePrincipalKey(a.id);
|
|
3327
3416
|
if (p)
|
|
3328
|
-
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: p.owner, actor: p.actor, agentId: a.id, userOwner: a.userOwner, secretPaths: a.secretPaths, launch: { allowSubscribe: a.launch.allowSubscribe }, startedAt: Date.now() });
|
|
3417
|
+
this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: p.owner, actor: p.actor, agentId: a.id, userOwner: a.userOwner, delegated: a.delegated, secretPaths: a.secretPaths, launch: { allowSubscribe: a.launch.allowSubscribe }, startedAt: Date.now() });
|
|
3329
3418
|
}
|
|
3330
3419
|
else if (this.auth) {
|
|
3331
3420
|
// Unit B: a STATIC lifecycle now also holds its name pending its own terminal (the F1
|
|
@@ -3376,6 +3465,14 @@ export class Manager {
|
|
|
3376
3465
|
/** The actual footprint teardown (wrapped by {@link deprovision}'s single-flight). */
|
|
3377
3466
|
async driveDeprovision(a) {
|
|
3378
3467
|
if (this.remoteAuthority) {
|
|
3468
|
+
if (a.delegated) {
|
|
3469
|
+
// The agent is its user's: only that user's retirement intent retires it, so the name stays
|
|
3470
|
+
// held until retireDelegatedAgent confirms the host retired this UID.
|
|
3471
|
+
const held = this.retiring.get(a.name);
|
|
3472
|
+
if (held?.lifecycleUid === a.lifecycleUid)
|
|
3473
|
+
held.lastError = `"${a.name}" was launched under a delegated user intent and retires only through its user's retirement intent`;
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3379
3476
|
// A hosted composition owns grant revocation and resumable footprint release. It calls this
|
|
3380
3477
|
// prerequisite before the terminal rail. The callback must preserve this UID and its pending
|
|
3381
3478
|
// state until the host decides terminal retirement, never map deprovisioning to suspension.
|
|
@@ -4031,10 +4128,16 @@ export class Manager {
|
|
|
4031
4128
|
* still refused downstream exactly as before. Offline rows do NOT occupy — a properly retired
|
|
4032
4129
|
* name stays reusable. A row this manager itself confirmed retired (its alias, principal, and
|
|
4033
4130
|
* lifecycleUid all matching a {@link confirmedRetiredPredecessors} entry) does not occupy
|
|
4034
|
-
* either, even while its advisory presence record ages out.
|
|
4131
|
+
* either, even while its advisory presence record ages out. A numbered name is never reissued
|
|
4132
|
+
* ({@link issuedNumberedNames}), even after its holder retired. The requested base is exempt
|
|
4133
|
+
* from that history: it is the persona's own name, which stays reusable even when it once came
|
|
4134
|
+
* out of another persona's numbering. */
|
|
4035
4135
|
uniqueName(base) {
|
|
4036
4136
|
const live = this.liveRosterNames();
|
|
4037
|
-
|
|
4137
|
+
const name = firstFreeName(base, (n) => this.nameInUse(n, live) || (n !== base && this.issuedNumberedNames.has(n)));
|
|
4138
|
+
if (name !== base)
|
|
4139
|
+
this.issuedNumberedNames.add(name);
|
|
4140
|
+
return name;
|
|
4038
4141
|
}
|
|
4039
4142
|
/** Spawn a teammate by persona ref (`name` loads `.cotal/agents/<name>.md`; the peer presents
|
|
4040
4143
|
* under that file's own `name:`), as if a peer asked via the control plane. Used to pre-spawn the
|
|
@@ -4434,6 +4537,14 @@ export class Manager {
|
|
|
4434
4537
|
}
|
|
4435
4538
|
}
|
|
4436
4539
|
async startAgentActive(opts, spawner, hooks) {
|
|
4540
|
+
if (opts.delegatedIntent) {
|
|
4541
|
+
if (!this.remoteAuthority?.executeDelegatedUserIntent)
|
|
4542
|
+
return { ok: false, error: `"${opts.name}" names a delegated user intent, and this manager has no host execution for one` };
|
|
4543
|
+
if (opts.supervise)
|
|
4544
|
+
return { ok: false, error: `"${opts.name}" names a delegated user intent, and a delegated agent is never restarted (SPEC 13.16)` };
|
|
4545
|
+
// A delegated launch's spawner is the user's principal, never the composition root's call.
|
|
4546
|
+
spawner = opts.delegatedIntent.parent;
|
|
4547
|
+
}
|
|
4437
4548
|
// The spawn argument is a persona REF — a filename in `.cotal/agents` (the unique spawn KEY), or
|
|
4438
4549
|
// a path via `--config`. It is NOT the mesh identity: the identity comes from inside the file
|
|
4439
4550
|
// (`name:`), so a persona can be filed descriptively (review-critic.md) yet present under a
|
|
@@ -4642,6 +4753,27 @@ export class Manager {
|
|
|
4642
4753
|
`An operator adds \`capabilities: [spawn]\` to ${configPath}; a peer-defined persona ` +
|
|
4643
4754
|
`(cotal_persona) cannot declare capabilities by design — ask an operator to grant it.`,
|
|
4644
4755
|
};
|
|
4756
|
+
// #581: the operator's per-role model allowlist, judged on the EFFECTIVE role and model resolved
|
|
4757
|
+
// above, before any reserve or mint. This is where the launch is performed, so no caller can
|
|
4758
|
+
// skip it, and a definition pinned to a stale model (or to none) is refused here instead of
|
|
4759
|
+
// serving on it. Read per spawn, so a policy edit reaches the next spawn without a restart.
|
|
4760
|
+
let policyRefusal;
|
|
4761
|
+
try {
|
|
4762
|
+
policyRefusal = modelPolicyRefusal(loadCotalConfig(this.workspaceRoot), {
|
|
4763
|
+
persona: opts.resolved ? `manifest agent "${ref}"` : `persona "${ref}" (${configPath})`,
|
|
4764
|
+
role, model, variant,
|
|
4765
|
+
// A manifest launch carries its own declared model/variant in these options
|
|
4766
|
+
// (launchAgentToStartOpts), so there they are the definition's field, never a flag.
|
|
4767
|
+
modelFlag: !opts.resolved && opts.model !== undefined,
|
|
4768
|
+
variantFlag: !opts.resolved && opts.variant !== undefined,
|
|
4769
|
+
launchOptions,
|
|
4770
|
+
});
|
|
4771
|
+
}
|
|
4772
|
+
catch (e) {
|
|
4773
|
+
return { ok: false, error: e.message };
|
|
4774
|
+
}
|
|
4775
|
+
if (policyRefusal)
|
|
4776
|
+
return { ok: false, error: policyRefusal };
|
|
4645
4777
|
// The alias-reuse gate (#29 piece 3): a name whose previous agent is still retiring REFUSES
|
|
4646
4778
|
// legibly (never a silent suffix), and the refusal re-drives the FULL durable teardown so
|
|
4647
4779
|
// "retry the spawn" is also the nudge. It routes through `deprovision` (not `requestRetirement`
|
|
@@ -4652,7 +4784,7 @@ export class Manager {
|
|
|
4652
4784
|
return { ok: false, error: `the name "${identityName}" is still reconciling at manager startup; its prior lifecycle terminal owns this alias until it completes. Retry shortly.` };
|
|
4653
4785
|
const held = this.retiring.get(identityName);
|
|
4654
4786
|
if (held !== undefined) {
|
|
4655
|
-
void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner, secretPaths: held.secretPaths, launch: held.launch }).catch(() => { });
|
|
4787
|
+
void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner, delegated: held.delegated, secretPaths: held.secretPaths, launch: held.launch }).catch(() => { });
|
|
4656
4788
|
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" });
|
|
4657
4789
|
return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
|
|
4658
4790
|
}
|
|
@@ -4844,6 +4976,15 @@ export class Manager {
|
|
|
4844
4976
|
capabilities,
|
|
4845
4977
|
label: ref,
|
|
4846
4978
|
lifecycleUid,
|
|
4979
|
+
delegatedIntent: opts.delegatedIntent,
|
|
4980
|
+
// Hold the name at the UID the host enrolled for the intent's user before any later local
|
|
4981
|
+
// step runs, so no later failure, its refusal included, releases a lifecycle that only that
|
|
4982
|
+
// user's retirement intent retires.
|
|
4983
|
+
onEnrolled: opts.delegatedIntent
|
|
4984
|
+
? (enrolled) => {
|
|
4985
|
+
provisioned = { id: principalKey(enrolled.owner, name).key, name, lifecycleUid: enrolled.lifecycleUid, userOwner: enrolled.owner, delegated: true, secretPaths: enrolled.files };
|
|
4986
|
+
}
|
|
4987
|
+
: undefined,
|
|
4847
4988
|
});
|
|
4848
4989
|
if ("error" in prep) {
|
|
4849
4990
|
this.reserved.delete(name);
|
|
@@ -4857,7 +4998,7 @@ export class Manager {
|
|
|
4857
4998
|
lifecycleUid = prep.lifecycleUid;
|
|
4858
4999
|
userLaunch = prep.launch;
|
|
4859
5000
|
userOwner = prep.owner;
|
|
4860
|
-
provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, secretPaths: prep.files, ...(custody ? { runtime: custody } : {}) };
|
|
5001
|
+
provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, ...(opts.delegatedIntent ? { delegated: true } : {}), secretPaths: prep.files, ...(custody ? { runtime: custody } : {}) };
|
|
4861
5002
|
}
|
|
4862
5003
|
else if (this.auth) {
|
|
4863
5004
|
// Unit B (§13.1): reserve + activate this incarnation's DURABLE identity BEFORE any
|
|
@@ -4983,6 +5124,7 @@ export class Manager {
|
|
|
4983
5124
|
// recorded truth teardown/preservation/health consume, never re-derived by name.
|
|
4984
5125
|
secretPaths: provisioned?.secretPaths,
|
|
4985
5126
|
...(userLaunch ? { userOwner } : { seed: identity.seed }),
|
|
5127
|
+
...(opts.delegatedIntent ? { delegated: true } : {}),
|
|
4986
5128
|
...(issued ? { issued } : {}),
|
|
4987
5129
|
spawner: spawner ?? this.ep.ref().id,
|
|
4988
5130
|
authorityParent: userLaunch && spawner && parsePrincipalKey(spawner) ? spawner : undefined,
|
|
@@ -5021,7 +5163,8 @@ export class Manager {
|
|
|
5021
5163
|
? Object.keys(opts.launchOptions).sort()
|
|
5022
5164
|
: undefined,
|
|
5023
5165
|
},
|
|
5024
|
-
|
|
5166
|
+
// A delegated agent is never restarted (SPEC 13.16), so its slot arms no recovery.
|
|
5167
|
+
...((connector.supportsSessionContinuation || opts.supervise !== undefined) && !opts.delegatedIntent
|
|
5025
5168
|
? {
|
|
5026
5169
|
restart: {
|
|
5027
5170
|
opts: launchOpts,
|
|
@@ -5139,6 +5282,11 @@ export class Manager {
|
|
|
5139
5282
|
// orphan down (detached, fail-loud) so a failed spawn leaves no creds/durables behind (#159 B).
|
|
5140
5283
|
if (provisioned) {
|
|
5141
5284
|
const orphan = provisioned;
|
|
5285
|
+
// The host enrolled this UID for the intent's user, so only that user's retirement intent
|
|
5286
|
+
// retires it, and its name stays held for retireDelegatedAgent.
|
|
5287
|
+
const p = orphan.delegated ? parsePrincipalKey(orphan.id) : null;
|
|
5288
|
+
if (p)
|
|
5289
|
+
this.retiring.set(orphan.name, { opId: retireOpId(orphan.lifecycleUid), lifecycleUid: orphan.lifecycleUid, owner: p.owner, actor: p.actor, agentId: orphan.id, userOwner: orphan.userOwner, delegated: true, secretPaths: orphan.secretPaths, startedAt: Date.now() });
|
|
5142
5290
|
this.trackDeprovision(orphan, "(orphaned spawn)");
|
|
5143
5291
|
}
|
|
5144
5292
|
}
|
|
@@ -5159,6 +5307,7 @@ export class Manager {
|
|
|
5159
5307
|
return { ok: false, agents: [], error: `resume inventory belongs to space "${inventory.space}", not "${this.space}"` };
|
|
5160
5308
|
const seen = new Set();
|
|
5161
5309
|
const principals = new Set();
|
|
5310
|
+
const orphans = [];
|
|
5162
5311
|
await this.ep.waitForPresenceSnapshot();
|
|
5163
5312
|
const liveRoster = this.ep.getRoster().filter((presence) => presence.status !== "offline");
|
|
5164
5313
|
const occupancy = this.occupancy();
|
|
@@ -5180,6 +5329,7 @@ export class Manager {
|
|
|
5180
5329
|
name: entry.name,
|
|
5181
5330
|
lifecycleUid: held.lifecycleUid,
|
|
5182
5331
|
userOwner: held.userOwner,
|
|
5332
|
+
delegated: held.delegated,
|
|
5183
5333
|
secretPaths: held.secretPaths,
|
|
5184
5334
|
}).catch(() => { });
|
|
5185
5335
|
return {
|
|
@@ -5210,8 +5360,12 @@ export class Manager {
|
|
|
5210
5360
|
presence.card.name !== entry.name ||
|
|
5211
5361
|
entry.identity.lifecycleUid === confirmedPredecessor.lifecycleUid;
|
|
5212
5362
|
});
|
|
5213
|
-
if (principalIsLive)
|
|
5214
|
-
|
|
5363
|
+
if (principalIsLive) {
|
|
5364
|
+
const orphan = await this.orphanedResumeSeat(entry, principal, liveRoster);
|
|
5365
|
+
if (!orphan)
|
|
5366
|
+
return { ok: false, agents: [], error: `retained principal "${principal}" is already live and this runtime cannot authoritatively adopt it` };
|
|
5367
|
+
orphans.push({ principal, reference: orphan });
|
|
5368
|
+
}
|
|
5215
5369
|
if (this.agents.has(entry.name) || this.reserved.has(entry.name))
|
|
5216
5370
|
return { ok: false, agents: [], error: `retained agent "${entry.name}" is already managed or reserved` };
|
|
5217
5371
|
}
|
|
@@ -5231,6 +5385,13 @@ export class Manager {
|
|
|
5231
5385
|
agents: preflight,
|
|
5232
5386
|
error: `${preflightFailures.length} retained agent${preflightFailures.length === 1 ? "" : "s"} failed preflight`,
|
|
5233
5387
|
};
|
|
5388
|
+
// Only now, with the whole inventory through preflight, is an orphan reaped: a refused batch
|
|
5389
|
+
// leaves every seat as it found it.
|
|
5390
|
+
for (const { principal, reference } of orphans) {
|
|
5391
|
+
const refusal = await this.reapOrphanedResumeSeat(principal, reference);
|
|
5392
|
+
if (refusal)
|
|
5393
|
+
return { ok: false, agents: [], error: refusal };
|
|
5394
|
+
}
|
|
5234
5395
|
const agents = [];
|
|
5235
5396
|
for (let i = 0; i < inventory.agents.length; i++) {
|
|
5236
5397
|
const entry = inventory.agents[i];
|
|
@@ -5430,6 +5591,46 @@ export class Manager {
|
|
|
5430
5591
|
throw new Error(`retained user principal ${entry.identity.owner}.${entry.identity.actor} could not be reused: ${e.message}`);
|
|
5431
5592
|
}
|
|
5432
5593
|
}
|
|
5594
|
+
/** The seat reference behind a live retained principal, when that seat is provably one a lost
|
|
5595
|
+
* manager of this logical instance launched for this exact incarnation. The resume binds the
|
|
5596
|
+
* launched handle's reference to the incarnation's active slot before it waits for readiness, and
|
|
5597
|
+
* the coordinator journals `resume-active` only after that wait, so a coordinator crash between the
|
|
5598
|
+
* two leaves `resume-intent` behind a live seat. Undefined for everything else, which stays refused:
|
|
5599
|
+
* another incarnation's presence, a slot another instance owns, a runtime that cannot reap the
|
|
5600
|
+
* reference, and user or open meshes, which keep no slot. */
|
|
5601
|
+
async orphanedResumeSeat(entry, principal, liveRoster) {
|
|
5602
|
+
if (!this.auth || this.userMode || entry.identity.mode !== "static")
|
|
5603
|
+
return undefined;
|
|
5604
|
+
if (typeof this.runtime.reap !== "function")
|
|
5605
|
+
return undefined;
|
|
5606
|
+
if (liveRoster.some((p) => p.card.id === principal && p.lifecycleUid !== entry.identity.lifecycleUid))
|
|
5607
|
+
return undefined;
|
|
5608
|
+
const slot = await this.readStaticReconcileSlot(entry.name);
|
|
5609
|
+
if (slot?.phase !== "active" || slot.actor !== entry.identity.id || slot.lifecycleUid !== entry.identity.lifecycleUid)
|
|
5610
|
+
return undefined;
|
|
5611
|
+
if (slot.ownerInstanceId !== undefined && slot.ownerInstanceId !== this.managerInstanceId)
|
|
5612
|
+
return undefined;
|
|
5613
|
+
return slot.runtime?.kind === this.runtime.kind ? slot.runtime : undefined;
|
|
5614
|
+
}
|
|
5615
|
+
/** Reap a seat {@link orphanedResumeSeat} found, then wait for its principal to leave presence so
|
|
5616
|
+
* the relaunch's readiness cannot match the dead seat's row. Returns the refusal when the reap
|
|
5617
|
+
* proves nothing or the principal stays live, which means another process holds it. */
|
|
5618
|
+
async reapOrphanedResumeSeat(principal, reference) {
|
|
5619
|
+
try {
|
|
5620
|
+
const evidence = await requireRuntimeReap(this.runtime, reference);
|
|
5621
|
+
console.error(`resume: reaped orphaned seat ${reference.kind}:${reference.id} of retained principal "${principal}" (${evidence.detail})`);
|
|
5622
|
+
}
|
|
5623
|
+
catch (e) {
|
|
5624
|
+
return `retained principal "${principal}" is already live and its recorded seat ${reference.kind}:${reference.id} could not be reaped: ${e.message}`;
|
|
5625
|
+
}
|
|
5626
|
+
const deadline = Date.now() + ORPHAN_PRESENCE_CLEAR_MS;
|
|
5627
|
+
while (this.ep.getRoster().some((p) => p.card.id === principal && p.status !== "offline")) {
|
|
5628
|
+
if (Date.now() >= deadline)
|
|
5629
|
+
return `retained principal "${principal}" is still live after its recorded seat ${reference.kind}:${reference.id} was reaped, so another process holds it and this runtime cannot authoritatively adopt it`;
|
|
5630
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
5631
|
+
}
|
|
5632
|
+
return undefined;
|
|
5633
|
+
}
|
|
5433
5634
|
/** Validate/relaunch one retained inventory entry. Called only through resumePreserved so all
|
|
5434
5635
|
* records pass the same preflight before the first child is exposed. */
|
|
5435
5636
|
async resumePreservedAgent(entry, preflightOnly = false, batchReserved = false, prepared) {
|
|
@@ -5986,6 +6187,53 @@ export class Manager {
|
|
|
5986
6187
|
void this.cancelAgentGoal(a.name, graceful ? "graceful" : "terminate"); // M4: cancel a live spawn goal
|
|
5987
6188
|
return { ok: true, data: { name: a.name, stopped: true, graceful } };
|
|
5988
6189
|
}
|
|
6190
|
+
/** Retire one agent this manager launched under a delegated user intent, through the host
|
|
6191
|
+
* (SPEC 13.16). It sends the user's retirement intent for the UID of the running slot or its
|
|
6192
|
+
* retirement hold, and only on `retired: true` for that exact target stops the slot and frees the
|
|
6193
|
+
* name; every other answer leaves the slot running or the name held. It never calls the holder's
|
|
6194
|
+
* own prepare or requester. */
|
|
6195
|
+
async retireDelegatedAgent(name, intentId) {
|
|
6196
|
+
const execute = this.remoteAuthority?.executeDelegatedUserIntent;
|
|
6197
|
+
if (!execute)
|
|
6198
|
+
return { ok: false, error: `this manager has no host execution for a delegated user intent; "${name}" was not retired` };
|
|
6199
|
+
const release = this.beginLifecycle();
|
|
6200
|
+
if (!release)
|
|
6201
|
+
return { ok: false, error: this.maintenanceError() };
|
|
6202
|
+
try {
|
|
6203
|
+
const live = this.agents.get(name);
|
|
6204
|
+
const held = live ? undefined : this.retiring.get(name);
|
|
6205
|
+
const principal = live?.delegated ? parsePrincipalKey(live.id) : held?.delegated ? held : null;
|
|
6206
|
+
const lifecycleUid = live?.lifecycleUid ?? held?.lifecycleUid;
|
|
6207
|
+
if (!principal || !lifecycleUid)
|
|
6208
|
+
return { ok: false, error: `no agent named "${name}" was launched on this manager under a delegated user intent` };
|
|
6209
|
+
const target = { owner: principal.owner, actor: principal.actor, lifecycleUid };
|
|
6210
|
+
try {
|
|
6211
|
+
const result = await execute({ intentId, operation: "retire", target });
|
|
6212
|
+
if (result.operation !== "retire" || result.intentId !== intentId || result.opId !== retireOpId(target.lifecycleUid) ||
|
|
6213
|
+
result.target.owner !== target.owner || result.target.actor !== target.actor || result.target.lifecycleUid !== target.lifecycleUid)
|
|
6214
|
+
throw new Error(`the host answered delegated intent ${intentId} with another execution`);
|
|
6215
|
+
if (result.retired !== true)
|
|
6216
|
+
return { ok: false, error: `the host did not confirm the delegated retirement of "${name}" at ${target.lifecycleUid}; "${name}" is unchanged` };
|
|
6217
|
+
}
|
|
6218
|
+
catch (e) {
|
|
6219
|
+
const error = `the delegated retirement under intent ${intentId} did not complete (${rejectionText(e)})`;
|
|
6220
|
+
const hold = this.retiring.get(name);
|
|
6221
|
+
if (hold?.lifecycleUid === target.lifecycleUid)
|
|
6222
|
+
hold.lastError = error;
|
|
6223
|
+
return { ok: false, error: `${error}; "${name}" is unchanged` };
|
|
6224
|
+
}
|
|
6225
|
+
// The host's retirement closed this UID's provider handle before its barrier, so the stop only
|
|
6226
|
+
// reaps the slot, and its hold clears with the confirmation.
|
|
6227
|
+
const slot = this.agents.get(name);
|
|
6228
|
+
if (slot?.lifecycleUid === target.lifecycleUid)
|
|
6229
|
+
this.despawnAuthorized(slot, true, false, `delegated retirement ${intentId}`);
|
|
6230
|
+
this.confirmRetirement({ id: principalKey(target.owner, target.actor).key, name, lifecycleUid: target.lifecycleUid });
|
|
6231
|
+
return { ok: true, data: { name, lifecycleUid: target.lifecycleUid, retired: true } };
|
|
6232
|
+
}
|
|
6233
|
+
finally {
|
|
6234
|
+
release();
|
|
6235
|
+
}
|
|
6236
|
+
}
|
|
5989
6237
|
/** Resolve a v0.4 TARGET triple (owner, actor, lifecycleUid — broker-validated subject/body
|
|
5990
6238
|
* agreement, currency re-checked by the serve boundary's resolver) to the live managed agent it
|
|
5991
6239
|
* names. Static agents key `(DEV_OWNER, nkey)`; user-mode agents store the principal dot-form
|
|
@@ -6167,7 +6415,7 @@ export class Manager {
|
|
|
6167
6415
|
try {
|
|
6168
6416
|
report = await reconcileEndpointGate({
|
|
6169
6417
|
kv: authKv, space: this.space, endpoint: MANAGER_ENDPOINT, instanceId,
|
|
6170
|
-
probeHolder: makeManagerHolderLivenessProbe({ space: this.space, servers, auth, log }),
|
|
6418
|
+
probeHolder: makeManagerHolderLivenessProbe({ space: this.space, servers, auth, log, unreachableWaitMs: DELIVERY_ADMIN_BOOT_WAIT_MS }),
|
|
6171
6419
|
evictHolders: makeManagerEndpointHolderEvictor({ space: this.space, servers, auth, log }),
|
|
6172
6420
|
log,
|
|
6173
6421
|
recordsKv,
|
|
@@ -6442,7 +6690,7 @@ export class Manager {
|
|
|
6442
6690
|
// error text — a crash-restart never silently skips eviction (no-fallbacks).
|
|
6443
6691
|
const barrier = endpointRegistrationBarrier(authKv, this.space, {
|
|
6444
6692
|
endpoint: MANAGER_ENDPOINT, instanceId: iid, opId: registrationOpId,
|
|
6445
|
-
...(auth ? { evict: makeManagerEndpointEvictor({ space: this.space, servers: this.servers ?? DEFAULT_SERVER, auth, log: (line) => console.error(line) }) } : {}),
|
|
6693
|
+
...(auth ? { evict: makeManagerEndpointEvictor({ space: this.space, servers: this.servers ?? DEFAULT_SERVER, auth, log: (line) => console.error(line), unreachableWaitMs: DELIVERY_ADMIN_BOOT_WAIT_MS }) } : {}),
|
|
6446
6694
|
});
|
|
6447
6695
|
const spec = { endpoint: MANAGER_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
|
|
6448
6696
|
const { registrationRevision } = await registerServiceInstance(recordsKv, {
|