@securecoms/agent-bridge 0.30.0 → 0.30.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.
@@ -353,6 +353,9 @@ export interface BridgeAgentDeps {
353
353
  transport?: any;
354
354
  backend?: any;
355
355
  fetchImpl?: typeof fetch;
356
+ /** TEST SEAM: WebSocket constructor for the real RestTransport's delivery-ping
357
+ * socket. Production leaves it undefined (globalThis.WebSocket). */
358
+ wsImpl?: typeof WebSocket;
356
359
  /**
357
360
  * Round 5 (runtime config delivery): the home directory under which the
358
361
  * managed runtime-config directory lives. Injected only by tests, so they can
@@ -714,6 +717,15 @@ export declare class RoomClassChangedSendError extends SendForbiddenError {
714
717
  * `activeGroupId` is the group the backend named, or null when the channel has
715
718
  * no active group (fail closed: nothing to converge to).
716
719
  */
720
+ /**
721
+ * C10 (spec P-15, §4.1.6): start() was superseded (4409) before it finished.
722
+ * start() has already run the memoized close() quiesce when this is thrown, and
723
+ * armed none of its timers or the receive pump. runMode catches it: the process
724
+ * stays alive holding the lock and the port, and serves no route.
725
+ */
726
+ export declare class SessionSupersededError extends Error {
727
+ constructor();
728
+ }
717
729
  export declare class GroupSupersededSendError extends Error {
718
730
  readonly channelId: string;
719
731
  readonly activeGroupId: string | null;
@@ -780,6 +792,13 @@ export declare class BridgeAgent {
780
792
  private readonly backend;
781
793
  private readonly custody;
782
794
  private readonly fetchImpl;
795
+ /** P-1: set once a 4409 close says another copy took this agent's session.
796
+ * The gated fetch built in the constructor refuses every request after it. */
797
+ private sessionSuperseded;
798
+ /** C9: who hears `session_superseded` (runMode's quiesce). */
799
+ private readonly sessionSupersededListeners;
800
+ /** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
801
+ private closing;
783
802
  /** Round 5: home for the managed runtime-config directory (test seam). */
784
803
  private readonly home;
785
804
  private readonly grantWriters;
@@ -1243,6 +1262,12 @@ export declare class BridgeAgent {
1243
1262
  readonly action: AgentActionAPI;
1244
1263
  private readonly waiters;
1245
1264
  private pingHandle?;
1265
+ /** C10 (P-15): the delivery-ping subscription start() opens at its top,
1266
+ * held unconsumed until startReceivePump() takes it. */
1267
+ private earlyPings;
1268
+ /** C10 fix round 1: settles (never rejects) when start()'s body does; close()
1269
+ * awaits it, bounded, so the store never closes under a start() step. */
1270
+ private startInFlight;
1246
1271
  private safetyTimer?;
1247
1272
  /** The ingest cycle now running, if any (see ingestOnce: single-flight). */
1248
1273
  private ingestInFlight;
@@ -1490,7 +1515,24 @@ export declare class BridgeAgent {
1490
1515
  * shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
1491
1516
  */
1492
1517
  private capBuffer;
1518
+ /**
1519
+ * C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
1520
+ * delivery-ping socket opens here, before anything else, so this copy claims
1521
+ * the device's session (and another copy gets its 4409) BEFORE this copy's
1522
+ * boot drain runs. The pings it carries are NOT consumed until
1523
+ * startReceivePump(), the same place as before: starting the pump first is
1524
+ * still forbidden (DRAIN BEFORE ROTATE, startBody).
1525
+ *
1526
+ * A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
1527
+ * 0). start() then arms nothing more (supersededDuringStart before each
1528
+ * timer and before the pump), runs the memoized close() quiesce, and rejects
1529
+ * with SessionSupersededError. A step that FAILED because of the supersede
1530
+ * (a refused fetch, a store the quiesce closed) is the same outcome.
1531
+ */
1493
1532
  start(): Promise<void>;
1533
+ /** C10: true once a 4409 landed; checked before each arming step of start(). */
1534
+ private supersededDuringStart;
1535
+ private startBody;
1494
1536
  /**
1495
1537
  * #811 L-1: send the re-Welcome signal ONCE for each channel whose onWedged
1496
1538
  * heal the boot drain refused. Runs after the drain settled and after the
@@ -3485,5 +3527,23 @@ export declare class BridgeAgent {
3485
3527
  * and zero on restart (spec §5.5). */
3486
3528
  build: string;
3487
3529
  };
3530
+ /** C9: `session_superseded` once a 4409 said another copy took this
3531
+ * agent's session (the gated fetch already refuses every request). The
3532
+ * local HTTP routes read it: every route but `/healthz` answers 503. */
3533
+ get sessionState(): "live" | "session_superseded";
3534
+ /** C9: subscribe to `session_superseded`. runMode subscribes right after
3535
+ * construction, before start(), so a 4409 during start() is heard too. */
3536
+ on(event: "session_superseded", listener: () => void): () => void;
3537
+ /** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
3538
+ * the session (4409). The gated-fetch switch is set FIRST, so no HTTP
3539
+ * request leaves the process after this, whatever a listener does. */
3540
+ private onSessionSuperseded;
3541
+ /**
3542
+ * Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
3543
+ * promise, so a SIGTERM during or after the supersede quiesce never runs
3544
+ * store.close() twice. A rejected close stays rejected: a retry would close
3545
+ * the store a second time.
3546
+ */
3488
3547
  close(): Promise<void>;
3548
+ private closeOnce;
3489
3549
  }
package/dist/errors.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * WebSocket returns `{type:"error", error, message, detail?}`. A client gets the
7
7
  * same `error` code regardless of which transport it used. (A2A §9.5/§10.6 pattern.)
8
8
  */
9
- export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "vault_ref_changed" | "internal";
9
+ export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "vault_ref_changed" | "session_superseded" | "internal";
10
10
  export interface BridgeErrorSpec {
11
11
  /** HTTP status this code maps to (the WS binding ignores it). */
12
12
  http: number;
package/dist/index.js CHANGED
@@ -24091,6 +24091,7 @@ function base64UrlNoPad(s) {
24091
24091
  }
24092
24092
  var BEARER_SUBPROTOCOL = "securecoms.bearer.v1";
24093
24093
  var WS_CLOSE_IDENTITY_REVOKED = 4006;
24094
+ var WS_CLOSE_SESSION_SUPERSEDED = 4409;
24094
24095
  function isIdentityRevokedClose(code, reason) {
24095
24096
  return code === WS_CLOSE_IDENTITY_REVOKED || reason === "identity_revoked";
24096
24097
  }
@@ -24306,7 +24307,7 @@ function openDeliveryPings(o) {
24306
24307
  end();
24307
24308
  };
24308
24309
  if (!hardened) {
24309
- if (code === 4001 || isIdentityRevokedClose(code, reason)) {
24310
+ if (code === 4001 || code === WS_CLOSE_SESSION_SUPERSEDED || isIdentityRevokedClose(code, reason)) {
24310
24311
  terminalClose();
24311
24312
  return;
24312
24313
  }
@@ -24315,7 +24316,7 @@ function openDeliveryPings(o) {
24315
24316
  }
24316
24317
  const tr = terminalReason;
24317
24318
  terminalReason = void 0;
24318
- if (code === 4006 || tr && code === 4001) {
24319
+ if (code === 4006 || code === WS_CLOSE_SESSION_SUPERSEDED || tr && code === 4001) {
24319
24320
  lifecycle("terminal", tr ?? `code ${code}`);
24320
24321
  terminalClose();
24321
24322
  return;
@@ -24472,10 +24473,14 @@ var RestTransport = class {
24472
24473
  f;
24473
24474
  ws;
24474
24475
  tokenProvider;
24475
- /** The most recent device-wide WS send handle, captured on subscribe and
24476
- * cleared on its close. Control frames (status keepalive) route through it;
24477
- * a no-op when no subscription is active. */
24476
+ /** The shared delivery-ping socket's send, set while that socket is open and
24477
+ * cleared when it closes (last subscriber gone, or terminal). Control frames
24478
+ * (status keepalive) route through it; a no-op when no subscription is active. */
24478
24479
  _controlSend = null;
24480
+ /** P-5: the one delivery-ping stream this transport holds, shared by every subscriber. */
24481
+ pingHub = null;
24482
+ /** Set once the shared stream ends on a terminal close; no socket is ever reopened. */
24483
+ pingsTerminal = false;
24479
24484
  constructor(opts) {
24480
24485
  this.opts = opts;
24481
24486
  this.f = opts.fetchImpl ?? fetch;
@@ -24577,25 +24582,52 @@ var RestTransport = class {
24577
24582
  idempotentReplay: j.idempotent_replay === true
24578
24583
  };
24579
24584
  }
24585
+ /** One device-wide delivery-ping socket per transport (P-5): the backend
24586
+ * supersedes a second socket for the same device, so every subscriber shares
24587
+ * one `openDeliveryPings` handle. Each gets its own bounded queue; its close()
24588
+ * detaches only itself; the socket closes with the last subscriber and a later
24589
+ * subscribe opens a fresh one. After a terminal close the transport is
24590
+ * terminal: later calls open no socket and get an already-ended iterator. */
24580
24591
  subscribeDeliveryPings(_channelId) {
24592
+ if (this.pingsTerminal) {
24593
+ const ended = new PingSubscriber();
24594
+ ended.end();
24595
+ return { pings: ended.pings, close: () => {
24596
+ }, send: () => {
24597
+ } };
24598
+ }
24599
+ const hub = this.pingHub ??= this.openPingHub();
24600
+ const sub = hub.attach();
24601
+ return { pings: sub.pings, close: () => hub.detach(sub), send: hub.send };
24602
+ }
24603
+ openPingHub() {
24581
24604
  let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
24582
24605
  if (this.opts.connectIdentityId) {
24583
24606
  wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
24584
24607
  }
24585
- const handle = openDeliveryPings({
24608
+ const hub = new PingHub(openDeliveryPings({
24586
24609
  url: wsUrl,
24587
24610
  tokenProvider: this.tokenProvider,
24588
24611
  wsImpl: this.ws,
24589
24612
  hardened: this.opts.hardenedReconnect ?? false,
24590
- onLifecycle: this.opts.onWsLifecycle
24591
- });
24592
- this._controlSend = handle.send;
24593
- const close = () => {
24594
- if (this._controlSend === handle.send)
24595
- this._controlSend = null;
24596
- handle.close();
24597
- };
24598
- return { pings: handle.pings, close, send: handle.send };
24613
+ onLifecycle: this.opts.onWsLifecycle,
24614
+ // Any terminal close (4001, 4006, 4409, hardened war-cap) of the shared
24615
+ // socket makes this transport terminal. The shared iterator ends right
24616
+ // after this call, so the pump exit tears down every subscriber.
24617
+ onTerminalClose: (close) => {
24618
+ this.pingsTerminal = true;
24619
+ this.opts.onTerminalClose?.(close);
24620
+ }
24621
+ }), () => this.dropPingHub(hub));
24622
+ this._controlSend = hub.send;
24623
+ return hub;
24624
+ }
24625
+ dropPingHub(hub) {
24626
+ if (this.pingHub === hub) {
24627
+ this.pingHub = null;
24628
+ this._controlSend = null;
24629
+ }
24630
+ hub.shutdown();
24599
24631
  }
24600
24632
  sendControlFrame(_channelId, frame) {
24601
24633
  try {
@@ -24623,6 +24655,90 @@ var RestTransport = class {
24623
24655
  }));
24624
24656
  }
24625
24657
  };
24658
+ var SUBSCRIBER_QUEUE_MAX = 8;
24659
+ var PingSubscriber = class {
24660
+ queue = [];
24661
+ waiter;
24662
+ ended = false;
24663
+ started = false;
24664
+ pings = {
24665
+ [Symbol.asyncIterator]: () => {
24666
+ if (this.started)
24667
+ throw new Error("subscribeDeliveryPings: single-use async iterable");
24668
+ this.started = true;
24669
+ return {
24670
+ next: () => {
24671
+ if (this.ended)
24672
+ return Promise.resolve({ value: void 0, done: true });
24673
+ if (this.queue.length)
24674
+ return Promise.resolve({ value: this.queue.shift(), done: false });
24675
+ return new Promise((resolve) => {
24676
+ this.waiter = resolve;
24677
+ });
24678
+ }
24679
+ };
24680
+ }
24681
+ };
24682
+ push(p) {
24683
+ if (this.ended)
24684
+ return;
24685
+ if (this.waiter) {
24686
+ const w = this.waiter;
24687
+ this.waiter = void 0;
24688
+ w({ value: p, done: false });
24689
+ } else if (this.queue.length < SUBSCRIBER_QUEUE_MAX) {
24690
+ this.queue.push(p);
24691
+ }
24692
+ }
24693
+ end() {
24694
+ this.ended = true;
24695
+ this.queue.length = 0;
24696
+ if (this.waiter) {
24697
+ const w = this.waiter;
24698
+ this.waiter = void 0;
24699
+ w({ value: void 0, done: true });
24700
+ }
24701
+ }
24702
+ };
24703
+ var PingHub = class {
24704
+ handle;
24705
+ onGone;
24706
+ subs = /* @__PURE__ */ new Set();
24707
+ send;
24708
+ constructor(handle, onGone) {
24709
+ this.handle = handle;
24710
+ this.onGone = onGone;
24711
+ this.send = handle.send;
24712
+ void this.pump();
24713
+ }
24714
+ /** `openDeliveryPings` iterables never reject and push() never throws, so
24715
+ * this loop only ends when the shared stream ends (our close, or terminal). */
24716
+ async pump() {
24717
+ for await (const p of this.handle.pings)
24718
+ for (const s of this.subs)
24719
+ s.push(p);
24720
+ this.onGone();
24721
+ }
24722
+ attach() {
24723
+ const s = new PingSubscriber();
24724
+ this.subs.add(s);
24725
+ return s;
24726
+ }
24727
+ /** Idempotent: detaches and ends only `s`; the last one out closes the socket. */
24728
+ detach(s) {
24729
+ this.subs.delete(s);
24730
+ s.end();
24731
+ if (this.subs.size === 0)
24732
+ this.onGone();
24733
+ }
24734
+ /** Idempotent: `openDeliveryPings().close()` is, and the set is empty after. */
24735
+ shutdown() {
24736
+ for (const s of this.subs)
24737
+ s.end();
24738
+ this.subs.clear();
24739
+ this.handle.close();
24740
+ }
24741
+ };
24626
24742
 
24627
24743
  // ../connect-sdk-ts/dist/mls/rest-backend.js
24628
24744
  function describeType(v) {
@@ -34952,6 +35068,7 @@ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
34952
35068
  Object.freeze({ tool: "current_time", mode: "read" })
34953
35069
  ]);
34954
35070
  var NO_TOOL_GRANTS = Object.freeze([]);
35071
+ var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
34955
35072
  var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
34956
35073
  var OWNER_TURN_UNCHECKED = Object.freeze({ kind: "unchecked" });
34957
35074
  function ownerDmDefaultGrantsEnabled(env = process.env) {
@@ -35226,6 +35343,12 @@ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
35226
35343
  var RoomGrantEpochMovedError = class extends Error {
35227
35344
  };
35228
35345
  var ROOM_GRANT_TRACK_MAX = 4096;
35346
+ var SessionSupersededError = class extends Error {
35347
+ constructor() {
35348
+ super("session_superseded: another copy of this agent took its session during start()");
35349
+ this.name = "SessionSupersededError";
35350
+ }
35351
+ };
35229
35352
  var GroupSupersededSendError = class extends Error {
35230
35353
  constructor(channelId, activeGroupId) {
35231
35354
  super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
@@ -35265,9 +35388,18 @@ var BridgeAgent = class _BridgeAgent {
35265
35388
  const seed = hexToBytes4(cfg.signingPrivateKey);
35266
35389
  this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
35267
35390
  this.replyIdKey = deriveReplyIdKey(seed);
35391
+ const base = deps.fetchImpl ?? fetch;
35392
+ this.fetchImpl = (input, init) => this.sessionSuperseded ? Promise.reject(new Error("session_superseded")) : base(input, init);
35268
35393
  this.transport = deps.transport ?? new RestTransport({
35269
35394
  baseUrl: cfg.apiBase,
35270
35395
  bearerToken: cfg.apiKey,
35396
+ fetchImpl: this.fetchImpl,
35397
+ wsImpl: deps.wsImpl,
35398
+ // A 4409 close means another copy of this agent took the session.
35399
+ // Every other terminal close keeps today's behaviour.
35400
+ onTerminalClose: (close) => {
35401
+ if (close.code === WS_CLOSE_SESSION_SUPERSEDED2) this.onSessionSuperseded();
35402
+ },
35271
35403
  connectIdentityId: cfg.identityId,
35272
35404
  // P1b: hardened delivery-ping reconnect (verify-before-terminal +
35273
35405
  // half-open + wake + auth war-cap). On by default for the daemon;
@@ -35281,7 +35413,7 @@ var BridgeAgent = class _BridgeAgent {
35281
35413
  else writeServiceLog2("warn", "transport.delivery_ping_lifecycle_unknown");
35282
35414
  }
35283
35415
  });
35284
- this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
35416
+ this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, fetchImpl: this.fetchImpl });
35285
35417
  this.store = new SqliteMlsStateStore({
35286
35418
  dbPath: cfg.storePath,
35287
35419
  identityId: cfg.identityId,
@@ -35352,7 +35484,6 @@ var BridgeAgent = class _BridgeAgent {
35352
35484
  // throws (mirrors the divergence-heal signal path).
35353
35485
  onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId, this.drainExemptionAtCallIn())
35354
35486
  });
35355
- this.fetchImpl = deps.fetchImpl ?? fetch;
35356
35487
  this.home = deps.home ?? homedir4();
35357
35488
  this.grantWriters = {
35358
35489
  writeManifestPolicy: deps.grantWriters?.writeManifestPolicy ?? writeManifestPolicy,
@@ -35484,6 +35615,13 @@ var BridgeAgent = class _BridgeAgent {
35484
35615
  // stored for peek-then-join in ingestOnce
35485
35616
  custody;
35486
35617
  fetchImpl;
35618
+ /** P-1: set once a 4409 close says another copy took this agent's session.
35619
+ * The gated fetch built in the constructor refuses every request after it. */
35620
+ sessionSuperseded = false;
35621
+ /** C9: who hears `session_superseded` (runMode's quiesce). */
35622
+ sessionSupersededListeners = /* @__PURE__ */ new Set();
35623
+ /** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
35624
+ closing = null;
35487
35625
  /** Round 5: home for the managed runtime-config directory (test seam). */
35488
35626
  home;
35489
35627
  grantWriters;
@@ -35959,6 +36097,12 @@ var BridgeAgent = class _BridgeAgent {
35959
36097
  action;
35960
36098
  waiters = [];
35961
36099
  pingHandle;
36100
+ /** C10 (P-15): the delivery-ping subscription start() opens at its top,
36101
+ * held unconsumed until startReceivePump() takes it. */
36102
+ earlyPings = null;
36103
+ /** C10 fix round 1: settles (never rejects) when start()'s body does; close()
36104
+ * awaits it, bounded, so the store never closes under a start() step. */
36105
+ startInFlight = null;
35962
36106
  safetyTimer;
35963
36107
  /** The ingest cycle now running, if any (see ingestOnce: single-flight). */
35964
36108
  ingestInFlight = null;
@@ -36536,7 +36680,50 @@ var BridgeAgent = class _BridgeAgent {
36536
36680
  this.droppedMessages += overflow;
36537
36681
  }
36538
36682
  }
36683
+ /**
36684
+ * C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
36685
+ * delivery-ping socket opens here, before anything else, so this copy claims
36686
+ * the device's session (and another copy gets its 4409) BEFORE this copy's
36687
+ * boot drain runs. The pings it carries are NOT consumed until
36688
+ * startReceivePump(), the same place as before: starting the pump first is
36689
+ * still forbidden (DRAIN BEFORE ROTATE, startBody).
36690
+ *
36691
+ * A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
36692
+ * 0). start() then arms nothing more (supersededDuringStart before each
36693
+ * timer and before the pump), runs the memoized close() quiesce, and rejects
36694
+ * with SessionSupersededError. A step that FAILED because of the supersede
36695
+ * (a refused fetch, a store the quiesce closed) is the same outcome.
36696
+ */
36539
36697
  async start() {
36698
+ const early = this.transport.subscribeDeliveryPings("");
36699
+ this.earlyPings = early;
36700
+ this.pingHandle = { close: early.close };
36701
+ const body = this.startBody();
36702
+ this.startInFlight = body.then(() => {
36703
+ }, () => {
36704
+ });
36705
+ try {
36706
+ await body;
36707
+ } catch (e) {
36708
+ if (!this.sessionSuperseded) {
36709
+ if (this.earlyPings) {
36710
+ this.earlyPings.close();
36711
+ this.earlyPings = null;
36712
+ this.pingHandle = void 0;
36713
+ }
36714
+ throw e;
36715
+ }
36716
+ if (!(e instanceof SessionSupersededError)) writeServiceLog2("warn", "startup.superseded_start_error", {}, e);
36717
+ await this.close().catch(() => {
36718
+ });
36719
+ throw new SessionSupersededError();
36720
+ }
36721
+ }
36722
+ /** C10: true once a 4409 landed; checked before each arming step of start(). */
36723
+ supersededDuringStart() {
36724
+ return this.sessionSuperseded;
36725
+ }
36726
+ async startBody() {
36540
36727
  this.startedAt = Date.now();
36541
36728
  await this.restoreRuntimeInbox();
36542
36729
  await this.loadToolGrantState();
@@ -36596,10 +36783,12 @@ var BridgeAgent = class _BridgeAgent {
36596
36783
  await this.warmRestoredNotes();
36597
36784
  await this.reindexRestoredTurns();
36598
36785
  if (this.unifiedOn) await this.reindexUnifiedRestored();
36786
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36599
36787
  this.runtimeConfigTimer = setInterval(() => {
36600
36788
  void this.refetchRuntimeConfig();
36601
36789
  }, 6 * 60 * 60 * 1e3);
36602
36790
  this.runtimeConfigTimer.unref?.();
36791
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36603
36792
  this.membershipSweepTimer = setInterval(() => {
36604
36793
  if (this.closingDown || this.membershipSweepWork) return;
36605
36794
  this.membershipSweepWork = this.runMembershipSweeps().finally(() => {
@@ -36607,6 +36796,7 @@ var BridgeAgent = class _BridgeAgent {
36607
36796
  });
36608
36797
  }, PURGE_SWEEP_INTERVAL_MS);
36609
36798
  this.membershipSweepTimer.unref?.();
36799
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36610
36800
  this.recallPruneTimer = setInterval(() => {
36611
36801
  if (this.closingDown || this.recallPruneWork) return;
36612
36802
  this.recallPruneWork = this.runRecallPruneTick().finally(() => {
@@ -36614,6 +36804,7 @@ var BridgeAgent = class _BridgeAgent {
36614
36804
  });
36615
36805
  }, 60 * 60 * 1e3);
36616
36806
  this.recallPruneTimer.unref?.();
36807
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36617
36808
  if (this.machinePaths) this.recallMaintenanceTimer = setInterval(() => {
36618
36809
  if (this.closingDown) return;
36619
36810
  const n = ++this.maintenanceTickN;
@@ -36624,6 +36815,7 @@ var BridgeAgent = class _BridgeAgent {
36624
36815
  });
36625
36816
  }, 1e3);
36626
36817
  this.recallMaintenanceTimer?.unref?.();
36818
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36627
36819
  this.startReceivePump();
36628
36820
  if (drainedInTime) {
36629
36821
  this.armSelfHealSweeps();
@@ -40887,7 +41079,8 @@ var BridgeAgent = class _BridgeAgent {
40887
41079
  }
40888
41080
  }
40889
41081
  startReceivePump() {
40890
- const sub = this.transport.subscribeDeliveryPings("");
41082
+ const sub = this.earlyPings ?? this.transport.subscribeDeliveryPings("");
41083
+ this.earlyPings = null;
40891
41084
  this.pingHandle = { close: sub.close };
40892
41085
  void (async () => {
40893
41086
  try {
@@ -42472,7 +42665,42 @@ ${channelId}`;
42472
42665
  build: BRIDGE_VERSION
42473
42666
  };
42474
42667
  }
42475
- async close() {
42668
+ /** C9: `session_superseded` once a 4409 said another copy took this
42669
+ * agent's session (the gated fetch already refuses every request). The
42670
+ * local HTTP routes read it: every route but `/healthz` answers 503. */
42671
+ get sessionState() {
42672
+ return this.sessionSuperseded ? "session_superseded" : "live";
42673
+ }
42674
+ /** C9: subscribe to `session_superseded`. runMode subscribes right after
42675
+ * construction, before start(), so a 4409 during start() is heard too. */
42676
+ on(event, listener) {
42677
+ this.sessionSupersededListeners.add(listener);
42678
+ return () => this.sessionSupersededListeners.delete(listener);
42679
+ }
42680
+ /** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
42681
+ * the session (4409). The gated-fetch switch is set FIRST, so no HTTP
42682
+ * request leaves the process after this, whatever a listener does. */
42683
+ onSessionSuperseded() {
42684
+ this.sessionSuperseded = true;
42685
+ for (const listener of [...this.sessionSupersededListeners]) {
42686
+ try {
42687
+ listener();
42688
+ } catch (e) {
42689
+ writeServiceLog2("error", "transport.session_superseded_listener_failed", {}, e);
42690
+ }
42691
+ }
42692
+ }
42693
+ /**
42694
+ * Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
42695
+ * promise, so a SIGTERM during or after the supersede quiesce never runs
42696
+ * store.close() twice. A rejected close stays rejected: a retry would close
42697
+ * the store a second time.
42698
+ */
42699
+ close() {
42700
+ this.closing ??= this.closeOnce();
42701
+ return this.closing;
42702
+ }
42703
+ async closeOnce() {
42476
42704
  this.closingDown = true;
42477
42705
  this.statusGate.close();
42478
42706
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
@@ -42485,6 +42713,28 @@ ${channelId}`;
42485
42713
  if (this.safetyTimer) clearInterval(this.safetyTimer);
42486
42714
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
42487
42715
  if (this.membershipSweepTimer) clearInterval(this.membershipSweepTimer);
42716
+ const ingest = this.ingestInFlight;
42717
+ if (ingest) {
42718
+ let timer;
42719
+ await Promise.race([
42720
+ ingest.released,
42721
+ new Promise((resolve) => {
42722
+ timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
42723
+ })
42724
+ ]);
42725
+ if (timer) clearTimeout(timer);
42726
+ }
42727
+ const starting = this.startInFlight;
42728
+ if (starting) {
42729
+ let timer;
42730
+ await Promise.race([
42731
+ starting,
42732
+ new Promise((resolve) => {
42733
+ timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
42734
+ })
42735
+ ]);
42736
+ if (timer) clearTimeout(timer);
42737
+ }
42488
42738
  if (this.membershipSweepWork) {
42489
42739
  let timer;
42490
42740
  await Promise.race([
@@ -42761,6 +43011,9 @@ var BRIDGE_ERRORS = {
42761
43011
  // longer holds the shared bytes or key. Terminal for that link: never fall
42762
43012
  // back to the current version. `detail.pin` names the refused pin.
42763
43013
  vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
43014
+ // C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
43015
+ // session (WS 4409), so this copy stopped. Every local route but /healthz.
43016
+ session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
42764
43017
  internal: { http: 500, message: "internal error" }
42765
43018
  };
42766
43019
  function errorBody(code, detail) {
@@ -42991,6 +43244,10 @@ function createHttpServer(agent, opts) {
42991
43244
  const url = new URL(req.url ?? "/", "http://localhost");
42992
43245
  const path = url.pathname;
42993
43246
  const method = req.method ?? "GET";
43247
+ if (agent.sessionState === "session_superseded") {
43248
+ if (method === "GET" && path === "/healthz") return json(res, 200, { state: "session_superseded" });
43249
+ return jsonError(res, "session_superseded");
43250
+ }
42994
43251
  if (method === "GET" && path === "/healthz") {
42995
43252
  return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
42996
43253
  }
@@ -43202,6 +43459,7 @@ var PING_INTERVAL_MS = 3e4;
43202
43459
  var INFLIGHT_STALE_SWEEP_MS = 6e4;
43203
43460
  var INFLIGHT_STALE_AFTER_MS = 15 * 6e4;
43204
43461
  var HELLO_WAIT_MS = 2e3;
43462
+ var WS_CLOSE_SESSION_SUPERSEDED3 = 4409;
43205
43463
  var MEMORY_NOTES_FEATURE = "memory_notes";
43206
43464
  var MEMORY_RECALL_FEATURE = "memory_recall";
43207
43465
  var MEMORY_UNIFIED_FEATURE = "memory_unified";
@@ -43222,7 +43480,12 @@ function authedUpgrade(req, opts) {
43222
43480
  return true;
43223
43481
  }
43224
43482
  function attachAgentWebSocket(server, agent, opts) {
43225
- const wss = new WebSocketServer({ server, path: "/v1/ws" });
43483
+ let sessionSuperseded = false;
43484
+ const wss = new WebSocketServer({
43485
+ server,
43486
+ path: "/v1/ws",
43487
+ verifyClient: (_info, done) => sessionSuperseded ? done(false, 503, "session_superseded") : done(true)
43488
+ });
43226
43489
  const keepalive = setInterval(() => {
43227
43490
  for (const client of wss.clients) {
43228
43491
  const sock = client;
@@ -43362,6 +43625,7 @@ function attachAgentWebSocket(server, agent, opts) {
43362
43625
  if (helloTimer) clearTimeout(helloTimer);
43363
43626
  agent.noteSessionClose();
43364
43627
  unsubscribe();
43628
+ if (sessionSuperseded) inflight.clear();
43365
43629
  if (inflight.size) {
43366
43630
  agent.requeueFailedRuntimeDeliveries(
43367
43631
  [...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
@@ -43383,7 +43647,10 @@ function attachAgentWebSocket(server, agent, opts) {
43383
43647
  sock.on("close", cleanup);
43384
43648
  sock.on("error", cleanup);
43385
43649
  sock.on("message", (data) => {
43386
- void handleInbound(sock, agent, data, inflight, flush, session);
43650
+ if (sessionSuperseded) return;
43651
+ handleInbound(sock, agent, data, inflight, flush, session).catch((e) => {
43652
+ writeServiceLog2("error", "ws.inbound_failed", {}, e);
43653
+ });
43387
43654
  });
43388
43655
  flush();
43389
43656
  });
@@ -43398,6 +43665,13 @@ function attachAgentWebSocket(server, agent, opts) {
43398
43665
  }
43399
43666
  }
43400
43667
  wss.close();
43668
+ },
43669
+ /** C9 (spec §4.1.6 step 1, OQ-9): close every open socket with 4409 /
43670
+ * `session_superseded`, and answer every later upgrade with 503. The
43671
+ * runtime's reconnect backoff does the rest; idempotent. */
43672
+ enterSessionSuperseded() {
43673
+ sessionSuperseded = true;
43674
+ for (const client of wss.clients) client.close(WS_CLOSE_SESSION_SUPERSEDED3, "session_superseded");
43401
43675
  }
43402
43676
  };
43403
43677
  }
@@ -1,5 +1,19 @@
1
1
  import { type ServiceInput } from "./config.js";
2
2
  import { type AutoWireDeps } from "./harness-wire.js";
3
+ /** The parsed `/healthz` body fields the install and status liveness checks read. */
4
+ export interface DaemonHealth {
5
+ agent_sessions?: number;
6
+ evictions?: number;
7
+ state?: string;
8
+ }
9
+ /**
10
+ * Liveness is a routed `/healthz` 200 whose state is not `session_superseded`
11
+ * (P-2). A TCP accept proves nothing: the daemon pre-binds its port before
12
+ * `agent.start()`, so a connect succeeds before the bridge routes anything.
13
+ */
14
+ export declare function isDaemonRouted(h: DaemonHealth | null): h is DaemonHealth;
15
+ /** Default install wait: the boot drain may take up to BOOT_DRAIN_WAIT_MS_DEFAULT, plus 15 s. */
16
+ export declare const VERIFY_BUDGET_MS: number;
3
17
  export interface PrepDeps {
4
18
  readFile: (p: string) => string;
5
19
  writeFileMode: (p: string, data: string, mode: number) => void;
@@ -8,8 +22,11 @@ export interface PrepDeps {
8
22
  realpath: (p: string) => string;
9
23
  listDir: (p: string) => string[];
10
24
  probeBindFree: (port: number) => boolean;
11
- probeConnect: (port: number) => boolean;
25
+ /** Routed `/healthz` probe (P-2): null unless the daemon answered a 200 with parseable JSON. */
26
+ getHealth: (port: number) => DaemonHealth | null;
12
27
  sleep: (ms: number) => Promise<void>;
28
+ /** Wall clock in ms (Date.now). verifyDaemonUp's deadline reads it, so probe time counts, not only sleep. */
29
+ now: () => number;
13
30
  }
14
31
  export interface ParsedCredential {
15
32
  deviceId: string;
@@ -19,6 +36,9 @@ export interface ParsedCredential {
19
36
  export declare function assertReadableCredential(absPath: string, deps: PrepDeps): ParsedCredential;
20
37
  export declare function durableDir(home: string, slug: string): string;
21
38
  export declare function durableCredentialPath(home: string, slug: string): string;
39
+ /** M5 (OQ-3): the approved refusal text for an identity that is already installed. */
40
+ export declare class InstallRefusedError extends Error {
41
+ }
22
42
  export declare function takeCustody(args: {
23
43
  home: string;
24
44
  slug: string;