@cotal-ai/core 0.35.0 → 0.37.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.
Files changed (64) hide show
  1. package/dist/agent-file.d.ts +18 -0
  2. package/dist/agent-file.d.ts.map +1 -1
  3. package/dist/agent-file.js +37 -1
  4. package/dist/agent-file.js.map +1 -1
  5. package/dist/connector-setup.d.ts +30 -0
  6. package/dist/connector-setup.d.ts.map +1 -0
  7. package/dist/connector-setup.js +2 -0
  8. package/dist/connector-setup.js.map +1 -0
  9. package/dist/connector.d.ts +31 -3
  10. package/dist/connector.d.ts.map +1 -1
  11. package/dist/connector.js +12 -1
  12. package/dist/connector.js.map +1 -1
  13. package/dist/endpoint-action.d.ts +6 -1
  14. package/dist/endpoint-action.d.ts.map +1 -1
  15. package/dist/endpoint-action.js +6 -2
  16. package/dist/endpoint-action.js.map +1 -1
  17. package/dist/endpoint-envelope.d.ts +1 -1
  18. package/dist/endpoint-envelope.d.ts.map +1 -1
  19. package/dist/endpoint-envelope.js +1 -1
  20. package/dist/endpoint-envelope.js.map +1 -1
  21. package/dist/endpoint-error.d.ts +26 -0
  22. package/dist/endpoint-error.d.ts.map +1 -1
  23. package/dist/endpoint-error.js +41 -0
  24. package/dist/endpoint-error.js.map +1 -1
  25. package/dist/endpoint-grants.d.ts +8 -7
  26. package/dist/endpoint-grants.d.ts.map +1 -1
  27. package/dist/endpoint-grants.js +9 -8
  28. package/dist/endpoint-grants.js.map +1 -1
  29. package/dist/endpoint-invoke.d.ts.map +1 -1
  30. package/dist/endpoint-invoke.js +58 -7
  31. package/dist/endpoint-invoke.js.map +1 -1
  32. package/dist/endpoint-serve-kv.d.ts +17 -0
  33. package/dist/endpoint-serve-kv.d.ts.map +1 -1
  34. package/dist/endpoint-serve-kv.js +59 -1
  35. package/dist/endpoint-serve-kv.js.map +1 -1
  36. package/dist/endpoint-service.d.ts +6 -0
  37. package/dist/endpoint-service.d.ts.map +1 -1
  38. package/dist/endpoint-service.js +12 -3
  39. package/dist/endpoint-service.js.map +1 -1
  40. package/dist/endpoint.d.ts +90 -15
  41. package/dist/endpoint.d.ts.map +1 -1
  42. package/dist/endpoint.js +470 -120
  43. package/dist/endpoint.js.map +1 -1
  44. package/dist/identity.d.ts +27 -1
  45. package/dist/identity.d.ts.map +1 -1
  46. package/dist/identity.js +5 -1
  47. package/dist/identity.js.map +1 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/lifecycle-saga.d.ts.map +1 -1
  53. package/dist/lifecycle-saga.js +24 -5
  54. package/dist/lifecycle-saga.js.map +1 -1
  55. package/dist/lifecycle-state.d.ts +15 -0
  56. package/dist/lifecycle-state.d.ts.map +1 -1
  57. package/dist/lifecycle-state.js +60 -0
  58. package/dist/lifecycle-state.js.map +1 -1
  59. package/dist/provision.d.ts.map +1 -1
  60. package/dist/provision.js +19 -7
  61. package/dist/provision.js.map +1 -1
  62. package/dist/types.d.ts +7 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -13,7 +13,7 @@ import { liveKvEntries } from "./kv-scan.js";
13
13
  import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
14
14
  import { assertValidName } from "./resolve.js";
15
15
  import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS } from "./streams.js";
16
- import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
16
+ import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
17
17
  import {} from "@nats-io/jetstream";
18
18
  import { Kvm } from "@nats-io/kv";
19
19
  import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
@@ -31,7 +31,9 @@ export const DEFAULT_SPACE = "main";
31
31
  * "connection" ({ connected: boolean }) — true on every successful (re)bind (initial start, manual
32
32
  * reconnect, AND background self-heal), false the moment the connection drops (rebuild null window /
33
33
  * terminal close). Lets an in-process agent track connectedness off the endpoint's own (re)binds
34
- * instead of an imperative flag the self-heal path can't reach.
34
+ * instead of an imperative flag the self-heal path can't reach; "transport" ({ connected, server? })
35
+ * is the lower-level NATS socket edge, true before the full bind finishes and false during an internal
36
+ * nats.js reconnect without changing `connection` readiness.
35
37
  *
36
38
  * Callers MUST attach an "error" listener before `start()`: async faults (incl. NATS
37
39
  * permission denials, surfaced via `watchStatus`) are emitted as "error", and Node throws
@@ -105,6 +107,12 @@ export class CotalEndpoint extends EventEmitter {
105
107
  jsm;
106
108
  kv;
107
109
  channelKv;
110
+ /** The presence/channel-registry watches' own handles. Each is an ORDERED push consumer: its idle
111
+ * heartbeat monitor lives on a JS timer independent of the connection, so a drain that doesn't
112
+ * `.stop()` it first leaves the monitor to fire into a closing connection every 30s, throwing
113
+ * `DrainingConnectionError` out of `reset()` on a timer nothing awaits. */
114
+ presenceWatchIter;
115
+ channelWatchIter;
108
116
  /** Plane-3 durable-membership registry KV — lazily opened by the privileged delivery daemon (or a
109
117
  * short-lived provisioner). */
110
118
  membersKv;
@@ -177,6 +185,15 @@ export class CotalEndpoint extends EventEmitter {
177
185
  roster = new Map();
178
186
  /** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
179
187
  presenceSnapshot = Promise.resolve();
188
+ /**
189
+ * Observer-local age of the last presence-KV delivery (any key, including DEL/PURGE). Distinct
190
+ * from each peer's `ts`: that is the publisher's heartbeat. Whole-bucket silence past TTL is
191
+ * the observer going deaf, not N simultaneous deaths, and sweep must not treat it as the
192
+ * latter (#1045).
193
+ */
194
+ lastPresenceWatchAt = 0;
195
+ /** Last emitted presence-view freshness. Suppresses duplicate `presence-view` events. */
196
+ presenceViewFresh = true;
180
197
  status = "idle";
181
198
  activity;
182
199
  /** Mirror of the connector's authoritative attention state, published in presence (advisory). The
@@ -198,6 +215,8 @@ export class CotalEndpoint extends EventEmitter {
198
215
  backoffResolve;
199
216
  backoffTimer;
200
217
  retryMs = 3000;
218
+ /** Consecutive failed rebuilds, driving {@link nextRetryDelayMs}. Reset on every success. */
219
+ retryAttempt = 0;
201
220
  /** The connection's authenticated nkey — dev: the creds' identity; user mode: the per-connection
202
221
  * ephemeral. Distinct from the {@link owner}+{@link actor} principal: it names the CONNECTION (the
203
222
  * broker-authenticated user), and scopes the private reply inbox (`_INBOX_<connId>`) + the credId
@@ -369,10 +388,15 @@ export class CotalEndpoint extends EventEmitter {
369
388
  }
370
389
  async start() {
371
390
  await this.connectAndBind();
372
- // nats.js auto-reconnects transient drops; when it exhausts its attempts and the
373
- // connection closes for good, rebuild from scratch so an in-process agent (e.g. the
374
- // OpenCode plugin) recovers without a host respawn. Armed only after a successful first
375
- // connect a first-connect failure throws to the caller's connect-retry loop instead.
391
+ // stop() can finish while the INITIAL connectAndBind is still awaiting its broker work. The
392
+ // rebuild path already closes that race; initial start needs the same fence or the late bind
393
+ // leaves a fresh nc, heartbeat, consumers, and presence live on an endpoint already stopped.
394
+ // superviseConnection below: nats.js auto-reconnects transient drops, and when it exhausts its
395
+ // attempts and the connection closes for good we rebuild from scratch, so an in-process agent
396
+ // (e.g. the OpenCode plugin) recovers without a host respawn. Armed only after a successful
397
+ // first connect; a first-connect failure throws to the caller's connect-retry loop instead.
398
+ if (await this.tearDownIfStopped())
399
+ return;
376
400
  this.superviseConnection();
377
401
  }
378
402
  /** How far ahead of the current bearer's `exp` a refresh fires, and how soon a FAILED refresh
@@ -380,9 +404,17 @@ export class CotalEndpoint extends EventEmitter {
380
404
  * so whatever `currentBearer` holds is what every attempt presents). */
381
405
  static BEARER_REFRESH_MARGIN_MS = 60_000;
382
406
  static BEARER_RETRY_MS = 15_000;
407
+ /**
408
+ * A condition the endpoint is already surviving. Node rethrows `error` when no listener is
409
+ * attached, so emitting retry notices on `error` killed hosts that the endpoint intended to
410
+ * keep running (#891). `warning` is observable and never fatal without a listener.
411
+ */
412
+ emitRecoverable(err) {
413
+ this.emit("warning", err);
414
+ }
383
415
  /** Fetch a fresh bearer from the source, pin its principal to ours, arm the next refresh. On a
384
416
  * fetch/principal failure: THROWS when `initial` (start() must fail loud before first connect);
385
- * otherwise emits "error" and retries — the live connection keeps working until its current JWT
417
+ * otherwise emits "warning" and retries — the live connection keeps working until its current JWT
386
418
  * expiry, so a dead auth service is loud without instantly dropping the mesh. */
387
419
  async refreshBearer(initial = false) {
388
420
  try {
@@ -396,7 +428,7 @@ export class CotalEndpoint extends EventEmitter {
396
428
  catch (e) {
397
429
  if (initial)
398
430
  throw e;
399
- this.emit("error", new Error(`bearer refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current token's expiry if the auth service stays down`));
431
+ this.emitRecoverable(new Error(`bearer refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current token's expiry if the auth service stays down`));
400
432
  this.armBearerRefresh(CotalEndpoint.BEARER_RETRY_MS);
401
433
  }
402
434
  }
@@ -464,7 +496,7 @@ export class CotalEndpoint extends EventEmitter {
464
496
  catch (e) {
465
497
  if (initial)
466
498
  throw e;
467
- this.emit("error", new Error(`creds refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current JWT's expiry if renewal keeps failing`));
499
+ this.emitRecoverable(new Error(`creds refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current JWT's expiry if renewal keeps failing`));
468
500
  this.armCredsRefresh(CotalEndpoint.CREDS_RETRY_MS);
469
501
  }
470
502
  }
@@ -544,7 +576,7 @@ export class CotalEndpoint extends EventEmitter {
544
576
  await this.swapConnectionOntoFreshCreds();
545
577
  }
546
578
  catch (e) {
547
- this.emit("error", new Error(`creds refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current JWT's expiry if renewal keeps failing`));
579
+ this.emitRecoverable(new Error(`creds refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current JWT's expiry if renewal keeps failing`));
548
580
  this.armCredsRefresh(CotalEndpoint.CREDS_RETRY_MS);
549
581
  }
550
582
  }
@@ -619,6 +651,23 @@ export class CotalEndpoint extends EventEmitter {
619
651
  if (stale)
620
652
  await this.refreshCreds(!this.currentCreds);
621
653
  }
654
+ // Never dial with material we can already prove is dead. The refresh above is best-effort (a
655
+ // failed renewal emits and retries rather than throwing, so a dead auth service does not
656
+ // instantly drop a still-live mesh), which used to leave the connect below presenting an
657
+ // EXPIRED bearer: a guaranteed denial that still costs a full auth-callout round trip, repeated
658
+ // by the reestablish loop at a flat rate for as long as the process lives. Refuse here instead.
659
+ // The loop's capped backoff paces the retries and re-enters this method, so a renewal that
660
+ // starts working reconnects on its own; an endpoint with no bearer source cannot renew at all,
661
+ // and says so.
662
+ if (this.userMode && this.currentBearer && bearerExpiryMs(this.currentBearer) <= Date.now())
663
+ throw new Error(this.bearerSource
664
+ ? "this endpoint's user bearer has expired and renewal through the auth exchange is failing - not presenting the expired token to the broker; retrying with backoff"
665
+ : "this endpoint's user bearer has expired and it holds no bearer source to renew it - re-authenticate and rebuild the endpoint (construct it with a bearer FUNCTION for standing renewal)");
666
+ const credsExp = this.currentCreds && credsClaims(this.currentCreds).exp;
667
+ if (typeof credsExp === "number" && credsExp * 1000 <= Date.now())
668
+ throw new Error(this.credsSource
669
+ ? "this endpoint's creds have expired and renewal is failing - not presenting the expired credential to the broker; retrying with backoff"
670
+ : "this endpoint's creds have expired and it holds no creds source to renew them - replace the credential and rebuild the endpoint (pass a creds FUNCTION for standing renewal)");
622
671
  this.nc = await dialerFor(this.servers)({
623
672
  servers: this.servers,
624
673
  // In USER MODE the connection `name` carries the client-chosen inbox nonce (= connId) the callout
@@ -705,7 +754,7 @@ export class CotalEndpoint extends EventEmitter {
705
754
  if (this.doRegister) {
706
755
  await this.publishPresence();
707
756
  this.heartbeatTimer = setInterval(() => {
708
- this.publishPresence().catch((e) => this.emit("error", e));
757
+ this.publishPresence().catch((e) => this.emitRecoverable(e));
709
758
  }, this.heartbeatMs);
710
759
  }
711
760
  // Caller-owned membership watches are INTENT rather than one-connection iterators. Re-open them
@@ -717,6 +766,20 @@ export class CotalEndpoint extends EventEmitter {
717
766
  await this.armPlane3();
718
767
  // Bound and live — covers initial start, manual reconnect, AND background self-heal (every
719
768
  // path lands here). The single signal an in-process agent's connected flag tracks.
769
+ //
770
+ // The stopped guard: stop() can land in any await above. Both callers tear the fresh
771
+ // connection back down (tearDownIfStopped), but an event has no undo, so a late
772
+ // `connection: true` would be the last edge a listener ever sees on a stopped endpoint and
773
+ // nothing follows it to correct the record. It belongs here rather than in a consumer because
774
+ // every listener reads the same edge; MeshAgent carries its own `stopping` guard and so was
775
+ // never the one exposed, which is the point.
776
+ //
777
+ // Measured for the start() caller by the broker suite's mid-bind cell. doRebuild is
778
+ // also measured, by a separate cell that holds the rebuild's connectAndBind at
779
+ // armPlane3 after a successful first bind and lands stop() in that window (#1028).
780
+ // Shared-line reasoning is no longer the rebuild proof.
781
+ if (this.stopped)
782
+ return;
720
783
  this.emit("connection", { connected: true });
721
784
  }
722
785
  /** Tear down everything {@link connectAndBind} (re)creates, so a rebind can't leak a
@@ -740,6 +803,20 @@ export class CotalEndpoint extends EventEmitter {
740
803
  }
741
804
  }
742
805
  this.streamMsgs.length = 0;
806
+ try {
807
+ this.presenceWatchIter?.stop();
808
+ }
809
+ catch {
810
+ /* already closed with the connection */
811
+ }
812
+ this.presenceWatchIter = undefined;
813
+ try {
814
+ this.channelWatchIter?.stop();
815
+ }
816
+ catch {
817
+ /* already closed with the connection */
818
+ }
819
+ this.channelWatchIter = undefined;
743
820
  for (const sub of this.chatSubs.values()) {
744
821
  try {
745
822
  sub.unsubscribe();
@@ -752,11 +829,13 @@ export class CotalEndpoint extends EventEmitter {
752
829
  this.chatSubDenied.clear();
753
830
  this.confirmingChatSubs.clear();
754
831
  this.roster.clear();
832
+ this.lastPresenceWatchAt = 0;
833
+ this.presenceViewFresh = true;
755
834
  this.joinSeq.clear();
756
835
  this.channelConfigs.clear();
757
836
  this.channelDefaults = {};
758
837
  for (const watch of this.membershipFeedWatches)
759
- watch.arm = watch.arm.catch(() => { }).then(() => this.disarmMembershipWatch(watch));
838
+ watch.arm = watch.arm.catch(() => { }).then(() => this.disarmMembershipWatch(watch)).catch((err) => { this.emit("error", err); });
760
839
  }
761
840
  /** If stop() ran during a rebuild's `await connectAndBind`, the just-bound connection +
762
841
  * heartbeat + supervisor would be left live on a stopped endpoint. Tear that fresh
@@ -792,7 +871,10 @@ export class CotalEndpoint extends EventEmitter {
792
871
  return;
793
872
  if (this.nc !== nc)
794
873
  return; // epoch-stale — a rebuild already swapped this connection
795
- this.emit("connection", { connected: false }); // dropped report it before the rebuild kicks in
874
+ // ORDER IS PART OF THE DIAGNOSTIC CONTRACT. MeshAgent retains endpoint errors only while it is
875
+ // not bound, so readiness must turn false before the matching terminal-close error is emitted.
876
+ // Reversing these two lines silently loses the only post-drop reason an agent can report.
877
+ this.emit("connection", { connected: false });
796
878
  this.emit("error", new Error(`mesh connection closed${err ? `: ${err.message}` : ""} - re-establishing`));
797
879
  void this.reestablishLoop();
798
880
  });
@@ -839,7 +921,11 @@ export class CotalEndpoint extends EventEmitter {
839
921
  // The manager's liveness-lease handle too: left bound to the old connection, every renew and
840
922
  // re-read after a reconnect times out, and the manager reports its lease unknown for good.
841
923
  this.managerLeaseKv = undefined;
842
- this.emit("connection", { connected: false }); // null window opened not live until the rebind below
924
+ // This is an application-requested epoch teardown, not a transient nats.js blip. The old
925
+ // status iterator is now stale by construction and its close is epoch-dropped, so this line is
926
+ // the authoritative raw-liveness edge for the no-nc window until the new watcher seeds true.
927
+ this.emit("transport", { connected: false });
928
+ this.emit("connection", { connected: false });
843
929
  try {
844
930
  await oldNc?.drain();
845
931
  }
@@ -850,17 +936,31 @@ export class CotalEndpoint extends EventEmitter {
850
936
  // stop() may have run during the await — don't leave a live connection + heartbeat +
851
937
  // supervisor on a stopped endpoint. (Reads this.nc in its own scope — a bare `this.nc`
852
938
  // here in doRebuild narrows to `never` via TS inlining connectAndBind's assignment.)
939
+ // Re-arm on the fresh nc only after this stopped fence accepts it.
853
940
  if (await this.tearDownIfStopped())
854
941
  return;
855
- this.superviseConnection(); // re-arm on the fresh nc
942
+ this.superviseConnection();
856
943
  }
857
944
  finally {
858
945
  this.reconnecting = false;
859
946
  }
860
947
  }
948
+ /** The ceiling {@link nextRetryDelayMs} grows to. A failure that outlives a couple of retries is
949
+ * a standing one (a down broker, a credential nothing can renew), and retrying it every few
950
+ * seconds forever is what turns one stuck client into a permanent flat load on the broker and its
951
+ * auth callout. */
952
+ static RETRY_BACKOFF_CAP_MS = 60_000;
953
+ /** The wait before the next rebuild attempt: {@link retryMs}, doubling per consecutive failure up
954
+ * to {@link RETRY_BACKOFF_CAP_MS}. The FIRST retry still waits exactly retryMs, so a transient
955
+ * drop recovers as fast as it always did; only a failure that repeats gets paced. */
956
+ nextRetryDelayMs() {
957
+ const delay = Math.min(this.retryMs * 2 ** this.retryAttempt, CotalEndpoint.RETRY_BACKOFF_CAP_MS);
958
+ this.retryAttempt++;
959
+ return delay;
960
+ }
861
961
  /** Rebuild with backoff until it sticks or we're stopped. Interruptible: a manual
862
962
  * {@link reconnect} kicks the backoff so the next attempt runs immediately instead of
863
- * awaiting the full retryMs. One loop at a time ({@link reestablishing}); concurrent
963
+ * awaiting the full delay. One loop at a time ({@link reestablishing}); concurrent
864
964
  * triggers coalesce via {@link rebuild}. */
865
965
  async reestablishLoop() {
866
966
  if (this.reestablishing)
@@ -870,14 +970,16 @@ export class CotalEndpoint extends EventEmitter {
870
970
  while (!this.stopped) {
871
971
  try {
872
972
  await this.rebuild();
973
+ this.retryAttempt = 0; // reconnected — the next drop starts from retryMs again
873
974
  return; // success — re-armed; the supervisor re-triggers on the next terminal close
874
975
  }
875
976
  catch (e) {
876
977
  if (!this.stopped)
877
- this.emit("error", e);
978
+ this.emitRecoverable(e);
979
+ const delay = this.nextRetryDelayMs();
878
980
  await new Promise((resolve) => {
879
981
  this.backoffResolve = resolve;
880
- this.backoffTimer = setTimeout(resolve, this.retryMs);
982
+ this.backoffTimer = setTimeout(resolve, delay);
881
983
  });
882
984
  }
883
985
  }
@@ -955,6 +1057,20 @@ export class CotalEndpoint extends EventEmitter {
955
1057
  /* already closed */
956
1058
  }
957
1059
  }
1060
+ try {
1061
+ this.presenceWatchIter?.stop();
1062
+ }
1063
+ catch {
1064
+ /* already closed */
1065
+ }
1066
+ this.presenceWatchIter = undefined;
1067
+ try {
1068
+ this.channelWatchIter?.stop();
1069
+ }
1070
+ catch {
1071
+ /* already closed */
1072
+ }
1073
+ this.channelWatchIter = undefined;
958
1074
  try {
959
1075
  if (this.doRegister) {
960
1076
  this.status = "offline";
@@ -1491,6 +1607,22 @@ export class CotalEndpoint extends EventEmitter {
1491
1607
  getRoster() {
1492
1608
  return [...this.roster.values()].sort((a, b) => a.card.name.localeCompare(b.card.name));
1493
1609
  }
1610
+ /**
1611
+ * Freshness of THIS observer's presence watch, not of any peer. `fresh: false` means the
1612
+ * whole bucket has been silent past the liveness window — the view is stale as of
1613
+ * `staleSince`, and {@link getRoster} is last-known rather than a current offline verdict.
1614
+ * A watch that has not yet delivered anything is not stale (there is no T to name).
1615
+ */
1616
+ presenceView() {
1617
+ if (!this.doWatch)
1618
+ return { fresh: true };
1619
+ if (this.lastPresenceWatchAt === 0)
1620
+ return { fresh: true };
1621
+ const staleSince = this.lastPresenceWatchAt + this.ttlMs;
1622
+ if (Date.now() < staleSince)
1623
+ return { fresh: true };
1624
+ return { fresh: false, staleSince };
1625
+ }
1494
1626
  /** Wait until the current presence watch has consumed its initial KV snapshot. An empty bucket
1495
1627
  * emits no watch entry, so the timeout keeps a genuinely empty mesh bounded. */
1496
1628
  async waitForPresenceSnapshot(timeoutMs = 1_000) {
@@ -1694,8 +1826,12 @@ export class CotalEndpoint extends EventEmitter {
1694
1826
  }
1695
1827
  }
1696
1828
  }
1697
- catch {
1698
- /* stream missing fall through to registry-only channels */
1829
+ catch (e) {
1830
+ // A genuinely absent CHAT stream means there are no retained message counts yet. Every other
1831
+ // failure, especially a STREAM.INFO permission denial, means the count view could not be read
1832
+ // and must stay loud rather than returning a valid-looking registry-only subset.
1833
+ if (!isJetStreamMissing(e, JetStreamApiCodes.StreamNotFound))
1834
+ throw e;
1699
1835
  }
1700
1836
  const channels = new Set([...counts.keys(), ...this.channelConfigs.keys()]);
1701
1837
  return [...channels]
@@ -1921,11 +2057,18 @@ export class CotalEndpoint extends EventEmitter {
1921
2057
  watch.consumerName = undefined;
1922
2058
  }
1923
2059
  else {
1924
- // A timeout is deferred only for an epoch that is actually closing/rebuilding; live timeouts stay loud.
1925
2060
  const closedEpoch = err.name === "ClosedConnectionError" || /^closed connection$/i.test(err.message);
1926
- const dyingEpochTimeout = /timeout/i.test(err.message) && (this.reconnecting || !this.nc || this.nc.isClosed());
1927
- if (!closedEpoch && !dyingEpochTimeout)
2061
+ const timeout = err.name === "TimeoutError" || /timeout/i.test(err.message);
2062
+ const dyingEpochTimeout = timeout && (this.reconnecting || !this.nc || this.nc.isClosed());
2063
+ // Cleanup of an ordered consumer: a delete timeout means the broker did not answer in time,
2064
+ // not that the endpoint is unusable. The broker reaps an idle/ephemeral consumer anyway.
2065
+ // Throwing here killed a live observer over a slow VPN (#1047). Catch, surface, continue.
2066
+ if (timeout || closedEpoch || dyingEpochTimeout) {
2067
+ this.emit("error", err);
2068
+ }
2069
+ else {
1928
2070
  throw err;
2071
+ }
1929
2072
  }
1930
2073
  // A terminal close leaves stream/name intact. The endpoint-owned stopped intent is retried
1931
2074
  // through the fresh JetStream manager before its public stop promise may resolve.
@@ -1952,10 +2095,11 @@ export class CotalEndpoint extends EventEmitter {
1952
2095
  }
1953
2096
  }));
1954
2097
  }
1955
- /** Fetch recent messages from a channel's JetStream backlog. */
2098
+ /** Fetch recent messages from a channel's JetStream backlog. `signal` cancels the active pull and
2099
+ * reclaims its ephemeral consumer before the promise rejects. */
1956
2100
  async channelHistory(channel, opts) {
1957
2101
  // history from any sender
1958
- return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100);
2102
+ return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100, undefined, opts?.signal);
1959
2103
  }
1960
2104
  /** Read a channel's recent history THROUGH THE DELIVERY DAEMON instead of through a consumer this
1961
2105
  * connection creates itself — the mediated read of SPEC's "Mediated reads (normative)" rule (no raw
@@ -1996,12 +2140,13 @@ export class CotalEndpoint extends EventEmitter {
1996
2140
  throw new Error("readHistory: the delivery daemon returned a malformed page (expected { items, complete })");
1997
2141
  return { items: data.items, complete: data.complete };
1998
2142
  }
1999
- /** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. God-view only:
2143
+ /** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. `signal` cancels the
2144
+ * active pull and reclaims its ephemeral consumer. God-view only:
2000
2145
  * a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
2001
2146
  * skips for them — only an `admin`-profile cred can read it. */
2002
2147
  async dmHistory(opts) {
2003
2148
  // every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
2004
- return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100);
2149
+ return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100, undefined, opts?.signal);
2005
2150
  }
2006
2151
  /**
2007
2152
  * The `limit` MOST RECENT messages matching `subject`, oldest-first within the page.
@@ -2019,14 +2164,17 @@ export class CotalEndpoint extends EventEmitter {
2019
2164
  * A filtered subject's sequences are non-contiguous (other channels interleave in the same
2020
2165
  * stream), so the window cannot be computed arithmetically. A FAILED attempt holds fewer than a
2021
2166
  * page by definition, so wasted transfer stays page-sized and geometric growth keeps the number of
2022
- * attempts logarithmic. The one unbounded case is named in the body: a channel whose matches are
2023
- * all old and sparse walks back to the start of the stream and reads its whole retained set.
2167
+ * attempts logarithmic. A channel with fewer than `limit` matches used to keep widening until
2168
+ * sequence 1 and drain the stream's whole retained set (#840). The walk now stops at the
2169
+ * subject's FIRST matching sequence (the mirror of the last-matching ceiling), probed on the
2170
+ * same CREATE surface, and only after a short drain so a dense page never pays for the floor.
2024
2171
  *
2025
2172
  * `before` pages toward the past: pass the `seq` of the oldest message you already have.
2026
2173
  */
2027
- async streamHistory(stream, subject, limit, before) {
2174
+ async streamHistory(stream, subject, limit, before, signal) {
2028
2175
  if (!this.nc)
2029
2176
  throw new Error("endpoint not started");
2177
+ signal?.throwIfAborted();
2030
2178
  // A LIMIT THAT IS NOT A FINITE NUMBER HAS NO ANSWER, AND THE SEARCH BELOW CANNOT REFUSE IT.
2031
2179
  // Every comparison against NaN is false, so `limit <= 0` does not fire for one, and neither of
2032
2180
  // the widening loop's exits can ever be true either: `page.length >= NaN` is false forever and
@@ -2062,7 +2210,7 @@ export class CotalEndpoint extends EventEmitter {
2062
2210
  // Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
2063
2211
  // needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
2064
2212
  // shipped once from this function and turned every non-admin history read into an empty list.
2065
- const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subject);
2213
+ const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subject, signal);
2066
2214
  if (ceiling < 1)
2067
2215
  return [];
2068
2216
  // Widen from the exact ceiling until a window holds a full page, or until the window IS the
@@ -2071,17 +2219,28 @@ export class CotalEndpoint extends EventEmitter {
2071
2219
  // Geometric growth keeps the number of attempts logarithmic, so total wasted transfer is a
2072
2220
  // small multiple of a page.
2073
2221
  //
2074
- // NAMED POLICY for the remaining case: when a channel's matches are all old and sparse, the
2075
- // search walks back to sequence 1 and the final drain transfers that subject's whole retained
2076
- // set. That is chosen deliberately — a FULL page of genuinely recent messages, at the cost of
2077
- // an unbounded read on a channel that has not been used in a long time — over returning a
2078
- // short page while older messages exist. The exact ceiling above means this is now reached
2079
- // only by real sparsity WITHIN a channel, never by the channel simply being quiet lately.
2222
+ // A SHORT PAGE is either "the channel is exhausted" or "the window is still above the
2223
+ // first match". Sequence 1 is the wrong floor for the first of those: three matches at
2224
+ // the high end of a busy stream are exhausted as soon as the window's lower edge passes
2225
+ // the subject's first matching sequence, and walking on to 1 re-reads everyone else's
2226
+ // retained set (#840). Probe that floor only after a short drain so a dense page (one
2227
+ // drain, full) never pays for it. The remaining unbounded-looking case is a subject
2228
+ // whose FIRST match really is near sequence 1; that span is the channel's own, not the
2229
+ // stream's.
2080
2230
  let span = Math.max(limit * 4, 64);
2231
+ let floor = 1;
2232
+ let floorKnown = false;
2081
2233
  for (;;) {
2082
- const start = Math.max(1, ceiling - span + 1);
2083
- const page = await this.drainWindow(js, stream, subject, start, ceiling);
2084
- if (page.length >= limit || start === 1)
2234
+ signal?.throwIfAborted();
2235
+ const start = Math.max(floor, ceiling - span + 1);
2236
+ const page = await this.drainWindow(js, stream, subject, start, ceiling, limit, signal);
2237
+ if (page.length >= limit)
2238
+ return page.slice(-limit);
2239
+ if (!floorKnown) {
2240
+ floor = Math.max(1, await this.firstMatchingSeq(js, stream, subject, signal));
2241
+ floorKnown = true;
2242
+ }
2243
+ if (start <= floor)
2085
2244
  return page.slice(-limit);
2086
2245
  span *= 4;
2087
2246
  }
@@ -2115,7 +2274,51 @@ export class CotalEndpoint extends EventEmitter {
2115
2274
  * (available from the create, before anything is delivered) is 0 for an empty subject, and
2116
2275
  * otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
2117
2276
  * already uses, so no broker authority is added. */
2118
- async lastMatchingSeq(js, stream, subject) {
2277
+ /** The oldest stream sequence matching `subject`, or 0 when the subject has no messages.
2278
+ * Mirror of {@link lastMatchingSeq}: same CREATE/INFO/NEXT/DELETE surface, `DeliverPolicy.All`
2279
+ * instead of `Last`, first delivered seq instead of last. Read credentials already hold this. */
2280
+ async firstMatchingSeq(js, stream, subject, signal) {
2281
+ signal?.throwIfAborted();
2282
+ const consumer = await js.consumers.get(stream, {
2283
+ filter_subjects: [subject],
2284
+ deliver_policy: DeliverPolicy.All,
2285
+ });
2286
+ try {
2287
+ if ((await consumer.info(true)).num_pending === 0)
2288
+ return 0;
2289
+ signal?.throwIfAborted();
2290
+ const iter = await consumer.fetch({ max_messages: 1 });
2291
+ const stop = () => iter.stop(abortReason(signal));
2292
+ signal?.addEventListener("abort", stop, { once: true });
2293
+ try {
2294
+ signal?.throwIfAborted();
2295
+ for await (const m of iter)
2296
+ return m.seq;
2297
+ }
2298
+ finally {
2299
+ signal?.removeEventListener("abort", stop);
2300
+ iter.stop();
2301
+ }
2302
+ throw new Error(`history: the broker reported messages on ${subject} but delivered none - the read was cut short, not empty`);
2303
+ }
2304
+ finally {
2305
+ try {
2306
+ await consumer.delete();
2307
+ }
2308
+ catch (e) {
2309
+ if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
2310
+ throw e;
2311
+ }
2312
+ }
2313
+ }
2314
+ /** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
2315
+ *
2316
+ * One ordered consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
2317
+ * (available from the create, before anything is delivered) is 0 for an empty subject, and
2318
+ * otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
2319
+ * already uses, so no broker authority is added. */
2320
+ async lastMatchingSeq(js, stream, subject, signal) {
2321
+ signal?.throwIfAborted();
2119
2322
  const consumer = await js.consumers.get(stream, {
2120
2323
  filter_subjects: [subject],
2121
2324
  deliver_policy: DeliverPolicy.Last,
@@ -2124,9 +2327,19 @@ export class CotalEndpoint extends EventEmitter {
2124
2327
  // Bind-time zero is the ONLY thing that means "this subject has no messages".
2125
2328
  if ((await consumer.info(true)).num_pending === 0)
2126
2329
  return 0;
2330
+ signal?.throwIfAborted();
2127
2331
  const iter = await consumer.fetch({ max_messages: 1 });
2128
- for await (const m of iter)
2129
- return m.seq;
2332
+ const stop = () => iter.stop(abortReason(signal));
2333
+ signal?.addEventListener("abort", stop, { once: true });
2334
+ try {
2335
+ signal?.throwIfAborted();
2336
+ for await (const m of iter)
2337
+ return m.seq;
2338
+ }
2339
+ finally {
2340
+ signal?.removeEventListener("abort", stop);
2341
+ iter.stop();
2342
+ }
2130
2343
  // Bind said a message was pending and none arrived. The pinned client's pull iterator ends
2131
2344
  // CLEANLY when the connection closes ("we don't propagate the error here"), so this is what a
2132
2345
  // dropped link looks like from here. Returning 0 would make the caller report an empty
@@ -2135,14 +2348,21 @@ export class CotalEndpoint extends EventEmitter {
2135
2348
  throw new Error(`history: the broker reported messages on ${subject} but delivered none - the read was cut short, not empty`);
2136
2349
  }
2137
2350
  finally {
2138
- await consumer.delete().catch(() => { });
2351
+ try {
2352
+ await consumer.delete();
2353
+ }
2354
+ catch (e) {
2355
+ if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
2356
+ throw e;
2357
+ }
2139
2358
  }
2140
2359
  }
2141
2360
  /** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
2142
2361
  * One ephemeral ordered consumer, one batched pull — `AckPolicy.None`, so no per-message ack
2143
2362
  * round trip. Fetches exactly the pending count so it returns as soon as the window is
2144
2363
  * delivered rather than blocking for the pull's full expiry. */
2145
- async drainWindow(js, stream, subject, start, ceiling) {
2364
+ async drainWindow(js, stream, subject, start, ceiling, limit, signal) {
2365
+ signal?.throwIfAborted();
2146
2366
  const out = [];
2147
2367
  const consumer = await js.consumers.get(stream, { filter_subjects: [subject], opt_start_seq: start });
2148
2368
  try {
@@ -2151,36 +2371,51 @@ export class CotalEndpoint extends EventEmitter {
2151
2371
  const pending = (await consumer.info(true)).num_pending;
2152
2372
  if (pending === 0)
2153
2373
  return out;
2154
- const iter = await consumer.fetch({ max_messages: pending });
2374
+ signal?.throwIfAborted();
2375
+ // Keep only a small rolling buffer in flight. Stopping a client iterator cannot unsend bytes the
2376
+ // broker already committed to that pull request, so fetching the whole page let an aborted history
2377
+ // read keep filling a constrained shared connection and starve the next dashboard poll. `consume`
2378
+ // replenishes this bounded buffer as it is read, so large pages still complete without committing
2379
+ // all of their bytes to the connection up front.
2380
+ const iter = await consumer.consume({ max_messages: Math.min(pending, 32) });
2381
+ const stop = () => iter.stop(abortReason(signal));
2382
+ signal?.addEventListener("abort", stop, { once: true });
2155
2383
  // PROVE THE WINDOW COMPLETED. The pull iterator ends cleanly on a dropped connection, so a
2156
2384
  // close after three of ten deliveries would otherwise return a convincing three-message page.
2157
2385
  // The window is done when we have reached its upper bound or consumed everything bind said
2158
2386
  // was pending; anything else is a cut-short read and must say so.
2159
2387
  let delivered = 0;
2160
2388
  let complete = false;
2161
- for await (const m of iter) {
2162
- delivered++;
2163
- if (m.seq >= ceiling) { // reached the page's upper bound
2164
- if (m.seq === ceiling) {
2165
- try {
2166
- out.push(m.json());
2389
+ try {
2390
+ signal?.throwIfAborted();
2391
+ for await (const m of iter) {
2392
+ delivered++;
2393
+ if (m.seq >= ceiling) { // reached the page's upper bound
2394
+ if (m.seq === ceiling) {
2395
+ try {
2396
+ out.push(m.json());
2397
+ }
2398
+ catch { /* skip undecodable */ }
2167
2399
  }
2168
- catch { /* skip undecodable */ }
2400
+ complete = true;
2401
+ break;
2402
+ }
2403
+ try {
2404
+ out.push(m.json());
2405
+ if (out.length > limit)
2406
+ out.shift();
2407
+ }
2408
+ catch { /* skip undecodable */ }
2409
+ if (delivered >= pending) {
2410
+ complete = true;
2411
+ break;
2169
2412
  }
2170
- complete = true;
2171
- break;
2172
- }
2173
- try {
2174
- out.push(m.json());
2175
- }
2176
- catch {
2177
- /* skip undecodable */
2178
- }
2179
- if (delivered >= pending) {
2180
- complete = true;
2181
- break;
2182
2413
  }
2183
2414
  }
2415
+ finally {
2416
+ signal?.removeEventListener("abort", stop);
2417
+ iter.stop();
2418
+ }
2184
2419
  if (!complete)
2185
2420
  throw new Error(`history: read ${delivered} of ${pending} messages on ${subject} before the stream ended early - the window was cut short, not empty`);
2186
2421
  return out;
@@ -2191,7 +2426,13 @@ export class CotalEndpoint extends EventEmitter {
2191
2426
  // up to eight per call, the dashboard makes one call per channel, and a reload repeats it.
2192
2427
  // Left alone that accumulates consumers on the broker until the thresholds expire, and the
2193
2428
  // resulting resource exhaustion would land in streamHistory's catch and read as empty history.
2194
- await consumer.delete().catch(() => { });
2429
+ try {
2430
+ await consumer.delete();
2431
+ }
2432
+ catch (e) {
2433
+ if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
2434
+ throw e;
2435
+ }
2195
2436
  }
2196
2437
  }
2197
2438
  // ---- internals -----------------------------------------------------------
@@ -2203,10 +2444,27 @@ export class CotalEndpoint extends EventEmitter {
2203
2444
  * denial is never mistaken for absence (which already has a benign cause: MCP reconnect).
2204
2445
  */
2205
2446
  watchStatus() {
2206
- if (!this.nc)
2447
+ const nc = this.nc;
2448
+ if (!nc)
2207
2449
  return;
2208
2450
  void (async () => {
2209
- for await (const s of this.nc.status()) {
2451
+ for await (const s of nc.status()) {
2452
+ // A rebuild can replace `this.nc` before the old iterator finishes. Late disconnect/close
2453
+ // from that old epoch says nothing about the replacement and must not flip its liveness.
2454
+ if (this.nc !== nc)
2455
+ continue;
2456
+ if (s.type === "disconnect") {
2457
+ this.emit("transport", { connected: false, server: s.server });
2458
+ continue;
2459
+ }
2460
+ if (s.type === "reconnect") {
2461
+ this.emit("transport", { connected: true, server: s.server });
2462
+ continue;
2463
+ }
2464
+ if (s.type === "close") {
2465
+ this.emit("transport", { connected: false });
2466
+ continue;
2467
+ }
2210
2468
  if (s.type !== "error")
2211
2469
  continue;
2212
2470
  // Suppress the EXPECTED permission violation from a manager-free join we're confirming: an
@@ -2217,9 +2475,24 @@ export class CotalEndpoint extends EventEmitter {
2217
2475
  this.emit("error", describeStatusError(s.error));
2218
2476
  }
2219
2477
  })().catch((e) => {
2220
- if (!this.stopped)
2478
+ // Defensive symmetry with the reachable in-loop epoch guard above. Measured against five real
2479
+ // broker loss/reconnect/terminal-close cycles on pinned nats.js 3.4.0: status iterators ended
2480
+ // normally and this catch never fired. Keep an old epoch from surfacing an error if a runtime or
2481
+ // future client version can reject here, but do not treat this as a currently reachable edge.
2482
+ if (!this.stopped && this.nc === nc)
2221
2483
  this.emit("error", e);
2222
2484
  });
2485
+ // The transport is already live when connect() returns, while the Cotal bind below is still in
2486
+ // progress. Seed this contract explicitly rather than requiring consumers to combine it with the
2487
+ // later, differently-scoped `connection:true` event.
2488
+ //
2489
+ // The same stopped race as the readiness emit at the end of connectAndBind, and it reaches here
2490
+ // FIRST: connectAndBind calls watchStatus right after the dial, so a stop() landing while the
2491
+ // dial is still pending has this seed fire on an endpoint that is already stopped. Measured
2492
+ // through a real pending dial, a stopped endpoint announced a live transport it never had.
2493
+ if (this.stopped)
2494
+ return;
2495
+ this.emit("transport", { connected: true, server: nc.getServer() });
2223
2496
  }
2224
2497
  /** The error message for a guard that finds the endpoint unbound: "reconnecting" during a
2225
2498
  * rebuild's null window OR an inter-retry backoff (so a concurrent op reports the real
@@ -2280,7 +2553,11 @@ export class CotalEndpoint extends EventEmitter {
2280
2553
  await jsm.consumers.info(stream, name);
2281
2554
  return; // this lifecycle's durable exists — keep its original frontier
2282
2555
  }
2283
- catch { /* absent; create below */ }
2556
+ catch (e) {
2557
+ if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
2558
+ throw e;
2559
+ // Genuinely absent consumer: create it below. A denial is not absence.
2560
+ }
2284
2561
  const frontier = (await jsm.streams.info(stream)).state.last_seq;
2285
2562
  try {
2286
2563
  await jsm.consumers.add(stream, dmDurableConfig(this.space, owner, actor, lifecycleUid, { ...opts, activationFrontier: frontier }));
@@ -2292,7 +2569,13 @@ export class CotalEndpoint extends EventEmitter {
2292
2569
  await jsm.consumers.info(stream, name);
2293
2570
  return;
2294
2571
  }
2295
- catch { /* not a lost race */ }
2572
+ catch (probeError) {
2573
+ // The probe never creates a successful result: it either proves the concurrent winner above,
2574
+ // or the original create failure stays authoritative. Preserve a denial from the probe because
2575
+ // it names the missing capability more accurately than an unrelated create conflict.
2576
+ if (isPermissionDenied(probeError))
2577
+ throw probeError;
2578
+ }
2296
2579
  throw e;
2297
2580
  }
2298
2581
  }
@@ -2414,7 +2697,10 @@ export class CotalEndpoint extends EventEmitter {
2414
2697
  try {
2415
2698
  await (await this.deliveryRegistry()).delete(leaseKey(shardIndex));
2416
2699
  }
2417
- catch { /* already gone */ }
2700
+ catch {
2701
+ // Intentionally best-effort for EVERY failure: the lease TTL is the crash-safe release authority,
2702
+ // and clean shutdown must continue even when the broker is already gone or draining.
2703
+ }
2418
2704
  }
2419
2705
  /** Read a shard's delivery lease (the daemon-availability signal), or `undefined` if none is live.
2420
2706
  * READ-ONLY surface — drives Component 6's `cotal_channels` delivery-health field (an agent reads it
@@ -2457,6 +2743,9 @@ export class CotalEndpoint extends EventEmitter {
2457
2743
  this.managerLeaseKv = await kvm.create(managerBucket(this.space), { ttl: MANAGER_LEASE_TTL_MS });
2458
2744
  }
2459
2745
  catch {
2746
+ // OPEN mode has no broker ACLs, so a permission denial is impossible here. `create` is only
2747
+ // the ensure-exists attempt; `open` below is the authority after either a pre-existing bucket
2748
+ // or a create race, and it still throws if the bucket cannot actually be used.
2460
2749
  this.managerLeaseKv = await kvm.open(managerBucket(this.space));
2461
2750
  }
2462
2751
  }
@@ -2502,7 +2791,10 @@ export class CotalEndpoint extends EventEmitter {
2502
2791
  else
2503
2792
  await kv.delete(managerLeaseKey(instanceId), { previousSeq: revision });
2504
2793
  }
2505
- catch { /* not ours / already gone */ }
2794
+ catch {
2795
+ // Intentionally best-effort for EVERY failure: a revision mismatch means the lease is no longer
2796
+ // ours, while a broker failure is recovered by the bucket TTL. Shutdown must not claim deletion.
2797
+ }
2506
2798
  }
2507
2799
  /** Read a live manager liveness lease, or undefined if NONE (no manager instance holds the space). A
2508
2800
  * presence/existence check for the CLI's `spawn -f` reuse and `waitLeaseGone`, which only need "is any
@@ -2538,9 +2830,19 @@ export class CotalEndpoint extends EventEmitter {
2538
2830
  // it returns that instance's latest state, so a DEL here retires only its own key. The
2539
2831
  // defect was asking one wildcard for the newest message in the whole subtree, where a
2540
2832
  // stopping peer's tombstone outranks a live sibling's older PUT.
2541
- const m = await jsm.streams.getMessage(stream, { last_by_subj: subject }).catch(() => null);
2833
+ let m;
2834
+ try {
2835
+ m = await jsm.streams.getMessage(stream, { last_by_subj: subject });
2836
+ }
2837
+ catch (e) {
2838
+ if (e.code === JetStreamApiCodes.NoMessageFound)
2839
+ continue;
2840
+ throw e;
2841
+ }
2542
2842
  if (m === null)
2543
2843
  continue; // key vanished between INFO and GET: it is not a live holder
2844
+ // A 10037 above means the key vanished between INFO and GET: it is not a live holder. A
2845
+ // denial or failed read is not absence and is rethrown.
2544
2846
  const op = m.header?.get("KV-Operation");
2545
2847
  if (op === "DEL" || op === "PURGE" || m.data.length === 0)
2546
2848
  continue;
@@ -2723,10 +3025,7 @@ export class CotalEndpoint extends EventEmitter {
2723
3025
  // delete/recreate a predecessor's in-flight catch-up consumer — the uid disambiguates.
2724
3026
  const cuP = parsePrincipalKey(owner);
2725
3027
  const name = `cu_${cuP ? lifecycleNameKey(cuP.owner, cuP.actor, lifecycleUid) : `${token(owner)}-${lifecycleUid}`}_${generation}`;
2726
- try {
2727
- await this.jsm.consumers.delete(chatStream(this.space), name);
2728
- }
2729
- catch { /* none */ }
3028
+ await this.deleteConsumerIfPresent(chatStream(this.space), name);
2730
3029
  await this.jsm.consumers.add(chatStream(this.space), {
2731
3030
  name, filter_subject: subject, ack_policy: AckPolicy.None, mem_storage: true,
2732
3031
  inactive_threshold: nanos(30_000), deliver_policy: DeliverPolicy.StartSequence, opt_start_seq: fromSeqExcl + 1,
@@ -2762,10 +3061,7 @@ export class CotalEndpoint extends EventEmitter {
2762
3061
  }
2763
3062
  }
2764
3063
  finally {
2765
- try {
2766
- await this.jsm.consumers.delete(chatStream(this.space), name);
2767
- }
2768
- catch { /* gone */ }
3064
+ await this.deleteConsumerIfPresent(chatStream(this.space), name);
2769
3065
  }
2770
3066
  return { copied, evicted };
2771
3067
  }
@@ -3126,10 +3422,9 @@ export class CotalEndpoint extends EventEmitter {
3126
3422
  async runFanout() {
3127
3423
  if (!this.js || !this.jsm)
3128
3424
  return;
3129
- try {
3130
- await this.jsm.consumers.add(chatStream(this.space), fanoutDurableConfig(this.space, { ackWaitMs: this.ackWaitMs }));
3131
- }
3132
- catch { /* exists */ }
3425
+ // Named consumer creation is idempotent for the same config. A denial must not be mistaken for
3426
+ // "already exists"; let the broker response decide and propagate every failure.
3427
+ await this.jsm.consumers.add(chatStream(this.space), fanoutDurableConfig(this.space, { ackWaitMs: this.ackWaitMs }));
3133
3428
  const consumer = await this.js.consumers.get(chatStream(this.space), FANOUT_DURABLE);
3134
3429
  const msgs = await consumer.consume();
3135
3430
  this.streamMsgs.push(msgs);
@@ -3215,10 +3510,8 @@ export class CotalEndpoint extends EventEmitter {
3215
3510
  async runReader() {
3216
3511
  if (!this.js || !this.jsm)
3217
3512
  return;
3218
- try {
3219
- await this.jsm.consumers.add(inboxStream(this.space), inboxReaderConfig(this.space, { ackWaitMs: this.ackWaitMs }));
3220
- }
3221
- catch { /* exists */ }
3513
+ // Same fail-loud rule as fan-out: idempotent success is success, denial is not existence.
3514
+ await this.jsm.consumers.add(inboxStream(this.space), inboxReaderConfig(this.space, { ackWaitMs: this.ackWaitMs }));
3222
3515
  const consumer = await this.js.consumers.get(inboxStream(this.space), INBOX_READER_DURABLE);
3223
3516
  const msgs = await consumer.consume();
3224
3517
  this.streamMsgs.push(msgs);
@@ -3311,7 +3604,8 @@ export class CotalEndpoint extends EventEmitter {
3311
3604
  });
3312
3605
  }
3313
3606
  catch {
3314
- // Transfer failed keep the entry pending (redeliver), bounded by the same ceiling so a poison
3607
+ // EVERY transfer failure is deliberately a retry, never a successful result. Keep the entry
3608
+ // pending (redeliver), bounded by the same ceiling so a poison
3315
3609
  // entry can't head-of-line the shared reader forever.
3316
3610
  if (redeliveries >= READER_MAX_REDELIVERIES) {
3317
3611
  m.term();
@@ -3337,9 +3631,11 @@ export class CotalEndpoint extends EventEmitter {
3337
3631
  try {
3338
3632
  consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.owner, this.actor, this.ownLifecycleUid));
3339
3633
  }
3340
- catch {
3341
- return;
3342
- } // no DLV durable — Plane-3 not active for us
3634
+ catch (e) {
3635
+ if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
3636
+ return;
3637
+ throw e; // a denied bind is not proof Plane-3 is absent
3638
+ }
3343
3639
  const msgs = await consumer.consume();
3344
3640
  this.streamMsgs.push(msgs);
3345
3641
  void (async () => {
@@ -3471,13 +3767,9 @@ export class CotalEndpoint extends EventEmitter {
3471
3767
  for (const channel of this.channels) {
3472
3768
  if (!isConcreteChannel(channel) || this.plane3Channels.has(channel))
3473
3769
  continue;
3474
- let cls;
3475
- try {
3476
- cls = await this.deliveryClassFresh(channel);
3477
- }
3478
- catch {
3479
- continue;
3480
- }
3770
+ // A missing channel row legitimately resolves through the defaults. A failed/denied registry
3771
+ // read does not prove the channel is live-only, so let it fail startup loudly.
3772
+ const cls = await this.deliveryClassFresh(channel);
3481
3773
  if (cls !== "durable")
3482
3774
  continue;
3483
3775
  try {
@@ -3489,7 +3781,7 @@ export class CotalEndpoint extends EventEmitter {
3489
3781
  }
3490
3782
  catch (e) {
3491
3783
  if (!this.isNoResponders(e))
3492
- this.emit("error", e); // no daemon ⇒ retry until it recovers
3784
+ this.emitRecoverable(e); // no daemon ⇒ retry until it recovers
3493
3785
  void this.reconcileBootJoin(channel);
3494
3786
  }
3495
3787
  }
@@ -3522,7 +3814,7 @@ export class CotalEndpoint extends EventEmitter {
3522
3814
  }
3523
3815
  catch (e) {
3524
3816
  if (attempt === 0 && !this.isNoResponders(e))
3525
- this.emit("error", new Error(`channel "${channel}": boot durable self-join not yet established - retrying until the delivery daemon is reachable (${e.message})`));
3817
+ this.emitRecoverable(new Error(`channel "${channel}": boot durable self-join not yet established - retrying until the delivery daemon is reachable (${e.message})`));
3526
3818
  }
3527
3819
  }
3528
3820
  }
@@ -3778,8 +4070,22 @@ export class CotalEndpoint extends EventEmitter {
3778
4070
  try {
3779
4071
  return await this.jsm.consumers.info(stream, durable);
3780
4072
  }
3781
- catch {
3782
- return null; // 404 — fresh durable
4073
+ catch (e) {
4074
+ if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
4075
+ return null; // structured absence — fresh durable
4076
+ throw e;
4077
+ }
4078
+ }
4079
+ /** Delete one named consumer, swallowing ONLY structured consumer/stream absence. */
4080
+ async deleteConsumerIfPresent(stream, durable) {
4081
+ if (!this.jsm)
4082
+ throw new Error("endpoint not started");
4083
+ try {
4084
+ await this.jsm.consumers.delete(stream, durable);
4085
+ }
4086
+ catch (e) {
4087
+ if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound, JetStreamApiCodes.StreamNotFound))
4088
+ throw e;
3783
4089
  }
3784
4090
  }
3785
4091
  /** Current frontier (last sequence) of the chat stream — a channel's join watermark, and the
@@ -3855,10 +4161,7 @@ export class CotalEndpoint extends EventEmitter {
3855
4161
  const out = [];
3856
4162
  // Clear any consumer leaked by a crashed prior read before re-creating it with THIS read's
3857
4163
  // single filter (the read ACL is enforced at create — see the doc above).
3858
- try {
3859
- await this.jsm.consumers.delete(stream, name);
3860
- }
3861
- catch { /* none; fine */ }
4164
+ await this.deleteConsumerIfPresent(stream, name);
3862
4165
  await this.jsm.consumers.add(stream, {
3863
4166
  name,
3864
4167
  filter_subject: subject,
@@ -3894,10 +4197,7 @@ export class CotalEndpoint extends EventEmitter {
3894
4197
  }
3895
4198
  }
3896
4199
  finally {
3897
- try {
3898
- await this.jsm.consumers.delete(stream, name);
3899
- }
3900
- catch { /* already gone */ }
4200
+ await this.deleteConsumerIfPresent(stream, name);
3901
4201
  }
3902
4202
  return out;
3903
4203
  }
@@ -3914,6 +4214,8 @@ export class CotalEndpoint extends EventEmitter {
3914
4214
  }
3915
4215
  catch (e) {
3916
4216
  this.emit("error", e);
4217
+ if (isPermissionDenied(e))
4218
+ throw e;
3917
4219
  return 0;
3918
4220
  }
3919
4221
  const noop = { ack: () => { }, nak: () => { }, durable: false };
@@ -3941,14 +4243,18 @@ export class CotalEndpoint extends EventEmitter {
3941
4243
  /**
3942
4244
  * Replay-gated pull of a channel's retained ambient from `sinceSeq` (exclusive) forward — the
3943
4245
  * focus-recall read behind `cotal_inbox`. Returns the messages (NOT emitted — this is a pull,
3944
- * not a push into context) plus `dropped: true` when the channel's earliest *retained* message
3945
- * is already newer than the watermark, i.e. some ambient aged out of the per-subject window and
3946
- * the caller must say so rather than silently short the window.
4246
+ * not a push into context) plus `dropped: true` when the window is not complete: either the
4247
+ * channel's earliest *retained* message is already newer than the watermark (some ambient aged
4248
+ * out of the per-subject window), or replay is off for the channel below. Either way the caller
4249
+ * must say so rather than silently reporting an empty, complete window.
3947
4250
  *
3948
4251
  * Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
3949
- * `replay=off` channel returns nothing, so `focus` can't become a history bypass for a channel
3950
- * that denies replay to everyone else (the read ACL bounds *which* channels recall can touch; this
3951
- * app gate bounds *whether* a permitted channel replays).
4252
+ * `replay=off` channel returns no messages, so `focus` can't become a history bypass for a
4253
+ * channel that denies replay to everyone else (the read ACL bounds *which* channels recall can
4254
+ * touch; this app gate bounds *whether* a permitted channel replays). Ingest still ack-drops
4255
+ * focus-mode ambient/mentions on this channel on the promise that they stay recallable (#977) —
4256
+ * the gate means that promise cannot be kept, so it reports `dropped: true` rather than pretend
4257
+ * the window was empty and complete.
3952
4258
  */
3953
4259
  async recallChannel(channel, sinceSeq) {
3954
4260
  if (!this.jsm)
@@ -3957,7 +4263,7 @@ export class CotalEndpoint extends EventEmitter {
3957
4263
  return { messages: [], dropped: false };
3958
4264
  const policy = await this.joinPolicyFresh(channel);
3959
4265
  if (!policy.replay)
3960
- return { messages: [], dropped: false };
4266
+ return { messages: [], dropped: true };
3961
4267
  const subject = chatSubject(this.space, "*", "*", channel);
3962
4268
  let raw;
3963
4269
  try {
@@ -3965,6 +4271,8 @@ export class CotalEndpoint extends EventEmitter {
3965
4271
  }
3966
4272
  catch (e) {
3967
4273
  this.emit("error", e);
4274
+ if (isPermissionDenied(e))
4275
+ throw e;
3968
4276
  raw = [];
3969
4277
  }
3970
4278
  const collected = [];
@@ -4054,6 +4362,7 @@ export class CotalEndpoint extends EventEmitter {
4054
4362
  let hydrated;
4055
4363
  this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
4056
4364
  const iter = await this.kv.watch();
4365
+ this.presenceWatchIter = iter;
4057
4366
  void (async () => {
4058
4367
  let ready = false;
4059
4368
  for await (const e of iter) {
@@ -4074,6 +4383,7 @@ export class CotalEndpoint extends EventEmitter {
4074
4383
  if (!this.channelKv)
4075
4384
  return;
4076
4385
  const iter = await this.channelKv.watch();
4386
+ this.channelWatchIter = iter;
4077
4387
  void (async () => {
4078
4388
  for await (const e of iter)
4079
4389
  this.handleChannelEntry(e);
@@ -4105,6 +4415,7 @@ export class CotalEndpoint extends EventEmitter {
4105
4415
  }
4106
4416
  }
4107
4417
  handleKvEntry(e) {
4418
+ this.lastPresenceWatchAt = Date.now();
4108
4419
  if (e.operation === "DEL" || e.operation === "PURGE") {
4109
4420
  this.markOffline(e.key);
4110
4421
  return;
@@ -4128,6 +4439,19 @@ export class CotalEndpoint extends EventEmitter {
4128
4439
  return;
4129
4440
  const prev = this.roster.get(id);
4130
4441
  const stale = Date.now() - raw.ts > this.ttlMs;
4442
+ // A watch recovering from a stall replays the bucket. Those PUTs still carry the publisher's
4443
+ // pre-stall timestamps, so they look TTL-expired even though the peers kept heartbeating on
4444
+ // the broker. Materializing them as offline is the empty-to-full flicker #1045 named: the
4445
+ // observer's catch-up, not N deaths. Keep last-known rows, stay view-stale, and wait for a
4446
+ // live timestamp (or an explicit offline/delete) before changing a known peer.
4447
+ if (stale && raw.status !== "offline") {
4448
+ if (prev)
4449
+ return;
4450
+ this.roster.set(id, this.toOffline(raw));
4451
+ this.emit("roster", this.getRoster());
4452
+ return;
4453
+ }
4454
+ this.setPresenceViewFresh(true);
4131
4455
  // Any offline materialization (a stale snapshot OR a graceful-leave record) drops the advisory
4132
4456
  // attention fields — an offline peer must not carry a stale `[focus]`/`locally muted` hint.
4133
4457
  const p = stale || raw.status === "offline" ? this.toOffline(raw) : raw;
@@ -4181,8 +4505,22 @@ export class CotalEndpoint extends EventEmitter {
4181
4505
  this.emit("presence", { type: "offline", presence: offline });
4182
4506
  this.emit("roster", this.getRoster());
4183
4507
  }
4508
+ setPresenceViewFresh(fresh) {
4509
+ if (fresh === this.presenceViewFresh)
4510
+ return;
4511
+ this.presenceViewFresh = fresh;
4512
+ this.emit("presence-view", this.presenceView());
4513
+ }
4184
4514
  sweep() {
4185
4515
  const now = Date.now();
4516
+ // Whole-bucket silence past TTL is THIS observer going deaf. Real rosters do not lose every
4517
+ // peer in one window, and treating that silence as N offline verdicts empties an online-only
4518
+ // sidebar with nothing saying the window went blind (#1045). Gate the per-peer age-out on
4519
+ // watch freshness; surface the view as stale instead.
4520
+ if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
4521
+ this.setPresenceViewFresh(false);
4522
+ return;
4523
+ }
4186
4524
  let changed = false;
4187
4525
  for (const [id, p] of this.roster) {
4188
4526
  if (p.status !== "offline" && now - p.ts > this.ttlMs) {
@@ -4381,6 +4719,18 @@ export function isPermissionDenied(e) {
4381
4719
  return true;
4382
4720
  return /permissions?\s+violation/i.test(String(e?.message ?? ""));
4383
4721
  }
4722
+ /** True only for the structured JetStream API absence codes named by the caller. A status 404 or
4723
+ * message regex is too broad here: the catch sites use absence to produce a successful empty/fresh
4724
+ * result, so a permission denial, timeout, or protocol failure must never pass as "not found". */
4725
+ function isJetStreamMissing(e, ...codes) {
4726
+ return e instanceof JetStreamApiError && codes.includes(e.code);
4727
+ }
4728
+ /** The signal's exact abort reason, or the platform-standard AbortError when none was supplied. */
4729
+ function abortReason(signal) {
4730
+ return signal?.reason instanceof Error
4731
+ ? signal.reason
4732
+ : new DOMException("This operation was aborted", "AbortError");
4733
+ }
4384
4734
  /** True ONLY for a denial on a **publish** — the single case that proves the message was never
4385
4735
  * ACCEPTED or stored. (Not "never reached the server": the server necessarily received enough of
4386
4736
  * it to reject it. The distinction matters precisely here, because this helper exists to separate