@cotal-ai/manager 0.161.1 → 0.163.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/manager.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { drainAndClose } from "@cotal-ai/core";
2
+ import { drainAndClose, requestSafeReconnect } from "@cotal-ai/core";
3
3
  import { hostname, homedir } from "node:os";
4
4
  import { listDirectory } from "./list-dir.js";
5
5
  import { credsAuthenticator, RequestError } from "@nats-io/transport-node";
@@ -177,6 +177,7 @@ const STARTUP_RECONCILING = "startup static lifecycle reconciliation";
177
177
  * authority. The finite ~36s window is intentionally not a liveness inference: exhausting it
178
178
  * leaves the durable alias held and requires a later manager start. */
179
179
  export const STATIC_RECONCILE_RETRY_DELAYS_MS = [1_000, 5_000, 30_000];
180
+ const REMOTE_STANDING_BUDGET_MS = 10_000;
180
181
  const STATIC_RECONCILE_MAX_ATTEMPTS = STATIC_RECONCILE_RETRY_DELAYS_MS.length + 1;
181
182
  /** The owner a retained seat resumes under: its inventory identity's, which is the same value the
182
183
  * adopted seat records as `userOwner`, so a resumed seat is counted against the owner that held
@@ -245,6 +246,13 @@ function withTimeout(p, ms, msg) {
245
246
  });
246
247
  return Promise.race([p.finally(() => clearTimeout(timer)), timeout]);
247
248
  }
249
+ /** One absolute budget across authority, preflight and adoption waits, or one closed-serve attempt. */
250
+ function remoteStandingTimeLeft(deadline) {
251
+ const left = deadline - performance.now();
252
+ if (left <= 0)
253
+ throw new Error("remote manager standing operation exceeded its 10000ms aggregate budget");
254
+ return Math.ceil(left);
255
+ }
248
256
  function sameStrings(a, b) {
249
257
  return JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
250
258
  }
@@ -886,6 +894,8 @@ export class Manager {
886
894
  remoteExecutorCreds;
887
895
  remoteSupervisorCreds;
888
896
  remoteBundleRenewal;
897
+ remoteBundleAdoption;
898
+ finishRemoteBundleAdoption;
889
899
  /** A refused or failed standing candidate is cleanup debt, never a silent loss: the last-good
890
900
  * family stays live and this records what the host may have signed but the manager never adopted. */
891
901
  remoteRenewalDebt;
@@ -900,6 +910,25 @@ export class Manager {
900
910
  const servers = this.servers ?? DEFAULT_SERVER;
901
911
  return dialerFor(servers)({ ...opts, servers });
902
912
  }
913
+ /** Initial NATS timeout is per dial, not per server list. Keep the aggregate deadline too, and
914
+ * close a late result rather than letting an abandoned preflight or recovery install a transport. */
915
+ async dialRemoteStanding(opts, deadline) {
916
+ const ms = remoteStandingTimeLeft(deadline);
917
+ const pending = this.dial({ ...opts, timeout: ms });
918
+ try {
919
+ const nc = await withTimeout(pending, ms, "remote manager standing dial exceeded its 10000ms aggregate budget");
920
+ remoteStandingTimeLeft(deadline);
921
+ if (this.leaseStopping)
922
+ throw new Error("manager stopped while a remote standing dial was in flight");
923
+ return nc;
924
+ }
925
+ catch (e) {
926
+ // Timing out our wait does not cancel connect(). It still owns its socket until it rejects
927
+ // or returns a connection, which must close without serving or changing a current holder.
928
+ void pending.then((nc) => nc.close(), () => { }).catch((error) => console.error(`! remote manager abandoned standing dial cleanup: ${rejectionText(error)}`));
929
+ throw e;
930
+ }
931
+ }
903
932
  constructor(opts) {
904
933
  if (opts.expectedConnectorGeneration !== undefined && opts.expectedConnectorGeneration !== MANAGER_VERSION)
905
934
  throw new Error(`expected connector generation ${opts.expectedConnectorGeneration} differs from this Manager's loaded generation ${MANAGER_VERSION}; restart from the installed CLI`);
@@ -1105,8 +1134,8 @@ export class Manager {
1105
1134
  * reads `--allow-subscribe` only for the agent profile while the observer arm of `permissionsFor`
1106
1135
  * hardcodes `chat.>`: an operator narrowing a reader to one event plane was handed a reader of every
1107
1136
  * channel in the space. The user half named a bare `cotal actor grant`, and an omitted flag there is
1108
- * not "leave it alone" but the WIDE default (`>` read, `>` post, `spawn,role:default` scope), so the same
1109
- * sentence handed out a full-mesh reader-writer with spawn. The static half was found by RUNNING
1137
+ * not "leave it alone" but the WIDE default (`>` read, `>` post, `spawn,run,role:default` scope), so the same
1138
+ * sentence handed out a full-mesh reader-writer with spawn and workflow scope. The static half was found by RUNNING
1110
1139
  * the printed command and decoding what it produced; the user half was found by READING it against
1111
1140
  * `runActor`'s defaults. Neither is the only way in, and a remedy string is not proved by either
1112
1141
  * one alone.
@@ -1167,7 +1196,7 @@ export class Manager {
1167
1196
  case "user":
1168
1197
  return (`\`cotal actor grant <reader> --owner ${owner} --scope '' --allow-subscribe '${channel}' ` +
1169
1198
  `--allow-publish ''\`, with every field spelled out: \`actor grant\` is an upsert of the ` +
1170
- `WHOLE row, and an omitted flag is refused, or with \`--full\` is the WIDE default (\`>\` read, \`>\` post, \`spawn,role:default\` ` +
1199
+ `WHOLE row, and an omitted flag is refused, or with \`--full\` is the WIDE default (\`>\` read, \`>\` post, \`spawn,run,role:default\` ` +
1171
1200
  `scope).`);
1172
1201
  case "static":
1173
1202
  return (`\`cotal mint <reader> --profile agent --allow-subscribe ${channel} --provision\`, where there ` +
@@ -1797,15 +1826,65 @@ export class Manager {
1797
1826
  this.endpointServeRenewalTimer.unref?.();
1798
1827
  console.error(`manager endpoint-serve renewal scheduled at ${renewAt}`);
1799
1828
  }
1800
- /** #2073: push a freshly re-minted credential onto its LIVE connection instead of waiting for a
1801
- * reconnect the expiry itself would force two dials too late. `nc.reconnect()` re-evaluates the
1802
- * authenticator, which reads the mutable `creds` holder on every attempt, so the new CONNECT
1803
- * presents the fresh credential without re-registration. In-flight requests on the connection
1804
- * fail once, on the reconnect. Best-effort, matching `CotalEndpoint.swapConnectionOntoFreshCreds`:
1805
- * a connection that is already closed (e.g. mid re-dial after a fault) cannot reconnect, and that
1806
- * failure is not this call's to report. */
1807
- async pushRenewedCreds(nc) {
1808
- await nc.reconnect().catch(() => { });
1829
+ credentialPushRetry;
1830
+ credentialPushes = new Map();
1831
+ supervisorCredentialPush;
1832
+ pendingCredentialPushes = new Set();
1833
+ supervisorCredentialPushPending = false;
1834
+ scheduleCredentialPushRetry() {
1835
+ if (this.credentialPushRetry || this.leaseStopping)
1836
+ return;
1837
+ this.credentialPushRetry = setTimeout(() => {
1838
+ this.credentialPushRetry = undefined;
1839
+ if (this.leaseStopping)
1840
+ return;
1841
+ void Promise.all([
1842
+ ...[...this.pendingCredentialPushes].map((nc) => this.pushRenewedCreds(nc)),
1843
+ ...(this.supervisorCredentialPushPending ? [this.pushSupervisorCreds()] : []),
1844
+ ]);
1845
+ }, 1_000);
1846
+ this.credentialPushRetry.unref();
1847
+ }
1848
+ /** Retain a busy transport and retry its adopted credential without replaying requests. */
1849
+ pushRenewedCreds(nc) {
1850
+ const active = this.credentialPushes.get(nc);
1851
+ if (active)
1852
+ return active;
1853
+ const push = this.pushRenewedCredsOnce(nc).finally(() => { this.credentialPushes.delete(nc); });
1854
+ this.credentialPushes.set(nc, push);
1855
+ return push;
1856
+ }
1857
+ async pushRenewedCredsOnce(nc) {
1858
+ this.pendingCredentialPushes.add(nc);
1859
+ try {
1860
+ await requestSafeReconnect(nc);
1861
+ this.pendingCredentialPushes.delete(nc);
1862
+ }
1863
+ catch (e) {
1864
+ if (nc.isClosed())
1865
+ this.pendingCredentialPushes.delete(nc);
1866
+ console.error(`! manager credential push deferred: ${rejectionText(e)}`);
1867
+ if (!nc.isClosed())
1868
+ this.scheduleCredentialPushRetry();
1869
+ }
1870
+ }
1871
+ pushSupervisorCreds() {
1872
+ if (this.supervisorCredentialPush)
1873
+ return this.supervisorCredentialPush;
1874
+ const push = this.pushSupervisorCredsOnce().finally(() => { this.supervisorCredentialPush = undefined; });
1875
+ this.supervisorCredentialPush = push;
1876
+ return push;
1877
+ }
1878
+ async pushSupervisorCredsOnce() {
1879
+ this.supervisorCredentialPushPending = true;
1880
+ try {
1881
+ await this.ep.refreshCredsRequestSafe();
1882
+ this.supervisorCredentialPushPending = false;
1883
+ }
1884
+ catch (e) {
1885
+ console.error(`! remote manager supervisor credential push deferred: ${rejectionText(e)}`);
1886
+ this.scheduleCredentialPushRetry();
1887
+ }
1809
1888
  }
1810
1889
  /** #2073: the shared bounded-retry shape for a closed standing connection: wait out each of
1811
1890
  * {@link STATIC_RECONCILE_RETRY_DELAYS_MS}, trying `attempt` after each. A stop landing while
@@ -2022,14 +2101,19 @@ export class Manager {
2022
2101
  return this.remoteBundleRenewal;
2023
2102
  if (this.leaseStopping)
2024
2103
  return;
2025
- const renewal = this.renewRemoteStandingBundleOnce(force);
2104
+ this.remoteBundleAdoption = new Promise((resolve) => { this.finishRemoteBundleAdoption = resolve; });
2105
+ const renewal = this.renewRemoteStandingBundleOnce(force, performance.now() + REMOTE_STANDING_BUDGET_MS);
2026
2106
  this.remoteBundleRenewal = renewal;
2027
2107
  try {
2028
2108
  await renewal;
2029
2109
  }
2030
2110
  finally {
2031
- if (this.remoteBundleRenewal === renewal)
2111
+ this.finishRemoteBundleAdoption?.();
2112
+ if (this.remoteBundleRenewal === renewal) {
2032
2113
  this.remoteBundleRenewal = undefined;
2114
+ this.remoteBundleAdoption = undefined;
2115
+ this.finishRemoteBundleAdoption = undefined;
2116
+ }
2033
2117
  }
2034
2118
  }
2035
2119
  /** A signerless manager's closed standing connection re-dials through the all-duty family only.
@@ -2042,18 +2126,29 @@ export class Manager {
2042
2126
  if (inspectCredHealth(read() ?? "").state !== "healthy")
2043
2127
  throw new Error(`the ${label} credential is not healthy after all-duty renewal; last-good is kept and nothing is minted locally`);
2044
2128
  }
2045
- async renewRemoteStandingBundleOnce(force) {
2129
+ async renewRemoteStandingBundleOnce(force, deadline) {
2046
2130
  const remote = this.remoteAuthority;
2047
2131
  if (!remote?.renewStandingBundle || !this.serviceServe)
2048
2132
  return;
2049
2133
  const current = [this.remoteSupervisorCreds, this.remoteExecutorCreds, this.serviceServe.creds,
2050
2134
  this.goalWriterCreds, this.sessionLedgerCreds];
2051
- if (!force && current.every((cred) => cred && inspectCredHealth(cred).state === "healthy"))
2135
+ if (!force && current.every((cred) => cred && inspectCredHealth(cred).state === "healthy")) {
2136
+ await Promise.all([
2137
+ ...[...this.pendingCredentialPushes].map((nc) => this.pushRenewedCreds(nc)),
2138
+ ...(this.supervisorCredentialPushPending ? [this.pushSupervisorCreds()] : []),
2139
+ ]);
2052
2140
  return;
2141
+ }
2053
2142
  const processEpoch = this.serviceServe.grant.epoch;
2054
2143
  let candidate;
2144
+ const serving = this.serviceServe;
2145
+ const previous = { nc: serving.nc, handle: serving.handle };
2146
+ let replacement;
2147
+ let adopted = false;
2055
2148
  try {
2056
- candidate = await remote.renewStandingBundle(processEpoch);
2149
+ // Only this continuation can adopt. A host operation may still issue after this wait times
2150
+ // out, but its late result cannot re-enter preflight or change any Manager holder.
2151
+ candidate = await withTimeout(remote.renewStandingBundle(processEpoch), remoteStandingTimeLeft(deadline), "remote manager standing authority exceeded its 10000ms aggregate budget; issuance outcome is unknown");
2057
2152
  if (Object.keys(candidate).sort().join(",") !== [...REMOTE_MANAGER_IDENTITY_NAMES].sort().join(","))
2058
2153
  throw new Error("host returned an incomplete or widened standing credential family");
2059
2154
  for (const name of REMOTE_MANAGER_IDENTITY_NAMES) {
@@ -2064,14 +2159,26 @@ export class Manager {
2064
2159
  if (credsClaims(candidate[name]).sub !== remote.identities[name].id)
2065
2160
  throw new Error(`host returned a ${name} credential for a different nkey than the held identity`);
2066
2161
  const staged = candidate[name];
2067
- const nc = await this.dial({ authenticator: (nonce) => credsAuthenticator(new TextEncoder().encode(staged))(nonce), maxReconnectAttempts: 0 });
2162
+ const nc = await this.dialRemoteStanding({ authenticator: (nonce) => credsAuthenticator(new TextEncoder().encode(staged))(nonce), maxReconnectAttempts: 0 }, deadline);
2068
2163
  await nc.close();
2069
2164
  }
2070
- if (this.leaseStopping || !this.serviceServe || this.serviceServe.grant.epoch !== processEpoch)
2071
- throw new Error("manager changed its active serve epoch while standing renewal was in flight");
2165
+ remoteStandingTimeLeft(deadline);
2166
+ if (this.leaseStopping || this.serviceServe !== serving || serving.nc !== previous.nc || serving.grant.epoch !== processEpoch)
2167
+ throw new Error("manager changed its active serve epoch or connection while standing renewal was in flight");
2168
+ const stagedServeCreds = candidate.serve;
2169
+ replacement = await this.dialServeConnection({ ...serving, creds: stagedServeCreds }, () => adopted ? serving.creds : stagedServeCreds, deadline);
2170
+ remoteStandingTimeLeft(deadline);
2171
+ if (this.leaseStopping || this.serviceServe !== serving || serving.nc !== previous.nc || serving.grant.epoch !== processEpoch)
2172
+ throw new Error("manager changed its active serve epoch or connection while standing renewal was in flight");
2173
+ if (REMOTE_MANAGER_IDENTITY_NAMES.some((name) => inspectCredHealth(candidate[name]).state !== "healthy"))
2174
+ throw new Error("standing candidate is no longer healthy after broker preflight");
2175
+ serving.nc = replacement.nc;
2176
+ serving.handle = replacement.handle;
2177
+ replacement = undefined;
2072
2178
  this.remoteSupervisorCreds = candidate.supervisor;
2073
2179
  this.remoteExecutorCreds = candidate.executor;
2074
2180
  this.serviceServe.creds = candidate.serve;
2181
+ adopted = true;
2075
2182
  this.goalWriterCreds = candidate.goalWriter;
2076
2183
  this.sessionLedgerCreds = candidate.sessionLedger;
2077
2184
  if (this.goalWriter)
@@ -2081,14 +2188,35 @@ export class Manager {
2081
2188
  // Adoption is committed above; a connection that cannot come back yet is a push failure the
2082
2189
  // endpoint's own reconnect loop retries with the adopted credential, not unadopted debt.
2083
2190
  this.remoteRenewalDebt = undefined;
2084
- await Promise.all([
2085
- this.ep.reconnect().catch((e) => console.error(`! remote manager supervisor reconnect after renewal: ${rejectionText(e)}`)),
2086
- this.pushRenewedCreds(this.serviceServe.nc),
2191
+ // Maintenance needed by admitted handlers must not wait for those same handlers to drain.
2192
+ this.finishRemoteBundleAdoption?.();
2193
+ await withTimeout(Promise.all([
2194
+ this.pushSupervisorCreds(),
2195
+ (async () => {
2196
+ try {
2197
+ // A handler or a lost broker PONG can keep stop/flush pending forever. Only the
2198
+ // retired transport is expendable: never retry its requests or close the replacement.
2199
+ await withTimeout((async () => {
2200
+ await previous.handle.stop();
2201
+ await previous.nc.flush();
2202
+ })(), 5_000, "old serve drain unfinished after 5000ms; closing retired transport, in-flight request outcomes are unknown and must not be retried");
2203
+ }
2204
+ finally {
2205
+ await previous.nc.close();
2206
+ }
2207
+ })().catch((e) => console.error(`! remote manager retired serve drainage after renewal: ${rejectionText(e)}`)),
2087
2208
  ...(this.goalWriter ? [this.pushRenewedCreds(this.goalWriter.nc)] : []),
2088
2209
  ...(this.sessionLedgerConn ? [this.pushRenewedCreds(this.sessionLedgerConn.nc)] : []),
2089
- ]);
2210
+ ]), remoteStandingTimeLeft(deadline), "remote manager standing reconnect exceeded its 10000ms aggregate budget; adopted transports keep their native reconnect ownership");
2090
2211
  }
2091
2212
  catch (e) {
2213
+ if (replacement) {
2214
+ await this.discardServeCandidate(replacement.nc, replacement.handle);
2215
+ }
2216
+ if (adopted) {
2217
+ console.error(`! remote manager adopted standing family: ${rejectionText(e)}`);
2218
+ return;
2219
+ }
2092
2220
  const unadoptedSubjects = candidate && typeof candidate === "object"
2093
2221
  ? Object.values(candidate).flatMap((cred) => { try {
2094
2222
  return typeof cred === "string" && credsClaims(cred).sub ? [credsClaims(cred).sub] : [];
@@ -2096,7 +2224,7 @@ export class Manager {
2096
2224
  catch {
2097
2225
  return [];
2098
2226
  } })
2099
- : [];
2227
+ : REMOTE_MANAGER_IDENTITY_NAMES.map((name) => remote.identities[name].id);
2100
2228
  this.remoteRenewalDebt = { at: Date.now(), processEpoch, reason: rejectionText(e), unadoptedSubjects };
2101
2229
  console.error(`! remote manager all-duty renewal: ${this.remoteRenewalDebt.reason} - no unsigned or local replacement is available`);
2102
2230
  if (force)
@@ -2695,19 +2823,13 @@ export class Manager {
2695
2823
  handle = runtime.adopt(reference);
2696
2824
  }
2697
2825
  catch (e) {
2698
- const reap = runtime.reap;
2699
- if (typeof reap !== "function")
2700
- return;
2701
- let outcome;
2702
2826
  try {
2703
- outcome = (await reap.call(runtime, reference)).outcome;
2827
+ await requireRuntimeReap(runtime, reference);
2704
2828
  }
2705
2829
  catch {
2706
2830
  // The runtime could not prove the seat gone. Leave the record for a later start.
2707
2831
  return;
2708
2832
  }
2709
- if (outcome !== "reaped")
2710
- return;
2711
2833
  console.error(`! ${name}: the released seat is gone (${rejectionText(e)}); retiring its lifecycle`);
2712
2834
  handle = {
2713
2835
  name,
@@ -2849,6 +2971,8 @@ export class Manager {
2849
2971
  }
2850
2972
  async stopOnce(withAgents) {
2851
2973
  this.leaseStopping = true;
2974
+ clearTimeout(this.credentialPushRetry);
2975
+ this.credentialPushRetry = undefined;
2852
2976
  this.transcripts?.stop();
2853
2977
  this.staticReconcileStopping = true;
2854
2978
  this.chatExpiry.stop();
@@ -2875,8 +2999,8 @@ export class Manager {
2875
2999
  // still move the key's revision after we clear the timer, and releasing against a cached
2876
3000
  // revision it has since moved is a CAS the broker correctly refuses, silently, forever.
2877
3001
  await this.leaseRenewTask?.catch(() => { });
2878
- // An already accepted host issuance must finish before standing transports close. The
2879
- // epoch/stop fence refuses adoption, but does not cancel the issuer's in-flight request.
3002
+ // Join the bounded Manager renewal. Its stop/deadline fence refuses late adoption, but the
3003
+ // host still owns any submitted issuance and its outcome can remain unknown after timeout.
2880
3004
  await this.remoteBundleRenewal?.catch(() => { });
2881
3005
  // A spawn admitted before the fence closed may still be launching. Its seat must be in the
2882
3006
  // snapshot below, or it would outlive a stop that reported success.
@@ -3061,6 +3185,14 @@ export class Manager {
3061
3185
  }
3062
3186
  catch { /* best effort */ }
3063
3187
  }
3188
+ /** A failed candidate must not extend preparation by draining subscriptions on a stalled socket.
3189
+ * Close only that candidate first. Native stop still owns any already-dispatched handlers, whose
3190
+ * outcomes remain unknown to callers and must not be replayed; they do not hold family adoption. */
3191
+ async discardServeCandidate(nc, handle) {
3192
+ await nc.close();
3193
+ if (handle)
3194
+ void handle.stop().catch((e) => console.error(`! remote manager discarded serve candidate cleanup: ${rejectionText(e)}`));
3195
+ }
3064
3196
  /** Track a dedicated connection's transport into {@link transportDown} from the first moment:
3065
3197
  * nats.js reports `disconnect` when the socket drops and `reconnect` only after it has re-sent the
3066
3198
  * connection's subscriptions. If the status stream itself fails, the transport can no longer be
@@ -8541,8 +8673,19 @@ export class Manager {
8541
8673
  // maintenance executor through the existing host operation; never resurrect standing duties.
8542
8674
  if (this.leaseStopping)
8543
8675
  await this.renewRemoteExecutor(true);
8676
+ else if (this.remoteAuthority.renewStandingBundle) {
8677
+ // The renewal drains old handlers after adopting. A handler waiting here must be
8678
+ // released at adoption, not after its own completion is required by that drain.
8679
+ const renewal = this.renewRemoteStandingBundle(true);
8680
+ void renewal.catch((error) => console.error(`! remote manager standing renewal completion: ${rejectionText(error)}`));
8681
+ await this.remoteBundleAdoption;
8682
+ if (!this.remoteExecutorCreds || inspectCredHealth(this.remoteExecutorCreds).state !== "healthy") {
8683
+ await renewal;
8684
+ throw new Error("standing renewal did not adopt a healthy scoped executor");
8685
+ }
8686
+ }
8544
8687
  else
8545
- await (this.remoteAuthority.renewStandingBundle ? this.renewRemoteStandingBundle(true) : this.renewRemoteExecutor(true));
8688
+ await this.renewRemoteExecutor(true);
8546
8689
  }
8547
8690
  const creds = this.remoteExecutorCreds ?? (this.auth
8548
8691
  ? await mintCreds(this.auth, identity, "endpoint-serve-executor", {
@@ -8714,13 +8857,14 @@ export class Manager {
8714
8857
  * so there is one place that dials and serves this endpoint. Attaches {@link
8715
8858
  * onServeConnectionClosed} as the close handler in both cases: a closed serve connection is
8716
8859
  * always a fault this manager acts on, never a bare log line. */
8717
- async dialServeConnection(state) {
8718
- const nc = await this.dial({
8860
+ async dialServeConnection(state, readCreds = () => state.creds, deadline) {
8861
+ const opts = {
8719
8862
  // Open mesh: a bare serve connection (no credential exists; the broker enforces nothing).
8720
- ...(state.creds !== undefined ? standaloneConnectOpts({ creds: () => state.creds, tls: false }) : {}),
8863
+ ...(state.creds !== undefined ? standaloneConnectOpts({ creds: readCreds, tls: false }) : {}),
8721
8864
  inboxPrefix: `_INBOX_${state.identity.id}`,
8722
8865
  maxReconnectAttempts: -1,
8723
- });
8866
+ };
8867
+ const nc = await (deadline === undefined ? this.dial(opts) : this.dialRemoteStanding(opts, deadline));
8724
8868
  this.trackTransport(nc);
8725
8869
  let handle;
8726
8870
  try {
@@ -8758,14 +8902,22 @@ export class Manager {
8758
8902
  },
8759
8903
  ...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
8760
8904
  });
8905
+ // A subscribed replacement must be visible to the broker before the old rails retire.
8906
+ if (deadline === undefined)
8907
+ await nc.flush();
8908
+ else
8909
+ await withTimeout(nc.flush(), remoteStandingTimeLeft(deadline), "remote manager standing serve flush exceeded its 10000ms aggregate budget");
8761
8910
  }
8762
8911
  catch (e) {
8763
- await drainAndClose(nc);
8912
+ await this.discardServeCandidate(nc, handle);
8764
8913
  throw e;
8765
8914
  }
8766
- // Wired only once the connection serves: a connection this method drains itself after a
8915
+ // Wired only once the connection serves: a candidate this method closes after a
8767
8916
  // refused serve is not a fault to recover, and the caller already sees the refusal.
8768
- nc.closed().then((err) => { void this.onServeConnectionClosed(err ?? undefined); });
8917
+ nc.closed().then((err) => {
8918
+ if (this.serviceServe?.nc === nc)
8919
+ void this.onServeConnectionClosed(err ?? undefined, nc);
8920
+ });
8769
8921
  return { nc, handle };
8770
8922
  }
8771
8923
  /** #2073: re-mint the SAME `endpoint-serve` identity through the SAME scoped one-shot executor and
@@ -8792,7 +8944,7 @@ export class Manager {
8792
8944
  * lease and answers nothing is the worst of the three states this issue named, and holding the
8793
8945
  * connection open under unbounded reconnects (the transport-drop case) never reaches this method
8794
8946
  * at all — `nc.closed()` does not resolve until the client itself gives up. */
8795
- async onServeConnectionClosed(err) {
8947
+ async onServeConnectionClosed(err, closed) {
8796
8948
  if (this.leaseStopping)
8797
8949
  return;
8798
8950
  const s = this.serviceServe;
@@ -8800,13 +8952,32 @@ export class Manager {
8800
8952
  const recovered = await this.boundedReconnect("service endpoint", async () => {
8801
8953
  if (!s)
8802
8954
  return false;
8803
- if (this.remoteAuthority)
8804
- await this.renewRemoteBeforeRedial("serve", () => s.creds);
8955
+ const deadline = this.remoteAuthority ? performance.now() + REMOTE_STANDING_BUDGET_MS : undefined;
8956
+ // Let an admitted renewal finish owning its candidate before recovery opens another.
8957
+ if (deadline === undefined)
8958
+ await this.remoteBundleRenewal;
8959
+ else
8960
+ await withTimeout(Promise.resolve(this.remoteBundleRenewal), remoteStandingTimeLeft(deadline), "remote manager closed-serve renewal wait exceeded its 10000ms aggregate budget");
8961
+ if (this.leaseStopping || this.serviceServe !== s || s.nc !== closed)
8962
+ return true;
8963
+ if (deadline !== undefined)
8964
+ await withTimeout(this.renewRemoteBeforeRedial("serve", () => s.creds), remoteStandingTimeLeft(deadline), "remote manager closed-serve renewal wait exceeded its 10000ms aggregate budget");
8805
8965
  else if (inspectCredHealth(s.creds ?? "").state !== "healthy" && s.creds !== undefined) {
8806
8966
  s.creds = await this.remintServeCreds(s);
8807
8967
  this.scheduleServeRenewal(s.creds);
8808
8968
  }
8809
- const { nc, handle } = await this.dialServeConnection(s);
8969
+ if (this.leaseStopping || this.serviceServe !== s || s.nc !== closed)
8970
+ return true;
8971
+ const { nc, handle } = await this.dialServeConnection(s, undefined, deadline);
8972
+ if (this.leaseStopping || this.serviceServe !== s || s.nc !== closed) {
8973
+ try {
8974
+ await handle.stop();
8975
+ }
8976
+ finally {
8977
+ await nc.close();
8978
+ }
8979
+ return true;
8980
+ }
8810
8981
  s.nc = nc;
8811
8982
  s.handle = handle;
8812
8983
  console.error(`manager service endpoint re-dialed: ${MANAGER_ENDPOINT}/${this.managerInstanceId} (epoch ${s.grant.epoch})`);