@cotal-ai/manager 0.100.1 → 0.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +56 -19
- package/dist/commands.js.map +1 -1
- package/dist/manager-service-contract.d.ts +3 -1
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +20 -3
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +29 -9
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +105 -85
- package/dist/manager.js.map +1 -1
- package/dist/remote-authority.d.ts +19 -0
- package/dist/remote-authority.d.ts.map +1 -1
- package/dist/remote-authority.js +16 -13
- package/dist/remote-authority.js.map +1 -1
- package/dist/run-hosting.d.ts +10 -4
- package/dist/run-hosting.d.ts.map +1 -1
- package/dist/run-hosting.js +53 -32
- package/dist/run-hosting.js.map +1 -1
- package/dist/service-adapter.d.ts.map +1 -1
- package/dist/service-adapter.js +55 -4
- package/dist/service-adapter.js.map +1 -1
- package/dist/service-child.d.ts +3 -0
- package/dist/service-child.d.ts.map +1 -1
- package/dist/service-child.js +15 -3
- package/dist/service-child.js.map +1 -1
- package/package.json +12 -12
package/dist/manager.js
CHANGED
|
@@ -4,7 +4,7 @@ import { credsAuthenticator } from "@nats-io/transport-node";
|
|
|
4
4
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
5
5
|
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
6
6
|
import Headless from "@xterm/headless";
|
|
7
|
-
import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, 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, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, } from "@cotal-ai/core";
|
|
7
|
+
import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, addDeprovisionAccounting, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, oneLine, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, } from "@cotal-ai/core";
|
|
8
8
|
import { loadLaunchPersonaFile, PersonaSourceError } from "@cotal-ai/workspace";
|
|
9
9
|
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimIdentityRecord, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, probeLiveness, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, spaceKey, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
10
10
|
import { createRuntime, exitProof, isCustodialRuntime, pollProvedExit, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, watchProvedExit, } from "./runtime/index.js";
|
|
@@ -16,6 +16,7 @@ import { launchSpecForRun, materializePersona, launchAgentToStartOpts, parseLaun
|
|
|
16
16
|
import { authorizeLaunch, authorizeNamedControl } from "./authorize.js";
|
|
17
17
|
import { controlShutdown } from "./control-shutdown.js";
|
|
18
18
|
import { RunHosting } from "./run-hosting.js";
|
|
19
|
+
import { enrolledAcl } from "./remote-authority.js";
|
|
19
20
|
import { controlSession } from "./control-session.js";
|
|
20
21
|
import { MAX_AGENTS, parseResumeCommitArgs, parseResumeControlArgs, parseResumeFinalizeArgs, readSeatRecord, removeSeatRecord, writeSeatRecord } from "./resume.js";
|
|
21
22
|
import { TranscriptReceiver } from "./transcript-receive.js";
|
|
@@ -140,7 +141,7 @@ const NO_OWNER_MATCHES = "-no-owner-";
|
|
|
140
141
|
* getter or `toString` throws. A handler that throws while building its text skips the work after
|
|
141
142
|
* it, and on a detached chain ends the manager with an unhandled rejection, so the coercion to text
|
|
142
143
|
* runs inside the guard. */
|
|
143
|
-
function rejectionText(e) {
|
|
144
|
+
export function rejectionText(e) {
|
|
144
145
|
try {
|
|
145
146
|
return String(e?.message ?? e);
|
|
146
147
|
}
|
|
@@ -260,13 +261,13 @@ function parseTurnNote(raw) {
|
|
|
260
261
|
return undefined;
|
|
261
262
|
const n = o;
|
|
262
263
|
if (typeof n.payload !== "string" || typeof n.deadlineAt !== "number"
|
|
263
|
-
|| typeof n.holdEpoch !== "number"
|
|
264
|
+
|| typeof n.holdEpoch !== "number")
|
|
264
265
|
return undefined;
|
|
265
266
|
if (n.holdToken !== undefined && typeof n.holdToken !== "string")
|
|
266
267
|
return undefined;
|
|
267
268
|
if (n.handoffFrom !== undefined && typeof n.handoffFrom !== "string")
|
|
268
269
|
return undefined;
|
|
269
|
-
return { payload: n.payload, deadlineAt: n.deadlineAt, holdEpoch: n.holdEpoch,
|
|
270
|
+
return { payload: n.payload, deadlineAt: n.deadlineAt, holdEpoch: n.holdEpoch, ...(typeof n.holdToken === "string" ? { holdToken: n.holdToken } : {}), ...(typeof n.handoffFrom === "string" ? { handoffFrom: n.handoffFrom } : {}) };
|
|
270
271
|
}
|
|
271
272
|
/** A goal's key in the manager's maps: the caller triple and goal id the goal plane stores the
|
|
272
273
|
* goal under, because a goal id is the caller's own (SPEC 13.6 item 1) and two callers may pick
|
|
@@ -550,7 +551,6 @@ export class Manager {
|
|
|
550
551
|
staticReconcileItems = new Map();
|
|
551
552
|
staticReconcileLastSweep;
|
|
552
553
|
staticReconcileLastSweepResources;
|
|
553
|
-
lastDeprovisionResources;
|
|
554
554
|
staticReconcileSweepsInFlight = 0;
|
|
555
555
|
staticReconcileDrainWaiters = [];
|
|
556
556
|
staticReconcileStopping = false;
|
|
@@ -2482,7 +2482,7 @@ export class Manager {
|
|
|
2482
2482
|
await this.ep.releaseManagerLease(this.managerInstanceId, own.revision);
|
|
2483
2483
|
}
|
|
2484
2484
|
catch {
|
|
2485
|
-
// Best-effort
|
|
2485
|
+
// Best-effort: a release that did not commit is reported by the re-read below.
|
|
2486
2486
|
}
|
|
2487
2487
|
try {
|
|
2488
2488
|
const stillOurs = await this.ep.readOwnManagerLease(this.managerInstanceId);
|
|
@@ -2534,7 +2534,7 @@ export class Manager {
|
|
|
2534
2534
|
catch (e) {
|
|
2535
2535
|
console.error(`! could not deregister manager instance ${iid}: ${rejectionText(e)}\n` +
|
|
2536
2536
|
` Its registration now outlives this process and will be frozen into every class scatter in space "${this.space}", each paying the full deadline.\n` +
|
|
2537
|
-
` NEXT:
|
|
2537
|
+
` NEXT: once this process is gone, start this manager again and stop it cleanly; on a static space \`cotal endpoint deregister manager ${iid} --yes\` from the space's own root also removes it.`);
|
|
2538
2538
|
}
|
|
2539
2539
|
}
|
|
2540
2540
|
/** Stop the v0.4 service-endpoint serve loop (drain subscriptions, await in-flight handlers)
|
|
@@ -3172,7 +3172,7 @@ export class Manager {
|
|
|
3172
3172
|
// follows owner-domain reach on a user mesh. Resolve the caller's admin tier once for
|
|
3173
3173
|
// the residual cross-owner case; opStart decides before connector resolution or grant
|
|
3174
3174
|
// mutation. Static meshes resolve true and nothing changes.
|
|
3175
|
-
spawn: (ctx) => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route, ctx.subject.caller))),
|
|
3175
|
+
spawn: (ctx) => this.loggedSpawnRefusal(ctx, () => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route, ctx.subject.caller)))),
|
|
3176
3176
|
despawn: (ctx) => this.serveGated(ctx, async () => {
|
|
3177
3177
|
const a = targetAgent(ctx);
|
|
3178
3178
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
@@ -3265,7 +3265,7 @@ export class Manager {
|
|
|
3265
3265
|
return this.runHost().amend(input, this.runAnswerer(ctx), (accepted) => this.authorizeRunAmend(ctx, accepted), ctx);
|
|
3266
3266
|
return this.runHost().answer(input, this.runAnswerer(ctx), (open) => this.authorizeRunAnswer(ctx, input, open), ctx);
|
|
3267
3267
|
}),
|
|
3268
|
-
runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx))),
|
|
3268
|
+
runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx), ctx)),
|
|
3269
3269
|
runPs: (ctx) => this.serveGated(ctx, () => this.runHost().list(args(ctx), ctx)),
|
|
3270
3270
|
preparePreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("preparePreservation", args(ctx)))),
|
|
3271
3271
|
commitPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("commitPreservation", args(ctx)))),
|
|
@@ -3726,7 +3726,14 @@ export class Manager {
|
|
|
3726
3726
|
for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files])
|
|
3727
3727
|
await this.shredUserSecrets(unshredded, family);
|
|
3728
3728
|
const leftover = unshredded.length ? `; cleanup failed: ${unshredded.join("; ")}` : "";
|
|
3729
|
-
|
|
3729
|
+
// A host that refused the enrollment enrolled nothing, so once every staged secret is gone
|
|
3730
|
+
// this attempt left nothing behind. A failure after the host answered, or a host that did not
|
|
3731
|
+
// answer, may have left a grant there.
|
|
3732
|
+
const refusal = material === undefined && !unshredded.length ? caughtEnvelope(e) : undefined;
|
|
3733
|
+
return {
|
|
3734
|
+
error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}`,
|
|
3735
|
+
...(refusal?.outcome === "not-executed" ? { refused: refusal.code } : {}),
|
|
3736
|
+
};
|
|
3730
3737
|
}
|
|
3731
3738
|
}
|
|
3732
3739
|
/** Shred a user agent's secret family from the store and from disk. Every removal is attempted,
|
|
@@ -4233,7 +4240,8 @@ export class Manager {
|
|
|
4233
4240
|
* is NOT freed while any teardown phase is still in flight — the hold clears only after the
|
|
4234
4241
|
* standing-authority revoke AND the lifecycle retirement both confirm (see {@link driveRetirement}) —
|
|
4235
4242
|
* but the deletes here are still lifecycle-uid-pinned so even a replayed/stale teardown can never
|
|
4236
|
-
* reach a same-name successor's footprint (its names embed a different uid).
|
|
4243
|
+
* reach a same-name successor's footprint (its names embed a different uid). Each call reports its
|
|
4244
|
+
* own accounting: the return value, or the {@link DeprovisionError} it throws once the deletes ran. */
|
|
4237
4245
|
async deprovisionBroker(a) {
|
|
4238
4246
|
// LIFECYCLE-PINNED (SPEC 13.1): both the credential's exact-name grants and the delete names
|
|
4239
4247
|
// carry a.lifecycleUid, so a stale/replayed teardown for this retired incarnation is broker-denied
|
|
@@ -4255,26 +4263,16 @@ export class Manager {
|
|
|
4255
4263
|
// Bound the detached broker teardown so a wedged broker can't leave the deprovision promise pending
|
|
4256
4264
|
// forever with no log — the timeout rejects into freeSlot's fail-loud `.catch` (paired with the
|
|
4257
4265
|
// helper's own fail-fast connect). The durables/ACL row still fall to space teardown as a backstop.
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
creds,
|
|
4267
|
-
}), DEPROVISION_TIMEOUT_MS, `deprovision ${a.name} (${a.id}): broker teardown timed out`);
|
|
4268
|
-
this.lastDeprovisionResources = accounting;
|
|
4269
|
-
}
|
|
4270
|
-
catch (e) {
|
|
4271
|
-
if (e instanceof DeprovisionError) {
|
|
4272
|
-
this.lastDeprovisionResources = e.accounting;
|
|
4273
|
-
}
|
|
4274
|
-
throw e;
|
|
4275
|
-
}
|
|
4266
|
+
const accounting = await withTimeout(deprovisionAgent({
|
|
4267
|
+
servers: this.servers ?? DEFAULT_SERVER,
|
|
4268
|
+
space: this.space,
|
|
4269
|
+
targetId: a.id,
|
|
4270
|
+
lifecycleUid: a.lifecycleUid,
|
|
4271
|
+
memberChannels: [...memberChannels],
|
|
4272
|
+
creds,
|
|
4273
|
+
}), DEPROVISION_TIMEOUT_MS, `deprovision ${a.name} (${a.id}): broker teardown timed out`);
|
|
4276
4274
|
if (!inventory.complete)
|
|
4277
|
-
throw new
|
|
4275
|
+
throw new DeprovisionError(`membership inventory incomplete (${inventory.reason}); rows on channels outside its launch policy stay RETAINED for uid ${a.lifecycleUid}`, accounting);
|
|
4278
4276
|
return accounting;
|
|
4279
4277
|
}
|
|
4280
4278
|
/** The retiring lifecycle's durable membership channels, read from the delivery daemon's
|
|
@@ -5175,7 +5173,7 @@ export class Manager {
|
|
|
5175
5173
|
else {
|
|
5176
5174
|
configPath = agentFilePath(this.workspaceRoot, ref);
|
|
5177
5175
|
if (!existsSync(configPath))
|
|
5178
|
-
return { error: `no persona "${ref}"
|
|
5176
|
+
return { error: `no persona "${ref}" in the catalog of manager ${this.managerInstanceId} on host ${hostname()}: ${configPath} not found; create it there` };
|
|
5179
5177
|
}
|
|
5180
5178
|
// Load the persona ONCE, here, ahead of the connector choice. The file's `agent:` pin feeds the
|
|
5181
5179
|
// harness resolution below; the spawn path's identity/ACL profile block consumes the SAME def (no
|
|
@@ -5656,8 +5654,11 @@ export class Manager {
|
|
|
5656
5654
|
}
|
|
5657
5655
|
: undefined,
|
|
5658
5656
|
});
|
|
5659
|
-
if ("error" in prep)
|
|
5657
|
+
if ("error" in prep) {
|
|
5658
|
+
if (prep.refused !== undefined)
|
|
5659
|
+
hooks?.onUndone?.(prep.refused);
|
|
5660
5660
|
return { ok: false, error: prep.error };
|
|
5661
|
+
}
|
|
5661
5662
|
// THE HOST'S UID WINS (#1972). On the local arm this is the value passed in, unchanged. On
|
|
5662
5663
|
// the hosted enrollment arm the host selected it, and from here every lifecycle-keyed thing
|
|
5663
5664
|
// this spawn records — the slot, the launch, the managed row, the teardown credential — must
|
|
@@ -5786,7 +5787,7 @@ export class Manager {
|
|
|
5786
5787
|
if (this.delegatedLaunched.has(lifecycleUid))
|
|
5787
5788
|
throw new Error(`lifecycle ${lifecycleUid} was already handed to runtime "${this.requiredRuntime.kind}"; a lifecycle is handed off at most once`);
|
|
5788
5789
|
this.delegatedLaunched.add(lifecycleUid);
|
|
5789
|
-
handle = this.requiredRuntime.spawnDelegated({ agent, persona: readFileSync(configPath, "utf8"), role, model, variant, prompt, launchOptions: launchOptions, events }, handoff);
|
|
5790
|
+
handle = this.requiredRuntime.spawnDelegated({ agent, persona: readFileSync(configPath, "utf8"), managerInstanceId: this.managerInstanceId, role, model, variant, prompt, launchOptions: launchOptions, events }, handoff);
|
|
5790
5791
|
}
|
|
5791
5792
|
else {
|
|
5792
5793
|
if (carried) {
|
|
@@ -5984,7 +5985,9 @@ export class Manager {
|
|
|
5984
5985
|
// canonicalJson (undefined never coerces to null, SPEC 13.6), so a role-less spawn would
|
|
5985
5986
|
// otherwise fail its succeeded terminal. The CLI/connector already render an absent role as
|
|
5986
5987
|
// "no role", so dropping the key preserves the reply (surfaced by readiness:live).
|
|
5987
|
-
|
|
5988
|
+
// A host that granted other channels than this spawn asked for: the receipt carries both.
|
|
5989
|
+
const acl = enrolled ? enrolledAcl({ allowSubscribe, allowPublish }, enrolled.material) : undefined;
|
|
5990
|
+
const okData = { name, agent, id: managed.id, mode: handle.kind, lifecycleUid, ...(role !== undefined ? { role } : {}), ...(opts.eventsNotice !== undefined ? { eventsNotice: opts.eventsNotice } : {}), ...(acl ? { acl } : {}) };
|
|
5988
5991
|
await hooks?.onOutcome?.({ kind: "succeeded", data: okData });
|
|
5989
5992
|
return { ok: true, data: okData };
|
|
5990
5993
|
}
|
|
@@ -6822,7 +6825,7 @@ export class Manager {
|
|
|
6822
6825
|
const uncertain = () => finish({
|
|
6823
6826
|
ok: false,
|
|
6824
6827
|
uncertain: true,
|
|
6825
|
-
detail: `${a.name} (${a.id}): launch status uncertain - no process exit and no mesh presence within ${
|
|
6828
|
+
detail: `${a.name} (${a.id}): launch status uncertain - no process exit and no mesh presence within ${readinessTimeoutMs}ms; it may still be booting or stuck before connector startup. Inspect with \`cotal attach ${a.name}\` / \`cotal ps\`; do not stop it solely because this bounded wait elapsed.`,
|
|
6826
6829
|
});
|
|
6827
6830
|
// The exit poll read the runtime up to a poll ago, and a window can be shorter than a poll, so
|
|
6828
6831
|
// the window closes on a fresh read: a child that exited by then is reported as exited. Nothing
|
|
@@ -8194,6 +8197,18 @@ export class Manager {
|
|
|
8194
8197
|
if (observed.processEpoch !== epoch)
|
|
8195
8198
|
throw new EpEnvelopeError("expired", `the manager's issuance gate epoch is ${observed.processEpoch} but this goal was accepted under epoch ${epoch}; a superseded incarnation never commits a goal terminal (SPEC 13.6)`);
|
|
8196
8199
|
}
|
|
8200
|
+
/** A spawn's refusal reaches only its caller's terminal, so the manager also writes one line for it
|
|
8201
|
+
* to its own log, naming the request ID the caller printed, the persona and the reason. */
|
|
8202
|
+
async loggedSpawnRefusal(ctx, serve) {
|
|
8203
|
+
try {
|
|
8204
|
+
return await serve();
|
|
8205
|
+
}
|
|
8206
|
+
catch (e) {
|
|
8207
|
+
const persona = ctx.request.args?.name;
|
|
8208
|
+
console.error(oneLine(`! spawn request ${ctx.request.id} for persona "${String(persona ?? "")}" refused (${e instanceof EpEnvelopeError ? e.code : "error"}): ${rejectionText(e)}`));
|
|
8209
|
+
throw e;
|
|
8210
|
+
}
|
|
8211
|
+
}
|
|
8197
8212
|
/** Serve `spawn`/`launch` as an ACTION. Authz already ran in {@link serveGated}. The
|
|
8198
8213
|
* accept path runs INLINE on the handler ({@link startAgent} with hooks): the goal binds + the
|
|
8199
8214
|
* acceptance replies the moment the identity is minted, BEFORE any provision, or on the
|
|
@@ -8261,6 +8276,8 @@ export class Manager {
|
|
|
8261
8276
|
let ownsGoal = false;
|
|
8262
8277
|
// Set by onEffect. A refusal that arrives with it still false changed nothing.
|
|
8263
8278
|
let effectStarted = false;
|
|
8279
|
+
// Set by onUndone: the host's code for a later refusal that left nothing behind.
|
|
8280
|
+
let undone;
|
|
8264
8281
|
// The terminal commits OFF-handler on the goal-writer connection (manager-only authority; a
|
|
8265
8282
|
// caller cannot publish it). TWO COMPOSED FENCES (defense in depth): (a) reads THIS
|
|
8266
8283
|
// incarnation's OWN gate epoch and REFUSES a superseded commit (the currency belt), and (b)
|
|
@@ -8381,14 +8398,16 @@ export class Manager {
|
|
|
8381
8398
|
// `failed` that would race that `cancel` (first-terminal-fact-wins).
|
|
8382
8399
|
onTerminalDeferred: () => { terminalEntered = true; },
|
|
8383
8400
|
onEffect: () => { effectStarted = true; },
|
|
8401
|
+
onUndone: (code) => { undone = code; },
|
|
8384
8402
|
});
|
|
8385
8403
|
bg.then((reply) => {
|
|
8386
8404
|
// Refused BEFORE onAccepted (hard-pin collision, capacity, persona-not-found) — no goal bound.
|
|
8387
|
-
// Only a refusal before the first effect
|
|
8388
|
-
// alias gate that re-drives a teardown, leaves the
|
|
8405
|
+
// Only a refusal before the first effect, or one whose effects were all removed, states
|
|
8406
|
+
// `not-executed`; a later one, such as the alias gate that re-drives a teardown, leaves the
|
|
8407
|
+
// outcome unknown.
|
|
8389
8408
|
if (acceptance === undefined) {
|
|
8390
8409
|
const details = reply.details;
|
|
8391
|
-
rejectAccept(new EpEnvelopeError("failed-precondition", reply.error ?? "spawn refused at accept", details, effectStarted ? undefined : "not-executed"));
|
|
8410
|
+
rejectAccept(new EpEnvelopeError(undone ?? "failed-precondition", reply.error ?? "spawn refused at accept", details, effectStarted && undone === undefined ? undefined : "not-executed"));
|
|
8392
8411
|
return;
|
|
8393
8412
|
}
|
|
8394
8413
|
// H1: `run` CATCHES its own body (a throw in buildLaunch/runtime.spawn) and RESOLVES `{ok:false}`
|
|
@@ -8506,7 +8525,7 @@ export class Manager {
|
|
|
8506
8525
|
const deadlineMs = deadlineAt - acceptedAt;
|
|
8507
8526
|
const handoffFrom = raw.handoffFrom === undefined ? undefined : String(raw.handoffFrom);
|
|
8508
8527
|
const holdToken = turnHoldToken(key);
|
|
8509
|
-
const note = JSON.stringify({ payload, deadlineAt, holdEpoch: executor.epoch,
|
|
8528
|
+
const note = JSON.stringify({ payload, deadlineAt, holdEpoch: executor.epoch, holdToken, ...(handoffFrom !== undefined ? { handoffFrom } : {}) });
|
|
8510
8529
|
// A goal that already ENDED is never accepted again, whatever the retry carries: the bind is
|
|
8511
8530
|
// create-only and outlives an unwind (the failed terminal, the cleared index), so a retry of an
|
|
8512
8531
|
// unwound accept would otherwise re-record an index entry over a tombstone and, before that
|
|
@@ -8518,7 +8537,7 @@ export class Manager {
|
|
|
8518
8537
|
throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" already ended ${ended.state}; a bound goal is never accepted again (SPEC 13.6)`);
|
|
8519
8538
|
// Index-CAS-before-bind, exactly as spawn: the entry (floor + note) is the durable relay
|
|
8520
8539
|
// record a successor rebuilds from, so it must exist before the acceptance is servable.
|
|
8521
|
-
const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name: a.name, actor: t.actor, uid: t.lifecycleUid, readinessDeadlineMs: deadlineMs }, note);
|
|
8540
|
+
const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name: a.name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid, readinessDeadlineMs: deadlineMs }, note);
|
|
8522
8541
|
if (!idx.recorded && idx.existing.iid !== executor.lifecycleUid)
|
|
8523
8542
|
throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted by instance "${idx.existing.iid}"; that instance owns its relay and this attempt provisions nothing (SPEC 13.6)`);
|
|
8524
8543
|
const b = await bindGoal(gw.ctx, ref, fingerprint);
|
|
@@ -8917,8 +8936,8 @@ export class Manager {
|
|
|
8917
8936
|
* entry carrying a parseable note rebuilds its pending relay — never an `uncertain` settle,
|
|
8918
8937
|
* because the deadline hold IS this goal's bounded ending and it survived the restart. No
|
|
8919
8938
|
* liveness verdict is made here (the agents map is empty during reconcile, so absence means
|
|
8920
|
-
* "not yet re-registered", never "dead"); a garbled note or a
|
|
8921
|
-
* left, the dead-pointer honesty rule. */
|
|
8939
|
+
* "not yet re-registered", never "dead"); a garbled note or a floor that does not name the
|
|
8940
|
+
* seat's owner is logged and left, the dead-pointer honesty rule. */
|
|
8922
8941
|
async adoptTurnGoal(entry) {
|
|
8923
8942
|
const gw = this.goalWriter;
|
|
8924
8943
|
if (!gw)
|
|
@@ -8935,8 +8954,8 @@ export class Manager {
|
|
|
8935
8954
|
return;
|
|
8936
8955
|
}
|
|
8937
8956
|
const parsed = entry.note !== undefined ? parseTurnNote(entry.note) : undefined;
|
|
8938
|
-
if (entry.allocated === undefined || parsed === undefined) {
|
|
8939
|
-
console.error(`turn reconcile ${entry.ref.goalId}: the relay record is garbled (no floor, or an unparseable note); left unsettled`);
|
|
8957
|
+
if (entry.allocated?.owner === undefined || parsed === undefined) {
|
|
8958
|
+
console.error(`turn reconcile ${entry.ref.goalId}: the relay record is garbled (no floor naming the seat's owner, or an unparseable note); left unsettled`);
|
|
8940
8959
|
return;
|
|
8941
8960
|
}
|
|
8942
8961
|
const spec = await readGoalSpec(gw.ctx, entry.ref);
|
|
@@ -8951,7 +8970,7 @@ export class Manager {
|
|
|
8951
8970
|
}
|
|
8952
8971
|
const p = {
|
|
8953
8972
|
ref: entry.ref, goalId: entry.ref.goalId,
|
|
8954
|
-
seat: { name: entry.allocated.name, owner:
|
|
8973
|
+
seat: { name: entry.allocated.name, owner: entry.allocated.owner, actor: entry.allocated.actor, uid: entry.allocated.uid },
|
|
8955
8974
|
payload: parsed.payload,
|
|
8956
8975
|
acceptedAt: spec.value.acceptedAt,
|
|
8957
8976
|
deadlineAt: parsed.deadlineAt,
|
|
@@ -9307,8 +9326,9 @@ export class Manager {
|
|
|
9307
9326
|
* wire principal joins {@link retiredPrincipals} (the F5 refusal index) and the name hold
|
|
9308
9327
|
* clears (ABA-guarded by uid). A PRE-UNIT-B lifecycle (no slot row — spawned before the
|
|
9309
9328
|
* durable registry existed) has nothing to terminalize: its footprint teardown runs directly
|
|
9310
|
-
* and the hold clears, the honest upgrade path.
|
|
9311
|
-
|
|
9329
|
+
* and the hold clears, the honest upgrade path. `onDeprovision` receives the accounting of the
|
|
9330
|
+
* broker teardown this call ran, on success or on a {@link DeprovisionError}. */
|
|
9331
|
+
async driveStaticRetirement(a, surfaceFailure = false, onDeprovision) {
|
|
9312
9332
|
// A renewal that published its flight before the synchronous terminal latch is accepted work.
|
|
9313
9333
|
// Drain it before the durable terminal enumerates credential ids and before cleanup deletes its
|
|
9314
9334
|
// material; a failed renewal must not block retirement because it may still have staged an id.
|
|
@@ -9340,7 +9360,17 @@ export class Manager {
|
|
|
9340
9360
|
await attemptCleanup(failed, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
|
|
9341
9361
|
await attemptCleanup(failed, `file ${creds}`, () => rmSync(creds, { force: true }));
|
|
9342
9362
|
}
|
|
9343
|
-
await attemptCleanup(failed, "broker teardown", () =>
|
|
9363
|
+
await attemptCleanup(failed, "broker teardown", async () => {
|
|
9364
|
+
try {
|
|
9365
|
+
const accounting = await this.deprovisionBroker(a);
|
|
9366
|
+
onDeprovision?.(accounting);
|
|
9367
|
+
}
|
|
9368
|
+
catch (e) {
|
|
9369
|
+
if (e instanceof DeprovisionError)
|
|
9370
|
+
onDeprovision?.(e.accounting);
|
|
9371
|
+
throw e;
|
|
9372
|
+
}
|
|
9373
|
+
});
|
|
9344
9374
|
if (failed.length)
|
|
9345
9375
|
throw new Error(`cleanup failed: ${failed.join("; ")}`);
|
|
9346
9376
|
};
|
|
@@ -9799,12 +9829,12 @@ export class Manager {
|
|
|
9799
9829
|
if (item.flight)
|
|
9800
9830
|
return item.flight;
|
|
9801
9831
|
if (item.timer)
|
|
9802
|
-
return Promise.resolve(false);
|
|
9832
|
+
return Promise.resolve({ succeeded: false });
|
|
9803
9833
|
// A timer/retry driver increments before its mandatory durable re-read, then calls with
|
|
9804
9834
|
// countAttempt=false. Let that already-consumed budget entry enter the terminal. Only a fresh
|
|
9805
9835
|
// sweep entry (countAttempt=true) is refused once the per-process budget is exhausted.
|
|
9806
9836
|
if (countAttempt && item.attempts >= item.maxAttempts)
|
|
9807
|
-
return Promise.resolve(false);
|
|
9837
|
+
return Promise.resolve({ succeeded: false });
|
|
9808
9838
|
const flight = (async () => {
|
|
9809
9839
|
if (countAttempt)
|
|
9810
9840
|
item.attempts++;
|
|
@@ -9813,8 +9843,9 @@ export class Manager {
|
|
|
9813
9843
|
item.nextRetryAt = undefined;
|
|
9814
9844
|
this.reconcilingAliases.add(row.alias);
|
|
9815
9845
|
console.error(`static reconcile terminal alias=${row.alias} phase=${row.phase} uid=${row.lifecycleUid} attempt=${item.attempts}/${item.maxAttempts}`);
|
|
9846
|
+
let deprovision;
|
|
9816
9847
|
try {
|
|
9817
|
-
await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid, runtime: row.runtime }, true);
|
|
9848
|
+
await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid, runtime: row.runtime }, true, (accounting) => { deprovision = accounting; });
|
|
9818
9849
|
if (item.attempts === 1) {
|
|
9819
9850
|
this.staticReconcileItems.delete(key);
|
|
9820
9851
|
}
|
|
@@ -9825,7 +9856,7 @@ export class Manager {
|
|
|
9825
9856
|
item.lastError = undefined;
|
|
9826
9857
|
console.error(`✓ static reconcile recovered alias=${item.alias} phase=retired uid=${item.lifecycleUid} attempts=${item.attempts}/${item.maxAttempts}`);
|
|
9827
9858
|
}
|
|
9828
|
-
return true;
|
|
9859
|
+
return { succeeded: true, deprovision };
|
|
9829
9860
|
}
|
|
9830
9861
|
catch (error) {
|
|
9831
9862
|
item.lastError = rejectionText(error);
|
|
@@ -9856,7 +9887,7 @@ export class Manager {
|
|
|
9856
9887
|
else
|
|
9857
9888
|
this.scheduleStaticReconcileRetry(key, item);
|
|
9858
9889
|
}
|
|
9859
|
-
return false;
|
|
9890
|
+
return { succeeded: false, deprovision };
|
|
9860
9891
|
}
|
|
9861
9892
|
finally {
|
|
9862
9893
|
this.reconcilingAliases.delete(row.alias);
|
|
@@ -10058,34 +10089,10 @@ export class Manager {
|
|
|
10058
10089
|
};
|
|
10059
10090
|
this.staticReconcileItems.set(key, item);
|
|
10060
10091
|
}
|
|
10061
|
-
this.
|
|
10062
|
-
|
|
10063
|
-
|
|
10064
|
-
if (
|
|
10065
|
-
sweepDeprovision.examined += acc.examined;
|
|
10066
|
-
sweepDeprovision.deleted = sweepDeprovision.deleted === null || acc.deleted === null ? null : sweepDeprovision.deleted + acc.deleted;
|
|
10067
|
-
sweepDeprovision.absent += acc.absent;
|
|
10068
|
-
sweepDeprovision.refused += acc.refused;
|
|
10069
|
-
sweepDeprovision.acknowledged += acc.acknowledged;
|
|
10070
|
-
sweepDeprovision.disappeared += acc.disappeared;
|
|
10071
|
-
sweepDeprovision.consumers.examined += acc.consumers.examined;
|
|
10072
|
-
sweepDeprovision.consumers.deleted = sweepDeprovision.consumers.deleted === null || acc.consumers.deleted === null ? null : sweepDeprovision.consumers.deleted + acc.consumers.deleted;
|
|
10073
|
-
sweepDeprovision.consumers.absent += acc.consumers.absent;
|
|
10074
|
-
sweepDeprovision.consumers.refused += acc.consumers.refused;
|
|
10075
|
-
sweepDeprovision.consumers.acknowledged += acc.consumers.acknowledged;
|
|
10076
|
-
sweepDeprovision.consumers.disappeared += acc.consumers.disappeared;
|
|
10077
|
-
sweepDeprovision.acls.examined += acc.acls.examined;
|
|
10078
|
-
sweepDeprovision.acls.deleted += acc.acls.deleted;
|
|
10079
|
-
sweepDeprovision.acls.absent += acc.acls.absent;
|
|
10080
|
-
sweepDeprovision.acls.refused += acc.acls.refused;
|
|
10081
|
-
sweepDeprovision.acls.disappeared += acc.acls.disappeared;
|
|
10082
|
-
sweepDeprovision.members.examined += acc.members.examined;
|
|
10083
|
-
sweepDeprovision.members.deleted += acc.members.deleted;
|
|
10084
|
-
sweepDeprovision.members.absent += acc.members.absent;
|
|
10085
|
-
sweepDeprovision.members.refused += acc.members.refused;
|
|
10086
|
-
sweepDeprovision.members.disappeared += acc.members.disappeared;
|
|
10087
|
-
}
|
|
10088
|
-
if (succeeded)
|
|
10092
|
+
const attempt = await this.attemptStaticReconcile(key, item, row);
|
|
10093
|
+
if (attempt.deprovision)
|
|
10094
|
+
addDeprovisionAccounting(sweepDeprovision, attempt.deprovision);
|
|
10095
|
+
if (attempt.succeeded)
|
|
10089
10096
|
sweep.succeeded++;
|
|
10090
10097
|
else
|
|
10091
10098
|
sweep.failed++;
|
|
@@ -10217,9 +10224,9 @@ export class Manager {
|
|
|
10217
10224
|
* arrive as explicit arguments or as a leading frontmatter block inside `persona`. A prompt
|
|
10218
10225
|
* that starts with `---` is parsed and merged (explicit args win) so the written file has one
|
|
10219
10226
|
* frontmatter block; a malformed block is refused by name rather than wrapped. A fresh name is
|
|
10220
|
-
* created with owner
|
|
10221
|
-
* only if `file.owner == caller`, else admin is required. Fail-closed: an
|
|
10222
|
-
* (legacy / operator-written) is admin-only. */
|
|
10227
|
+
* created with the owner {@link personaOwner} records. Redefining an EXISTING file is allowed on
|
|
10228
|
+
* the privileged tier only if `file.owner == caller`, else admin is required. Fail-closed: an
|
|
10229
|
+
* ownerless file (legacy / operator-written) is admin-only. */
|
|
10223
10230
|
opDefinePersona(args, caller, admin) {
|
|
10224
10231
|
if (this.execution === "none")
|
|
10225
10232
|
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
@@ -10256,7 +10263,7 @@ export class Manager {
|
|
|
10256
10263
|
try {
|
|
10257
10264
|
def = composeWirePersona({
|
|
10258
10265
|
name,
|
|
10259
|
-
owner: caller,
|
|
10266
|
+
owner: this.personaOwner(caller),
|
|
10260
10267
|
prompt: persona,
|
|
10261
10268
|
model,
|
|
10262
10269
|
role: args.role !== undefined && args.role !== null && String(args.role) !== "" ? String(args.role) : undefined,
|
|
@@ -10277,6 +10284,19 @@ export class Manager {
|
|
|
10277
10284
|
}
|
|
10278
10285
|
return { ok: true, data: { name, path } };
|
|
10279
10286
|
}
|
|
10287
|
+
/** The owner a fresh wire-defined persona records. A user-auth caller's principal is stable, and
|
|
10288
|
+
* so is one of this manager's own seats for its life. Any other static caller, such as the CLI's
|
|
10289
|
+
* operator instrument minted for one call, would leave an owner no later call matches, so it
|
|
10290
|
+
* records none and the file is operator-owned, as a local `persona create` writes it. A peer
|
|
10291
|
+
* joined with its own credential is such a caller too. */
|
|
10292
|
+
personaOwner(caller) {
|
|
10293
|
+
if (this.userMode)
|
|
10294
|
+
return caller;
|
|
10295
|
+
for (const a of this.agents.values())
|
|
10296
|
+
if (this.managedPrincipal(a) === caller)
|
|
10297
|
+
return caller;
|
|
10298
|
+
return undefined;
|
|
10299
|
+
}
|
|
10280
10300
|
/** Whether this caller may READ a catalog card's details (role/model/description/body). Same
|
|
10281
10301
|
* ownership rule as redefine: owner == caller, or admin. Ownerless / operator-written files are
|
|
10282
10302
|
* admin-only. Names of parseable files still appear on the list so a definer can see a taken
|