@gajae-code/coding-agent 0.15.4 → 0.15.6

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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +27 -5
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +99 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
@@ -8,9 +8,14 @@ import {
8
8
  isSessionAuthorityEligible,
9
9
  type SessionIndex,
10
10
  } from "../broker/session-index";
11
- import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
11
+ import { cancellableSleep, lifecycleRequestTimeoutMs } from "../broker/startup-budget";
12
12
  import { SdkClient, SdkClientError, type SdkDispatchContext, type SdkDispatchHandler } from "../client/client";
13
- import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
13
+ import {
14
+ endpointDirectory,
15
+ readSdkBrokerDiscovery,
16
+ readSdkSessionEndpoint,
17
+ type SdkSessionEndpoint,
18
+ } from "../client/discovery";
14
19
  import {
15
20
  type ActivatedPreparedSession,
16
21
  type PreparedSessionActivationClient,
@@ -19,6 +24,14 @@ import {
19
24
  } from "../session-activation";
20
25
  import { ACP_SESSION_RECONNECT, SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
21
26
 
27
+ export interface SessionEndpointIdentity {
28
+ readonly mtimeMs: number;
29
+ readonly mtimeNs: bigint;
30
+ readonly ctimeNs: bigint;
31
+ readonly size: bigint;
32
+ readonly ino: bigint;
33
+ }
34
+
22
35
  /**
23
36
  * Exact identity of one attached SDK session endpoint. Providers persist it next to
24
37
  * their conversation state and re-prove it before every resume, so it must be derived
@@ -32,11 +45,22 @@ export function sessionAttachmentAuthorityId(input: {
32
45
  endpointMtimeMs: number | undefined;
33
46
  url: string;
34
47
  token: string;
48
+ /** Proven no-follow endpoint identity, when the caller has one. */
49
+ endpointIdentity?: SessionEndpointIdentity;
35
50
  }): string {
36
51
  const endpointAuthorityDigest = crypto
37
52
  .createHash("sha256")
38
53
  .update(JSON.stringify({ url: input.url, token: input.token }))
39
54
  .digest("hex");
55
+ const endpointIdentity = input.endpointIdentity
56
+ ? {
57
+ mtimeMs: input.endpointIdentity.mtimeMs,
58
+ mtimeNs: input.endpointIdentity.mtimeNs.toString(),
59
+ ctimeNs: input.endpointIdentity.ctimeNs.toString(),
60
+ size: input.endpointIdentity.size.toString(),
61
+ ino: input.endpointIdentity.ino.toString(),
62
+ }
63
+ : undefined;
40
64
  return crypto
41
65
  .createHash("sha256")
42
66
  .update(
@@ -46,6 +70,7 @@ export function sessionAttachmentAuthorityId(input: {
46
70
  pid: input.pid,
47
71
  endpointMtimeMs: input.endpointMtimeMs,
48
72
  endpointAuthorityDigest,
73
+ endpointIdentity,
49
74
  }),
50
75
  )
51
76
  .digest("hex");
@@ -287,6 +312,8 @@ type AttachedSession = {
287
312
  readonly generation: number;
288
313
  readonly pid: number;
289
314
  readonly endpointMtimeMs: number;
315
+ /** No-follow endpoint identity proven before this attachment became current. */
316
+ readonly endpointIdentity: SessionEndpointIdentity;
290
317
  readonly runEpoch: number;
291
318
  readonly client: SessionRouterClient;
292
319
  readonly indexed: IndexedSession;
@@ -295,6 +322,8 @@ type AttachedSession = {
295
322
  readonly capability: SessionAttachment;
296
323
  readonly notificationSubscription: NotificationSubscription;
297
324
  notificationCancelled: boolean;
325
+ /** Consecutive refused notification publications; reset by the next delivered frame. */
326
+ notificationFailures: number;
298
327
  readonly notificationCursor: { generation: number; seq: number };
299
328
  published: boolean;
300
329
  initializingPublication: boolean;
@@ -342,6 +371,9 @@ const STARTUP_ATTACH_BUDGET_MS = 5_000;
342
371
  const STARTUP_STRAGGLER_LOG_LIMIT = 8;
343
372
  const ATTACH_CONNECT_TIMEOUT_MS = 10_000;
344
373
  const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
374
+ // Core shutdown waits only for Router reconciliation and client close; provider
375
+ // notification work is bounded separately by NOTIFICATION_WORK_TIMEOUT_MS.
376
+ const ROUTER_SHUTDOWN_TIMEOUT_MS = 5_000;
345
377
  /**
346
378
  * Idle liveness-sweep cadence (#4689). When the session index is unchanged and
347
379
  * no adoption is pending, the 2s reconcile tick is only a change-stamp check;
@@ -352,6 +384,17 @@ const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
352
384
  */
353
385
  const SESSION_ROUTER_IDLE_SWEEP_MS = 30_000;
354
386
  const NOTIFICATION_WORK_TIMEOUT = Symbol("notification_work_timeout");
387
+ /**
388
+ * Consecutive local notification-dispatch failures tolerated before the
389
+ * subscription is conceded. A provider that refuses ONE publication is not a
390
+ * dead subscription: it has already settled that publication as rejected on its
391
+ * own side, and the next frame normally succeeds. Cancelling on the first
392
+ * rejection latched `notificationCancelled` for the life of the
393
+ * `AttachedSession`, which only a new attachment clears -- a still-running
394
+ * session never gets one, so its mirroring stayed dead until the session was
395
+ * restarted. Mirrors {@link DELIVERY_ATTEMPT_LIMIT} on the ordered path.
396
+ */
397
+ const NOTIFICATION_LOCAL_FAILURE_LIMIT = 5;
355
398
  /**
356
399
  * Client-message types the native session server authorizes with the
357
400
  * per-session endpoint token (`tokens_match` in crates/gjc-sdk server.rs).
@@ -382,6 +425,56 @@ function readSequence(value: unknown): number | undefined {
382
425
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
383
426
  }
384
427
 
428
+ async function lstatEndpoint(file: string): Promise<SessionEndpointIdentity | undefined> {
429
+ const stat = await fs.lstat(file).catch(() => undefined);
430
+ if (!stat || !stat.isFile()) return undefined;
431
+ const identity = await fs.lstat(file, { bigint: true }).catch(() => undefined);
432
+ if (!identity || !identity.isFile()) return undefined;
433
+ if (
434
+ stat.size !== Number(identity.size) ||
435
+ stat.ino !== Number(identity.ino) ||
436
+ Math.abs(stat.mtimeMs - Number(identity.mtimeNs) / 1_000_000) > 0.0005 ||
437
+ Math.abs(stat.ctimeMs - Number(identity.ctimeNs) / 1_000_000) > 0.0005
438
+ )
439
+ return undefined;
440
+ return {
441
+ mtimeMs: stat.mtimeMs,
442
+ mtimeNs: identity.mtimeNs,
443
+ ctimeNs: identity.ctimeNs,
444
+ size: identity.size,
445
+ ino: identity.ino,
446
+ };
447
+ }
448
+
449
+ function sameEndpointIdentity(expected: SessionEndpointIdentity, current: SessionEndpointIdentity): boolean {
450
+ return (
451
+ expected.mtimeMs === current.mtimeMs &&
452
+ expected.mtimeNs === current.mtimeNs &&
453
+ expected.ctimeNs === current.ctimeNs &&
454
+ expected.size === current.size &&
455
+ expected.ino === current.ino
456
+ );
457
+ }
458
+
459
+ function readFrameSequenceClaim(frame: Record<string, unknown>): {
460
+ claimed: boolean;
461
+ valid: boolean;
462
+ seq?: number;
463
+ } {
464
+ const payload =
465
+ frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
466
+ ? (frame.payload as Record<string, unknown>)
467
+ : undefined;
468
+ const claims: unknown[] = [];
469
+ if (Object.hasOwn(frame, "seq")) claims.push(frame.seq);
470
+ if (payload && Object.hasOwn(payload, "seq")) claims.push(payload.seq);
471
+ if (claims.length === 0) return { claimed: false, valid: true };
472
+ const sequences = claims.map(readSequence);
473
+ if (sequences.some(sequence => sequence === undefined)) return { claimed: true, valid: false };
474
+ if (new Set(sequences).size !== 1) return { claimed: true, valid: false };
475
+ return { claimed: true, valid: true, seq: sequences[0] };
476
+ }
477
+
385
478
  function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame | undefined {
386
479
  const payload =
387
480
  frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
@@ -487,16 +580,11 @@ export class SessionRouter {
487
580
  { generation: number; frames: Array<{ seq: number; frame: Record<string, unknown> }> }
488
581
  >();
489
582
  readonly #reviving = new Set<string>();
490
- /**
491
- * Endpoint-file inode captured when each attachment was published (#4730
492
- * review). Maintenance renewal compares against this so a rename-replace that
493
- * preserves size, mtime and body cannot keep a superseded endpoint authorized.
494
- */
495
- readonly #endpointInodes = new Map<string, bigint>();
496
583
  readonly #notificationReceipts = new Map<string, NotificationCleanupReceipt>();
497
584
  #stopTimer: (() => void) | undefined;
498
585
  #reconcileTail: Promise<void> = Promise.resolve();
499
586
  #reconcilePending: { readonly runEpoch: number; force: boolean } | undefined;
587
+ #attachmentTails = new Map<string, Promise<boolean>>();
500
588
  /**
501
589
  * Set when a live indexed session failed to attach; keeps the idle gate
502
590
  * from parking retries until the 30s sweep (#4689 review).
@@ -508,6 +596,8 @@ export class SessionRouter {
508
596
  #lastReconciledIndexSeq = -1;
509
597
  #ready = false;
510
598
  #started = false;
599
+ #startPromise: Promise<void> | undefined;
600
+ #stopPromise: Promise<void> | undefined;
511
601
  #stopController = new AbortController();
512
602
  #runEpoch = 0;
513
603
 
@@ -529,14 +619,27 @@ export class SessionRouter {
529
619
 
530
620
  /** Starts reconciliation and the index watcher. */
531
621
  async start(): Promise<void> {
622
+ if (this.#stopPromise) await this.#stopPromise;
623
+ if (this.#startPromise) return await this.#startPromise;
532
624
  if (this.#started) return;
533
625
  this.#started = true;
626
+ const startup = this.#startImpl();
627
+ this.#startPromise = startup;
628
+ try {
629
+ await startup;
630
+ } finally {
631
+ if (this.#startPromise === startup) this.#startPromise = undefined;
632
+ }
633
+ }
634
+
635
+ async #startImpl(): Promise<void> {
534
636
  const runEpoch = ++this.#runEpoch;
535
637
  if (this.#stopController.signal.aborted) {
536
638
  this.#stopController = new AbortController();
537
639
  this.#reconcileTail = Promise.resolve();
538
640
  this.#reconcilePending = undefined;
539
641
  this.#frameTails.clear();
642
+ this.#attachmentTails.clear();
540
643
  // A restart must re-run the full body on the first tick: reset the
541
644
  // idle-gate markers so stale state cannot carry across run epochs.
542
645
  this.#lastReconcileSweepAt = 0;
@@ -552,10 +655,16 @@ export class SessionRouter {
552
655
  // initial replay delivered. Past that budget the pass continues in the
553
656
  // background instead of owning the caller's deadline.
554
657
  const initialPass = this.#serialReconcile(runEpoch, false, true);
658
+ // Cancelled as soon as the race settles. Promise.race leaves the loser pending,
659
+ // and a pending Bun.sleep keeps the event loop alive, so a startup that settled
660
+ // in milliseconds still owed the rest of the budget before the process could exit.
661
+ const startupCutoff = new AbortController();
555
662
  const settled = await Promise.race([
556
663
  initialPass.then(() => true),
557
- Bun.sleep(this.#deps.startupAttachBudgetMs ?? STARTUP_ATTACH_BUDGET_MS).then(() => false),
558
- ]);
664
+ cancellableSleep(this.#deps.startupAttachBudgetMs ?? STARTUP_ATTACH_BUDGET_MS, startupCutoff.signal).then(
665
+ () => false,
666
+ ),
667
+ ]).finally(() => startupCutoff.abort());
559
668
  if (!settled) {
560
669
  // Name the sessions the budget was spent on. Without this the lapse is
561
670
  // silent and the only symptom is a slow startup somewhere else, which
@@ -586,6 +695,10 @@ export class SessionRouter {
586
695
  () => this.#schedule(this.#serialReconcile(runEpoch, true)),
587
696
  2_000,
588
697
  );
698
+ // Reconciliation is background upkeep and must not be the reason a host process
699
+ // stays alive. stop() still clears it on the normal path; this only keeps a
700
+ // caller that never stops the Router from hanging at exit.
701
+ timer.unref?.();
589
702
  this.#stopTimer = () => (this.#deps.clearInterval ?? clearInterval)(timer);
590
703
  } catch (error) {
591
704
  if (this.#running(runEpoch)) await this.stop();
@@ -675,6 +788,20 @@ export class SessionRouter {
675
788
  }
676
789
 
677
790
  async stop(): Promise<void> {
791
+ if (this.#stopPromise) return await this.#stopPromise;
792
+ const stopping = this.#stopImpl();
793
+ this.#stopPromise = stopping;
794
+ try {
795
+ await stopping;
796
+ } finally {
797
+ if (this.#stopPromise === stopping) this.#stopPromise = undefined;
798
+ }
799
+ }
800
+
801
+ async #stopImpl(): Promise<void> {
802
+ // Detach the in-flight startup promise before invalidating its epoch. A
803
+ // later start must not return a promise owned by the stopped epoch.
804
+ this.#startPromise = undefined;
678
805
  if (this.#stopTimer) this.#stopTimer();
679
806
  this.#stopTimer = undefined;
680
807
  this.#started = false;
@@ -694,14 +821,19 @@ export class SessionRouter {
694
821
  this.#adopted.clear();
695
822
  // Provider notification work is detached and bounded independently; core
696
823
  // shutdown waits only for Router reconciliation and client close.
697
- const pending = Promise.allSettled([this.#reconcileTail, ...shutdownTasks]);
824
+ const pending = Promise.allSettled([this.#reconcileTail, ...this.#attachmentTails.values(), ...shutdownTasks]);
825
+ // Cancelled once the race settles, so a completed shutdown does not hold the
826
+ // event loop open for the remainder of the timeout.
827
+ const shutdownCutoff = new AbortController();
698
828
  const outcome = await Promise.race([
699
829
  pending.then(results => ({ kind: "settled" as const, results })),
700
- Bun.sleep(5_000).then(() => ({ kind: "timeout" as const })),
701
- ]);
830
+ cancellableSleep(ROUTER_SHUTDOWN_TIMEOUT_MS, shutdownCutoff.signal).then(() => ({
831
+ kind: "timeout" as const,
832
+ })),
833
+ ]).finally(() => shutdownCutoff.abort());
702
834
  if (outcome.kind === "timeout") {
703
835
  logger.warn(
704
- "SessionRouter shutdown exceeded 5000ms; authority is revoked and cleanup continues in background.",
836
+ `SessionRouter shutdown exceeded ${ROUTER_SHUTDOWN_TIMEOUT_MS}ms; authority is revoked and cleanup continues in background.`,
705
837
  );
706
838
  return;
707
839
  }
@@ -759,14 +891,15 @@ export class SessionRouter {
759
891
  if (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
760
892
  throw new SessionRouterError("pre_send", "SDK session attachment changed before command dispatch.");
761
893
  if (attached.initializingPublication) {
762
- const endpoint = await this.#readEndpoint(attached.indexed);
894
+ const proven = await this.#readProvenEndpoint(attached.indexed);
763
895
  if (
764
- !endpoint ||
765
- endpoint.url !== attached.endpoint.url ||
766
- endpoint.token !== attached.endpoint.token ||
767
- endpoint.pid !== attached.pid
896
+ !proven ||
897
+ proven.endpoint.url !== attached.endpoint.url ||
898
+ proven.endpoint.token !== attached.endpoint.token ||
899
+ proven.endpoint.pid !== attached.pid ||
900
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
768
901
  ) {
769
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
902
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
770
903
  throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
771
904
  }
772
905
  }
@@ -834,8 +967,15 @@ export class SessionRouter {
834
967
  )
835
968
  return undefined;
836
969
  if (!Number.isSafeInteger(indexed.pid) || indexed.pid <= 0) return undefined;
837
- const endpoint = await this.#readEndpoint(indexed).catch(() => null);
838
- if (!endpoint || endpoint.stale === true || endpoint.pid !== indexed.pid || !endpoint.token) return undefined;
970
+ const proven = await this.#readProvenEndpoint(indexed).catch(() => null);
971
+ if (
972
+ !proven ||
973
+ proven.endpoint.stale === true ||
974
+ proven.endpoint.pid !== indexed.pid ||
975
+ !proven.endpoint.token ||
976
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
977
+ )
978
+ return undefined;
839
979
  if (this.#sessions.get(sessionId) !== attached || !this.#attachmentPublished(attached)) return undefined;
840
980
  return { sessionId, endpointGeneration: attached.generation };
841
981
  }
@@ -914,8 +1054,16 @@ export class SessionRouter {
914
1054
  "session_not_live",
915
1055
  "Session activation requires an exact live session endpoint.",
916
1056
  );
917
- const endpoint = await this.#readEndpoint(indexed).catch(() => null);
918
- if (!endpoint || endpoint.stale === true || !endpoint.url || !endpoint.token || endpoint.pid !== indexed.pid)
1057
+ const provenEndpoint = await this.#readProvenEndpoint(indexed).catch(() => null);
1058
+ const endpoint = provenEndpoint?.endpoint;
1059
+ if (
1060
+ !provenEndpoint ||
1061
+ !endpoint ||
1062
+ endpoint.stale === true ||
1063
+ !endpoint.url ||
1064
+ !endpoint.token ||
1065
+ endpoint.pid !== indexed.pid
1066
+ )
919
1067
  throw new SessionActivationError(
920
1068
  "session_not_live",
921
1069
  "Session activation requires a readable session discovery endpoint.",
@@ -935,12 +1083,15 @@ export class SessionRouter {
935
1083
  throw new SessionActivationError("activation_unavailable", "The session endpoint could not be reached.");
936
1084
  }
937
1085
  try {
938
- const currentEndpoint = await this.#readEndpoint(indexed).catch(() => null);
1086
+ const currentProvenEndpoint = await this.#readProvenEndpoint(indexed).catch(() => null);
1087
+ const currentEndpoint = currentProvenEndpoint?.endpoint;
939
1088
  if (
1089
+ !currentProvenEndpoint ||
940
1090
  !currentEndpoint ||
941
1091
  currentEndpoint.url !== endpoint.url ||
942
1092
  currentEndpoint.token !== endpoint.token ||
943
- currentEndpoint.pid !== endpoint.pid
1093
+ currentEndpoint.pid !== endpoint.pid ||
1094
+ !sameEndpointIdentity(provenEndpoint.identity, currentProvenEndpoint.identity)
944
1095
  )
945
1096
  throw new SessionActivationError(
946
1097
  "session_not_live",
@@ -1169,15 +1320,9 @@ export class SessionRouter {
1169
1320
  // attachment was PUBLISHED against is therefore the identity, and the
1170
1321
  // generation is compared so a re-registration reusing the same url/token/pid
1171
1322
  // cannot keep the old attachment authorized.
1172
- const publishedIno = this.#endpointInodes.get(attached.id);
1173
- if (publishedIno === undefined) return false;
1174
- const statBefore = await fs.stat(attached.endpoint.path).catch(() => undefined);
1175
- const inoBefore = await fs
1176
- .stat(attached.endpoint.path, { bigint: true })
1177
- .then(value => value.ino)
1178
- .catch(() => undefined);
1179
- if (!statBefore || inoBefore === undefined || inoBefore !== publishedIno) return false;
1180
- if (statBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
1323
+ const identityBefore = await lstatEndpoint(attached.endpoint.path);
1324
+ if (!identityBefore || !sameEndpointIdentity(attached.endpointIdentity, identityBefore)) return false;
1325
+ if (identityBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
1181
1326
  let raw: Record<string, unknown>;
1182
1327
  try {
1183
1328
  const parsed = JSON.parse(await Bun.file(attached.endpoint.path).text());
@@ -1198,26 +1343,23 @@ export class SessionRouter {
1198
1343
  // comes from the indexed row this attachment was published against.
1199
1344
  if (indexed.endpointGeneration !== attached.generation) return false;
1200
1345
  if (raw.endpointGeneration !== undefined && raw.endpointGeneration !== attached.generation) return false;
1201
- const statAfter = await fs.stat(attached.endpoint.path).catch(() => undefined);
1202
- const inoAfter = await fs
1203
- .stat(attached.endpoint.path, { bigint: true })
1204
- .then(value => value.ino)
1205
- .catch(() => undefined);
1346
+ const identityAfter = await lstatEndpoint(attached.endpoint.path);
1206
1347
  return (
1207
- statAfter !== undefined &&
1208
- inoAfter !== undefined &&
1209
- statAfter.mtimeMs === indexed.endpointMtimeMs &&
1210
- inoAfter === publishedIno
1348
+ identityAfter !== undefined &&
1349
+ identityAfter.mtimeMs === indexed.endpointMtimeMs &&
1350
+ sameEndpointIdentity(attached.endpointIdentity, identityAfter)
1211
1351
  );
1212
1352
  }
1213
1353
 
1214
1354
  /**
1215
- * Returns the endpoint together with the inode this read PROVED (#4730
1355
+ * Returns the endpoint together with the full identity this read PROVED (#4730
1216
1356
  * review). The identity travels with the value instead of living in a side
1217
1357
  * map, so every consumer decision compares the same proof and the two cannot
1218
1358
  * drift apart.
1219
1359
  */
1220
- async #readProvenEndpoint(indexed: IndexedSession): Promise<{ endpoint: SdkSessionEndpoint; ino: bigint } | null> {
1360
+ async #readProvenEndpoint(
1361
+ indexed: IndexedSession,
1362
+ ): Promise<{ endpoint: SdkSessionEndpoint; identity: SessionEndpointIdentity } | null> {
1221
1363
  if (!isSessionAuthorityEligible(indexed)) return null;
1222
1364
  const repo = path.resolve(indexed.locator.repo);
1223
1365
  const defaultStateRoot = path.join(repo, ".gjc", "state");
@@ -1236,18 +1378,15 @@ export class SessionRouter {
1236
1378
  ? "chat"
1237
1379
  : undefined;
1238
1380
  if (!scope || indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return null;
1381
+ const endpointPath = path.join(endpointDirectory(repo, scope), `${indexed.sessionId}.json`);
1382
+ const endpointIdentity = await lstatEndpoint(endpointPath);
1239
1383
  const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
1240
1384
  if (!endpoint || endpoint.stale || endpoint.pid !== indexed.pid) return null;
1241
- const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
1242
- const endpointIno = await fs
1243
- .stat(endpoint.path, { bigint: true })
1244
- .then(value => value.ino)
1245
- .catch(() => undefined);
1246
- if (!endpointStat || endpointIno === undefined || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return null;
1385
+ if (!endpointIdentity || endpointIdentity.mtimeMs !== indexed.endpointMtimeMs) return null;
1247
1386
  // Identity is proven INSIDE this authority read (#4730 review): sampling it
1248
1387
  // afterwards would let an identical rename between the read and the sample
1249
1388
  // install the replacement's inode as the trusted baseline.
1250
- const provenIno = endpointIno;
1389
+ const provenIdentity = endpointIdentity;
1251
1390
  let raw: Record<string, unknown>;
1252
1391
  try {
1253
1392
  const parsed = JSON.parse(await Bun.file(endpoint.path).text());
@@ -1265,16 +1404,11 @@ export class SessionRouter {
1265
1404
  )
1266
1405
  return null;
1267
1406
  // Fail CLOSED on stat error and on any identity change across the body read.
1268
- const endpointStatAfterRead = await fs.stat(endpoint.path).catch(() => undefined);
1269
- const inoAfterRead = await fs
1270
- .stat(endpoint.path, { bigint: true })
1271
- .then(value => value.ino)
1272
- .catch(() => undefined);
1407
+ const endpointIdentityAfterRead = await lstatEndpoint(endpoint.path);
1273
1408
  if (
1274
- !endpointStatAfterRead ||
1275
- inoAfterRead === undefined ||
1276
- endpointStatAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
1277
- inoAfterRead !== provenIno
1409
+ !endpointIdentityAfterRead ||
1410
+ endpointIdentityAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
1411
+ !sameEndpointIdentity(endpointIdentity, endpointIdentityAfterRead)
1278
1412
  )
1279
1413
  return null;
1280
1414
  await this.#index.refresh();
@@ -1282,7 +1416,7 @@ export class SessionRouter {
1282
1416
  if (listing.warnings.length > 0) return null;
1283
1417
  const current = listing.sessions.find(session => session.sessionId === indexed.sessionId);
1284
1418
  if (!current || !sameIndexedAuthority(indexed, current)) return null;
1285
- return { endpoint, ino: provenIno };
1419
+ return { endpoint, identity: provenIdentity };
1286
1420
  }
1287
1421
 
1288
1422
  /** Endpoint-only view for callers that do not make an authority decision. */
@@ -1344,6 +1478,28 @@ export class SessionRouter {
1344
1478
  skipReplay = false,
1345
1479
  deferPublication = false,
1346
1480
  deferReplay = false,
1481
+ ): Promise<boolean> {
1482
+ const previous = this.#attachmentTails.get(indexed.sessionId) ?? Promise.resolve(true);
1483
+ const task = previous
1484
+ .catch(() => false)
1485
+ .then(() =>
1486
+ this.#attachUnserialized(indexed, runEpoch, resolvedEndpoint, skipReplay, deferPublication, deferReplay),
1487
+ );
1488
+ this.#attachmentTails.set(indexed.sessionId, task);
1489
+ try {
1490
+ return await task;
1491
+ } finally {
1492
+ if (this.#attachmentTails.get(indexed.sessionId) === task) this.#attachmentTails.delete(indexed.sessionId);
1493
+ }
1494
+ }
1495
+
1496
+ async #attachUnserialized(
1497
+ indexed: IndexedSession,
1498
+ runEpoch: number,
1499
+ resolvedEndpoint?: SdkSessionEndpoint,
1500
+ skipReplay = false,
1501
+ deferPublication = false,
1502
+ deferReplay = false,
1347
1503
  ): Promise<boolean> {
1348
1504
  const retirementVersion = this.#retirementVersions.get(indexed.sessionId) ?? 0;
1349
1505
  const retirement = this.#retirements.get(indexed.sessionId);
@@ -1355,18 +1511,14 @@ export class SessionRouter {
1355
1511
  // authority read of its own, so it samples once here.
1356
1512
  const proven = resolvedEndpoint === undefined ? await this.#readProvenEndpoint(indexed) : null;
1357
1513
  const endpoint = resolvedEndpoint ?? proven?.endpoint ?? null;
1358
- let provenIno = proven?.ino;
1359
- if (resolvedEndpoint !== undefined)
1360
- provenIno = await fs
1361
- .stat(resolvedEndpoint.path, { bigint: true })
1362
- .then(value => value.ino)
1363
- .catch(() => undefined);
1514
+ const endpointIdentity =
1515
+ resolvedEndpoint === undefined ? proven?.identity : await lstatEndpoint(resolvedEndpoint.path);
1364
1516
  const retirementAfterValidation = this.#retirements.get(indexed.sessionId);
1365
1517
  if (retirementAfterValidation) await retirementAfterValidation;
1366
1518
  if ((this.#retirementVersions.get(indexed.sessionId) ?? 0) !== retirementVersion)
1367
- return await this.#attach(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1519
+ return await this.#attachUnserialized(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1368
1520
  if (!this.#running(runEpoch)) return false;
1369
- if (!endpoint) return false;
1521
+ if (!endpoint || !endpointIdentity) return false;
1370
1522
  const existing = this.#sessions.get(indexed.sessionId);
1371
1523
  const resumable =
1372
1524
  existing !== undefined &&
@@ -1374,7 +1526,9 @@ export class SessionRouter {
1374
1526
  existing.endpoint.token === endpoint.token &&
1375
1527
  existing.generation === indexed.endpointGeneration &&
1376
1528
  existing.pid === indexed.pid &&
1377
- existing.endpointMtimeMs === indexed.endpointMtimeMs;
1529
+ existing.endpointMtimeMs === indexed.endpointMtimeMs &&
1530
+ endpointIdentity !== undefined &&
1531
+ sameEndpointIdentity(existing.endpointIdentity, endpointIdentity);
1378
1532
  if (existing && resumable && !existing.barrier.failed) {
1379
1533
  this.#reviveTransport(existing);
1380
1534
  return true;
@@ -1403,7 +1557,9 @@ export class SessionRouter {
1403
1557
  (existing.endpoint.url !== endpoint.url ||
1404
1558
  existing.endpoint.token !== endpoint.token ||
1405
1559
  existing.pid !== indexed.pid ||
1406
- existing.endpointMtimeMs !== indexed.endpointMtimeMs)
1560
+ existing.endpointMtimeMs !== indexed.endpointMtimeMs ||
1561
+ endpointIdentity === undefined ||
1562
+ !sameEndpointIdentity(existing.endpointIdentity, endpointIdentity))
1407
1563
  ? "replaced_same_generation"
1408
1564
  : "replaced",
1409
1565
  ),
@@ -1424,7 +1580,7 @@ export class SessionRouter {
1424
1580
  await client.close().catch(() => undefined);
1425
1581
  const currentRetirement = this.#retirements.get(indexed.sessionId);
1426
1582
  if (currentRetirement) await currentRetirement;
1427
- return await this.#attach(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1583
+ return await this.#attachUnserialized(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1428
1584
  }
1429
1585
  let attached: AttachedSession | undefined;
1430
1586
  const barrier: ReplayBarrier = { held: undefined, detached: false, failed: false };
@@ -1438,6 +1594,7 @@ export class SessionRouter {
1438
1594
  endpointMtimeMs: indexed.endpointMtimeMs,
1439
1595
  url: endpoint.url,
1440
1596
  token: endpoint.token,
1597
+ endpointIdentity,
1441
1598
  }),
1442
1599
  sessionId: indexed.sessionId,
1443
1600
  generation: indexed.endpointGeneration,
@@ -1449,14 +1606,15 @@ export class SessionRouter {
1449
1606
  if (!attached || !this.#attachmentPublished(attached))
1450
1607
  throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
1451
1608
  if (attached.initializingPublication) {
1452
- const endpoint = await this.#readEndpoint(attached.indexed);
1609
+ const proven = await this.#readProvenEndpoint(attached.indexed);
1453
1610
  if (
1454
- !endpoint ||
1455
- endpoint.url !== attached.endpoint.url ||
1456
- endpoint.token !== attached.endpoint.token ||
1457
- endpoint.pid !== attached.pid
1611
+ !proven ||
1612
+ proven.endpoint.url !== attached.endpoint.url ||
1613
+ proven.endpoint.token !== attached.endpoint.token ||
1614
+ proven.endpoint.pid !== attached.pid ||
1615
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1458
1616
  ) {
1459
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
1617
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1460
1618
  throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
1461
1619
  }
1462
1620
  } else await this.#serialReconcile(runEpoch, true, true);
@@ -1538,6 +1696,7 @@ export class SessionRouter {
1538
1696
  endpoint,
1539
1697
  pid: indexed.pid,
1540
1698
  endpointMtimeMs: indexed.endpointMtimeMs,
1699
+ endpointIdentity,
1541
1700
  generation: indexed.endpointGeneration,
1542
1701
  runEpoch,
1543
1702
  client,
@@ -1548,6 +1707,7 @@ export class SessionRouter {
1548
1707
  capability,
1549
1708
  notificationSubscription,
1550
1709
  notificationCancelled: false,
1710
+ notificationFailures: 0,
1551
1711
  notificationCursor,
1552
1712
  published: false,
1553
1713
  publication,
@@ -1560,10 +1720,6 @@ export class SessionRouter {
1560
1720
  new SessionRouterError("pre_send", "SDK session publication was detached before completion."),
1561
1721
  );
1562
1722
  barrier.held = undefined;
1563
- // Every teardown path (retirement, replacement, stop, failed
1564
- // publication) routes through dispose(), so the published-inode entry
1565
- // is dropped here rather than at one call site (#4730 review).
1566
- if (attached) this.#endpointInodes.delete(attached.id);
1567
1723
  },
1568
1724
  };
1569
1725
  // Re-check at publication (#4730 review): the endpoint must STILL be the
@@ -1577,14 +1733,9 @@ export class SessionRouter {
1577
1733
  await client.close().catch(() => undefined);
1578
1734
  return false;
1579
1735
  };
1580
- if (provenIno === undefined) return await rollback();
1581
- const publishIno = await fs
1582
- .stat(endpoint.path, { bigint: true })
1583
- .then(value => value.ino)
1584
- .catch(() => undefined);
1585
- if (publishIno === undefined || publishIno !== provenIno) return await rollback();
1736
+ const publishIdentity = await lstatEndpoint(endpoint.path);
1737
+ if (!publishIdentity || !sameEndpointIdentity(endpointIdentity, publishIdentity)) return await rollback();
1586
1738
  this.#sessions.set(indexed.sessionId, attached);
1587
- this.#endpointInodes.set(attached.id, provenIno);
1588
1739
  if (deferPublication)
1589
1740
  this.#adopted.set(indexed.sessionId, {
1590
1741
  generation: indexed.endpointGeneration,
@@ -1624,19 +1775,17 @@ export class SessionRouter {
1624
1775
  if (!this.#attachmentLive(attached)) return false;
1625
1776
  const proven = await this.#readProvenEndpoint(attached.indexed).catch(() => null);
1626
1777
  if (!this.#attachmentLive(attached)) return false;
1627
- // The commit point compares the PROVEN inode alongside url/token/pid
1778
+ // The commit point compares the PROVEN endpoint identity alongside url/token/pid
1628
1779
  // (#4730 review): an identical-byte rename during the pre-publication
1629
1780
  // hooks matches every other field, so identity is what rejects it. The
1630
1781
  // value compared here is the one the authority read proved, not a later
1631
1782
  // observation, so the proof and its consumer cannot drift apart.
1632
- const publishedIno = this.#endpointInodes.get(attached.id);
1633
1783
  if (
1634
1784
  !proven ||
1635
1785
  proven.endpoint.url !== attached.endpoint.url ||
1636
1786
  proven.endpoint.token !== attached.endpoint.token ||
1637
1787
  proven.endpoint.pid !== attached.pid ||
1638
- publishedIno === undefined ||
1639
- proven.ino !== publishedIno
1788
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1640
1789
  ) {
1641
1790
  await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1642
1791
  return false;
@@ -1699,14 +1848,15 @@ export class SessionRouter {
1699
1848
  .catch(() => undefined)
1700
1849
  .then(async () => {
1701
1850
  if (!this.#attachmentLive(attached)) return;
1702
- const endpoint = await this.#readEndpoint(attached.indexed);
1851
+ const proven = await this.#readProvenEndpoint(attached.indexed);
1703
1852
  if (
1704
- !endpoint ||
1705
- endpoint.url !== attached.endpoint.url ||
1706
- endpoint.token !== attached.endpoint.token ||
1707
- endpoint.pid !== attached.pid
1853
+ !proven ||
1854
+ proven.endpoint.url !== attached.endpoint.url ||
1855
+ proven.endpoint.token !== attached.endpoint.token ||
1856
+ proven.endpoint.pid !== attached.pid ||
1857
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1708
1858
  ) {
1709
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
1859
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1710
1860
  return;
1711
1861
  }
1712
1862
  attached.initializingPublication = true;
@@ -1780,10 +1930,20 @@ export class SessionRouter {
1780
1930
  }
1781
1931
 
1782
1932
  async #boundedNotificationWork<T>(work: () => Promise<T> | T): Promise<T | typeof NOTIFICATION_WORK_TIMEOUT> {
1783
- const timeout: Promise<typeof NOTIFICATION_WORK_TIMEOUT> = Bun.sleep(NOTIFICATION_WORK_TIMEOUT_MS).then(
1784
- () => NOTIFICATION_WORK_TIMEOUT,
1785
- );
1786
- return await Promise.race([Promise.resolve().then(work), timeout]);
1933
+ // The dominant cost of a one-shot `gjc sdk` command: notification work that
1934
+ // finished in milliseconds left this timeout pending, and a pending Bun.sleep
1935
+ // keeps the event loop alive, so the process wrote its response and then idled
1936
+ // for the full budget before exiting.
1937
+ const cutoff = new AbortController();
1938
+ const timeout: Promise<typeof NOTIFICATION_WORK_TIMEOUT> = cancellableSleep(
1939
+ NOTIFICATION_WORK_TIMEOUT_MS,
1940
+ cutoff.signal,
1941
+ ).then(() => NOTIFICATION_WORK_TIMEOUT);
1942
+ try {
1943
+ return await Promise.race([Promise.resolve().then(work), timeout]);
1944
+ } finally {
1945
+ cutoff.abort();
1946
+ }
1787
1947
  }
1788
1948
 
1789
1949
  #detachNotification(
@@ -1827,15 +1987,25 @@ export class SessionRouter {
1827
1987
  this.#detachNotification(attached, "cancelled");
1828
1988
  return;
1829
1989
  }
1990
+ // A delivered frame ends the consecutive-failure run.
1991
+ attached.notificationFailures = 0;
1830
1992
  if (frame.seq !== undefined)
1831
1993
  attached.notificationSubscription.advanceCursor(frame.generation ?? attached.generation, frame.seq);
1832
1994
  },
1833
1995
  (error: unknown) => {
1834
- this.#detachNotification(attached, "cancelled");
1996
+ // Concede only after a bounded consecutive run: a single refused
1997
+ // publication drops that frame, it does not end the subscription.
1998
+ attached.notificationFailures += 1;
1999
+ const detail = error instanceof Error ? error.message : String(error);
2000
+ if (attached.notificationFailures >= NOTIFICATION_LOCAL_FAILURE_LIMIT) {
2001
+ this.#detachNotification(attached, "cancelled");
2002
+ logger.warn(
2003
+ `SDK notification subscription ${attached.notificationSubscription.subscriptionId} failed locally ${attached.notificationFailures} times in a row; cancelling: ${detail}`,
2004
+ );
2005
+ return;
2006
+ }
1835
2007
  logger.warn(
1836
- `SDK notification subscription ${attached.notificationSubscription.subscriptionId} failed locally: ${
1837
- error instanceof Error ? error.message : String(error)
1838
- }`,
2008
+ `SDK notification subscription ${attached.notificationSubscription.subscriptionId} refused one frame (${attached.notificationFailures}/${NOTIFICATION_LOCAL_FAILURE_LIMIT}); subscription retained: ${detail}`,
1839
2009
  );
1840
2010
  },
1841
2011
  );
@@ -1979,7 +2149,21 @@ export class SessionRouter {
1979
2149
  }
1980
2150
  const correlated = this.#correlateFrame(frame);
1981
2151
  if (!correlated) return;
1982
- const seq = typeof frame.seq === "number" && Number.isSafeInteger(frame.seq) ? frame.seq : undefined;
2152
+ const sequenceClaim = readFrameSequenceClaim(frame);
2153
+ if (!sequenceClaim.valid) {
2154
+ this.#failBarrier(attached, "protocol error: malformed sequence coordinate");
2155
+ return;
2156
+ }
2157
+ const correlatedSeq = correlated.seq === undefined ? undefined : readSequence(correlated.seq);
2158
+ if (correlated.seq !== undefined && correlatedSeq === undefined) {
2159
+ this.#failBarrier(attached, "protocol error: malformed correlated sequence coordinate");
2160
+ return;
2161
+ }
2162
+ if (sequenceClaim.claimed && correlatedSeq !== undefined && correlatedSeq !== sequenceClaim.seq) {
2163
+ this.#failBarrier(attached, "protocol error: inconsistent sequence coordinate");
2164
+ return;
2165
+ }
2166
+ const seq = sequenceClaim.seq ?? correlatedSeq;
1983
2167
  if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
1984
2168
  if (correlated.generation !== undefined && correlated.generation !== attached.generation) return;
1985
2169
  if (seq !== undefined && correlated.generation === undefined) return;
@@ -2092,7 +2276,9 @@ export class SessionRouter {
2092
2276
  this.#failBarrier(attached, "replay went unanswered");
2093
2277
  return;
2094
2278
  }
2095
- await Bun.sleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt);
2279
+ // Awaited backoff rather than a raced timeout, but it must still not sleep
2280
+ // through a shutdown: cancelling on stop lets the process exit promptly.
2281
+ await cancellableSleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt, this.#stopController.signal);
2096
2282
  if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
2097
2283
  }
2098
2284
  }