@cotal-ai/manager 0.101.7 → 0.103.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/manager.js CHANGED
@@ -6,7 +6,7 @@ import { isAbsolute, join, dirname, resolve } from "node:path";
6
6
  import Headless from "@xterm/headless";
7
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
- 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";
9
+ import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimIdentityRecord, claimManagerSiblingIdentities, connectorInstallHint, DEFAULT_SEAT_CAP, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, probeLiveness, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, SEAT_CAP_UNLIMITED, 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";
11
11
  import { AttachEndpoint } from "./attach-endpoint.js";
12
12
  import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers, withScopedEndpoint } from "./endpoint-evict.js";
@@ -32,7 +32,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
32
32
  // The manager as an ordinary v0.4 `service` endpoint — the §13.1
33
33
  // endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
34
34
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
35
- 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";
35
+ 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, turnNoticeSubject, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, seatCapRefused, lifecycleBlockedFrom, renderLifecycleBlocked, controlReplyFromThrown, } from "@cotal-ai/core";
36
36
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
37
37
  import { measureManagerVitals } from "./vitals.js";
38
38
  import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, walkStaticSlots, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
@@ -136,6 +136,12 @@ const STATIC_RECONCILE_MAX_ATTEMPTS = STATIC_RECONCILE_RETRY_DELAYS_MS.length +
136
136
  function retireOpId(lifecycleUid) {
137
137
  return managedRetirementOpId(lifecycleUid);
138
138
  }
139
+ /** The owner a retained seat resumes under: its inventory identity's, which is the same value the
140
+ * adopted seat records as `userOwner`, so a resumed seat is counted against the owner that held
141
+ * it before the restart ({@link Manager.ownerSeats}) and not against the mesh's default one. */
142
+ function resumeOwner(entry) {
143
+ return entry.identity.mode === "user" ? entry.identity.owner : DEV_OWNER;
144
+ }
139
145
  /** Sentinel owner-filter value that matches NO agent's `userOwner` (owner tokens never contain a
140
146
  * dash) — what {@link Manager.psOwnerFilter} returns for an unparseable caller so a malformed
141
147
  * principal fail-closes to an empty `ps` instead of an unbounded one. */
@@ -355,6 +361,8 @@ export class Manager {
355
361
  eventsRequired;
356
362
  /** The operator-set global live-session ceiling (see {@link ManagerOptions.maxSessions}). */
357
363
  maxSessions;
364
+ /** The operator-set per-owner seat ceiling (see {@link ManagerOptions.seatCap}). */
365
+ seatCap;
358
366
  /** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
359
367
  * See {@link ManagerOptions.secretStore}. */
360
368
  secrets;
@@ -371,8 +379,11 @@ export class Manager {
371
379
  agents = new Map();
372
380
  /** Names whose spawn is in flight (reserved synchronously before the provision await) — counted
373
381
  * toward the ceiling (once per name, see {@link Manager.occupancy}) so two concurrent same-name
374
- * spawns can't both pass the gate (P4a). */
375
- reserved = new Set();
382
+ * spawns can't both pass the gate (P4a). Each name carries the OWNER its launch was admitted
383
+ * for, so the per-owner ceiling ({@link Manager.ownerSeats}) counts a launch from the moment it
384
+ * is reserved; a cap read only off `agents` would let a burst of concurrent spawns overshoot it,
385
+ * because none of them has a seat yet when the next one checks. */
386
+ reserved = new Map();
376
387
  /** Reserved names whose launch already put its seat in `agents`. The name stays reserved until
377
388
  * the launch settles, so no other spawn can take it, but the ceiling counts that seat through
378
389
  * `agents` and, if it frees young, its cooling stamp, never through the reservation (#906). */
@@ -687,6 +698,7 @@ export class Manager {
687
698
  this.onServiceIdentity = opts.onServiceIdentity;
688
699
  this.eventsRequired = opts.eventsRequired === true;
689
700
  this.maxSessions = opts.maxSessions;
701
+ this.seatCap = opts.seatCap ?? DEFAULT_SEAT_CAP;
690
702
  this.remoteAuthority = opts.remoteAuthority;
691
703
  this.remoteExecutorCreds = opts.remoteAuthority?.executorCreds;
692
704
  this.remoteSupervisorCreds = opts.remoteAuthority?.supervisorCreds;
@@ -3048,7 +3060,7 @@ export class Manager {
3048
3060
  // only — the action model gives failures a typed channel.
3049
3061
  const unwrap = (r) => {
3050
3062
  if (!r.ok)
3051
- throw new EpEnvelopeError("failed-precondition", r.error ?? "the operation failed");
3063
+ throw new EpEnvelopeError((r.code ?? "failed-precondition"), r.error ?? "the operation failed", r.details);
3052
3064
  return r.data;
3053
3065
  };
3054
3066
  // The admin-family serve gate: every `manager.admin`-class
@@ -5531,6 +5543,16 @@ export class Manager {
5531
5543
  // collision, a manifest name and a `--name` override all meet it, and before the reservation.
5532
5544
  if (name.startsWith(CHAT_ALIAS_PREFIX))
5533
5545
  return { ok: false, error: `the name "${name}" starts with "${CHAT_ALIAS_PREFIX}", which is reserved for chat sessions; pick another name` };
5546
+ // THE PER-OWNER CEILING, read in the same synchronous window as the reserve below rather than
5547
+ // beside the host capacity gate higher up: the count it takes must be the one the reservation
5548
+ // lands against, or a burst of concurrent launches for one owner each reads a count taken
5549
+ // before any of the others reserved, and they all pass. Nothing between here and the reserve
5550
+ // awaits. It refuses BEFORE `onEffect`, so the launch states `not-executed` and leaves no
5551
+ // name, no mint and no footprint behind.
5552
+ const owner = this.launchOwner(opts, spawner);
5553
+ const overCap = this.seatCapRefusal(owner);
5554
+ if (overCap)
5555
+ return { ok: false, code: overCap.code, error: overCap.message, ...(overCap.details ? { details: overCap.details } : {}) };
5534
5556
  // Set once the agent's footprint (durables + creds, or the user-mode grant + secret files)
5535
5557
  // exists; cleared when a live slot takes ownership. If it survives to `finally`, the spawn threw
5536
5558
  // AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
@@ -5547,7 +5569,7 @@ export class Manager {
5547
5569
  // Taken right before the try so its finally is the only release; a refusal belongs above the
5548
5570
  // allocation, where there is no name to give back.
5549
5571
  hooks?.onEffect?.();
5550
- this.reserved.add(name);
5572
+ this.reserved.set(name, owner);
5551
5573
  try {
5552
5574
  // A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
5553
5575
  // COTAL_ID); the seed is retained to mint matching creds later.
@@ -5577,8 +5599,8 @@ export class Manager {
5577
5599
  // freshly-minted nkey; user mode keys on the derived owner (opts.owner, else a u_-owner spawner)
5578
5600
  // + the alias — derived HERE where the mode and owner source are in scope.
5579
5601
  const agentTriple = this.userMode
5580
- ? { owner: opts.owner ?? (spawner && parsePrincipalKey(spawner)?.owner.startsWith("u_") ? parsePrincipalKey(spawner).owner : DEV_OWNER), actor: name, uid: lifecycleUid }
5581
- : { owner: DEV_OWNER, actor: identity.id, uid: lifecycleUid };
5602
+ ? { owner, actor: name, uid: lifecycleUid }
5603
+ : { owner, actor: identity.id, uid: lifecycleUid };
5582
5604
  // THE EVENT GRANT, derived from the principal that was actually ALLOCATED and never from the
5583
5605
  // display name. A display name is UI convenience: this mesh permits two live agents to carry
5584
5606
  // one, so a name-keyed channel fuses two principals onto one subject and, in auth mode,
@@ -5661,7 +5683,7 @@ export class Manager {
5661
5683
  if ("error" in prep) {
5662
5684
  if (prep.refused !== undefined)
5663
5685
  hooks?.onUndone?.(prep.refused);
5664
- return { ok: false, error: prep.error, ...(prep.details ? { details: prep.details } : {}) };
5686
+ return { ok: false, error: prep.error, ...(prep.refused !== undefined ? { code: prep.refused } : {}), ...(prep.details ? { details: prep.details } : {}) };
5665
5687
  }
5666
5688
  // THE HOST'S UID WINS (#1972). On the local arm this is the value passed in, unchanged. On
5667
5689
  // the hosted enrollment arm the host selected it, and from here every lifecycle-keyed thing
@@ -6000,15 +6022,15 @@ export class Manager {
6000
6022
  catch (e) {
6001
6023
  // Failure after reserve (provision / launch threw): the slot was never live, so no cold-start
6002
6024
  // was paid — the reserved rollback (finally) is enough, no cooling stamp.
6003
- // A lifecycle-blocked envelope already named the barrier; keep its details on the ControlReply
6004
- // so follow/CLI/cotal_spawn do not collapse it to a generic string (#873). An extension can throw
6025
+ // Keep the envelope's code and lifecycle details on the ControlReply so the serve boundary
6026
+ // can carry the refusal's classification and barrier to the caller. An extension can throw
6005
6027
  // any value, and an envelope's `message` is only typed as a string, so the refusal text always
6006
6028
  // comes from rejectionText; telling an envelope apart is guarded because a thrown Proxy can
6007
6029
  // throw from `instanceof` or from its own fields.
6008
6030
  const error = rejectionText(e);
6009
6031
  try {
6010
6032
  if (e instanceof EpEnvelopeError)
6011
- return { ok: false, error: renderLifecycleBlocked(error, e), ...(e.details ? { details: e.details } : {}) };
6033
+ return controlReplyFromThrown(e, error);
6012
6034
  }
6013
6035
  catch { /* not a readable envelope */ }
6014
6036
  return { ok: false, error };
@@ -6108,7 +6130,7 @@ export class Manager {
6108
6130
  return { ok: false, agents: [], error: `retained agent "${entry.name}" is already managed or reserved` };
6109
6131
  }
6110
6132
  for (const entry of entries) {
6111
- this.reserved.add(entry.name);
6133
+ this.reserved.set(entry.name, resumeOwner(entry));
6112
6134
  batchReservations.push(entry.name);
6113
6135
  }
6114
6136
  const preflight = [];
@@ -6528,7 +6550,7 @@ export class Manager {
6528
6550
  }
6529
6551
  async launchPreparedResume(entry, prepared, batchReserved, supervised) {
6530
6552
  if (!batchReserved)
6531
- this.reserved.add(entry.name);
6553
+ this.reserved.set(entry.name, resumeOwner(entry));
6532
6554
  try {
6533
6555
  // Unit B F5(b): recover the STATIC identity's nkey seed from the adopted credential (the
6534
6556
  // creds file embeds it) so the manager stays this incarnation's RENEWAL OWNER across a
@@ -6932,7 +6954,7 @@ export class Manager {
6932
6954
  const cooling = this.coolingCount();
6933
6955
  let reserved = 0;
6934
6956
  let launching = 0;
6935
- for (const name of this.reserved) {
6957
+ for (const name of this.reserved.keys()) {
6936
6958
  const placed = this.reservedLive.has(name);
6937
6959
  if (!placed)
6938
6960
  reserved++;
@@ -6963,6 +6985,59 @@ export class Manager {
6963
6985
  : "waiting frees no slot; despawn one";
6964
6986
  return `at capacity (${this.occupancyText(o)}); ${remedy}`;
6965
6987
  }
6988
+ /**
6989
+ * The owner a launch belongs to, read ONCE so the per-owner ceiling, the agent's addressing
6990
+ * triple and the user-mode provisioner cannot charge three different owners for one seat. A
6991
+ * static or open mesh has one owner by construction; a user-auth mesh takes the spec's owner,
6992
+ * else the owner of the principal that asked, which is what {@link provisionUserAgent} resolves
6993
+ * from the same two values (it refuses outright where this falls back, so the two never disagree
6994
+ * on an admitted launch).
6995
+ */
6996
+ launchOwner(opts, spawner) {
6997
+ if (!this.userMode)
6998
+ return DEV_OWNER;
6999
+ const asked = spawner === undefined ? undefined : parsePrincipalKey(spawner)?.owner;
7000
+ return opts.owner ?? (asked?.startsWith("u_") ? asked : DEV_OWNER);
7001
+ }
7002
+ /** The owner of a seat this manager supervises, by the same rule {@link launchOwner} admitted it
7003
+ * under: a user-mode seat records its derived owner, and every other seat is the mesh's one owner. */
7004
+ seatOwner(a) {
7005
+ return a.userOwner ?? DEV_OWNER;
7006
+ }
7007
+ /**
7008
+ * Live seats this manager holds for one owner: the seats it supervises, plus the launches it has
7009
+ * reserved for that owner and not yet placed. It deliberately does NOT count cooling stamps — a
7010
+ * cooling slot is a seat that already freed, and the recycle floor it holds is a global
7011
+ * fork-bomb bound (P4c), not one tenant's occupancy. Mirrors {@link occupancy}'s reserved rule,
7012
+ * so a reservation stops being counted twice the moment its seat reaches `agents`.
7013
+ */
7014
+ ownerSeats(owner) {
7015
+ let seats = 0;
7016
+ for (const a of this.agents.values())
7017
+ if (this.seatOwner(a) === owner)
7018
+ seats++;
7019
+ for (const [name, reservedFor] of this.reserved)
7020
+ if (reservedFor === owner && !this.reservedLive.has(name))
7021
+ seats++;
7022
+ return seats;
7023
+ }
7024
+ /**
7025
+ * The per-owner ceiling, consulted at accept: `undefined` admits the launch, and a refusal
7026
+ * carries the owner's count and the cap so a caller can render a remedy and a script can decide
7027
+ * without re-reading prose. Distinct from {@link capacityRefusal}, which bounds the HOST: that
7028
+ * one frees when anyone stops a seat or a cooling slot expires, this one frees only when THIS
7029
+ * owner stops one of its own, or the operator raises the cap, so they cannot share a sentence.
7030
+ */
7031
+ seatCapRefusal(owner) {
7032
+ const cap = this.seatCap;
7033
+ if (cap === SEAT_CAP_UNLIMITED)
7034
+ return undefined;
7035
+ const seats = this.ownerSeats(owner);
7036
+ if (seats < cap)
7037
+ return undefined;
7038
+ return seatCapRefused(`owner ${owner} holds ${seats} of the ${cap} seat${cap === 1 ? "" : "s"} manager ${this.managerInstanceId} admits per owner; ` +
7039
+ `stop one of that owner's seats, or ask the operator who runs this manager for a higher seat cap`, { owner, seats, cap });
7040
+ }
6966
7041
  async opStop(args, caller, admin) {
6967
7042
  const name = String(args.name ?? "").trim();
6968
7043
  const a = this.agents.get(name);
@@ -8120,6 +8195,11 @@ export class Manager {
8120
8195
  }
8121
8196
  finally {
8122
8197
  this.goalReconcileDone = true;
8198
+ // A seat that pulled during the window was refused, and a predecessor may have died before
8199
+ // its notice went out, so every seat holding a rebuilt turn is told once.
8200
+ const seats = new Map([...this.pendingTurns.values()].map((p) => [`${p.seat.owner}.${p.seat.actor}.${p.seat.uid}`, p.seat]));
8201
+ for (const seat of seats.values())
8202
+ this.noticeSeatTurns(seat);
8123
8203
  }
8124
8204
  }
8125
8205
  /** Settle ONE inherited goal by evidence: no goal record (a crash between the index write and the
@@ -8429,8 +8509,7 @@ export class Manager {
8429
8509
  // outcome unknown.
8430
8510
  if (acceptance === undefined) {
8431
8511
  const details = reply.details;
8432
- // A hard-pinned name a live incarnation holds is the one refusal here that names its own code.
8433
- const code = undone ?? (reply.code === "already-exists" ? "already-exists" : "failed-precondition");
8512
+ const code = undone ?? reply.code ?? "failed-precondition";
8434
8513
  rejectAccept(new EpEnvelopeError(code, reply.error ?? "spawn refused at accept", details, effectStarted && undone === undefined ? undefined : "not-executed"));
8435
8514
  return;
8436
8515
  }
@@ -8644,6 +8723,7 @@ export class Manager {
8644
8723
  };
8645
8724
  this.pendingTurns.set(key, pending);
8646
8725
  this.ensureTurnSweep();
8726
+ this.noticeSeatTurns(pending.seat);
8647
8727
  const acceptance = { name: a.name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid, goalId, fingerprint, deadlineAt, executor };
8648
8728
  this.turnAcceptances.set(key, { acceptance, ref });
8649
8729
  this.emitGoalProgress(ref, executor.epoch, { phase: "relayed" });
@@ -8666,13 +8746,33 @@ export class Manager {
8666
8746
  // its own pause, so handing it to the seat buys work whose yield is refused. It also cannot be
8667
8747
  // allowed to stay the head, or one expired turn dams every later turn for that seat.
8668
8748
  const now = Date.now();
8669
- const turns = [...this.pendingTurns.values()]
8749
+ const head = [...this.pendingTurns.values()]
8670
8750
  .filter((p) => p.seat.owner === c.owner && p.seat.actor === c.actor && p.seat.uid === c.uid)
8671
8751
  .filter((p) => now < p.deadlineAt)
8672
8752
  .sort((x, y) => x.acceptedAt - y.acceptedAt)
8673
- .slice(0, 1)
8674
- .map((p) => ({ goalId: p.goalId, payload: p.payload, acceptedAt: p.acceptedAt, deadlineAt: p.deadlineAt }));
8675
- return { turns };
8753
+ .slice(0, 1);
8754
+ for (const p of head)
8755
+ p.pulledAt ??= now;
8756
+ const turns = head.map((p) => ({ goalId: p.goalId, payload: p.payload, acceptedAt: p.acceptedAt, deadlineAt: p.deadlineAt }));
8757
+ return { turns, notices: true };
8758
+ }
8759
+ /** Tell a seat that its `turn-pending` answer changed (SPEC 14.3 turn notice), over the serve
8760
+ * connection that holds this instance's reply rows. The notice carries no turn and the seat
8761
+ * pulls on it, so a lost notice delays that seat until its next pull, and nothing else. */
8762
+ noticeSeatTurns(seat) {
8763
+ const serve = this.serviceServe;
8764
+ if (!serve)
8765
+ return;
8766
+ // An issued seat credential reads only the versioned rail, under the generation it was issued.
8767
+ const a = this.agents.get(seat.name);
8768
+ const generation = a?.lifecycleUid === seat.uid ? a.issued?.generation : undefined;
8769
+ const caller = { owner: seat.owner, actor: seat.actor, uid: seat.uid, ...(generation !== undefined ? { generation } : {}) };
8770
+ try {
8771
+ serve.nc.publish(turnNoticeSubject(this.space, this.managerInstanceId, serve.grant.epoch, caller), new TextEncoder().encode(JSON.stringify({ v: 1 })));
8772
+ }
8773
+ catch (e) {
8774
+ console.error(`! turn notice for ${seat.actor} failed: ${rejectionText(e)}`);
8775
+ }
8676
8776
  }
8677
8777
  /** A seat names a turn by goal id alone when it yields, so one incarnation holding two callers'
8678
8778
  * turns under one id could not say which of the two it answered. */
@@ -8767,6 +8867,7 @@ export class Manager {
8767
8867
  this.emitGoalProgress(p.ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
8768
8868
  await clearGoalIndex(gw.ctx, p.ref);
8769
8869
  this.pendingTurns.delete(key);
8870
+ this.noticeSeatTurns(p.seat);
8770
8871
  this.markTurnLatchDropped(key, at);
8771
8872
  this.rememberSettledTurn(key, fact.state, at);
8772
8873
  this.maybeStopTurnSweep();
@@ -8820,6 +8921,7 @@ export class Manager {
8820
8921
  throw e;
8821
8922
  }
8822
8923
  this.emitGoalProgress(ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
8924
+ this.noticeSeatTurns(p.seat);
8823
8925
  this.markTurnLatchDropped(key);
8824
8926
  this.rememberSettledTurn(key, fact.state, Date.now());
8825
8927
  await clearGoalIndex(gw.ctx, ref);
@@ -8837,6 +8939,7 @@ export class Manager {
8837
8939
  const key = goalKey(p.ref);
8838
8940
  if (!this.pendingTurns.delete(key))
8839
8941
  return;
8942
+ this.noticeSeatTurns(p.seat);
8840
8943
  this.markTurnLatchDropped(key);
8841
8944
  const epoch = this.serviceServe?.grant.epoch ?? 0;
8842
8945
  try {
@@ -8943,6 +9046,20 @@ export class Manager {
8943
9046
  // The prune above is the one event after which the sweep may have nothing left to do, and
8944
9047
  // the settle-time callers cannot see it: they run when an answer has just been remembered.
8945
9048
  this.maybeStopTurnSweep();
9049
+ // A notice is at-most-once, so a seat offline when it went out has not pulled its head turn.
9050
+ // Repeat the notice until it does; a seat that pulled is not told again for that turn.
9051
+ const heads = new Map();
9052
+ for (const p of this.pendingTurns.values()) {
9053
+ if (now >= p.deadlineAt)
9054
+ continue;
9055
+ const k = `${p.seat.owner}.${p.seat.actor}.${p.seat.uid}`;
9056
+ const h = heads.get(k);
9057
+ if (h === undefined || p.acceptedAt < h.acceptedAt)
9058
+ heads.set(k, p);
9059
+ }
9060
+ for (const p of heads.values())
9061
+ if (p.pulledAt === undefined)
9062
+ this.noticeSeatTurns(p.seat);
8946
9063
  for (const p of [...this.pendingTurns.values()]) {
8947
9064
  if (now < p.deadlineAt)
8948
9065
  continue;