@cotal-ai/manager 0.69.0 → 0.70.1
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 +24 -21
- package/dist/commands.js.map +1 -1
- package/dist/manager-service-contract.d.ts +8 -7
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +27 -21
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +37 -14
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +199 -114
- package/dist/manager.js.map +1 -1
- package/dist/remote-authority.d.ts +1 -1
- package/dist/remote-authority.d.ts.map +1 -1
- package/dist/remote-authority.js +23 -42
- package/dist/remote-authority.js.map +1 -1
- package/package.json +12 -12
package/dist/manager.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
3
2
|
import { hostname } from "node:os";
|
|
4
3
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
5
4
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
5
|
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
7
|
-
import
|
|
6
|
+
import Headless from "@xterm/headless";
|
|
7
|
+
import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, 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, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
9
|
import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
|
|
10
10
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
@@ -30,7 +30,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
|
|
|
30
30
|
// P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
|
|
31
31
|
// endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
|
|
32
32
|
// the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
|
|
33
|
-
import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
|
|
33
|
+
import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, requestGoalCancel, goalAlreadyTerminal, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
|
|
34
34
|
import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
|
|
35
35
|
import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
|
|
36
36
|
/** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
|
|
@@ -147,19 +147,6 @@ async function attemptCleanup(failed, what, step) {
|
|
|
147
147
|
failed.push(`${what}: ${rejectionText(err)}`);
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
-
/** Run the agent's bearer argv once, pre-launch — the end-to-end auth preflight (state dir, daemon,
|
|
151
|
-
* ledger row, secret). Its stderr is the provider command's operator-exact sentence; surface it
|
|
152
|
-
* verbatim as the spawn refusal. */
|
|
153
|
-
function execBearerPreflight(argv) {
|
|
154
|
-
const timeout = 30_000;
|
|
155
|
-
return new Promise((res, rej) => {
|
|
156
|
-
execFile(argv[0], argv.slice(1), { timeout, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
|
|
157
|
-
if (err)
|
|
158
|
-
return rej(bearerCommandFailure(err, stderr, timeout));
|
|
159
|
-
res();
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
150
|
/** Reject `p` with `Error(msg)` if it hasn't settled within `ms`; clears the timer when `p` settles so it
|
|
164
151
|
* never keeps the loop alive. Used to bound the detached deprovision so its fail-loud log is guaranteed. */
|
|
165
152
|
function withTimeout(p, ms, msg) {
|
|
@@ -251,6 +238,20 @@ function foreignEventChannels(channels, owner, actor) {
|
|
|
251
238
|
return p !== null && !(p.owner === owner && p.actor === actor);
|
|
252
239
|
});
|
|
253
240
|
}
|
|
241
|
+
/** One connector's declared harness binaries resolved on PATH now. The refusal sentence is built only
|
|
242
|
+
* here, so the boot row and the check for a connector registered after boot can never word it apart. */
|
|
243
|
+
function resolveHarness(name, requires) {
|
|
244
|
+
const binaries = {};
|
|
245
|
+
const missing = [];
|
|
246
|
+
for (const bin of requires) {
|
|
247
|
+
const path = resolveOnPath(bin);
|
|
248
|
+
if (path)
|
|
249
|
+
binaries[bin] = path;
|
|
250
|
+
else
|
|
251
|
+
missing.push(bin);
|
|
252
|
+
}
|
|
253
|
+
return missing.length ? { binaries, reason: `${name} harness needs ${missing.join(", ")} on PATH - not found` } : { binaries };
|
|
254
|
+
}
|
|
254
255
|
function injectedManagerStoreIdentity(store) {
|
|
255
256
|
if (store.identity !== undefined)
|
|
256
257
|
return parseSecretStoreIdentity(store.identity);
|
|
@@ -380,7 +381,7 @@ export class Manager {
|
|
|
380
381
|
* standing connection DISJOINT from the serve credential (Q2), scoped to exactly this endpoint's
|
|
381
382
|
* goal bind/terminal facts + goal-record writes ({@link goalWriterGrants}). Auth mode mints the
|
|
382
383
|
* `goal-writer` cred; an open mesh uses a bare connection (no credential system to mint from).
|
|
383
|
-
* `gate` (auth mode) is the own-issuance-gate READER for the
|
|
384
|
+
* `gate` (auth mode) is the own-issuance-gate READER for the own-gate currency belt — the
|
|
384
385
|
* manager reads its OWN `epgate.<e>.<iid>` epoch over this connection before a terminal commit
|
|
385
386
|
* and skips a superseded commit (the fast-fail belt paired with the (b) barrier-revoke fence). */
|
|
386
387
|
goalWriter;
|
|
@@ -388,7 +389,7 @@ export class Manager {
|
|
|
388
389
|
* per-run credential and connection. Absent under a remote authority, which mints no driver
|
|
389
390
|
* credentials, so the `run-*` family refuses there rather than connecting on a weaker identity. */
|
|
390
391
|
runHosting;
|
|
391
|
-
/**
|
|
392
|
+
/** The STABLE goal-writer identity (auth mode) — minted once at
|
|
392
393
|
* registration alongside the serve identity; a renewal re-mints the SAME nkey with a fresh
|
|
393
394
|
* bounded exp and re-stages its distinct credId into the §13.1 revocation family. The current
|
|
394
395
|
* goal-writer credential is minted INSIDE {@link registerManagerService}'s run block (fence
|
|
@@ -419,10 +420,10 @@ export class Manager {
|
|
|
419
420
|
sessionLedgerCreds;
|
|
420
421
|
/** P2 item 2: the acceptance replied for each in-flight goalId this incarnation accepted, so an
|
|
421
422
|
* idempotent same-goalId retry serves the IDENTICAL acceptance (same allocated name/triple) without
|
|
422
|
-
* a second spawn. Durable cross-incarnation reconstruction rides the
|
|
423
|
+
* a second spawn. Durable cross-incarnation reconstruction rides the goal index; here the
|
|
423
424
|
* live map covers same-incarnation retries, with the committed result fact as the fallback. */
|
|
424
425
|
goalAcceptances = new Map();
|
|
425
|
-
/**
|
|
426
|
+
/** The boot reconcile of the durable goal index runs ONCE at start (a
|
|
426
427
|
* fresh incarnation inherits the endpoint's accepted-but-unterminal goals from any predecessor).
|
|
427
428
|
* Spawn-as-action REFUSES to accept until it completes, so the sweep never races a live goal's
|
|
428
429
|
* acceptance (settling one mid-flight would steal its real terminal). */
|
|
@@ -801,7 +802,7 @@ export class Manager {
|
|
|
801
802
|
// First mint on a fresh root is exclusive create (#1263). Two processes that both observe no
|
|
802
803
|
// file must not keep different in-memory ids: of N concurrent starts exactly one creates the
|
|
803
804
|
// file (`link` / O_EXCL) and the others adopt that identity before taking a lease, or refuse
|
|
804
|
-
// with
|
|
805
|
+
// with identity-record-create-lost. Atomic rename is not enough, because the loser
|
|
805
806
|
// of a replace would still serve under the id it minted.
|
|
806
807
|
{
|
|
807
808
|
if (this.remoteAuthority) {
|
|
@@ -1018,7 +1019,7 @@ export class Manager {
|
|
|
1018
1019
|
// rides the serve grant's epoch + the family-staged session-ledger cred), on its own standing
|
|
1019
1020
|
// connection disjoint from both the serve and goal-writer creds.
|
|
1020
1021
|
await this.startSessionPlane();
|
|
1021
|
-
//
|
|
1022
|
+
// Reconcile any accepted-but-unterminal goals inherited from a predecessor
|
|
1022
1023
|
// BEFORE spawn-as-action begins accepting (the goalReconcileDone gate) — a fresh incarnation
|
|
1023
1024
|
// never drops a goal a dead predecessor accepted. Never fatal; the gate opens either way.
|
|
1024
1025
|
await this.reconcileGoalIndex();
|
|
@@ -1410,7 +1411,7 @@ export class Manager {
|
|
|
1410
1411
|
console.error(`! endpoint-serve renewal: ${e.message} - the manager's service endpoint dies loud at this cred's expiry unless it is re-registered`);
|
|
1411
1412
|
}
|
|
1412
1413
|
}
|
|
1413
|
-
//
|
|
1414
|
+
// The manager is also the goal-writer's renewal owner — re-mint the SAME
|
|
1414
1415
|
// goal-writer nkey with a fresh bounded exp AND re-stage its new credId into the §13.1 family,
|
|
1415
1416
|
// through the scoped executor (never the standing seed). Without this the standing goal-writer
|
|
1416
1417
|
// connection dies at its TTL and spawn-as-action stops accepting until a restart. The
|
|
@@ -2874,6 +2875,9 @@ export class Manager {
|
|
|
2874
2875
|
// turns addressed to ITS incarnation and yields them; no reach beyond itself exists here.
|
|
2875
2876
|
turnPending: (ctx) => this.serveGated(ctx, () => this.turnPendingFor(ctx.subject.caller)),
|
|
2876
2877
|
turnYield: (ctx) => this.serveGated(ctx, () => this.serveTurnYield(ctx.subject.caller, args(ctx))),
|
|
2878
|
+
// The reserved goal cancel (SPEC 13.6 item 4), served for the relay: the goal ref is the
|
|
2879
|
+
// authenticated caller's own, so a run withdraws only the turns it submitted.
|
|
2880
|
+
cancel: (ctx) => this.serveGated(ctx, () => this.serveTurnCancel(ctx)),
|
|
2877
2881
|
stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
|
|
2878
2882
|
definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
|
|
2879
2883
|
listPersonas: (ctx) => this.serveGated(ctx, () => unwrap(this.opListPersonas(callerOf(ctx), false))),
|
|
@@ -3247,7 +3251,9 @@ export class Manager {
|
|
|
3247
3251
|
"--token-file", tokenPath,
|
|
3248
3252
|
"--health-file", healthPath,
|
|
3249
3253
|
];
|
|
3250
|
-
|
|
3254
|
+
// One bearer run before the launch is the end-to-end auth preflight (state dir, daemon, ledger
|
|
3255
|
+
// row, secret); its failure sentence is the spawn refusal.
|
|
3256
|
+
await runAgentBearer(bearerCmd);
|
|
3251
3257
|
return { owner, lifecycleUid: opts.lifecycleUid, files, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
|
|
3252
3258
|
}
|
|
3253
3259
|
catch (e) {
|
|
@@ -3349,7 +3355,7 @@ export class Manager {
|
|
|
3349
3355
|
"--token-file", files.actorToken,
|
|
3350
3356
|
"--health-file", files.health,
|
|
3351
3357
|
];
|
|
3352
|
-
await
|
|
3358
|
+
await runAgentBearer(bearerCmd);
|
|
3353
3359
|
return {
|
|
3354
3360
|
owner: material.owner,
|
|
3355
3361
|
lifecycleUid: material.lifecycleUid,
|
|
@@ -3365,16 +3371,22 @@ export class Manager {
|
|
|
3365
3371
|
// A failed removal joins the refusal instead of throwing, so every removal is attempted and
|
|
3366
3372
|
// the refusal still reaches the caller.
|
|
3367
3373
|
const unshredded = [];
|
|
3368
|
-
for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files])
|
|
3369
|
-
|
|
3370
|
-
await attemptCleanup(unshredded, `secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
|
|
3371
|
-
for (const path of [family.actorToken, family.sentinelCreds, family.health])
|
|
3372
|
-
await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
|
|
3373
|
-
}
|
|
3374
|
+
for (const family of files.actorToken === staged.actorToken ? [staged] : [staged, files])
|
|
3375
|
+
await this.shredUserSecrets(unshredded, family);
|
|
3374
3376
|
const leftover = unshredded.length ? `; cleanup failed: ${unshredded.join("; ")}` : "";
|
|
3375
3377
|
return { error: `agent auth preflight failed for "${name}": ${rejectionText(e)}${leftover}` };
|
|
3376
3378
|
}
|
|
3377
3379
|
}
|
|
3380
|
+
/** Shred a user agent's secret family from the store and from disk. Every removal is attempted,
|
|
3381
|
+
* and each failure joins `unshredded`, so one fault cannot leave the rest of the family behind. */
|
|
3382
|
+
async shredUserSecrets(unshredded, family) {
|
|
3383
|
+
for (const path of [family.actorToken, family.sentinelCreds])
|
|
3384
|
+
if (path)
|
|
3385
|
+
await attemptCleanup(unshredded, `secret ${path}`, () => this.secrets.delete(agentSecretKeyForFile(path, this.space)));
|
|
3386
|
+
for (const path of [family.actorToken, family.sentinelCreds, family.health])
|
|
3387
|
+
if (path)
|
|
3388
|
+
await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
|
|
3389
|
+
}
|
|
3378
3390
|
/** This manager's registry record for its space, which pins the broker and IdP a delegated seat's
|
|
3379
3391
|
* handoff carries. It must pin the exchange the host enrolls against, or the child would register
|
|
3380
3392
|
* a mesh whose bearers come from somewhere else. */
|
|
@@ -3635,12 +3647,7 @@ export class Manager {
|
|
|
3635
3647
|
// the agent's standing mint authority, so delete it (next exchange refused, next connect
|
|
3636
3648
|
// denied) and shred the secret/sentinel/health files. A copied actor token dies here; a
|
|
3637
3649
|
// still-LIVE connection ends at its bearer-bound JWT expiry (≤ the agent TTL).
|
|
3638
|
-
|
|
3639
|
-
if (path)
|
|
3640
|
-
await attemptCleanup(unshredded, `secret ${path}`, () => secrets.delete(agentSecretKeyForFile(path, this.space)));
|
|
3641
|
-
for (const path of [files.actorToken, files.sentinelCreds, files.health])
|
|
3642
|
-
if (path)
|
|
3643
|
-
await attemptCleanup(unshredded, `file ${path}`, () => rmSync(path, { force: true }));
|
|
3650
|
+
await this.shredUserSecrets(unshredded, files);
|
|
3644
3651
|
// The ledger row IS the agent's STANDING mint authority (a different store from the auth-plane
|
|
3645
3652
|
// cred ledger the rail retirement covers): while it lives, a copied actor token can still mint a
|
|
3646
3653
|
// fresh connect credential. So a FAILED revoke must NOT be swallowed into a clean terminal (INT-2):
|
|
@@ -4200,7 +4207,7 @@ export class Manager {
|
|
|
4200
4207
|
restart.armed = false;
|
|
4201
4208
|
let tail = "";
|
|
4202
4209
|
try {
|
|
4203
|
-
tail = this.tail(
|
|
4210
|
+
tail = await this.tail((replacement ?? a.handle).attach());
|
|
4204
4211
|
}
|
|
4205
4212
|
catch { /* runtime has no readable tail */ }
|
|
4206
4213
|
console.error(`! ${a.name}: ${supervised ? "supervised restart" : "Pi session recovery"} failed: ${error.message}${tail ? ` - last output: ${tail}` : ""} - retiring the managed seat`);
|
|
@@ -4494,19 +4501,9 @@ export class Manager {
|
|
|
4494
4501
|
else {
|
|
4495
4502
|
declared = registry.all("connector").map((connector) => ({ name: connector.name, requires: connector.requires ?? [] }));
|
|
4496
4503
|
}
|
|
4497
|
-
for (const
|
|
4498
|
-
const
|
|
4499
|
-
|
|
4500
|
-
const missing = [];
|
|
4501
|
-
for (const bin of connector.requires) {
|
|
4502
|
-
const path = resolveOnPath(bin);
|
|
4503
|
-
if (path)
|
|
4504
|
-
binaries[bin] = path;
|
|
4505
|
-
else
|
|
4506
|
-
missing.push(bin);
|
|
4507
|
-
}
|
|
4508
|
-
if (missing.length) {
|
|
4509
|
-
const reason = `${name} harness needs ${missing.join(", ")} on PATH - not found`;
|
|
4504
|
+
for (const { name, requires } of declared) {
|
|
4505
|
+
const { binaries, reason } = resolveHarness(name, requires);
|
|
4506
|
+
if (reason) {
|
|
4510
4507
|
rows.push({ agent: name, state: "unavailable", binaries, reason });
|
|
4511
4508
|
console.error(`! manager boot: connector ${name} unavailable - ${reason}`);
|
|
4512
4509
|
}
|
|
@@ -4538,18 +4535,17 @@ export class Manager {
|
|
|
4538
4535
|
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)`;
|
|
4539
4536
|
}
|
|
4540
4537
|
/** The harness binaries a managed launch runs, as boot resolved them, or the refusal naming what is
|
|
4541
|
-
* missing. Spawn and
|
|
4542
|
-
* starts it
|
|
4538
|
+
* missing. Spawn, resume and the model catalog all take it, so a seat launches from the same files
|
|
4539
|
+
* whichever path starts it and the catalog never disagrees with a launch about whether the harness
|
|
4540
|
+
* is there. A connector registered after boot has no inventory row and is checked on PATH now. */
|
|
4543
4541
|
launchHarness(connector) {
|
|
4544
4542
|
const row = this.connectorStatuses.find((status) => status.agent === connector.name);
|
|
4545
4543
|
if (row?.state === "unavailable")
|
|
4546
4544
|
return { refusal: row.reason ?? `${connector.name} harness is unavailable` };
|
|
4547
4545
|
if (row)
|
|
4548
4546
|
return { binaries: row.binaries };
|
|
4549
|
-
const
|
|
4550
|
-
|
|
4551
|
-
return { refusal: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found` };
|
|
4552
|
-
return {};
|
|
4547
|
+
const { reason } = resolveHarness(connector.name, connector.requires ?? []);
|
|
4548
|
+
return reason ? { refusal: reason } : {};
|
|
4553
4549
|
}
|
|
4554
4550
|
/** The refusal for a launch choice the connector cannot honour. Spawn and resume both ask before
|
|
4555
4551
|
* any reserve or mint, so a rule changed here holds on both paths. */
|
|
@@ -4571,14 +4567,9 @@ export class Manager {
|
|
|
4571
4567
|
const one = async (connector) => {
|
|
4572
4568
|
if (!connector.listModels)
|
|
4573
4569
|
return { agent: connector.name, supported: false, models: [] };
|
|
4574
|
-
const
|
|
4575
|
-
if (
|
|
4576
|
-
return {
|
|
4577
|
-
agent: connector.name,
|
|
4578
|
-
supported: true,
|
|
4579
|
-
models: [],
|
|
4580
|
-
error: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found`,
|
|
4581
|
-
};
|
|
4570
|
+
const harness = this.launchHarness(connector);
|
|
4571
|
+
if ("refusal" in harness)
|
|
4572
|
+
return { agent: connector.name, supported: true, models: [], error: harness.refusal };
|
|
4582
4573
|
try {
|
|
4583
4574
|
const catalog = await connector.listModels({ refresh });
|
|
4584
4575
|
return { agent: connector.name, supported: true, ...catalog };
|
|
@@ -5127,7 +5118,8 @@ export class Manager {
|
|
|
5127
5118
|
// been provisioned yet — the action serve path binds the goal + replies the acceptance HERE. A
|
|
5128
5119
|
// throw (bind conflict / duplicate goalId) aborts the spawn before provisioning: the catch below
|
|
5129
5120
|
// returns the failure and the finally releases the reserve, so a refused accept leaves zero
|
|
5130
|
-
// footprint (pin 1).
|
|
5121
|
+
// footprint (pin 1). The hosted enrollment arm is the exception, below. Blocking callers
|
|
5122
|
+
// (roster boot) pass no hooks and this is a no-op.
|
|
5131
5123
|
// The ALLOCATED agent's addressing triple (the acceptance floor names what was actually
|
|
5132
5124
|
// allocated, never the requested-but-unallocated name). Static/open key on DEV_OWNER + the
|
|
5133
5125
|
// freshly-minted nkey; user mode keys on the derived owner (opts.owner, else a u_-owner spawner)
|
|
@@ -5180,7 +5172,11 @@ export class Manager {
|
|
|
5180
5172
|
if (events)
|
|
5181
5173
|
allowPublish = [...(allowPublish ?? []), connector.eventChannel({ owner: agentTriple.owner, actor: agentTriple.actor })];
|
|
5182
5174
|
await hooks?.onReadinessWindow?.(readinessTimeoutMs);
|
|
5183
|
-
|
|
5175
|
+
// The hosted enrollment arm accepts only once the host has answered: the floor names the uid
|
|
5176
|
+
// the agent runs at, and before the host picks it there is no such uid to name.
|
|
5177
|
+
const hostedEnrollment = this.userMode && (opts.delegatedIntent !== undefined || this.remoteAuthority?.enrollManagedAgent !== undefined);
|
|
5178
|
+
if (!hostedEnrollment)
|
|
5179
|
+
await hooks?.onAccepted?.({ name, identity, lifecycleUid, agentTriple });
|
|
5184
5180
|
// In auth mode, mint the agent's creds from the space signing key and write them where the
|
|
5185
5181
|
// spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
|
|
5186
5182
|
// subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
|
|
@@ -5222,6 +5218,20 @@ export class Manager {
|
|
|
5222
5218
|
userOwner = prep.owner;
|
|
5223
5219
|
enrolled = prep.enrolled;
|
|
5224
5220
|
provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, ...(opts.delegatedIntent ? { delegated: true } : {}), secretPaths: prep.files, ...(custody ? { runtime: custody } : {}) };
|
|
5221
|
+
if (hostedEnrollment) {
|
|
5222
|
+
try {
|
|
5223
|
+
await hooks?.onAccepted?.({ name, identity, lifecycleUid, agentTriple: { owner: prep.owner, actor: name, uid: lifecycleUid } });
|
|
5224
|
+
}
|
|
5225
|
+
catch (e) {
|
|
5226
|
+
// A refused accept rolls the enrollment back: the host's grant through `provisioned`, and
|
|
5227
|
+
// this participant's secret family here, because the hosted retirement leaves it on disk.
|
|
5228
|
+
const unshredded = [];
|
|
5229
|
+
await this.shredUserSecrets(unshredded, prep.files);
|
|
5230
|
+
if (unshredded.length)
|
|
5231
|
+
throw new Error(`${rejectionText(e)}; cleanup failed: ${unshredded.join("; ")}`, { cause: e });
|
|
5232
|
+
throw e;
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5225
5235
|
}
|
|
5226
5236
|
else if (this.auth) {
|
|
5227
5237
|
// Unit B (§13.1): reserve + activate this incarnation's DURABLE identity BEFORE any
|
|
@@ -6144,19 +6154,7 @@ export class Manager {
|
|
|
6144
6154
|
await this.recordSlotRuntime(managed);
|
|
6145
6155
|
}
|
|
6146
6156
|
catch (error) {
|
|
6147
|
-
|
|
6148
|
-
this.stopHandle(managed, false);
|
|
6149
|
-
try {
|
|
6150
|
-
await this.awaitHandleExit(managed.handle);
|
|
6151
|
-
}
|
|
6152
|
-
catch (exit) {
|
|
6153
|
-
// A resumed seat keeps its retained credentials, so freeing it runs no deprovision and
|
|
6154
|
-
// nothing would reap it: it stays managed until it exits or the manager stops it.
|
|
6155
|
-
this.watchExit(managed);
|
|
6156
|
-
return { ok: false, error: `${detail}; it stays managed because its stop is unproven: ${exit.message}` };
|
|
6157
|
-
}
|
|
6158
|
-
this.freeSlot(managed, true, "resume-custody-unrecorded", true);
|
|
6159
|
-
return { ok: false, error: detail };
|
|
6157
|
+
return await this.stopFailedResume(managed, "resume-custody-unrecorded", `${managed.name} resumed, but ${error.message}`);
|
|
6160
6158
|
}
|
|
6161
6159
|
const readiness = await this.awaitReadiness(managed, readinessTimeoutMs);
|
|
6162
6160
|
if (!readiness.ok && !readiness.uncertain)
|
|
@@ -6183,9 +6181,7 @@ export class Manager {
|
|
|
6183
6181
|
}
|
|
6184
6182
|
}
|
|
6185
6183
|
catch (error) {
|
|
6186
|
-
this.
|
|
6187
|
-
this.freeSlot(managed, true, "resume-session-rebind-failed", true);
|
|
6188
|
-
return { ok: false, error: `${managed.name} resumed, but its exact host session could not be rebound: ${error.message}` };
|
|
6184
|
+
return await this.stopFailedResume(managed, "resume-session-rebind-failed", `${managed.name} resumed, but its exact host session could not be rebound: ${error.message}`);
|
|
6189
6185
|
}
|
|
6190
6186
|
}
|
|
6191
6187
|
if (!this.resumeAttemptId)
|
|
@@ -6208,6 +6204,21 @@ export class Manager {
|
|
|
6208
6204
|
}
|
|
6209
6205
|
}
|
|
6210
6206
|
}
|
|
6207
|
+
/** A resumed seat keeps its retained credentials, so freeing it runs no deprovision and nothing
|
|
6208
|
+
* would reap it: it is freed only once its exit is proved, and otherwise stays managed until it
|
|
6209
|
+
* exits or the manager stops it. */
|
|
6210
|
+
async stopFailedResume(managed, cause, detail) {
|
|
6211
|
+
this.stopHandle(managed, false);
|
|
6212
|
+
try {
|
|
6213
|
+
await this.awaitHandleExit(managed.handle);
|
|
6214
|
+
}
|
|
6215
|
+
catch (exit) {
|
|
6216
|
+
this.watchExit(managed);
|
|
6217
|
+
return { ok: false, error: `${detail}; it stays managed because its stop is unproven: ${exit.message}` };
|
|
6218
|
+
}
|
|
6219
|
+
this.freeSlot(managed, true, cause, true);
|
|
6220
|
+
return { ok: false, error: detail };
|
|
6221
|
+
}
|
|
6211
6222
|
probeStaticCredential(creds) {
|
|
6212
6223
|
return probeConnect(this.servers ?? DEFAULT_SERVER, { creds, timeoutMs: 5_000 });
|
|
6213
6224
|
}
|
|
@@ -6331,7 +6342,7 @@ export class Manager {
|
|
|
6331
6342
|
// waitForExit may close the attach stream before this snapshot
|
|
6332
6343
|
let tail = "";
|
|
6333
6344
|
try {
|
|
6334
|
-
tail = this.tail(
|
|
6345
|
+
tail = await this.tail(s);
|
|
6335
6346
|
}
|
|
6336
6347
|
catch { }
|
|
6337
6348
|
if (opts.reapOnExit !== false)
|
|
@@ -6367,19 +6378,34 @@ export class Manager {
|
|
|
6367
6378
|
onPresence();
|
|
6368
6379
|
});
|
|
6369
6380
|
}
|
|
6370
|
-
/** Last non-empty line of
|
|
6371
|
-
*
|
|
6372
|
-
*
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
|
|
6381
|
+
/** Last non-empty line of the session's rendered backlog as a single trimmed snippet (≤160 chars)
|
|
6382
|
+
* — a readable one-line cause for an early-exit diagnostic. The backlog repaints cells a TUI
|
|
6383
|
+
* skipped with cursor moves, so it is rendered and the line is read off the terminal; stripping
|
|
6384
|
+
* its escapes would glue the words those moves separate. */
|
|
6385
|
+
async tail(session) {
|
|
6386
|
+
const backlog = await session.backlog();
|
|
6387
|
+
// Sized after the snapshot: a custodial seat's snapshot carries the geometry it was taken at.
|
|
6388
|
+
// The headless build exposes `buffer` only as proposed API.
|
|
6389
|
+
const term = new Headless.Terminal({ cols: session.cols, rows: session.rows, allowProposedApi: true });
|
|
6390
|
+
await new Promise((done) => term.write(backlog, done));
|
|
6391
|
+
const screen = term.buffer.active;
|
|
6392
|
+
let text = "";
|
|
6393
|
+
for (let y = screen.length - 1; y >= 0; y--) {
|
|
6394
|
+
const row = screen.getLine(y);
|
|
6395
|
+
// A line wider than the screen continues across wrapped rows, so it is read whole.
|
|
6396
|
+
text = row.translateToString(text === "") + text;
|
|
6397
|
+
if (row.isWrapped)
|
|
6398
|
+
continue;
|
|
6399
|
+
// Blanks a program painted survive the right trim, so a line of only blanks is skipped.
|
|
6400
|
+
if (text.trim())
|
|
6401
|
+
break;
|
|
6402
|
+
text = "";
|
|
6403
|
+
}
|
|
6404
|
+
term.dispose();
|
|
6405
|
+
text = text.trim();
|
|
6406
|
+
// Cut on code points: half a surrogate pair is not I-JSON, and a launch terminal carrying it is refused.
|
|
6407
|
+
const chars = [...text];
|
|
6408
|
+
return chars.length > 160 ? `…${chars.slice(-160).join("")}` : text;
|
|
6383
6409
|
}
|
|
6384
6410
|
/** Subscribe to a managed agent's process-exit so a self-driven exit frees its slot and reaps
|
|
6385
6411
|
* its children (P4b/P4c). Only pty streams exit (via the attach session's `onExit`); external runtimes'
|
|
@@ -6930,7 +6956,7 @@ export class Manager {
|
|
|
6930
6956
|
const servePrincipal = principalKey(DEV_OWNER, serveIdentity.id).key;
|
|
6931
6957
|
// An open mesh mints no credentials, so it has no family for a fresh pair to grow.
|
|
6932
6958
|
const siblings = auth ? claimManagerSiblingIdentities(this.workspaceRoot, this.space) : { goalWriter: newIdentity(), sessionLedger: newIdentity() };
|
|
6933
|
-
//
|
|
6959
|
+
// The STABLE goal-writer identity — a SIBLING credential in the same §13.1 family
|
|
6934
6960
|
// (not the gate's bound serving principal), so the run block can family-stage it.
|
|
6935
6961
|
this.goalWriterIdentity = siblings.goalWriter;
|
|
6936
6962
|
// P2 item 6: the STABLE session-LEDGER identity — another SIBLING in the SAME §13.1 family, so
|
|
@@ -7001,7 +7027,7 @@ export class Manager {
|
|
|
7001
7027
|
// Open mesh: NO mint - the §13.1 fence is issuance-only and nothing is ever issued, so the
|
|
7002
7028
|
// gate keeps an empty `epcred` family; the serve connection below stays bare.
|
|
7003
7029
|
const creds = auth ? await mintCreds(auth, serveIdentity, "endpoint-serve", { serveIssuance: fence, endpointServe: grant }) : undefined;
|
|
7004
|
-
//
|
|
7030
|
+
// Mint + family-stage the goal-writer credential HERE, over this executor's
|
|
7005
7031
|
// authKv (the fence is live), so its credId lands in `epcred.<e>.<iid>` and the takeover
|
|
7006
7032
|
// barrier revokes it. Open mesh: no mint (no credential system; the goal-writer conn is bare).
|
|
7007
7033
|
const goalWriterCreds = auth ? await this.mintAndStageGoalWriter(authKv) : undefined;
|
|
@@ -7056,7 +7082,7 @@ export class Manager {
|
|
|
7056
7082
|
if (state.creds !== undefined)
|
|
7057
7083
|
this.scheduleServeRenewal(state.creds);
|
|
7058
7084
|
}
|
|
7059
|
-
/**
|
|
7085
|
+
/** Mint the standing `goal-writer` credential and STAGE it into this
|
|
7060
7086
|
* instance's §13.1 revocation family (`epcred.<e>.<iid>`), over the passed executor's `authKv`
|
|
7061
7087
|
* (the scoped `endpoint-serve-executor`, which holds the epcred write grant). The GRANT profile
|
|
7062
7088
|
* stays goal-writer-only (Q2 — disjoint from the serve credential); only the FAMILY membership
|
|
@@ -7494,7 +7520,7 @@ export class Manager {
|
|
|
7494
7520
|
}
|
|
7495
7521
|
}
|
|
7496
7522
|
}
|
|
7497
|
-
/**
|
|
7523
|
+
/** The boot reconcile: a fresh incarnation (a manager restart takes a NEW
|
|
7498
7524
|
* instanceId, so the in-memory acceptance map starts empty) inherits the endpoint's accepted-but-
|
|
7499
7525
|
* unterminal goals from any predecessor. The local signer enumerates with an ephemeral PROVISIONER.
|
|
7500
7526
|
* A remote manager calls the authenticated host-owned scan, which keeps the sealed consumer and its
|
|
@@ -7655,19 +7681,20 @@ export class Manager {
|
|
|
7655
7681
|
if (observed === null)
|
|
7656
7682
|
throw new EpEnvelopeError("expired", `the manager's issuance gate for ${MANAGER_ENDPOINT}/${this.managerInstanceId} is gone; a retired incarnation never commits a goal terminal (SPEC 13.1/13.6)`);
|
|
7657
7683
|
if (observed.processEpoch !== epoch)
|
|
7658
|
-
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 (
|
|
7684
|
+
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)`);
|
|
7659
7685
|
}
|
|
7660
7686
|
/** Serve `spawn`/`launch` as an ACTION (P2 item 2). Authz already ran in {@link serveGated}. The
|
|
7661
7687
|
* accept path runs INLINE on the handler ({@link startAgent} with hooks): the goal binds + the
|
|
7662
|
-
* acceptance replies the moment the identity is minted, BEFORE any provision (pin 1)
|
|
7663
|
-
*
|
|
7688
|
+
* acceptance replies the moment the identity is minted, BEFORE any provision (pin 1), or on the
|
|
7689
|
+
* hosted enrollment arm once the host has enrolled the agent; progress and the terminal are driven
|
|
7690
|
+
* OFF-handler, so the ~30s readiness wait no longer blocks the reply.
|
|
7664
7691
|
* Returns the acceptance floor payload {name, owner, actor, uid, goalId, fingerprint, executor}
|
|
7665
7692
|
* (the ALLOCATED identity). goalId = the request id (env.id, Q3). */
|
|
7666
7693
|
async serveSpawnGoal(ctx, run) {
|
|
7667
7694
|
const gw = this.goalWriter;
|
|
7668
7695
|
if (!gw)
|
|
7669
7696
|
throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)");
|
|
7670
|
-
//
|
|
7697
|
+
// Refuse to accept until the boot reconcile of inherited goals completes, so a fresh
|
|
7671
7698
|
// acceptance never races the sweep (settling a live goal mid-flight would steal its real terminal).
|
|
7672
7699
|
if (!this.goalReconcileDone)
|
|
7673
7700
|
throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; retry shortly (SPEC 13.6)");
|
|
@@ -7712,7 +7739,7 @@ export class Manager {
|
|
|
7712
7739
|
// message as the caller-visible outcome.
|
|
7713
7740
|
let ownsGoal = false;
|
|
7714
7741
|
// The terminal commits OFF-handler on the goal-writer connection (manager-only authority; a
|
|
7715
|
-
// caller cannot publish it). TWO COMPOSED FENCES (defense in depth):
|
|
7742
|
+
// caller cannot publish it). TWO COMPOSED FENCES (defense in depth): (a) reads THIS
|
|
7716
7743
|
// incarnation's OWN gate epoch and REFUSES a superseded commit (the currency belt), and (b)
|
|
7717
7744
|
// barrier-revoke evicts this connection on takeover. The terminal lands on the ONE subject
|
|
7718
7745
|
// SPEC §13.2 (reserved subjects) reserves; first-terminal-fact-wins is global, so a committed outcome is visible
|
|
@@ -7730,7 +7757,7 @@ export class Manager {
|
|
|
7730
7757
|
return;
|
|
7731
7758
|
terminalEntered = true; // entered, not succeeded — see the catch below
|
|
7732
7759
|
try {
|
|
7733
|
-
await this.assertGoalWriterEpochCurrent(epoch); //
|
|
7760
|
+
await this.assertGoalWriterEpochCurrent(epoch); // a superseded corpse never commits
|
|
7734
7761
|
let fact;
|
|
7735
7762
|
if (o.kind === "succeeded") {
|
|
7736
7763
|
this.emitGoalProgress(ref, epoch, { phase: "presence" });
|
|
@@ -7746,7 +7773,7 @@ export class Manager {
|
|
|
7746
7773
|
({ fact } = await settleGoalUncertain(gw.ctx, { ref, now: Date.now(), committer: { instanceId: this.managerInstanceId, epoch }, ...(typeof why === "string" && why.length > 0 ? { reason: why } : {}) }));
|
|
7747
7774
|
}
|
|
7748
7775
|
this.emitGoalProgress(ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
|
|
7749
|
-
await clearGoalIndex(gw.ctx, ref); //
|
|
7776
|
+
await clearGoalIndex(gw.ctx, ref); // terminal reached - the successor never reconciles it
|
|
7750
7777
|
if (acceptance)
|
|
7751
7778
|
this.agentGoals.delete(acceptance.name); // goal terminal - no cancel path left
|
|
7752
7779
|
}
|
|
@@ -7763,7 +7790,7 @@ export class Manager {
|
|
|
7763
7790
|
};
|
|
7764
7791
|
const bg = run({
|
|
7765
7792
|
onAccepted: async ({ name, agentTriple }) => {
|
|
7766
|
-
//
|
|
7793
|
+
// Record the goal in the reconcile index BEFORE the bind (index-CAS-before-bind),
|
|
7767
7794
|
// so a successor incarnation finds + settles this goal if we crash before its terminal. A
|
|
7768
7795
|
// crash between this write and the bind leaves an index entry whose goal status is absent —
|
|
7769
7796
|
// the sweep clears it as a no-goal; a crash before it leaves no entry (never durable). A
|
|
@@ -8138,6 +8165,58 @@ export class Manager {
|
|
|
8138
8165
|
this.maybeStopTurnSweep();
|
|
8139
8166
|
return { goalId, state: fact.state };
|
|
8140
8167
|
}
|
|
8168
|
+
/** The caller withdraws a turn it submitted (the reserved `cancel`, SPEC 13.6 item 4), so the
|
|
8169
|
+
* seat is not shown it again and a late yield is answered from the cancelled terminal. Only a
|
|
8170
|
+
* relay this incarnation holds is moved to `cancelling`, because only its holder can commit the
|
|
8171
|
+
* terminal; an ended goal is refused with its cached outcome, which keeps a repeated cancel
|
|
8172
|
+
* idempotent. The pending entry is the latch the yield and the deadline sweep race on, taken
|
|
8173
|
+
* before the first await so a concurrent cancel finds nothing to take. A failed cancel puts it
|
|
8174
|
+
* back so the relay still ends at its deadline, unless something ended the relay while it was
|
|
8175
|
+
* held: a yield that read the entry first, or this commit with only its reply lost. An entry
|
|
8176
|
+
* whose outcome cannot be read back stays out and converges through the goal index at the
|
|
8177
|
+
* next boot, as a failed deadline commit does. */
|
|
8178
|
+
async serveTurnCancel(ctx) {
|
|
8179
|
+
if (!this.goalReconcileDone)
|
|
8180
|
+
throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; the pending-turn index is not rebuilt yet, retry (SPEC 13.6)");
|
|
8181
|
+
const gw = this.goalWriter;
|
|
8182
|
+
if (!gw)
|
|
8183
|
+
throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing (SPEC 13.6)");
|
|
8184
|
+
const raw = (ctx.request.args ?? {});
|
|
8185
|
+
const goalId = String(raw.goalId);
|
|
8186
|
+
const ref = goalRefOf(ctx.subject, goalId);
|
|
8187
|
+
const p = this.pendingTurns.get(goalId);
|
|
8188
|
+
const c = p?.ref.caller;
|
|
8189
|
+
if (p === undefined || c?.owner !== ref.caller.owner || c.actor !== ref.caller.actor || c.uid !== ref.caller.uid) {
|
|
8190
|
+
const ended = await readGoalResult(gw.ctx, ref);
|
|
8191
|
+
if (ended !== undefined)
|
|
8192
|
+
throw goalAlreadyTerminal(goalId, ended);
|
|
8193
|
+
throw new EpEnvelopeError("failed-precondition", `no turn "${goalId}" of this caller is pending on this manager; cancel withdraws a relayed turn (SPEC 13.6)`);
|
|
8194
|
+
}
|
|
8195
|
+
this.pendingTurns.delete(goalId);
|
|
8196
|
+
const epoch = this.serviceServe?.grant.epoch ?? 0;
|
|
8197
|
+
let fact;
|
|
8198
|
+
try {
|
|
8199
|
+
await requestGoalCancel(gw.ctx, { request: ctx.subject, goalId, mode: (raw.mode ?? "graceful") });
|
|
8200
|
+
await this.assertGoalWriterEpochCurrent(epoch);
|
|
8201
|
+
({ fact } = await commitGoalResult(gw.ctx, { ref, now: Date.now(), cause: "cancel", data: { cancelledBy: "caller" }, committer: { instanceId: this.managerInstanceId, epoch } }));
|
|
8202
|
+
}
|
|
8203
|
+
catch (e) {
|
|
8204
|
+
// The remembered answer is read after the durable one, with no await before the put back:
|
|
8205
|
+
// a yield that has dropped the entry remembered its answer, and one still committing drops
|
|
8206
|
+
// the entry it finds. An ended relay is stamped so its acceptance still ages off.
|
|
8207
|
+
if ((await readGoalResult(gw.ctx, ref)) !== undefined)
|
|
8208
|
+
this.markTurnLatchDropped(goalId);
|
|
8209
|
+
else if (this.turnAcceptances.get(goalId)?.settled === undefined)
|
|
8210
|
+
this.pendingTurns.set(goalId, p);
|
|
8211
|
+
throw e;
|
|
8212
|
+
}
|
|
8213
|
+
this.emitGoalProgress(ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
|
|
8214
|
+
this.markTurnLatchDropped(goalId);
|
|
8215
|
+
this.rememberSettledTurn(goalId, fact.state, Date.now());
|
|
8216
|
+
await clearGoalIndex(gw.ctx, ref);
|
|
8217
|
+
this.maybeStopTurnSweep();
|
|
8218
|
+
return { goalId, state: fact.state };
|
|
8219
|
+
}
|
|
8141
8220
|
/** Commit the deadline terminal for one pending turn whose hold settled EXPIRED: the deny's
|
|
8142
8221
|
* predicate is that recorded settle, verified by core against the spec's goal binding. The map
|
|
8143
8222
|
* delete is the idempotency latch (yield-loss and sweep race here); a commit failure after it
|
|
@@ -8356,11 +8435,11 @@ export class Manager {
|
|
|
8356
8435
|
this.agentGoals.delete(name);
|
|
8357
8436
|
const epoch = this.serviceServe?.grant.epoch ?? 0;
|
|
8358
8437
|
try {
|
|
8359
|
-
await this.assertGoalWriterEpochCurrent(epoch); //
|
|
8438
|
+
await this.assertGoalWriterEpochCurrent(epoch); // a superseded corpse never commits a cancel terminal either
|
|
8360
8439
|
await transitionGoal(gw.ctx, ref, "cancelling", { fields: { cancelMode: mode } });
|
|
8361
8440
|
const r = await commitGoalResult(gw.ctx, { ref, now: Date.now(), cause: "cancel", data: { cancelledBy: "despawn" }, committer: { instanceId: this.managerInstanceId, epoch } });
|
|
8362
8441
|
this.emitGoalProgress(ref, epoch, { phase: "terminal", state: r.fact.state, ...(r.fact.data !== undefined ? { data: r.fact.data } : {}) });
|
|
8363
|
-
await clearGoalIndex(gw.ctx, ref); //
|
|
8442
|
+
await clearGoalIndex(gw.ctx, ref); // terminal reached - the successor never reconciles it
|
|
8364
8443
|
}
|
|
8365
8444
|
catch {
|
|
8366
8445
|
// the goal already terminalized (the readiness outcome won the settle race) - nothing to cancel.
|
|
@@ -8499,11 +8578,17 @@ export class Manager {
|
|
|
8499
8578
|
console.error(`static retirement ${a.name}: retired ${retired} issuance(s) bound to uid ${a.lifecycleUid}`);
|
|
8500
8579
|
const secrets = this.secrets;
|
|
8501
8580
|
const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
|
|
8581
|
+
// The broker teardown mints its own credential, so a creds file that cannot be removed must not
|
|
8582
|
+
// skip it. The failures are thrown together, so the terminal still stops at an incomplete cleanup.
|
|
8583
|
+
const failed = [];
|
|
8502
8584
|
if (files.creds) {
|
|
8503
|
-
|
|
8504
|
-
|
|
8585
|
+
const creds = files.creds;
|
|
8586
|
+
await attemptCleanup(failed, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
|
|
8587
|
+
await attemptCleanup(failed, `file ${creds}`, () => rmSync(creds, { force: true }));
|
|
8505
8588
|
}
|
|
8506
|
-
await this.deprovisionBroker(a);
|
|
8589
|
+
await attemptCleanup(failed, "broker teardown", () => this.deprovisionBroker(a));
|
|
8590
|
+
if (failed.length)
|
|
8591
|
+
throw new Error(`cleanup failed: ${failed.join("; ")}`);
|
|
8507
8592
|
};
|
|
8508
8593
|
// The process goes before its footprint: reap the orphan seat by reference, then tear down.
|
|
8509
8594
|
const reapThenCleanup = async () => {
|