@gajae-code/coding-agent 0.11.6 → 0.11.8

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 (259) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/commands/daemon.d.ts +2 -2
  6. package/dist/types/commands/sdk.d.ts +20 -2
  7. package/dist/types/config/keybindings.d.ts +34 -4
  8. package/dist/types/config/model-profile-activation.d.ts +4 -3
  9. package/dist/types/config/model-profile-contract.d.ts +38 -0
  10. package/dist/types/config/settings-schema.d.ts +70 -7
  11. package/dist/types/edit/modes/patch.d.ts +13 -0
  12. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  16. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
  18. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
  20. package/dist/types/main.d.ts +32 -1
  21. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  22. package/dist/types/modes/components/model-selector.d.ts +2 -0
  23. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  25. package/dist/types/modes/components/welcome.d.ts +2 -0
  26. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  29. package/dist/types/modes/interactive-mode.d.ts +7 -2
  30. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  31. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  32. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  33. package/dist/types/modes/types.d.ts +4 -0
  34. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  35. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/sdk/broker/broker.d.ts +2 -0
  40. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  41. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
  42. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  43. package/dist/types/sdk/bus/config.d.ts +2 -0
  44. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  45. package/dist/types/sdk/bus/index.d.ts +26 -1
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  49. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  50. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
  51. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  52. package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
  53. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  54. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  55. package/dist/types/sdk/client/discovery.d.ts +2 -0
  56. package/dist/types/sdk/client/liveness.d.ts +7 -0
  57. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  58. package/dist/types/sdk/host/host.d.ts +1 -1
  59. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  60. package/dist/types/sdk/index.d.ts +2 -0
  61. package/dist/types/sdk/prompt-status.d.ts +88 -0
  62. package/dist/types/sdk/session-directory.d.ts +1 -1
  63. package/dist/types/sdk/startup-capability.d.ts +3 -0
  64. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  65. package/dist/types/sdk/transport/index.d.ts +12 -0
  66. package/dist/types/sdk/transport/relay.d.ts +43 -0
  67. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  68. package/dist/types/sdk/transport/socket.d.ts +7 -0
  69. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  70. package/dist/types/session/agent-session.d.ts +30 -5
  71. package/dist/types/session/btw-contract.d.ts +16 -0
  72. package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
  73. package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
  74. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  75. package/dist/types/session/session-manager.d.ts +25 -4
  76. package/dist/types/session/session-storage.d.ts +18 -0
  77. package/dist/types/setup/credential-import.d.ts +5 -1
  78. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  79. package/dist/types/ssh/utils.d.ts +1 -0
  80. package/dist/types/task/discovery.d.ts +3 -1
  81. package/dist/types/task/index.d.ts +3 -1
  82. package/dist/types/task/receipt.d.ts +2 -0
  83. package/dist/types/task/types.d.ts +94 -0
  84. package/dist/types/tools/ask.d.ts +454 -174
  85. package/dist/types/tools/fetch.d.ts +5 -8
  86. package/dist/types/tools/image-gen.d.ts +63 -1
  87. package/dist/types/tools/index.d.ts +4 -0
  88. package/dist/types/tools/skill-discovery.d.ts +7 -0
  89. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  90. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  91. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  92. package/dist/types/web/insane/bridge.d.ts +1 -0
  93. package/dist/types/web/insane/url-guard.d.ts +17 -0
  94. package/package.json +8 -8
  95. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  96. package/scripts/dogfood-repository-binding.ts +209 -0
  97. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  98. package/scripts/run-test-manifest.ts +4 -4
  99. package/src/capability/index.ts +50 -42
  100. package/src/capability/types.ts +3 -0
  101. package/src/cli/args.ts +2 -92
  102. package/src/cli/notify-cli.ts +5 -38
  103. package/src/cli/setup-cli.ts +10 -4
  104. package/src/cli.ts +52 -9
  105. package/src/commands/daemon.ts +22 -5
  106. package/src/commands/sdk.ts +44 -11
  107. package/src/config/keybindings.ts +158 -25
  108. package/src/config/model-profile-activation.ts +7 -18
  109. package/src/config/model-profile-contract.ts +161 -0
  110. package/src/config/model-profiles.ts +18 -0
  111. package/src/config/model-registry.ts +11 -1
  112. package/src/config/settings-schema.ts +73 -7
  113. package/src/coordinator-mcp/model-preset.ts +21 -74
  114. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  115. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  116. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  117. package/src/discovery/ssh.ts +12 -1
  118. package/src/edit/index.ts +3 -6
  119. package/src/edit/modes/patch.ts +38 -1
  120. package/src/edit/modes/replace.ts +42 -0
  121. package/src/edit/path-mutation-lock.ts +112 -0
  122. package/src/eval/py/executor.ts +30 -11
  123. package/src/eval/py/kernel.ts +1 -21
  124. package/src/eval/py/prelude.py +2 -2
  125. package/src/eval/py/runner-artifact.ts +84 -0
  126. package/src/eval/py/tool-bridge.ts +34 -18
  127. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  128. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  129. package/src/gjc-runtime/launch-tmux.ts +1 -1
  130. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  131. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  132. package/src/gjc-runtime/repository-binding.ts +267 -0
  133. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  134. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  135. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  136. package/src/gjc-runtime/ultragoal-guard.ts +93 -15
  137. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  138. package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
  139. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  140. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  141. package/src/internal-urls/docs-index.generated.ts +12 -10
  142. package/src/internal-urls/local-protocol.ts +64 -21
  143. package/src/internal-urls/mcp-protocol.ts +73 -11
  144. package/src/main.ts +111 -37
  145. package/src/modes/DESIGN.md +5 -0
  146. package/src/modes/acp/acp-agent.ts +50 -16
  147. package/src/modes/action-registry.ts +1 -1
  148. package/src/modes/components/btw-panel.ts +82 -16
  149. package/src/modes/components/model-selector.ts +73 -11
  150. package/src/modes/components/plan-preview-overlay.ts +1 -1
  151. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  152. package/src/modes/components/tool-status-header.ts +25 -4
  153. package/src/modes/components/welcome.ts +22 -18
  154. package/src/modes/controllers/btw-controller.ts +119 -36
  155. package/src/modes/controllers/command-controller.ts +97 -54
  156. package/src/modes/controllers/event-controller.ts +10 -0
  157. package/src/modes/controllers/input-controller.ts +38 -2
  158. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  159. package/src/modes/controllers/selector-controller.ts +149 -63
  160. package/src/modes/interactive-mode.ts +57 -34
  161. package/src/modes/prompt-action-autocomplete.ts +32 -13
  162. package/src/modes/prompt-suggestion-controller.ts +96 -0
  163. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  164. package/src/modes/types.ts +4 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  166. package/src/modes/utils/ui-helpers.ts +5 -3
  167. package/src/prompts/agents/architect.md +0 -1
  168. package/src/prompts/agents/critic.md +0 -1
  169. package/src/prompts/system/btw-user.md +3 -8
  170. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  171. package/src/prompts/system/system-prompt.md +6 -4
  172. package/src/prompts/tools/cron.md +3 -1
  173. package/src/prompts/tools/skill-discovery.md +1 -0
  174. package/src/runtime-mcp/client.ts +41 -55
  175. package/src/runtime-mcp/content-limits.ts +71 -0
  176. package/src/runtime-mcp/json-rpc.ts +23 -9
  177. package/src/runtime-mcp/manager.ts +51 -53
  178. package/src/runtime-mcp/oauth-flow.ts +0 -72
  179. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  180. package/src/runtime-mcp/transports/http.ts +63 -22
  181. package/src/sdk/broker/broker.ts +34 -20
  182. package/src/sdk/broker/lifecycle.ts +185 -36
  183. package/src/sdk/bus/chat-daemon-control.ts +19 -5
  184. package/src/sdk/bus/config-commands.ts +2 -2
  185. package/src/sdk/bus/config.ts +9 -1
  186. package/src/sdk/bus/conversation-store.ts +40 -6
  187. package/src/sdk/bus/index.ts +244 -70
  188. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  189. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  190. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  191. package/src/sdk/bus/notification-orchestration.ts +12 -0
  192. package/src/sdk/bus/notification-service.ts +68 -20
  193. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  194. package/src/sdk/bus/recent-activity.ts +90 -26
  195. package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
  196. package/src/sdk/bus/telegram-daemon-control.ts +147 -35
  197. package/src/sdk/bus/telegram-daemon.ts +2463 -450
  198. package/src/sdk/bus/telegram-reference.ts +20 -2
  199. package/src/sdk/bus/topic-registry.ts +451 -12
  200. package/src/sdk/cli/session-cli.ts +15 -2
  201. package/src/sdk/client/discovery.ts +19 -3
  202. package/src/sdk/client/liveness.ts +49 -0
  203. package/src/sdk/host/control/dispatch.ts +1 -1
  204. package/src/sdk/host/control/operations.ts +1 -1
  205. package/src/sdk/host/host.ts +2 -6
  206. package/src/sdk/host/query/handlers.ts +70 -5
  207. package/src/sdk/index.ts +12 -0
  208. package/src/sdk/prompt-status.ts +97 -0
  209. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  210. package/src/sdk/protocol/operation-registry.ts +18 -4
  211. package/src/sdk/session-directory.ts +5 -1
  212. package/src/sdk/session.ts +31 -5
  213. package/src/sdk/startup-capability.ts +9 -1
  214. package/src/sdk/transport/auth-preface.ts +82 -0
  215. package/src/sdk/transport/index.ts +18 -0
  216. package/src/sdk/transport/relay.ts +252 -0
  217. package/src/sdk/transport/serve-cli.ts +105 -0
  218. package/src/sdk/transport/socket.ts +197 -0
  219. package/src/sdk/transport/stdio.ts +17 -0
  220. package/src/session/agent-session.ts +292 -154
  221. package/src/session/blob-store.ts +25 -7
  222. package/src/session/btw-contract.ts +59 -0
  223. package/src/session/internal/managed-session-scope.ts +1163 -137
  224. package/src/session/internal/managed-session-storage.ts +397 -234
  225. package/src/session/internal/native-publish-outcome.ts +266 -0
  226. package/src/session/session-manager.ts +620 -403
  227. package/src/session/session-storage.ts +128 -11
  228. package/src/setup/credential-import.ts +28 -3
  229. package/src/setup/provider-onboarding.ts +30 -1
  230. package/src/setup/provider-presets.json +2 -1
  231. package/src/slash-commands/builtin-registry.ts +1 -1
  232. package/src/ssh/utils.ts +14 -0
  233. package/src/task/discovery.ts +8 -2
  234. package/src/task/gjc-command.ts +2 -2
  235. package/src/task/index.ts +121 -15
  236. package/src/task/receipt.ts +3 -0
  237. package/src/task/types.ts +34 -3
  238. package/src/tools/ask.ts +199 -89
  239. package/src/tools/ast-edit.ts +2 -2
  240. package/src/tools/bash-pty-selection.ts +2 -2
  241. package/src/tools/browser/screenshot-format.ts +6 -1
  242. package/src/tools/cron.ts +1 -1
  243. package/src/tools/fetch.ts +13 -131
  244. package/src/tools/image-gen.ts +476 -23
  245. package/src/tools/index.ts +4 -0
  246. package/src/tools/read.ts +2 -0
  247. package/src/tools/render-utils.ts +7 -1
  248. package/src/tools/skill-discovery.ts +39 -3
  249. package/src/tools/sqlite-reader.ts +63 -5
  250. package/src/utils/edit-mode.ts +2 -2
  251. package/src/utils/pasted-image-path.ts +15 -6
  252. package/src/utils/prompt-suggestion.ts +265 -0
  253. package/src/utils/shell-snapshot.ts +173 -63
  254. package/src/utils/sixel.ts +3 -3
  255. package/src/web/insane/bridge.ts +5 -1
  256. package/src/web/insane/url-guard.ts +140 -27
  257. package/src/web/scrapers/docs-rs.ts +30 -5
  258. package/src/web/scrapers/types.ts +19 -49
  259. package/src/web/scrapers/utils.ts +12 -26
@@ -20,6 +20,7 @@ import {
20
20
  import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
21
21
  import {
22
22
  acquireDaemonTransitionLock,
23
+ classifyNotificationEndpoint,
23
24
  type DaemonTransitionLock,
24
25
  daemonTransitionLockIsHeld,
25
26
  exactUnlinkNotificationFile,
@@ -36,7 +37,7 @@ import {
36
37
  type TelegramDaemonControlDeps,
37
38
  TelegramDaemonController,
38
39
  } from "./telegram-daemon-control";
39
- import { withTelegramSetupLease } from "./telegram-setup";
40
+ import { type TelegramSetupPreflight, withTelegramSetupLease } from "./telegram-setup";
40
41
 
41
42
  export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
42
43
 
@@ -60,6 +61,7 @@ import {
60
61
  formatLifecycleOutcome,
61
62
  isLifecycleCommandLikeText,
62
63
  isLifecycleCommandText,
64
+ type LifecycleCommandVerb,
63
65
  lifecycleUsage,
64
66
  parseLifecycleCommand,
65
67
  validateLifecycleTarget,
@@ -95,7 +97,7 @@ import {
95
97
  } from "./telegram-reference";
96
98
  import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
97
99
  import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
98
- import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
100
+ import { type TopicEndpointBinding, TopicRegistry, type TopicRegistryState } from "./topic-registry";
99
101
 
100
102
  export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
101
103
  /** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
@@ -141,7 +143,7 @@ export interface TelegramDaemonFs {
141
143
  open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
142
144
  readdir(path: string): Promise<string[]>;
143
145
  chmod(path: string, mode: number): Promise<void>;
144
- stat?(path: string): Promise<{ mtimeMs: number }>;
146
+ stat?(path: string): Promise<{ mtimeMs: number; size?: number; dev?: number; ino?: number; ctimeMs?: number }>;
145
147
  readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
146
148
  exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
147
149
  }
@@ -221,11 +223,45 @@ const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
221
223
  // buffered ask is delivered up to 25s late — or never, if the user answers the
222
224
  // local ask first (which clears the buffered ask).
223
225
  const SESSION_SCAN_INTERVAL_MS = 1_000;
226
+ // Retry a session endpoint whose socket never completes its WebSocket handshake.
227
+ const CONNECTING_RECONNECT_MS = 1_000;
228
+
224
229
  // Transient Telegram API delivery is retried this many times before giving up.
225
230
  const BOT_API_RETRY_ATTEMPTS = 3;
226
231
  // Backoff after a failed getUpdates long-poll so a persistent outage does not
227
232
  // busy-loop the daemon.
228
233
  const POLL_BACKOFF_MS = 1_000;
234
+ const AUTOMATIC_RELOAD_COOLDOWN_MS = 10 * 60 * 1_000;
235
+ // Default freshness-poll window a cooldown contender waits for a sibling's
236
+ // replacement daemon to publish a fresh ready owner before reloading itself.
237
+ const RELOAD_FRESHNESS_WAIT_MS = 15_000;
238
+ // Cooperative-then-forced termination + replacement-readiness bounds the reload
239
+ // controller can spend while holding the reservation lock (mirrors the control
240
+ // plane's graceful/kill defaults plus a readiness wait).
241
+ const RELOAD_CONTROLLER_GRACEFUL_MS = 8_000;
242
+ const RELOAD_CONTROLLER_KILL_MS = 3_000;
243
+ const RELOAD_RESERVATION_HEADROOM_MS = 10_000;
244
+
245
+ /**
246
+ * File-lock options whose acquisition budget covers the full reload-reservation
247
+ * critical section: the in-lock freshness poll plus the controller's
248
+ * graceful+kill+readiness sequence, with headroom. A contender must be able to
249
+ * wait out a legitimate slow reload and then attach, never fail startup.
250
+ */
251
+ export function reloadReservationLockOptions(input: {
252
+ freshnessWaitMs: number;
253
+ readinessTimeoutMs: number;
254
+ retryDelayMs?: number;
255
+ }): { staleMs: number; retries: number; retryDelayMs: number } {
256
+ const retryDelayMs = Math.max(input.retryDelayMs ?? 100, 1);
257
+ const criticalMs =
258
+ Math.max(input.freshnessWaitMs, 0) +
259
+ RELOAD_CONTROLLER_GRACEFUL_MS +
260
+ RELOAD_CONTROLLER_KILL_MS +
261
+ Math.max(input.readinessTimeoutMs, 0) +
262
+ RELOAD_RESERVATION_HEADROOM_MS;
263
+ return { staleMs: 10_000, retries: Math.max(1, Math.ceil(criticalMs / retryDelayMs)), retryDelayMs };
264
+ }
229
265
  // Telegram clears a chat action after ~5s; refresh slightly sooner to keep the
230
266
  // typing indicator alive while the agent is busy.
231
267
  const TYPING_REFRESH_INTERVAL_MS = 4_000;
@@ -245,8 +281,13 @@ const BTW_USAGE_TEXT = "Usage: /btw <question>";
245
281
  const BTW_CAPACITY_TEXT = "Too many /btw questions are pending. Wait for one to finish and try again.";
246
282
  export const BTW_QUESTION_MAX_UNICODE_SCALARS = 4_096;
247
283
  export const BTW_QUESTION_MAX_UTF8_BYTES = 16_384;
284
+ const TELEGRAM_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
285
+ const TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT = 20;
286
+ const TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
287
+ const TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 60_000;
248
288
  const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar values and 16384 UTF-8 bytes.";
249
289
  type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
290
+ type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
250
291
  class ThreadedModeCapabilityRefusal extends Error {}
251
292
 
252
293
  /** Only an explicit Bot API capability refusal may enable flat private-chat delivery. */
@@ -497,9 +538,17 @@ type LegacyOwnershipLockMetadata = {
497
538
  incarnation?: string;
498
539
  startedAt: number;
499
540
  };
541
+ type V010OwnershipLockMetadata = {
542
+ size: 0;
543
+ mtimeMs?: number;
544
+ dev?: number;
545
+ ino?: number;
546
+ ctimeMs?: number;
547
+ };
500
548
  type OwnershipLockRead =
501
549
  | { kind: "missing" }
502
550
  | { kind: "malformed"; raw: string; mtimeMs?: number }
551
+ | { kind: "v010"; metadata: V010OwnershipLockMetadata }
503
552
  | { kind: "legacy"; metadata: LegacyOwnershipLockMetadata }
504
553
  | { kind: "valid"; metadata: OwnershipLockMetadata };
505
554
 
@@ -512,6 +561,20 @@ export async function readOwnershipLock(fsImpl: TelegramDaemonFs, file: string):
512
561
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
513
562
  throw error;
514
563
  }
564
+ if (raw.length === 0) {
565
+ const stat = await fsImpl.stat?.(file).catch(() => undefined);
566
+ if (stat?.size !== 0) return { kind: "malformed", raw, mtimeMs: stat?.mtimeMs };
567
+ return {
568
+ kind: "v010",
569
+ metadata: {
570
+ size: 0,
571
+ ...(stat?.mtimeMs === undefined ? {} : { mtimeMs: stat.mtimeMs }),
572
+ ...(stat?.dev === undefined ? {} : { dev: stat.dev }),
573
+ ...(stat?.ino === undefined ? {} : { ino: stat.ino }),
574
+ ...(stat?.ctimeMs === undefined ? {} : { ctimeMs: stat.ctimeMs }),
575
+ },
576
+ };
577
+ }
515
578
  try {
516
579
  const value = JSON.parse(raw) as Partial<OwnershipLockMetadata>;
517
580
  const pid = value.pid;
@@ -554,6 +617,7 @@ export function liveOwnershipLockDecision(input: {
554
617
  | { acquired: false; attached: false; blocked: true }
555
618
  | { acquired: false; attached: false; provisional: true }
556
619
  | undefined {
620
+ if (input.lock.kind === "v010") return { acquired: false, attached: false, blocked: true };
557
621
  if (input.lock.kind !== "valid" && input.lock.kind !== "legacy") return undefined;
558
622
  if (!input.pidAlive(input.lock.metadata.pid)) return undefined;
559
623
  if (input.lock.kind === "legacy") {
@@ -575,8 +639,9 @@ function ownershipLockMatches(left: OwnershipLockRead, right: OwnershipLockRead)
575
639
  if (left.kind === "missing") return true;
576
640
  if (left.kind === "malformed" && right.kind === "malformed")
577
641
  return left.raw === right.raw && left.mtimeMs !== undefined && left.mtimeMs === right.mtimeMs;
578
- if ((left.kind !== "valid" && left.kind !== "legacy") || (right.kind !== "valid" && right.kind !== "legacy"))
579
- return false;
642
+ if ((left.kind === "legacy" && right.kind === "legacy") || (left.kind === "v010" && right.kind === "v010"))
643
+ return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
644
+ if (left.kind !== "valid" || right.kind !== "valid") return false;
580
645
  return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
581
646
  }
582
647
 
@@ -591,8 +656,27 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
591
656
  );
592
657
  }
593
658
 
594
- function ownershipLockMatchesStoppedState(lock: OwnershipLockRead, state: DaemonState | undefined): boolean {
595
- return Boolean(state && isExplicitlyStoppedDaemonState(state) && ownershipLockMatchesState(lock, state));
659
+ function ownershipLockMatchesStoppedState(
660
+ lock: OwnershipLockRead,
661
+ state: unknown,
662
+ pidAlive: (pid: number) => boolean,
663
+ ): boolean {
664
+ if (isExplicitlyStoppedDaemonState(state)) return ownershipLockMatchesState(lock, state);
665
+ if (!isLegacyStoppedDaemonState(state)) return false;
666
+ const legacyState = state as Pick<DaemonState, "pid" | "startedAt" | "generation"> & { stoppedAt: number };
667
+ if (lock.kind === "v010")
668
+ return (
669
+ legacyState.generation === 3 &&
670
+ !pidAlive(legacyState.pid) &&
671
+ lock.metadata.mtimeMs !== undefined &&
672
+ lock.metadata.mtimeMs <= legacyState.stoppedAt
673
+ );
674
+ if (lock.kind !== "legacy") return false;
675
+ return (
676
+ lock.metadata.pid === legacyState.pid &&
677
+ lock.metadata.startedAt <= legacyState.startedAt &&
678
+ legacyState.startedAt <= legacyState.stoppedAt
679
+ );
596
680
  }
597
681
 
598
682
  async function transitionLockIsHeldByCaller(input: {
@@ -607,6 +691,37 @@ function ownershipLockMatchesMetadata(lock: OwnershipLockRead, metadata: Ownersh
607
691
  return lock.kind === "valid" && JSON.stringify(lock.metadata) === JSON.stringify(metadata);
608
692
  }
609
693
 
694
+ /**
695
+ * Accept an exact unlink only when it either completed or returned typed
696
+ * retained authority — a concrete detached quarantine plus a proven-absent
697
+ * canonical pathname. Anything else stays fail-closed.
698
+ */
699
+ async function exactUnlinkAcceptedWithRetainedEvidence(
700
+ fsImpl: TelegramDaemonFs,
701
+ file: string,
702
+ identity: NotificationEndpointFileIdentity,
703
+ ): Promise<boolean> {
704
+ const removed = await fsImpl.exactUnlink!(file, identity);
705
+ if (removed.ok) return true;
706
+ return (
707
+ removed.code === "cleanup_pending" &&
708
+ typeof removed.detachedPath === "string" &&
709
+ removed.detachedPath.length > 0 &&
710
+ (await fsImpl.readEndpointFile!(file).catch(() => undefined)) === undefined
711
+ );
712
+ }
713
+
714
+ async function unlinkOwnershipLockExactly(
715
+ fsImpl: TelegramDaemonFs,
716
+ file: string,
717
+ expected: OwnershipLockRead,
718
+ ): Promise<boolean> {
719
+ if (expected.kind === "missing" || !fsImpl.readEndpointFile || !fsImpl.exactUnlink) return false;
720
+ const endpoint = await fsImpl.readEndpointFile(file).catch(() => undefined);
721
+ if (!endpoint || !ownershipLockMatches(expected, await readOwnershipLock(fsImpl, file))) return false;
722
+ return await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, file, endpoint.identity);
723
+ }
724
+
610
725
  /**
611
726
  * Replace an acquisition's lease only after proving the old lease is unchanged.
612
727
  * The transition fence serializes lifecycle writers; the exact read-back prevents
@@ -670,6 +785,7 @@ async function ownershipLockIsReclaimable(input: {
670
785
  pidIncarnation: (pid: number) => string | undefined;
671
786
  }): Promise<boolean> {
672
787
  if (input.lock.kind === "missing") return true;
788
+ if (input.lock.kind === "v010") return false;
673
789
  if (input.lock.kind === "malformed") {
674
790
  if (!input.fs.stat) return false;
675
791
  const stat = await input.fs.stat(input.path).catch(() => undefined);
@@ -853,8 +969,174 @@ function notificationRootForCwd(cwd: string): string {
853
969
  return path.join(cwd, ".gjc", "state");
854
970
  }
855
971
 
972
+ /**
973
+ * Leak artifact prefixes left when native exact-unlink cleanup is retained
974
+ * (#2956). Nothing previously reaped these; they accumulate in the notifications
975
+ * directory across ownership transitions.
976
+ */
977
+ export const NOTIFICATION_LEAK_ARTIFACT_PREFIXES = [
978
+ ".gjc-delete-daemon-transition-",
979
+ ".gjc-exact-unlink-placeholder-",
980
+ ".gjc-delete-notification-endpoint-",
981
+ ] as const;
982
+
983
+ /** Grace window before a leak artifact is reaped (covers in-flight unlinks). */
984
+ export const NOTIFICATION_LEAK_ARTIFACT_GRACE_MS = 5 * 60_000;
985
+
986
+ /** True when a path is permanently gone (not a transient I/O blip). */
987
+ export function isPermanentMissingPathError(error: unknown): boolean {
988
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
989
+ return code === "ENOENT" || code === "ENOTDIR";
990
+ }
991
+
992
+ export function isNotificationLeakArtifactName(name: string): boolean {
993
+ return NOTIFICATION_LEAK_ARTIFACT_PREFIXES.some(prefix => name.startsWith(prefix));
994
+ }
995
+
996
+ type NotificationRootsRegistry = {
997
+ version?: number;
998
+ roots?: string[];
999
+ managedRoots?: string[];
1000
+ sessions?: Record<string, string>;
1001
+ };
1002
+
1003
+ /**
1004
+ * Drop permanently missing scan roots from the durable registry under the roots
1005
+ * lock. Session map entries that pointed only at those roots are removed too.
1006
+ */
1007
+ export async function pruneMissingNotificationRoots(input: {
1008
+ settings: Settings;
1009
+ fs?: TelegramDaemonFs;
1010
+ /** When set, only these roots are considered; otherwise every registered root is probed. */
1011
+ candidates?: readonly string[];
1012
+ }): Promise<{ pruned: string[]; remaining: number }> {
1013
+ const fsImpl = input.fs ?? nodeFs;
1014
+ const paths = daemonPaths(input.settings.getAgentDir());
1015
+ await ensureDir(fsImpl, paths.dir);
1016
+ const pruned: string[] = [];
1017
+ let remaining = 0;
1018
+ await withFileLock(
1019
+ paths.roots,
1020
+ async () => {
1021
+ const current = (await readJson<NotificationRootsRegistry>(fsImpl, paths.roots)) ?? {};
1022
+ const roots = [...(current.roots ?? [])];
1023
+ const managedRoots = new Set(current.managedRoots ?? []);
1024
+ const sessions = { ...(current.sessions ?? {}) };
1025
+ const candidateSet = input.candidates ? new Set(input.candidates) : undefined;
1026
+ const survivors: string[] = [];
1027
+ for (const root of roots) {
1028
+ if (candidateSet && !candidateSet.has(root)) {
1029
+ survivors.push(root);
1030
+ continue;
1031
+ }
1032
+ const sdkDir = path.join(root, "sdk");
1033
+ try {
1034
+ await fsImpl.readdir(sdkDir);
1035
+ survivors.push(root);
1036
+ } catch (error) {
1037
+ if (!isPermanentMissingPathError(error)) {
1038
+ // Transient unreadable: keep for the next pass.
1039
+ survivors.push(root);
1040
+ continue;
1041
+ }
1042
+ // Also accept a missing sdk dir when the root itself is still a directory
1043
+ // (empty registration) — still permanently useless for scans.
1044
+ try {
1045
+ await fsImpl.readdir(root);
1046
+ } catch (rootError) {
1047
+ if (!isPermanentMissingPathError(rootError)) {
1048
+ survivors.push(root);
1049
+ continue;
1050
+ }
1051
+ }
1052
+ pruned.push(root);
1053
+ managedRoots.delete(root);
1054
+ for (const [sessionId, mapped] of Object.entries(sessions)) {
1055
+ if (mapped === root) delete sessions[sessionId];
1056
+ }
1057
+ }
1058
+ }
1059
+ remaining = survivors.length;
1060
+ if (pruned.length === 0) return;
1061
+ await writeJsonAtomic(fsImpl, paths.roots, {
1062
+ version: 1,
1063
+ roots: Array.from(new Set(survivors)).sort(),
1064
+ managedRoots: Array.from(managedRoots).sort(),
1065
+ sessions,
1066
+ });
1067
+ },
1068
+ { staleMs: 10_000 },
1069
+ );
1070
+ return { pruned, remaining };
1071
+ }
1072
+
1073
+ /**
1074
+ * Reap retained exact-unlink / ownership-transition quarantine files older than
1075
+ * the grace window from the notifications directory.
1076
+ */
1077
+ export async function reapStaleNotificationArtifacts(input: {
1078
+ settings: Settings;
1079
+ fs?: TelegramDaemonFs;
1080
+ now?: () => number;
1081
+ graceMs?: number;
1082
+ }): Promise<{ removed: string[]; skipped: number }> {
1083
+ const fsImpl = input.fs ?? nodeFs;
1084
+ const paths = daemonPaths(input.settings.getAgentDir());
1085
+ await ensureDir(fsImpl, paths.dir);
1086
+ const now = input.now?.() ?? Date.now();
1087
+ const graceMs = input.graceMs ?? NOTIFICATION_LEAK_ARTIFACT_GRACE_MS;
1088
+ const removed: string[] = [];
1089
+ let skipped = 0;
1090
+ let names: string[];
1091
+ try {
1092
+ names = await fsImpl.readdir(paths.dir);
1093
+ } catch (error) {
1094
+ if (isPermanentMissingPathError(error)) return { removed, skipped };
1095
+ throw error;
1096
+ }
1097
+ for (const name of names) {
1098
+ if (!isNotificationLeakArtifactName(name)) continue;
1099
+ const file = path.join(paths.dir, name);
1100
+ try {
1101
+ const stat = fsImpl.stat ? await fsImpl.stat(file) : undefined;
1102
+ const age = stat ? now - stat.mtimeMs : Number.POSITIVE_INFINITY;
1103
+ if (Number.isFinite(age) && age < graceMs) {
1104
+ skipped += 1;
1105
+ continue;
1106
+ }
1107
+ await fsImpl.unlink(file);
1108
+ removed.push(file);
1109
+ } catch (error) {
1110
+ if (isPermanentMissingPathError(error)) continue;
1111
+ // Best-effort: a busy file must not fail daemon ownership.
1112
+ skipped += 1;
1113
+ }
1114
+ }
1115
+ return { removed, skipped };
1116
+ }
1117
+
1118
+ /**
1119
+ * Startup / ownership self-heal: prune dead roots and reap leak artifacts so
1120
+ * `gjc daemon reload` recovers a degraded install without manual surgery (#2956).
1121
+ */
1122
+ export async function healTelegramDaemonNotificationState(input: {
1123
+ settings: Settings;
1124
+ fs?: TelegramDaemonFs;
1125
+ now?: () => number;
1126
+ graceMs?: number;
1127
+ }): Promise<{ prunedRoots: string[]; removedArtifacts: string[] }> {
1128
+ const prune = await pruneMissingNotificationRoots(input);
1129
+ const reap = await reapStaleNotificationArtifacts(input);
1130
+ if (prune.pruned.length > 0 || reap.removed.length > 0) {
1131
+ logger.warn(
1132
+ `notifications: self-heal pruned ${prune.pruned.length} dead root(s), reaped ${reap.removed.length} leak artifact(s)`,
1133
+ );
1134
+ }
1135
+ return { prunedRoots: prune.pruned, removedArtifacts: reap.removed };
1136
+ }
1137
+
856
1138
  function validBotToken(token: unknown): token is string {
857
- return typeof token === "string" && token.length > 0;
1139
+ return typeof token === "string" && token.trim().length > 0;
858
1140
  }
859
1141
 
860
1142
  function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
@@ -942,24 +1224,98 @@ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
942
1224
  );
943
1225
  }
944
1226
 
945
- type LegacyParentDaemonState = Omit<DaemonState, "incarnation" | "acquisitionId" | "ownershipPhase" | "generation"> & {
1227
+ const V010_PARENT_STATE_KEYS = [
1228
+ "pid",
1229
+ "ownerId",
1230
+ "tokenFingerprint",
1231
+ "chatId",
1232
+ "startedAt",
1233
+ "heartbeatAt",
1234
+ "roots",
1235
+ "version",
1236
+ ] as const;
1237
+ const V010_GENERATION_3_PARENT_STATE_KEYS = [...V010_PARENT_STATE_KEYS, "generation"] as const;
1238
+
1239
+ type ParentDaemonStateBase = Omit<
1240
+ DaemonState,
1241
+ "incarnation" | "acquisitionId" | "ownershipPhase" | "generation" | "launcherPid"
1242
+ > & {
946
1243
  incarnation?: undefined;
947
1244
  acquisitionId?: undefined;
948
1245
  ownershipPhase?: undefined;
1246
+ generation?: unknown;
1247
+ launcherPid?: undefined;
1248
+ };
1249
+ type GenerationAbsentParentDaemonState = Omit<ParentDaemonStateBase, "generation"> & {
949
1250
  generation?: undefined;
950
1251
  };
1252
+ type Generation3ReleaseDaemonState = Omit<ParentDaemonStateBase, "generation"> & {
1253
+ generation: 3;
1254
+ };
1255
+ export type LegacyParentDaemonState = GenerationAbsentParentDaemonState | Generation3ReleaseDaemonState;
951
1256
 
952
1257
  interface LegacyMigrationAttestation {
1258
+ stateDigest: string;
1259
+ confirmed?: true;
1260
+ lock?: V010OwnershipLockMetadata;
953
1261
  pid: number;
954
1262
  incarnation: string;
955
1263
  heartbeatAt: number;
956
1264
  observedAt: number;
1265
+ tokenFingerprint: string;
1266
+ chatId: string;
1267
+ }
1268
+
1269
+ function historicalStateSerializer(state: LegacyParentDaemonState): string {
1270
+ const historicalState = {
1271
+ pid: state.pid,
1272
+ ownerId: state.ownerId,
1273
+ tokenFingerprint: state.tokenFingerprint,
1274
+ chatId: state.chatId,
1275
+ startedAt: state.startedAt,
1276
+ heartbeatAt: state.heartbeatAt,
1277
+ roots: state.roots,
1278
+ version: state.version,
1279
+ ...(state.generation === undefined ? {} : { generation: 3 }),
1280
+ };
1281
+ return `${JSON.stringify(historicalState, null, 2)}\n`;
1282
+ }
1283
+
1284
+ function legacyParentStateDigest(state: LegacyParentDaemonState): string {
1285
+ const { heartbeatAt: _heartbeatAt, ...immutableState } = state;
1286
+ return crypto
1287
+ .createHash("sha256")
1288
+ .update(historicalStateSerializer({ ...immutableState, heartbeatAt: 0 }))
1289
+ .digest("hex");
1290
+ }
1291
+
1292
+ function hasExactParentStateKeys(state: object, keys: readonly string[]): boolean {
1293
+ const actual = Object.keys(state);
1294
+ return actual.length === keys.length && actual.every((key, index) => key === keys[index]);
1295
+ }
1296
+
1297
+ function isGenerationAbsentParentDaemonState(state: unknown): state is GenerationAbsentParentDaemonState {
1298
+ return hasParentDaemonStateShape(state) && hasExactParentStateKeys(state, V010_PARENT_STATE_KEYS);
1299
+ }
1300
+
1301
+ function isGeneration3ReleaseDaemonState(state: unknown): state is Generation3ReleaseDaemonState {
1302
+ return (
1303
+ hasParentDaemonStateShape(state) &&
1304
+ state.generation === 3 &&
1305
+ hasExactParentStateKeys(state, V010_GENERATION_3_PARENT_STATE_KEYS)
1306
+ );
1307
+ }
1308
+
1309
+ function isParentDaemonState(state: unknown): state is LegacyParentDaemonState {
1310
+ return isGenerationAbsentParentDaemonState(state) || isGeneration3ReleaseDaemonState(state);
957
1311
  }
958
1312
 
959
- function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonState {
960
- const candidate = state as Partial<LegacyParentDaemonState> | undefined;
1313
+ function hasParentDaemonStateShape(state: unknown): state is ParentDaemonStateBase {
1314
+ const candidate = state as Partial<ParentDaemonStateBase> | undefined;
961
1315
  return Boolean(
962
1316
  candidate &&
1317
+ typeof candidate === "object" &&
1318
+ !Array.isArray(candidate) &&
963
1319
  Number.isSafeInteger(candidate.pid) &&
964
1320
  (candidate.pid ?? 0) > 0 &&
965
1321
  typeof candidate.ownerId === "string" &&
@@ -974,11 +1330,70 @@ function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonS
974
1330
  candidate.incarnation === undefined &&
975
1331
  candidate.acquisitionId === undefined &&
976
1332
  candidate.ownershipPhase === undefined &&
977
- candidate.generation === undefined &&
978
- candidate.stoppedAt === undefined,
1333
+ candidate.launcherPid === undefined &&
1334
+ candidate.stoppedAt === undefined &&
1335
+ (candidate.generation === undefined || candidate.generation === 3),
979
1336
  );
980
1337
  }
981
1338
 
1339
+ const isLegacyParentDaemonState = isParentDaemonState;
1340
+
1341
+ /**
1342
+ * A live v0.10-shaped record is authority only in its exact historical form.
1343
+ * Parsed JSON alone cannot establish that form: field order and serialization
1344
+ * bytes are part of the migration attestation.
1345
+ */
1346
+ async function isLiveNoncanonicalParentState(input: {
1347
+ fs: TelegramDaemonFs;
1348
+ statePath: string;
1349
+ state: unknown;
1350
+ pidAlive: (pid: number) => boolean;
1351
+ pidIncarnation: (pid: number) => string | undefined;
1352
+ tokenFingerprint: string;
1353
+ chatId: string;
1354
+ }): Promise<boolean> {
1355
+ const candidate = input.state as Partial<ParentDaemonStateBase> | undefined;
1356
+ if (
1357
+ !candidate ||
1358
+ typeof candidate !== "object" ||
1359
+ Array.isArray(candidate) ||
1360
+ !validDaemonPid(candidate.pid) ||
1361
+ typeof candidate.ownerId !== "string" ||
1362
+ candidate.ownerId.length === 0 ||
1363
+ typeof candidate.tokenFingerprint !== "string" ||
1364
+ typeof candidate.chatId !== "string" ||
1365
+ !Number.isSafeInteger(candidate.startedAt) ||
1366
+ !Number.isSafeInteger(candidate.heartbeatAt) ||
1367
+ !Array.isArray(candidate.roots) ||
1368
+ !candidate.roots.every(root => typeof root === "string") ||
1369
+ candidate.version !== DAEMON_VERSION ||
1370
+ !input.pidAlive(candidate.pid)
1371
+ )
1372
+ return false;
1373
+ if (
1374
+ isStoppedDaemonState(candidate) ||
1375
+ (candidate.incarnation !== undefined &&
1376
+ (candidate.acquisitionId !== undefined || candidate.ownershipPhase !== undefined))
1377
+ )
1378
+ return false;
1379
+ if (
1380
+ candidate.incarnation !== undefined &&
1381
+ isProcessIncarnation(input.pidIncarnation(candidate.pid)) &&
1382
+ input.pidIncarnation(candidate.pid) !== candidate.incarnation &&
1383
+ !(candidate.tokenFingerprint === input.tokenFingerprint && candidate.chatId === input.chatId)
1384
+ )
1385
+ return false;
1386
+ if (!isParentDaemonState(candidate)) return true;
1387
+ try {
1388
+ return (await input.fs.readFile(input.statePath, "utf8")) !== historicalStateSerializer(candidate);
1389
+ } catch {
1390
+ return false;
1391
+ }
1392
+ }
1393
+ function legacyOwnershipLockMatchesHandoffState(lock: OwnershipLockRead, state: unknown): boolean {
1394
+ return lock.kind === "v010" && isGeneration3ReleaseDaemonState(state);
1395
+ }
1396
+
982
1397
  function legacyMigrationAttestationPath(statePath: string): string {
983
1398
  return `${statePath}.legacy-migration.json`;
984
1399
  }
@@ -986,6 +1401,7 @@ function legacyMigrationAttestationPath(statePath: string): string {
986
1401
  async function legacyParentHandoffDecision(input: {
987
1402
  fs: TelegramDaemonFs;
988
1403
  statePath: string;
1404
+ lockPath: string;
989
1405
  state: LegacyParentDaemonState;
990
1406
  now: number;
991
1407
  pidAlive: (pid: number) => boolean;
@@ -993,7 +1409,15 @@ async function legacyParentHandoffDecision(input: {
993
1409
  tokenFingerprint: string;
994
1410
  chatId: string;
995
1411
  }): Promise<
996
- { acquired: false; attached: false; provisional?: boolean; reloadRequired?: boolean; blocked?: boolean } | undefined
1412
+ | {
1413
+ acquired: false;
1414
+ attached: false;
1415
+ provisional?: boolean;
1416
+ reloadRequired?: boolean;
1417
+ legacyReloadRequired?: boolean;
1418
+ blocked?: boolean;
1419
+ }
1420
+ | undefined
997
1421
  > {
998
1422
  const { state } = input;
999
1423
  if (!input.pidAlive(state.pid)) return undefined;
@@ -1001,33 +1425,133 @@ async function legacyParentHandoffDecision(input: {
1001
1425
  return { acquired: false, attached: false, blocked: true };
1002
1426
  const incarnation = input.pidIncarnation(state.pid);
1003
1427
  if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
1004
- const attestationPath = legacyMigrationAttestationPath(input.statePath);
1005
- const previous = await readJson<LegacyMigrationAttestation>(input.fs, attestationPath);
1428
+ let stateBytes: string;
1429
+ try {
1430
+ stateBytes = await input.fs.readFile(input.statePath, "utf8");
1431
+ } catch {
1432
+ return { acquired: false, attached: false, provisional: true };
1433
+ }
1434
+ if (stateBytes !== historicalStateSerializer(state)) return { acquired: false, attached: false, blocked: true };
1435
+ const lock = await readOwnershipLock(input.fs, input.lockPath);
1436
+ const attestationLock =
1437
+ state.generation === undefined && lock.kind === "missing"
1438
+ ? undefined
1439
+ : lock.kind === "v010" && legacyOwnershipLockMatchesHandoffState(lock, state)
1440
+ ? lock.metadata
1441
+ : null;
1442
+ if (attestationLock === null) return { acquired: false, attached: false, blocked: true };
1443
+ const previous = await readJson<LegacyMigrationAttestation>(
1444
+ input.fs,
1445
+ legacyMigrationAttestationPath(input.statePath),
1446
+ );
1447
+ const stateDigest = legacyParentStateDigest(state);
1006
1448
  const attested = Boolean(
1007
1449
  previous &&
1008
- Number.isSafeInteger(previous.pid) &&
1009
- isProcessIncarnation(previous.incarnation) &&
1010
- Number.isSafeInteger(previous.heartbeatAt) &&
1011
- Number.isSafeInteger(previous.observedAt) &&
1450
+ previous.stateDigest === stateDigest &&
1451
+ JSON.stringify(previous.lock) === JSON.stringify(attestationLock) &&
1012
1452
  previous.pid === state.pid &&
1013
1453
  previous.incarnation === incarnation &&
1454
+ previous.tokenFingerprint === input.tokenFingerprint &&
1455
+ previous.chatId === input.chatId &&
1014
1456
  state.heartbeatAt > previous.heartbeatAt &&
1015
1457
  input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
1016
1458
  );
1017
- await writeJsonAtomic(input.fs, attestationPath, {
1459
+ await writeJsonAtomic(input.fs, legacyMigrationAttestationPath(input.statePath), {
1460
+ stateDigest,
1461
+ ...(attested ? { confirmed: true as const } : {}),
1462
+ ...(attestationLock === undefined ? {} : { lock: attestationLock }),
1018
1463
  pid: state.pid,
1019
1464
  incarnation,
1020
1465
  heartbeatAt: state.heartbeatAt,
1021
1466
  observedAt: input.now,
1467
+ tokenFingerprint: input.tokenFingerprint,
1468
+ chatId: input.chatId,
1022
1469
  } satisfies LegacyMigrationAttestation);
1023
- return attested
1024
- ? { acquired: false, attached: false, reloadRequired: true }
1025
- : { acquired: false, attached: false, provisional: true };
1470
+ if (!attested) return { acquired: false, attached: false, provisional: true };
1471
+ return { acquired: false, attached: false, reloadRequired: true, legacyReloadRequired: true };
1472
+ }
1473
+
1474
+ export interface AttestedLegacyDaemonOwner {
1475
+ state: LegacyParentDaemonState;
1476
+ incarnation: string;
1477
+ }
1478
+
1479
+ /** Revalidate the exact two-observation legacy proof immediately before signaling. */
1480
+ export async function readAttestedLegacyDaemonOwner(input: {
1481
+ settings: Settings;
1482
+ fs?: TelegramDaemonFs;
1483
+ now?: () => number;
1484
+ pidIncarnation?: (pid: number) => string | undefined;
1485
+ tokenFingerprint: string;
1486
+ chatId: string;
1487
+ }): Promise<AttestedLegacyDaemonOwner | undefined> {
1488
+ const fsImpl = input.fs ?? nodeFs;
1489
+ const paths = daemonPaths(input.settings.getAgentDir());
1490
+ const state = await readJson<unknown>(fsImpl, paths.state);
1491
+ if (!isGeneration3ReleaseDaemonState(state)) return undefined;
1492
+ let stateBytes: string;
1493
+ try {
1494
+ stateBytes = await fsImpl.readFile(paths.state, "utf8");
1495
+ } catch {
1496
+ return undefined;
1497
+ }
1498
+ if (stateBytes !== historicalStateSerializer(state)) return undefined;
1499
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
1500
+ if (lock.kind !== "v010") return undefined;
1501
+ const attestation = await readJson<LegacyMigrationAttestation>(fsImpl, legacyMigrationAttestationPath(paths.state));
1502
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(state.pid);
1503
+ const now = (input.now ?? Date.now)();
1504
+ if (
1505
+ attestation?.confirmed !== true ||
1506
+ !isProcessIncarnation(incarnation) ||
1507
+ attestation.stateDigest !== legacyParentStateDigest(state) ||
1508
+ JSON.stringify(attestation.lock) !== JSON.stringify(lock.metadata) ||
1509
+ attestation.pid !== state.pid ||
1510
+ attestation.incarnation !== incarnation ||
1511
+ attestation.tokenFingerprint !== input.tokenFingerprint ||
1512
+ attestation.chatId !== input.chatId ||
1513
+ attestation.heartbeatAt > state.heartbeatAt ||
1514
+ now < attestation.observedAt ||
1515
+ now - attestation.observedAt > HEARTBEAT_TTL_MS
1516
+ )
1517
+ return undefined;
1518
+ return { state, incarnation };
1026
1519
  }
1027
1520
 
1028
1521
  function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
1029
- return (
1030
- generation === undefined || (Number.isSafeInteger(generation) && generation > 0 && generation < DAEMON_GENERATION)
1522
+ return generation === undefined || generation === 3;
1523
+ }
1524
+
1525
+ /**
1526
+ * A predecessor is reloadable only when it has the complete modern ownership
1527
+ * proof. Generation is absent from the first fully-provenanced modern records,
1528
+ * so it is an incompatible predecessor too; parent-format records remain
1529
+ * excluded because they lack this ownership proof.
1530
+ */
1531
+ function hasFullModernOwnerProvenance(
1532
+ state: DaemonState | undefined,
1533
+ pidIncarnation?: (pid: number) => string | undefined,
1534
+ ): boolean {
1535
+ return Boolean(
1536
+ hasSafeDaemonStateShape(state) &&
1537
+ state.generation !== 3 &&
1538
+ state.ownershipPhase === "ready" &&
1539
+ typeof state.acquisitionId === "string" &&
1540
+ state.acquisitionId.length > 0 &&
1541
+ ownerProvenanceMatches(state, pidIncarnation),
1542
+ );
1543
+ }
1544
+
1545
+ function isFullModernPredecessor(
1546
+ state: DaemonState | undefined,
1547
+ pidIncarnation?: (pid: number) => string | undefined,
1548
+ ): boolean {
1549
+ return Boolean(
1550
+ hasFullModernOwnerProvenance(state, pidIncarnation) &&
1551
+ (state?.generation === undefined ||
1552
+ (Number.isSafeInteger(state?.generation) &&
1553
+ (state?.generation as number) > 0 &&
1554
+ (state?.generation as number) < DAEMON_GENERATION)),
1031
1555
  );
1032
1556
  }
1033
1557
 
@@ -1050,7 +1574,7 @@ function classifyForeignLiveOwner(input: {
1050
1574
  if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
1051
1575
  // A stopped tombstone with a canonical acquisition is not a live owner.
1052
1576
  if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1053
- const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
1577
+ const currentIncarnation = input.pidIncarnation ? input.pidIncarnation(state.pid) : defaultPidIncarnation(state.pid);
1054
1578
  if (
1055
1579
  !hasSafeDaemonStateShape(state) ||
1056
1580
  !isProcessIncarnation(state.incarnation) ||
@@ -1069,18 +1593,23 @@ export function isPhysicalMatchingOwner(input: {
1069
1593
  pidAlive: (pid: number) => boolean;
1070
1594
  pidIncarnation?: (pid: number) => string | undefined;
1071
1595
  }): boolean {
1072
- const { state } = input;
1073
- return Boolean(
1074
- state &&
1075
- hasSafeDaemonStateShape(state) &&
1076
- state.stoppedAt === undefined &&
1077
- ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1078
- input.pidAlive(state.pid) &&
1079
- ownerProvenanceMatches(state, input.pidIncarnation),
1080
- );
1596
+ const persistedState: unknown = input.state;
1597
+ const legacyParentState = isLegacyParentDaemonState(persistedState) ? persistedState : undefined;
1598
+ const modernState = hasSafeDaemonStateShape(persistedState) ? persistedState : undefined;
1599
+ const state = modernState ?? legacyParentState;
1600
+ if (
1601
+ !state ||
1602
+ state.stoppedAt !== undefined ||
1603
+ !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
1604
+ !input.pidAlive(state.pid)
1605
+ )
1606
+ return false;
1607
+ return legacyParentState
1608
+ ? isProcessIncarnation((input.pidIncarnation ?? defaultPidIncarnation)(legacyParentState.pid))
1609
+ : modernState !== undefined && ownerProvenanceMatches(modernState, input.pidIncarnation);
1081
1610
  }
1082
1611
 
1083
- /** True only for a signalable legacy owner or this generation's ready bound child. */
1612
+ /** True only for a fully-provenanced modern owner outside the generation-3 parent schema. */
1084
1613
  export function isSignalableMatchingOwner(input: {
1085
1614
  state: DaemonState | undefined;
1086
1615
  tokenFingerprint: string;
@@ -1089,14 +1618,7 @@ export function isSignalableMatchingOwner(input: {
1089
1618
  pidIncarnation?: (pid: number) => string | undefined;
1090
1619
  }): boolean {
1091
1620
  const { state } = input;
1092
- return Boolean(
1093
- isPhysicalMatchingOwner(input) &&
1094
- (isRecognizedLegacyGeneration(state?.generation) ||
1095
- (state?.generation === DAEMON_GENERATION &&
1096
- state.ownershipPhase === "ready" &&
1097
- typeof state.acquisitionId === "string" &&
1098
- state.acquisitionId.length > 0)),
1099
- );
1621
+ return Boolean(isPhysicalMatchingOwner(input) && hasFullModernOwnerProvenance(state, input.pidIncarnation));
1100
1622
  }
1101
1623
 
1102
1624
  export function isFreshLiveOwner(input: {
@@ -1163,6 +1685,7 @@ export async function acquireDaemonOwnership(input: {
1163
1685
  provisional?: boolean;
1164
1686
  reason?: "identity_mismatch";
1165
1687
  reloadRequired?: boolean;
1688
+ legacyReloadRequired?: boolean;
1166
1689
  }> {
1167
1690
  const fsImpl = input.fs ?? nodeFs;
1168
1691
  const now = input.now ?? Date.now;
@@ -1199,6 +1722,7 @@ export async function acquireDaemonOwnership(input: {
1199
1722
  }
1200
1723
  if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
1201
1724
  return undefined;
1725
+ if (state.generation === 3) return { acquired: false, attached: false, blocked: true };
1202
1726
  // Unavailable provenance is ambiguous and must remain fail-closed. A
1203
1727
  // different authoritative incarnation proves PID reuse, so allow only the
1204
1728
  // transition-locked path below to reclaim the stale owner artifacts.
@@ -1221,24 +1745,33 @@ export async function acquireDaemonOwnership(input: {
1221
1745
  )
1222
1746
  return { acquired: false, attached: true };
1223
1747
  // A physical owner that cannot prove current compatibility is never safe to
1224
- // attach. Legacy, stale, and malformed generations require a handoff;
1225
- // otherwise retain the explicit provisional classification.
1748
+ // attach. A reload handoff additionally requires a fresh heartbeat: a stale
1749
+ // record must remain blocked rather than authorizing a signal to its PID.
1226
1750
  if (
1227
- state.version !== DAEMON_VERSION ||
1228
- !isFreshLiveOwner({
1751
+ isFreshLiveOwner({
1229
1752
  state,
1230
1753
  now: now(),
1231
1754
  tokenFingerprint: input.tokenFingerprint,
1232
1755
  chatId: input.chatId,
1233
1756
  pidAlive,
1234
1757
  pidIncarnation,
1235
- }) ||
1236
- isRecognizedLegacyGeneration(state.generation)
1758
+ }) &&
1759
+ (state.version !== DAEMON_VERSION || isFullModernPredecessor(state, pidIncarnation))
1237
1760
  )
1238
1761
  return { acquired: false, attached: false, reloadRequired: true };
1239
1762
  return { acquired: false, attached: false, provisional: true };
1240
1763
  };
1241
1764
  const existing = await readJson<DaemonState>(fsImpl, paths.state);
1765
+ if (
1766
+ existing &&
1767
+ validDaemonPid(existing.pid) &&
1768
+ !ownerIdentityMatches(existing, input.tokenFingerprint, input.chatId) &&
1769
+ (!hasSafeDaemonStateShape(existing) ||
1770
+ !isProcessIncarnation(existing.incarnation) ||
1771
+ !isProcessIncarnation(pidIncarnation(existing.pid))) &&
1772
+ pidAlive(existing.pid)
1773
+ )
1774
+ return { acquired: false, attached: false, blocked: true };
1242
1775
  const foreignOwner = classifyForeignLiveOwner({
1243
1776
  state: existing,
1244
1777
  tokenFingerprint: input.tokenFingerprint,
@@ -1251,12 +1784,48 @@ export async function acquireDaemonOwnership(input: {
1251
1784
  ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1252
1785
  : { acquired: false, attached: false, blocked: true };
1253
1786
  }
1787
+ if (
1788
+ await isLiveNoncanonicalParentState({
1789
+ fs: fsImpl,
1790
+ statePath: paths.state,
1791
+ state: existing,
1792
+ pidAlive,
1793
+ pidIncarnation,
1794
+ tokenFingerprint: input.tokenFingerprint,
1795
+ chatId: input.chatId,
1796
+ })
1797
+ )
1798
+ return { acquired: false, attached: false, blocked: true };
1254
1799
 
1255
1800
  const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
1256
1801
  if (!transition) return { acquired: false, attached: false, provisional: true };
1257
1802
  try {
1258
1803
  const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
1259
1804
  const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
1805
+ const recheckedForeignOwner = classifyForeignLiveOwner({
1806
+ state: rechecked,
1807
+ tokenFingerprint: input.tokenFingerprint,
1808
+ chatId: input.chatId,
1809
+ pidAlive,
1810
+ pidIncarnation,
1811
+ });
1812
+ if (recheckedForeignOwner) {
1813
+ return recheckedForeignOwner === "identity_mismatch"
1814
+ ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1815
+ : { acquired: false, attached: false, blocked: true };
1816
+ }
1817
+ if (
1818
+ await isLiveNoncanonicalParentState({
1819
+ fs: fsImpl,
1820
+ statePath: paths.state,
1821
+ state: rechecked,
1822
+ pidAlive,
1823
+ pidIncarnation,
1824
+ tokenFingerprint: input.tokenFingerprint,
1825
+ chatId: input.chatId,
1826
+ })
1827
+ )
1828
+ return { acquired: false, attached: false, blocked: true };
1260
1829
  const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
1261
1830
  if (
1262
1831
  recheckedDecision &&
@@ -1270,13 +1839,19 @@ export async function acquireDaemonOwnership(input: {
1270
1839
  // A newer initializer may have replaced the pathname while that tombstone
1271
1840
  // remained, and must receive the same liveness/freshness protection as any
1272
1841
  // other live reservation.
1273
- const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
1274
- const lockDecision = stoppedLockMatches
1275
- ? undefined
1276
- : liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
1842
+ const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked, pidAlive);
1843
+ // A v0.10 parent owns a legacy { pid, startedAt } lock. An exact
1844
+ // state/lock pair is authority only to attest or retry handoff of that
1845
+ // owner; it is never attached or unlinked here.
1846
+ const legacyHandoffLockMatches = legacyOwnershipLockMatchesHandoffState(recheckedLock, rechecked);
1847
+ const lockDecision =
1848
+ stoppedLockMatches || legacyHandoffLockMatches
1849
+ ? undefined
1850
+ : liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
1277
1851
  if (lockDecision) return lockDecision;
1278
1852
  if (
1279
1853
  !stoppedLockMatches &&
1854
+ !legacyHandoffLockMatches &&
1280
1855
  !(await ownershipLockIsReclaimable({
1281
1856
  fs: fsImpl,
1282
1857
  path: paths.lock,
@@ -1291,6 +1866,7 @@ export async function acquireDaemonOwnership(input: {
1291
1866
  const legacyDecision = await legacyParentHandoffDecision({
1292
1867
  fs: fsImpl,
1293
1868
  statePath: paths.state,
1869
+ lockPath: paths.lock,
1294
1870
  state: rechecked,
1295
1871
  now: now(),
1296
1872
  pidAlive,
@@ -1302,18 +1878,6 @@ export async function acquireDaemonOwnership(input: {
1302
1878
  } else if (recheckedDecision) {
1303
1879
  return recheckedDecision;
1304
1880
  }
1305
- const recheckedForeignOwner = classifyForeignLiveOwner({
1306
- state: rechecked,
1307
- tokenFingerprint: input.tokenFingerprint,
1308
- chatId: input.chatId,
1309
- pidAlive,
1310
- pidIncarnation,
1311
- });
1312
- if (recheckedForeignOwner) {
1313
- return recheckedForeignOwner === "identity_mismatch"
1314
- ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1315
- : { acquired: false, attached: false, blocked: true };
1316
- }
1317
1881
  if (
1318
1882
  hasSafeDaemonStateShape(rechecked) &&
1319
1883
  rechecked.stoppedAt === undefined &&
@@ -1327,7 +1891,8 @@ export async function acquireDaemonOwnership(input: {
1327
1891
  if (currentLock.kind !== "missing") {
1328
1892
  if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
1329
1893
  return { acquired: false, attached: false, provisional: true };
1330
- await fsImpl.unlink(paths.lock);
1894
+ if (!(await unlinkOwnershipLockExactly(fsImpl, paths.lock, currentLock)))
1895
+ return { acquired: false, attached: false, provisional: true };
1331
1896
  }
1332
1897
  const ownershipLock: OwnershipLockMetadata = {
1333
1898
  pid,
@@ -1563,14 +2128,15 @@ export async function retireProvisionalDaemonOwnership(input: {
1563
2128
  )
1564
2129
  return false;
1565
2130
  if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
2131
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2132
+ if (!ownershipLockMatchesState(lock, state)) return false;
1566
2133
  await writeJsonAtomic(fsImpl, paths.state, {
1567
2134
  ...state,
1568
2135
  ownershipPhase: "retired",
1569
2136
  stoppedAt: (input.now ?? Date.now)(),
1570
2137
  });
1571
- if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1572
- await fsImpl.unlink(paths.lock).catch(() => undefined);
1573
- return true;
2138
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
2139
+ return await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
1574
2140
  } finally {
1575
2141
  await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1576
2142
  }
@@ -1727,6 +2293,8 @@ export async function confirmTelegramDaemonSpawn(input: {
1727
2293
  sleep?: (ms: number) => Promise<void>;
1728
2294
  waitStepMs?: number;
1729
2295
  timeoutMs?: number;
2296
+ /** Retain an unproven no-PID child lease during a successor handoff. */
2297
+ preserveOnUnprovenChildExit?: boolean;
1730
2298
  }): Promise<boolean> {
1731
2299
  if (input.spawned.result !== "owner_spawned") return true;
1732
2300
  const childPid = input.spawned.acquisition.pid;
@@ -1748,6 +2316,14 @@ export async function confirmTelegramDaemonSpawn(input: {
1748
2316
  timeoutMs: input.timeoutMs,
1749
2317
  });
1750
2318
  if (ready) return true;
2319
+ // An identified live child remains fenced. A no-PID launch retains its lease
2320
+ // only when a successor handoff explicitly requests that protection; ordinary
2321
+ // confirmation keeps the historical cleanup behavior.
2322
+ if (
2323
+ (hasExactChildPid && (input.pidAlive ?? defaultPidAlive)(childPid as number)) ||
2324
+ (!hasExactChildPid && input.preserveOnUnprovenChildExit)
2325
+ )
2326
+ return false;
1751
2327
  const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
1752
2328
  const retired = await retireProvisionalDaemonOwnership({
1753
2329
  settings: input.settings,
@@ -1829,9 +2405,11 @@ export async function releaseDaemonOwnership(input: {
1829
2405
  )
1830
2406
  return;
1831
2407
  if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
2408
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2409
+ if (!ownershipLockMatchesState(lock, state)) return;
1832
2410
  await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
1833
2411
  if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1834
- await fsImpl.unlink(paths.lock).catch(() => undefined);
2412
+ await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
1835
2413
  } finally {
1836
2414
  await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1837
2415
  }
@@ -1854,12 +2432,63 @@ export async function readDaemonRoots(
1854
2432
  return roots?.roots ?? [];
1855
2433
  }
1856
2434
 
2435
+ /** Injectable readers for {@link resolveTelegramSetupPreflight}, defaulting to the real OS/state probes. */
2436
+ export interface ResolveTelegramSetupPreflightDeps {
2437
+ readDaemonState?: (settings: Settings) => Promise<DaemonState | undefined>;
2438
+ pidAlive?: (pid: number) => boolean;
2439
+ pidIncarnation?: (pid: number) => string | undefined;
2440
+ }
2441
+
2442
+ /**
2443
+ * Build the Telegram setup preflight from persisted daemon state. The daemon is
2444
+ * reported live ONLY when its PID is alive AND its current process incarnation
2445
+ * still matches the persisted incarnation. Skipping the incarnation check makes
2446
+ * a stale state file whose PID has been recycled by an unrelated process
2447
+ * masquerade as a live owner, which wrongly blocks discovery pairing. Both the
2448
+ * `notify setup` CLI and the /settings Notifications tab share this resolver so
2449
+ * pairing behaves identically on both surfaces.
2450
+ */
2451
+ export async function resolveTelegramSetupPreflight(
2452
+ settings: Settings,
2453
+ deps: ResolveTelegramSetupPreflightDeps = {},
2454
+ ): Promise<TelegramSetupPreflight> {
2455
+ const storedChatId = getNotificationConfig(settings).chatId;
2456
+ const pidAlive = deps.pidAlive ?? defaultPidAlive;
2457
+ const pidIncarnation = deps.pidIncarnation ?? defaultPidIncarnation;
2458
+ try {
2459
+ const state = await (deps.readDaemonState ?? readDaemonState)(settings);
2460
+ if (!state) return { storedChatId };
2461
+ const validPid = Number.isSafeInteger(state.pid) && state.pid > 0;
2462
+ if (!validPid || !pidAlive(state.pid)) return { storedChatId };
2463
+ const persistedIncarnation = state.incarnation;
2464
+ const currentIncarnation = pidIncarnation(state.pid);
2465
+ if (
2466
+ !isProcessIncarnation(persistedIncarnation) ||
2467
+ !isProcessIncarnation(currentIncarnation) ||
2468
+ persistedIncarnation !== currentIncarnation
2469
+ )
2470
+ return { storedChatId };
2471
+ return {
2472
+ storedChatId,
2473
+ daemon: {
2474
+ live: true,
2475
+ tokenFingerprint: typeof state.tokenFingerprint === "string" ? state.tokenFingerprint : undefined,
2476
+ chatId: typeof state.chatId === "string" ? state.chatId : undefined,
2477
+ },
2478
+ };
2479
+ } catch {
2480
+ // A state read failure is not proof of a live daemon; proceed normally. The
2481
+ // daemon's own 409 handling remains the backstop against poller contention.
2482
+ return { storedChatId };
2483
+ }
2484
+ }
2485
+
1857
2486
  function defaultPidAlive(pid: number): boolean {
1858
2487
  try {
1859
2488
  process.kill(pid, 0);
1860
2489
  return true;
1861
- } catch {
1862
- return false;
2490
+ } catch (error) {
2491
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
1863
2492
  }
1864
2493
  }
1865
2494
 
@@ -1918,7 +2547,13 @@ export interface TelegramSpawnAcquisition {
1918
2547
 
1919
2548
  export type TelegramSpawnOwnerResult =
1920
2549
  | { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
1921
- | { result: "attached"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean }
2550
+ | {
2551
+ result: "attached";
2552
+ runtime: DaemonRuntimeInfo;
2553
+ warnings: string[];
2554
+ reloadRequired?: boolean;
2555
+ legacyReloadRequired?: boolean;
2556
+ }
1922
2557
  | { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
1923
2558
 
1924
2559
  /**
@@ -2001,6 +2636,7 @@ export async function spawnTelegramDaemonOwner(
2001
2636
  runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
2002
2637
  warnings: [],
2003
2638
  reloadRequired: ownership.reloadRequired,
2639
+ legacyReloadRequired: ownership.legacyReloadRequired,
2004
2640
  };
2005
2641
  }
2006
2642
  const launcherPid = deps.pid ?? process.pid;
@@ -2044,35 +2680,49 @@ export async function spawnTelegramDaemonOwner(
2044
2680
  }
2045
2681
 
2046
2682
  /**
2047
- * Owner-bound reclamation of a confirmed-dead daemon owner's lock, mirroring the
2048
- * daemon step of `gjc notify recovery`. Removes only the lock of a recorded owner
2049
- * that is still the same, still-dead process — never a live or superseded owner,
2050
- * because it re-checks ownership and liveness while holding the same steal-mutex
2051
- * the daemon's own takeover path uses. Lets a fresh SDK session self-heal a stale
2052
- * owner (e.g. after a crash, or a `stop --force` that left the record behind)
2053
- * instead of requiring manual reconciliation. Returns true when a stale lock was
2054
- * cleared and a spawn retry is worthwhile.
2683
+ * Owner-bound reclamation of a confirmed-dead daemon owner, mirroring the
2684
+ * daemon step of `gjc notify recovery`. It returns a structured, actionable
2685
+ * result and removes only identity-verified dead-owner artifacts while holding
2686
+ * the transition fence; live, successor, unknown, or unreadable evidence is
2687
+ * retained.
2055
2688
  */
2056
- async function reclaimDeadDaemonOwner(input: {
2689
+ export type DeadOwnerRecoveryResult =
2690
+ | { recovered: true; reason: "cleared" }
2691
+ | {
2692
+ recovered: false;
2693
+ reason:
2694
+ | "not-confirmed-dead"
2695
+ | "unsafe-lock"
2696
+ | "transition-contended"
2697
+ | "owner-superseded"
2698
+ | "unsafe-endpoint"
2699
+ | "endpoint-changed"
2700
+ | "endpoint-directory-unreadable"
2701
+ | "lock-changed";
2702
+ };
2703
+
2704
+ /** Preflight cleanup for a confirmed-dead owner, with identity-bound removal. */
2705
+ export async function reclaimDeadDaemonOwner(input: {
2057
2706
  settings: Settings;
2707
+ endpointDir?: string;
2058
2708
  fs?: TelegramDaemonFs;
2059
2709
  now?: () => number;
2060
2710
  pidAlive?: (pid: number) => boolean;
2061
2711
  pidIncarnation?: (pid: number) => string | undefined;
2062
- }): Promise<boolean> {
2712
+ }): Promise<DeadOwnerRecoveryResult> {
2063
2713
  const fsImpl = input.fs ?? nodeFs;
2064
2714
  const now = input.now ?? Date.now;
2065
2715
  const pidAlive = input.pidAlive ?? defaultPidAlive;
2066
2716
  const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
2067
2717
  const paths = daemonPaths(input.settings.getAgentDir());
2068
2718
  const state = await readDaemonState(input.settings, fsImpl);
2069
- if (!state || pidAlive(state.pid)) return false;
2719
+ if (!state || !hasSafeDaemonStateShape(state) || pidAlive(state.pid))
2720
+ return { recovered: false, reason: "not-confirmed-dead" };
2070
2721
  const lock = await readOwnershipLock(fsImpl, paths.lock);
2071
- if (lock.kind === "missing") return false;
2072
2722
  if (
2073
2723
  !(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
2074
2724
  )
2075
- return false;
2725
+ return { recovered: false, reason: "unsafe-lock" };
2076
2726
  const transition = await acquireDaemonTransitionLock({
2077
2727
  fs: fsImpl,
2078
2728
  path: paths.steal,
@@ -2080,18 +2730,79 @@ async function reclaimDeadDaemonOwner(input: {
2080
2730
  pidAlive: defaultPidAlive,
2081
2731
  pidIncarnation: defaultPidIncarnation,
2082
2732
  });
2083
- if (!transition) return false;
2733
+ const readEndpointFile = fsImpl.readEndpointFile;
2734
+ if (!transition || !readEndpointFile || !fsImpl.exactUnlink)
2735
+ return { recovered: false, reason: "transition-contended" };
2084
2736
  try {
2085
2737
  const current = await readDaemonState(input.settings, fsImpl);
2086
- if (!current || current.ownerId !== state.ownerId || current.pid !== state.pid) return false;
2087
- if (pidAlive(current.pid)) return false;
2088
- if (!(await daemonTransitionLockIsHeld({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
2089
- try {
2090
- await fsImpl.unlink(paths.lock);
2091
- return true;
2092
- } catch {
2093
- return false;
2738
+ if (
2739
+ !current ||
2740
+ !hasSafeDaemonStateShape(current) ||
2741
+ current.ownerId !== state.ownerId ||
2742
+ current.acquisitionId !== state.acquisitionId ||
2743
+ current.pid !== state.pid ||
2744
+ current.incarnation !== state.incarnation ||
2745
+ current.generation !== state.generation ||
2746
+ current.ownershipPhase !== state.ownershipPhase ||
2747
+ current.tokenFingerprint !== state.tokenFingerprint ||
2748
+ current.chatId !== state.chatId ||
2749
+ pidAlive(current.pid)
2750
+ )
2751
+ return { recovered: false, reason: "owner-superseded" };
2752
+ const currentLock = await readOwnershipLock(fsImpl, paths.lock);
2753
+ if (
2754
+ !ownershipLockMatches(lock, currentLock) ||
2755
+ !(await ownershipLockIsReclaimable({
2756
+ fs: fsImpl,
2757
+ path: paths.lock,
2758
+ lock: currentLock,
2759
+ now: now(),
2760
+ pidAlive,
2761
+ pidIncarnation,
2762
+ })) ||
2763
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
2764
+ )
2765
+ return { recovered: false, reason: "unsafe-lock" };
2766
+ const endpoints: Array<{ file: string; identity: NotificationEndpointFileIdentity }> = [];
2767
+ if (input.endpointDir) {
2768
+ let names: string[];
2769
+ try {
2770
+ names = await fsImpl.readdir(input.endpointDir);
2771
+ } catch (error) {
2772
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") names = [];
2773
+ else return { recovered: false, reason: "endpoint-directory-unreadable" };
2774
+ }
2775
+ for (const name of names) {
2776
+ if (!name.endsWith(".json")) continue;
2777
+ const file = path.join(input.endpointDir, name);
2778
+ const endpoint = await classifyNotificationEndpoint(
2779
+ { readEndpointFile: fsImpl.readEndpointFile! },
2780
+ file,
2781
+ pidAlive,
2782
+ );
2783
+ if (endpoint.kind === "non-endpoint") continue;
2784
+ if (endpoint.kind !== "endpoint" || endpoint.liveness !== "dead")
2785
+ return { recovered: false, reason: "unsafe-endpoint" };
2786
+ endpoints.push({ file, identity: endpoint.identity });
2787
+ }
2094
2788
  }
2789
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
2790
+ return { recovered: false, reason: "transition-contended" };
2791
+ for (const endpoint of endpoints)
2792
+ if (!(await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, endpoint.file, endpoint.identity)))
2793
+ return { recovered: false, reason: "endpoint-changed" };
2794
+ if (currentLock.kind === "missing") return { recovered: true, reason: "cleared" };
2795
+ const exactLock = await fsImpl.readEndpointFile!(paths.lock).catch(() => undefined);
2796
+ const exactCurrentLock = await readOwnershipLock(fsImpl, paths.lock);
2797
+ if (
2798
+ !exactLock ||
2799
+ !ownershipLockMatches(currentLock, exactCurrentLock) ||
2800
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
2801
+ )
2802
+ return { recovered: false, reason: "lock-changed" };
2803
+ return (await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, paths.lock, exactLock.identity))
2804
+ ? { recovered: true, reason: "cleared" }
2805
+ : { recovered: false, reason: "lock-changed" };
2095
2806
  } finally {
2096
2807
  await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
2097
2808
  }
@@ -2109,6 +2820,31 @@ export async function ensureTelegramDaemonRunningDetailed(
2109
2820
  if (!isTelegramConfigured(cfg)) return "disabled";
2110
2821
  const root = notificationRootForCwd(input.cwd);
2111
2822
  const fp = tokenFingerprint(cfg.botToken);
2823
+ // A live v0.10 parent has no stable process authority on Windows. Never turn an
2824
+ // unproven cooperative handoff into destructive cleanup or a replacement spawn.
2825
+ if ((deps.platform ?? process.platform) === "win32") {
2826
+ const parentState: unknown = await readDaemonState(input.settings, deps.fs);
2827
+ if (isParentDaemonState(parentState) && (deps.pidAlive ?? defaultPidAlive)(parentState.pid))
2828
+ return "blocked_identity";
2829
+ }
2830
+ // Windows can retain dead launcher metadata without an ownership lock; reclaim
2831
+ // its dead discovery records before the replacement can publish a new owner.
2832
+ if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
2833
+ const preflight = await reclaimDeadDaemonOwner({
2834
+ settings: input.settings,
2835
+ endpointDir: path.join(root, "sdk"),
2836
+ fs: deps.fs,
2837
+ now: deps.now,
2838
+ pidAlive: deps.pidAlive,
2839
+ pidIncarnation: deps.pidIncarnation,
2840
+ });
2841
+ if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") {
2842
+ logger.warn(
2843
+ `notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`,
2844
+ );
2845
+ return "blocked_identity";
2846
+ }
2847
+ }
2112
2848
  let spawned = await withTelegramSetupLease(
2113
2849
  cfg.botToken,
2114
2850
  async () =>
@@ -2139,7 +2875,11 @@ export async function ensureTelegramDaemonRunningDetailed(
2139
2875
  waitStepMs: deps.waitStepMs,
2140
2876
  timeoutMs: deps.readinessTimeoutMs,
2141
2877
  });
2142
- if (ready) {
2878
+ // A legacy parent cannot satisfy the current ready-state shape, but the
2879
+ // bounded wait gives its heartbeat a second observation window. Retry the
2880
+ // acquisition once so legacyParentHandoffDecision can consume that
2881
+ // attestation before startup treats the owner as blocked.
2882
+ if (ready || isLegacyParentDaemonState(provisional)) {
2143
2883
  spawned = await withTelegramSetupLease(
2144
2884
  cfg.botToken,
2145
2885
  async () =>
@@ -2150,39 +2890,108 @@ export async function ensureTelegramDaemonRunningDetailed(
2150
2890
  );
2151
2891
  }
2152
2892
  }
2153
- if (
2154
- spawned.result === "blocked" &&
2155
- !spawned.warnings[0]?.includes("provisional") &&
2156
- (await reclaimDeadDaemonOwner({
2893
+ let recoveryReason: Extract<DeadOwnerRecoveryResult, { recovered: false }>["reason"] | undefined;
2894
+ if (spawned.result === "blocked" && !spawned.warnings[0]?.includes("provisional")) {
2895
+ const recovery = await reclaimDeadDaemonOwner({
2157
2896
  settings: input.settings,
2897
+ endpointDir: path.join(root, "sdk"),
2158
2898
  fs: deps.fs,
2159
2899
  now: deps.now,
2160
2900
  pidAlive: deps.pidAlive,
2161
2901
  pidIncarnation: deps.pidIncarnation,
2162
- }))
2163
- ) {
2164
- // A crashed owner (or a `stop --force` that found no live process) can leave a
2165
- // stale lock behind that blocks re-acquisition. Automate the recovery step so a
2166
- // fresh session self-heals instead of requiring a manual `gjc notify recovery`.
2167
- spawned = await withTelegramSetupLease(
2168
- cfg.botToken,
2169
- async () =>
2170
- await spawnTelegramDaemonOwner(
2171
- { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2172
- deps,
2173
- ),
2174
- );
2902
+ });
2903
+ if (recovery.recovered) {
2904
+ spawned = await withTelegramSetupLease(
2905
+ cfg.botToken,
2906
+ async () =>
2907
+ await spawnTelegramDaemonOwner(
2908
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2909
+ deps,
2910
+ ),
2911
+ );
2912
+ } else recoveryReason = recovery.reason;
2175
2913
  }
2176
2914
  if (spawned.result === "blocked") {
2177
- logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
2915
+ logger.warn(
2916
+ `notifications: failed to ensure Telegram daemon: ${recoveryReason ? `stale recovery ${recoveryReason}; run \`gjc notify recovery\`` : spawned.warnings.join("; ")}`,
2917
+ );
2178
2918
  return "blocked_identity";
2179
2919
  }
2180
2920
  if (spawned.result === "attached" && spawned.reloadRequired) {
2181
2921
  const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
2182
2922
  await registerNotificationRoot({ ...input, fs: deps.fs });
2183
- const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
2184
- const upgrade = await controller.reloadForGenerationUpgrade();
2185
- if (upgrade.outcome !== "ready") {
2923
+ const fsImpl = deps.fs ?? nodeFs;
2924
+ const now = deps.now ?? Date.now;
2925
+ const pidAlive = deps.pidAlive ?? defaultPidAlive;
2926
+ const pidIncarnation = deps.pidIncarnation ?? defaultPidIncarnation;
2927
+ const reloadAttemptPath = path.join(
2928
+ daemonPaths(input.settings.getAgentDir()).dir,
2929
+ "telegram-daemon.reload-attempt.json",
2930
+ );
2931
+ await ensureDir(fsImpl, daemonPaths(input.settings.getAgentDir()).dir);
2932
+ const reloadWaitStepMs = Math.max(deps.waitStepMs ?? 100, 1);
2933
+ const reloadFreshnessWaitMs = Math.max(deps.readinessTimeoutMs ?? RELOAD_FRESHNESS_WAIT_MS, reloadWaitStepMs);
2934
+ const reloadReadinessTimeoutMs = deps.readinessTimeoutMs ?? RELOAD_FRESHNESS_WAIT_MS;
2935
+ const reloadResult = await withFileLock(
2936
+ reloadAttemptPath,
2937
+ async () => {
2938
+ const currentState = await readDaemonState(input.settings, fsImpl);
2939
+ const previousAttempt = await readJson<{
2940
+ lastReloadAt?: number;
2941
+ ownerId?: string;
2942
+ targetGeneration?: number;
2943
+ }>(fsImpl, reloadAttemptPath).catch(() => undefined);
2944
+ const reloadNow = now();
2945
+ const cooldownApplies =
2946
+ previousAttempt?.targetGeneration === DAEMON_GENERATION &&
2947
+ typeof previousAttempt.lastReloadAt === "number" &&
2948
+ Number.isFinite(previousAttempt.lastReloadAt) &&
2949
+ reloadNow - previousAttempt.lastReloadAt < AUTOMATIC_RELOAD_COOLDOWN_MS;
2950
+ if (cooldownApplies) {
2951
+ // A sibling ensure may have just finished its reload. Its replacement
2952
+ // daemon heartbeats only after startup, so wait briefly for the fresh
2953
+ // ready owner instead of issuing a duplicate reload in the gap.
2954
+ const sleep = deps.sleep ?? Bun.sleep;
2955
+ const waitStepMs = reloadWaitStepMs;
2956
+ const waitBudgetMs = reloadFreshnessWaitMs;
2957
+ const deadline = reloadNow + waitBudgetMs;
2958
+ // Iteration-capped so a frozen `now` cannot spin forever.
2959
+ const maxWaits = Math.ceil(waitBudgetMs / waitStepMs);
2960
+ for (let waits = 0; waits <= maxWaits; waits++) {
2961
+ const state = await readDaemonState(input.settings, fsImpl);
2962
+ const t = now();
2963
+ if (
2964
+ isFreshLiveOwner({
2965
+ state,
2966
+ now: t,
2967
+ tokenFingerprint: fp,
2968
+ chatId: cfg.chatId,
2969
+ pidAlive,
2970
+ pidIncarnation,
2971
+ })
2972
+ )
2973
+ return "attached" as const;
2974
+ if (t >= deadline) break;
2975
+ await sleep(waitStepMs);
2976
+ }
2977
+ }
2978
+ await writeJsonAtomic(fsImpl, reloadAttemptPath, {
2979
+ lastReloadAt: reloadNow,
2980
+ ownerId: currentState?.ownerId ?? "",
2981
+ targetGeneration: DAEMON_GENERATION,
2982
+ });
2983
+ const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
2984
+ const upgrade = await controller.reloadForGenerationUpgrade({}, spawned.legacyReloadRequired === true);
2985
+ return upgrade.outcome === "ready" ? ("reloaded" as const) : upgrade;
2986
+ },
2987
+ reloadReservationLockOptions({
2988
+ freshnessWaitMs: reloadFreshnessWaitMs,
2989
+ readinessTimeoutMs: reloadReadinessTimeoutMs,
2990
+ retryDelayMs: reloadWaitStepMs,
2991
+ }),
2992
+ );
2993
+ if (reloadResult === "attached") return "attached";
2994
+ if (reloadResult !== "reloaded") {
2186
2995
  await restoreNotificationRootRegistration({
2187
2996
  settings: input.settings,
2188
2997
  sessionId: input.sessionId,
@@ -2190,7 +2999,7 @@ export async function ensureTelegramDaemonRunningDetailed(
2190
2999
  previous,
2191
3000
  fs: deps.fs,
2192
3001
  });
2193
- throw new Error(`Unable to replace stale Telegram daemon: ${upgrade.operation.message}`);
3002
+ throw new Error(`Unable to replace stale Telegram daemon: ${reloadResult.operation.message}`);
2194
3003
  }
2195
3004
  return "reloaded";
2196
3005
  }
@@ -2243,12 +3052,28 @@ export async function ensureTelegramDaemonRunning(
2243
3052
  }
2244
3053
 
2245
3054
  function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
3055
+ // The legacy signal seam has no authority metadata of its own. Pair it with
3056
+ // the captured process incarnation so the controller retains its identity
3057
+ // fence; Windows continues through its hard-authority refusal path.
3058
+ const cooperativeSignalReference =
3059
+ deps.sendSignal && (deps.platform ?? process.platform) !== "win32"
3060
+ ? (pid: number): DaemonProcessReference | undefined => {
3061
+ const incarnation = deps.pidIncarnation?.(pid);
3062
+ if (!isProcessIncarnation(incarnation)) return undefined;
3063
+ return {
3064
+ incarnation,
3065
+ termination: "cooperative",
3066
+ signalRoot: signal => deps.sendSignal!(pid, signal),
3067
+ };
3068
+ }
3069
+ : undefined;
2246
3070
  return {
2247
3071
  fs: deps.fs,
2248
3072
  now: deps.now,
2249
3073
  pidAlive: deps.pidAlive,
2250
- processReference: deps.processReference,
3074
+ processReference: cooperativeSignalReference ?? deps.processReference,
2251
3075
  pidIncarnation: deps.pidIncarnation,
3076
+ platform: deps.platform,
2252
3077
  spawn: deps.spawn,
2253
3078
  execPath: deps.execPath,
2254
3079
  ownerPid: deps.pid,
@@ -2521,7 +3346,10 @@ export class TelegramUpdatePoller {
2521
3346
  /** Mutable dispatch state shared by session frames and inbound Telegram updates. */
2522
3347
  export class TelegramEventDispatchState {
2523
3348
  readonly busy = new Set<string>();
2524
- readonly inboundReactions = new Map<number, { messageId: number }>();
3349
+ readonly inboundReactions = new Map<
3350
+ number,
3351
+ { messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
3352
+ >();
2525
3353
  readonly seenUpdateIds = new Set<number>();
2526
3354
  }
2527
3355
 
@@ -2610,11 +3438,16 @@ interface SessionSocket {
2610
3438
  sessionId: string;
2611
3439
  /** Current logical session carried by valid threaded frames on this transport. */
2612
3440
  logicalSessionId: string;
3441
+ /** Endpoint metadata proved this logical id belongs to this discovery record. */
3442
+ logicalSessionIdTrusted: boolean;
2613
3443
  token: string;
2614
3444
  endpointKey: string;
2615
3445
  endpointDigest: string;
2616
3446
  hostGeneration: number;
2617
3447
  ws: WebSocket;
3448
+ /** Timestamp (via opts.now) at which this socket began connecting. */
3449
+ connectingSince: number;
3450
+
2618
3451
  pending: Map<string, { sessionId: string; actionId: string }>;
2619
3452
  /** True once the server advertised the `client_ping_pong` capability. */
2620
3453
  capable: boolean;
@@ -2630,11 +3463,20 @@ interface SessionSocket {
2630
3463
  /** Queues live frames until startup replay is applied. */
2631
3464
  replayPending: boolean;
2632
3465
  replayQueue: Record<string, unknown>[];
3466
+ /** Trusted recovery may not route until its durable endpoint lease is committed. */
3467
+ recoveryLease?: {
3468
+ state: "pending" | "authorized" | "rejected";
3469
+ logicalSessionId: string;
3470
+ binding: TopicEndpointBinding;
3471
+ token: number;
3472
+ };
2633
3473
  }
2634
3474
 
2635
3475
  interface ModelChoiceRoute {
2636
3476
  /** Exact transport socket that rendered this choice. */
2637
3477
  session: SessionSocket;
3478
+ /** Immutable owner lease captured when this choice was rendered. */
3479
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2638
3480
  /** Logical session current when this choice was rendered. */
2639
3481
  sessionId: string;
2640
3482
  selector: string;
@@ -2665,7 +3507,8 @@ interface ToolActivityOwner {
2665
3507
  interface PendingThreadedFrame {
2666
3508
  send: ThreadedSend;
2667
3509
  msg: Record<string, unknown>;
2668
- endpointDigest: string;
3510
+ logicalSessionId: string;
3511
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2669
3512
  toolActivity?: ToolActivityOwner;
2670
3513
  }
2671
3514
 
@@ -2690,6 +3533,8 @@ interface SelectedAckQueueItem {
2690
3533
  requestId: string;
2691
3534
  commitKey: string;
2692
3535
  session: SessionSocket;
3536
+ /** Immutable owner lease captured when this acknowledgement was admitted. */
3537
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2693
3538
  state: "queued" | "dispatching" | "sending";
2694
3539
  controller?: AbortController;
2695
3540
  followers: Array<{ pendingKey: string; requestId: string; commitKey: string }>;
@@ -2698,6 +3543,8 @@ interface SelectedAckQueueItem {
2698
3543
  interface TelegramQueuePayload {
2699
3544
  send: ThreadedSend;
2700
3545
  topicLease?: TopicAuthorityLease;
3546
+ /** Immutable owner lease captured when the work was admitted. */
3547
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
2701
3548
  selectedAck?: SelectedAckQueueItem;
2702
3549
  btwDelivery?: BtwQueuedDelivery;
2703
3550
  toolActivity?: ToolActivityOwner;
@@ -2708,6 +3555,10 @@ interface PendingBtwTurn {
2708
3555
  transportSessionId: string;
2709
3556
  /** Logical session id supplied to the session endpoint. */
2710
3557
  logicalSessionId: string;
3558
+ /** Immutable committed owner lease captured when the inbound turn was admitted. */
3559
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
3560
+ /** Lease token to which this request was most recently dispatched. */
3561
+ dispatchedSocketLeaseToken?: number;
2711
3562
  endpointDigest: string;
2712
3563
  generation: number;
2713
3564
  question: string;
@@ -2810,12 +3661,22 @@ export class TelegramNotificationDaemon {
2810
3661
  private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
2811
3662
  private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
2812
3663
  private running = false;
3664
+ /** Once set, a concurrent startup await can never restore a running daemon. */
3665
+ private stopRequested = false;
3666
+
2813
3667
  private readonly fsImpl: TelegramDaemonFs;
2814
3668
  private readonly botApi: BotApi;
2815
3669
  private readonly effects = new TelegramEffectSupervisor();
2816
3670
  private readonly topics = new TopicRegistry();
2817
3671
  /** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
3672
+ /** Legacy sockets have no durable token, so explicit teardown is their revocation fence. */
3673
+ private readonly droppedSessions = new WeakSet<SessionSocket>();
2818
3674
  private topicsPersistQueue: Promise<void> = Promise.resolve();
3675
+ /** Serializes recovery compare/write/publish claims so competing endpoint migrations cannot durably diverge. */
3676
+ private recoveryBindingClaimQueue: Promise<void> = Promise.resolve();
3677
+ /** Durable compensation fences retry under supervision until persistence succeeds. */
3678
+ private readonly compensationFenceRetries = new Map<string, Promise<void>>();
3679
+
2819
3680
  /** Daemon edit attempts that can race an accepted user service message. */
2820
3681
  private readonly daemonRenameAttempts = new Map<string, number>();
2821
3682
  private readonly selectedAckPending = new Map<string, SelectedAckQueueItem>();
@@ -2837,10 +3698,17 @@ export class TelegramNotificationDaemon {
2837
3698
  private readonly draftStream = new DraftStreamState();
2838
3699
  /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
2839
3700
  private readonly topicOwnerByIdentity = new Map<string, string>();
3701
+ /** Ephemeral legacy topic owners retained across a same-socket config rekey. */
3702
+ private readonly legacyTopicOwners = new Map<string, SessionSocket>();
3703
+ /** Preserved initiator topics must not route through a rekeyed transport. */
3704
+ private readonly preservedInitiatorTopics = new Set<string>();
2840
3705
  /** Non-identity frames held until identity creates the correct thread. */
2841
3706
  private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
2842
- /** Endpoint generation tombstones for sessions that already sent session_closed. */
2843
- private readonly closedEndpointKeys = new Map<string, string>();
3707
+ /** Durable endpoint leases for sessions that already sent an authorized session_closed. */
3708
+ private readonly closedEndpointKeys = new Map<string, TopicEndpointBinding>();
3709
+ /** Exactly one authorized transport may route each recovered logical session. */
3710
+ private readonly logicalSessionOwners = new Map<string, SessionSocket>();
3711
+ private nextSocketLeaseToken = 1;
2844
3712
  /** True once the daemon has nudged the user to enable Threaded Mode. */
2845
3713
  private threadedFallbackNoticeSent = false;
2846
3714
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
@@ -2854,7 +3722,10 @@ export class TelegramNotificationDaemon {
2854
3722
  return this.dispatchState.busy;
2855
3723
  }
2856
3724
  /** Inbound update id → originating Telegram message, for delivery reactions. */
2857
- private get inboundReactions(): Map<number, { messageId: number }> {
3725
+ private get inboundReactions(): Map<
3726
+ number,
3727
+ { messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
3728
+ > {
2858
3729
  return this.dispatchState.inboundReactions;
2859
3730
  }
2860
3731
  /**
@@ -2890,7 +3761,7 @@ export class TelegramNotificationDaemon {
2890
3761
  this.selectedAckPending.delete(item.pendingKey);
2891
3762
  for (const follower of item.followers) this.selectedAckPending.delete(follower.pendingKey);
2892
3763
  this.cacheSelectedAck(item.cacheKey, outcome);
2893
- if (item.session.ws.readyState === WebSocket.OPEN) {
3764
+ if (this.#leaseTokenAllows(item.socketLease) && item.session.ws.readyState === WebSocket.OPEN) {
2894
3765
  for (const result of [{ requestId: item.requestId, commitKey: item.commitKey }, ...item.followers]) {
2895
3766
  item.session.ws.send(
2896
3767
  JSON.stringify({
@@ -2910,6 +3781,8 @@ export class TelegramNotificationDaemon {
2910
3781
  * ~25s getUpdates timeout. Safe to call from a signal handler.
2911
3782
  */
2912
3783
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
3784
+ this.stopRequested = true;
3785
+
2913
3786
  const toolShutdown = this.beginToolActivityShutdown();
2914
3787
  void toolShutdown
2915
3788
  .finally(() => {
@@ -3231,7 +4104,7 @@ export class TelegramNotificationDaemon {
3231
4104
 
3232
4105
  const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
3233
4106
  const response = await this.submitLifecycleFrame(frame);
3234
- await reply(this.formatLifecycleResponse(response));
4107
+ await reply(this.formatLifecycleResponse(response, verb));
3235
4108
  return true;
3236
4109
  }
3237
4110
 
@@ -3247,8 +4120,8 @@ export class TelegramNotificationDaemon {
3247
4120
  }
3248
4121
 
3249
4122
  /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
3250
- private formatLifecycleResponse(r: SessionLifecycleResponse): string {
3251
- return formatLifecycleOutcome(r);
4123
+ private formatLifecycleResponse(r: SessionLifecycleResponse, verb: LifecycleCommandVerb): string {
4124
+ return formatLifecycleOutcome(r, verb);
3252
4125
  }
3253
4126
 
3254
4127
  constructor(private readonly opts: TelegramDaemonOptions) {
@@ -3330,10 +4203,14 @@ export class TelegramNotificationDaemon {
3330
4203
  finishImmediately({ status: "failed", reason: "route_missing" });
3331
4204
  return;
3332
4205
  }
3333
- const topicLease = this.topicAuthorityLeaseFromRegistry(session.sessionId);
4206
+ const logicalSessionId = this.#logicalSessionId(session);
4207
+ const socketLease = this.#socketLease(session, logicalSessionId);
4208
+ if (!socketLease) return;
4209
+ const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
3334
4210
  if (
3335
- this.topics.get(session.sessionId)?.authorityState === "delete_pending" ||
3336
- (mode === "recovery" && (!topicLease || msg.sessionId !== session.sessionId))
4211
+ this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
4212
+ this.topics.get(logicalSessionId)?.bindingMalformed ||
4213
+ (mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
3337
4214
  ) {
3338
4215
  finishImmediately({ status: "failed", reason: "route_missing" });
3339
4216
  return;
@@ -3359,13 +4236,14 @@ export class TelegramNotificationDaemon {
3359
4236
  requestId,
3360
4237
  commitKey,
3361
4238
  session,
4239
+ socketLease,
3362
4240
  state: "queued",
3363
4241
  followers: [],
3364
4242
  };
3365
4243
  this.selectedAckPending.set(pendingKey, item);
3366
4244
  this.submitPool({
3367
- sessionId: session.sessionId,
3368
4245
  lane: "ask",
4246
+ sessionId: logicalSessionId,
3369
4247
  itemId: item.itemId,
3370
4248
  deadlineAt,
3371
4249
  payload: {
@@ -3442,34 +4320,117 @@ export class TelegramNotificationDaemon {
3442
4320
  name: "activity",
3443
4321
  matches: msg => msg.type === "activity",
3444
4322
  handle: async (session, msg) => {
4323
+ const logicalSessionId = this.#logicalSessionId(session);
3445
4324
  if (msg.state === "busy") {
3446
- this.busy.add(session.sessionId);
3447
- await this.sendTyping(session.sessionId);
4325
+ this.busy.add(logicalSessionId);
4326
+ await this.sendTyping(logicalSessionId, this.#socketLease(session, logicalSessionId));
3448
4327
  } else {
3449
- this.busy.delete(session.sessionId);
4328
+ this.busy.delete(logicalSessionId);
3450
4329
  }
3451
4330
  },
3452
4331
  })
3453
4332
  .add({
3454
4333
  name: "inbound_ack",
3455
4334
  matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
3456
- handle: async (_session, msg) => {
4335
+ handle: async (session, msg) => {
3457
4336
  const target = this.inboundReactions.get(msg.updateId as number);
3458
4337
  if (target && msg.state === "consumed") {
3459
4338
  this.inboundReactions.delete(msg.updateId as number);
3460
- await this.setReaction(target.messageId, CONSUMED_REACTION);
4339
+ await this.setReaction(
4340
+ target.messageId,
4341
+ CONSUMED_REACTION,
4342
+ target.socketLease ?? this.#socketLease(session),
4343
+ );
3461
4344
  }
3462
4345
  },
3463
4346
  })
3464
4347
  .add({
3465
4348
  name: "session_closed",
3466
4349
  matches: msg => msg.type === "session_closed",
3467
- handle: async session => {
3468
- this.busy.delete(session.sessionId);
3469
- this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
4350
+ handle: async (session, msg) => {
4351
+ const logicalSessionId = this.#logicalSessionId(session);
4352
+ if (
4353
+ (this.sessions.has(session.sessionId) && this.sessions.get(session.sessionId) !== session) ||
4354
+ typeof msg.sessionId !== "string" ||
4355
+ msg.sessionId !== logicalSessionId ||
4356
+ (session.logicalSessionIdTrusted && (!this.#leaseAllows(session) || session.hostGeneration < 1))
4357
+ )
4358
+ return;
4359
+ const socketLease = session.logicalSessionIdTrusted
4360
+ ? this.#socketLease(session, logicalSessionId)
4361
+ : undefined;
4362
+ if (session.logicalSessionIdTrusted && !socketLease) return;
4363
+ this.busy.delete(logicalSessionId);
3470
4364
  await this.#terminalizeBtwTurnsForSession(session, true);
3471
- await this.deleteTopic(session.sessionId);
3472
- this.dropSession(session, "session_closed");
4365
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
4366
+ await this.#withRecoveryBindingClaim(async () => {
4367
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
4368
+
4369
+ const closedBinding = this.#endpointBinding(session);
4370
+ const closeTopicAuthority = this.topics.captureDeleteAuthority(logicalSessionId);
4371
+ const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId);
4372
+ await this.#persistTopicMutation(
4373
+ () => {
4374
+ this.closedEndpointKeys.set(session.sessionId, closedBinding);
4375
+ this.topics.beginDelete(logicalSessionId);
4376
+ },
4377
+ () => {
4378
+ this.topics.restoreDeleteAuthority(closeTopicAuthority);
4379
+ if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId);
4380
+ else if (this.closedEndpointKeys.get(session.sessionId) === closedBinding)
4381
+ this.closedEndpointKeys.set(session.sessionId, previousClosedBinding);
4382
+ },
4383
+ );
4384
+ if (
4385
+ socketLease &&
4386
+ (this.sessions.get(session.sessionId) !== session ||
4387
+ session.recoveryLease?.token !== socketLease.token ||
4388
+ session.recoveryLease.state !== "authorized" ||
4389
+ this.logicalSessionOwners.get(logicalSessionId) !== session)
4390
+ ) {
4391
+ // A replacement won after the close fence committed. Restore the exact
4392
+ // pre-close authority and remove the predecessor tombstone together.
4393
+ const restoreCloseAuthority = (): Promise<boolean> =>
4394
+ this.#persistTopicMutation(
4395
+ () => {
4396
+ const restored = this.topics.restoreDeleteAuthority(closeTopicAuthority);
4397
+ if (!restored) throw new Error("close authority changed before compensation");
4398
+ this.closedEndpointKeys.delete(session.sessionId);
4399
+ return true;
4400
+ },
4401
+ () => {
4402
+ this.topics.restoreDeleteFence(closeTopicAuthority);
4403
+ this.closedEndpointKeys.set(session.sessionId, closedBinding);
4404
+ },
4405
+ );
4406
+ try {
4407
+ await restoreCloseAuthority();
4408
+ } catch {
4409
+ await restoreCloseAuthority();
4410
+ }
4411
+ return;
4412
+ }
4413
+ const deleteOutcome = await this.deleteTopic(logicalSessionId, socketLease, true);
4414
+ if (
4415
+ deleteOutcome === "pre_dispatch_cancelled" &&
4416
+ socketLease &&
4417
+ !this.#deleteLeaseAllows(socketLease)
4418
+ ) {
4419
+ await this.#persistTopicMutation(
4420
+ () => {
4421
+ if (!this.topics.restoreDeleteAuthority(closeTopicAuthority))
4422
+ throw new Error("close authority changed before compensation");
4423
+ this.closedEndpointKeys.delete(session.sessionId);
4424
+ },
4425
+ () => {
4426
+ this.topics.restoreDeleteFence(closeTopicAuthority);
4427
+ this.closedEndpointKeys.set(session.sessionId, closedBinding);
4428
+ },
4429
+ );
4430
+ return;
4431
+ }
4432
+ this.dropSession(session, "session_closed");
4433
+ });
3473
4434
  },
3474
4435
  });
3475
4436
  }
@@ -3550,17 +4511,44 @@ export class TelegramNotificationDaemon {
3550
4511
  return true;
3551
4512
  }
3552
4513
 
4514
+ private async releaseSeenUpdateId(updateId: number): Promise<void> {
4515
+ if (!this.dispatchState.seenUpdateIds.has(updateId)) return;
4516
+ const candidate = new Set(this.dispatchState.seenUpdateIds);
4517
+ candidate.delete(updateId);
4518
+ try {
4519
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
4520
+ await ensureDir(this.fsImpl, paths.dir);
4521
+ await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
4522
+ version: 1,
4523
+ updateIds: [...candidate],
4524
+ });
4525
+ } catch {
4526
+ logger.warn("notifications: Telegram update state release failed");
4527
+ return;
4528
+ }
4529
+ this.dispatchState.seenUpdateIds.clear();
4530
+ for (const seenId of candidate) this.dispatchState.seenUpdateIds.add(seenId);
4531
+ }
4532
+
3553
4533
  async scanRoots(): Promise<void> {
4534
+ await this.reconcilePendingTopicDeletes();
3554
4535
  const paths = daemonPaths(this.opts.settings.getAgentDir());
3555
4536
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
3556
4537
  const endpointSessionIds = new Set<string>();
4538
+ // Permanent absences prune; only transient I/O keeps orphan reconciliation
4539
+ // gated (so one deleted worktree cannot disable cleanup forever) (#2956).
3557
4540
  let allRootsReadable = true;
4541
+ const permanentlyMissingRoots: string[] = [];
3558
4542
  for (const root of rootState?.roots ?? []) {
3559
4543
  const dir = path.join(root, "sdk");
3560
4544
  let files: string[];
3561
4545
  try {
3562
4546
  files = await this.fsImpl.readdir(dir);
3563
- } catch {
4547
+ } catch (error) {
4548
+ if (isPermanentMissingPathError(error)) {
4549
+ permanentlyMissingRoots.push(root);
4550
+ continue;
4551
+ }
3564
4552
  allRootsReadable = false;
3565
4553
  continue;
3566
4554
  }
@@ -3573,6 +4561,11 @@ export class TelegramNotificationDaemon {
3573
4561
  // A hard-killed owner can leave both its endpoint file and socket map
3574
4562
  // entry behind; skipping the read in that case permanently preserves
3575
4563
  // the stale Telegram topic.
4564
+ const owner = this.logicalSessionOwners.get(sessionId);
4565
+ if (owner && this.#leaseAllows(owner, sessionId)) {
4566
+ if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
4567
+ continue;
4568
+ }
3576
4569
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
3577
4570
  if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
3578
4571
  const connected = this.sessions.get(sessionId);
@@ -3581,22 +4574,57 @@ export class TelegramNotificationDaemon {
3581
4574
  await this.observeOrphanedTopic(sessionId);
3582
4575
  continue;
3583
4576
  }
4577
+
3584
4578
  if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
3585
4579
  const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
3586
4580
  const connected = this.sessions.get(sessionId);
3587
4581
  if (connected) {
3588
- if (connected.endpointKey !== endpointKey)
4582
+ if (
4583
+ connected.endpointKey !== endpointKey ||
4584
+ (connected.ws.readyState === WebSocket.CONNECTING &&
4585
+ this.runtime.now() - connected.connectingSince >= CONNECTING_RECONNECT_MS)
4586
+ )
3589
4587
  this.connectSession(sessionId, endpoint.url, endpoint.token);
3590
4588
  continue;
3591
4589
  }
3592
- if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
3593
- this.closedEndpointKeys.delete(sessionId);
4590
+ const closed = this.closedEndpointKeys.get(sessionId);
4591
+ if (closed?.endpointKey === endpointKey) continue;
4592
+ if (closed) {
4593
+ this.closedEndpointKeys.delete(sessionId);
4594
+ await this.persistTopics();
4595
+ }
3594
4596
  this.connectSession(sessionId, endpoint.url, endpoint.token);
3595
4597
  } catch {}
3596
4598
  }
3597
4599
  }
4600
+ if (permanentlyMissingRoots.length > 0) {
4601
+ try {
4602
+ await pruneMissingNotificationRoots({
4603
+ settings: this.opts.settings,
4604
+ fs: this.fsImpl,
4605
+ candidates: permanentlyMissingRoots,
4606
+ });
4607
+ } catch (error) {
4608
+ logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
4609
+ }
4610
+ }
4611
+ // Best-effort periodic reap of retained exact-unlink quarantines (#2956).
4612
+ try {
4613
+ await reapStaleNotificationArtifacts({
4614
+ settings: this.opts.settings,
4615
+ fs: this.fsImpl,
4616
+ now: this.opts.now,
4617
+ });
4618
+ } catch (error) {
4619
+ logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
4620
+ }
3598
4621
  if (allRootsReadable) {
3599
4622
  for (const sessionId of this.topics.sessionIds()) {
4623
+ const owner = this.logicalSessionOwners.get(sessionId);
4624
+ if (owner && this.#leaseAllows(owner)) {
4625
+ if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
4626
+ continue;
4627
+ }
3600
4628
  if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
3601
4629
  this.#terminalizeBtwTurnsForTransportSession(sessionId);
3602
4630
  await this.observeOrphanedTopic(sessionId);
@@ -3610,7 +4638,6 @@ export class TelegramNotificationDaemon {
3610
4638
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
3611
4639
  const endpointKey = endpointGenerationKey(url, token);
3612
4640
  const endpointDigest = endpointAuthorityDigest(url, token);
3613
- this.closedEndpointKeys.delete(sessionId);
3614
4641
  const existing = this.sessions.get(sessionId);
3615
4642
  if (existing) {
3616
4643
  this.dropSession(
@@ -3625,12 +4652,13 @@ export class TelegramNotificationDaemon {
3625
4652
  const session: SessionSocket = {
3626
4653
  sessionId,
3627
4654
  logicalSessionId: sessionId,
3628
-
4655
+ logicalSessionIdTrusted: false,
3629
4656
  token,
3630
4657
  endpointKey,
3631
4658
  endpointDigest,
3632
4659
  hostGeneration: 0,
3633
4660
  ws,
4661
+ connectingSince: this.runtime.now(),
3634
4662
  pending: new Map(),
3635
4663
  capable: false,
3636
4664
  ephemeralCapable: false,
@@ -3642,13 +4670,20 @@ export class TelegramNotificationDaemon {
3642
4670
  replayQueue: [],
3643
4671
  };
3644
4672
  this.sessions.set(sessionId, session);
4673
+ if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
4674
+
3645
4675
  // Bidirectional capability advertisement: announce client_ping_pong once the
3646
4676
  // socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
3647
4677
  // while CONNECTING — and liveness starts only after a capable ServerHello.
3648
4678
  ws.addEventListener("open", () => {
4679
+ if (this.sessions.get(sessionId) !== session) return;
3649
4680
  session.replayPending = true;
3650
4681
  session.replayQueue = [];
3651
- const replayCursor = this.topics.replayCursor(sessionId);
4682
+ // Cursors are endpoint-authority scoped. Reusing a prior host's cursor can
4683
+ // skip its identity event when a fresh host restarts at generation 1.
4684
+ const persistedTopic = this.topics.get(sessionId);
4685
+ const replayCursor =
4686
+ persistedTopic?.endpointDigest === session.endpointDigest ? this.topics.replayCursor(sessionId) : undefined;
3652
4687
  if (session.ws.readyState === WebSocket.OPEN) {
3653
4688
  try {
3654
4689
  session.ws.send(
@@ -3676,12 +4711,17 @@ export class TelegramNotificationDaemon {
3676
4711
  );
3677
4712
  } catch {}
3678
4713
  }
3679
- // Eagerly create the session's Telegram topic as soon as it connects, so
3680
- // a thread exists the moment a notifications-enabled session is live —
3681
- // not lazily on the first delivered frame (which only arrives once the
3682
- // user sends a prompt). A provisional "GJC <id>" name is used; the
3683
- // identity_header frame renames it to "{repo}/{branch} - {title}" later.
3684
- void this.ensureTopic(sessionId, this.topicNameFor(sessionId, {})).catch(() => undefined);
4714
+ void (async () => {
4715
+ if (this.#logicalSessionId(session) !== sessionId) return;
4716
+ const topic = this.topics.get(sessionId);
4717
+ if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
4718
+ const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
4719
+ if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease);
4720
+ })().catch(err =>
4721
+ logger.warn(
4722
+ `notifications: Telegram topic reattach flush failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
4723
+ ),
4724
+ );
3685
4725
  });
3686
4726
  ws.addEventListener("message", ev => {
3687
4727
  // Identity guard: a delayed frame from a superseded socket must not act
@@ -3856,7 +4896,12 @@ export class TelegramNotificationDaemon {
3856
4896
  }
3857
4897
 
3858
4898
  private dropSession(session: SessionSocket, reason: string): void {
4899
+ // A dropped socket must lose authority before any teardown-triggered asynchronous
4900
+ // work can observe it. Its immutable lease token remains captured by queued work,
4901
+ // but is no longer usable.
4902
+ if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
3859
4903
  const isCurrentSession = this.sessions.get(session.sessionId) === session;
4904
+ if (isCurrentSession) this.droppedSessions.add(session);
3860
4905
  if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
3861
4906
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
3862
4907
  if (session.pingTimer) {
@@ -3874,6 +4919,12 @@ export class TelegramNotificationDaemon {
3874
4919
  if (isCurrentSession) {
3875
4920
  this.#clearModelChoiceAliasesForSocket(session);
3876
4921
  this.sessions.delete(session.sessionId);
4922
+ for (const [topicSessionId, owner] of this.legacyTopicOwners) {
4923
+ if (owner === session) this.legacyTopicOwners.delete(topicSessionId);
4924
+ }
4925
+ for (const [logicalSessionId, owner] of this.logicalSessionOwners) {
4926
+ if (owner === session) this.logicalSessionOwners.delete(logicalSessionId);
4927
+ }
3877
4928
  }
3878
4929
 
3879
4930
  for (const item of new Set(this.selectedAckPending.values())) {
@@ -3951,6 +5002,7 @@ export class TelegramNotificationDaemon {
3951
5002
  void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
3952
5003
  }
3953
5004
  }
5005
+
3954
5006
  #resumeBtwTurnsForSession(session: SessionSocket): void {
3955
5007
  if (!session.ephemeralCapable || session.hostGeneration < 1 || session.ws.readyState !== WebSocket.OPEN) return;
3956
5008
  const logicalSessionId = this.#logicalSessionId(session);
@@ -3961,6 +5013,7 @@ export class TelegramNotificationDaemon {
3961
5013
  continue;
3962
5014
  }
3963
5015
  if (
5016
+ !this.#leaseTokenAllows(pending.socketLease) ||
3964
5017
  pending.transportSessionId !== session.sessionId ||
3965
5018
  pending.logicalSessionId !== logicalSessionId ||
3966
5019
  pending.endpointDigest !== session.endpointDigest ||
@@ -3971,6 +5024,9 @@ export class TelegramNotificationDaemon {
3971
5024
  }
3972
5025
  }
3973
5026
  #sendPendingBtwTurn(session: SessionSocket, requestId: string, pending: PendingBtwTurn): boolean {
5027
+ if (!this.#leaseTokenAllows(pending.socketLease) || this.sessions.get(session.sessionId) !== session)
5028
+ return false;
5029
+ if (pending.dispatchedSocketLeaseToken === pending.socketLease.token) return true;
3974
5030
  try {
3975
5031
  session.ws.send(
3976
5032
  JSON.stringify({
@@ -3984,6 +5040,7 @@ export class TelegramNotificationDaemon {
3984
5040
  messageId: pending.messageId,
3985
5041
  }),
3986
5042
  );
5043
+ pending.dispatchedSocketLeaseToken = pending.socketLease.token;
3987
5044
  return true;
3988
5045
  } catch {
3989
5046
  return false;
@@ -4041,8 +5098,14 @@ export class TelegramNotificationDaemon {
4041
5098
  parseMode?: typeof TELEGRAM_PARSE_MODE;
4042
5099
  allowWhileStopping?: boolean;
4043
5100
  signal?: AbortSignal;
5101
+ isAuthoritative?: () => boolean;
4044
5102
  }): Promise<unknown> {
4045
- if ((!input.allowWhileStopping && this.#stoppingBtw) || this.#btwDeliveryAbort.signal.aborted) return undefined;
5103
+ if (
5104
+ (!input.allowWhileStopping && this.#stoppingBtw) ||
5105
+ this.#btwDeliveryAbort.signal.aborted ||
5106
+ (input.isAuthoritative && !input.isAuthoritative())
5107
+ )
5108
+ return undefined;
4046
5109
  const signals = [this.#btwDeliveryAbort.signal, AbortSignal.timeout(30_000)];
4047
5110
  if (input.signal) signals.unshift(input.signal);
4048
5111
  return this.botApi.call(
@@ -4175,12 +5238,149 @@ export class TelegramNotificationDaemon {
4175
5238
  return session.logicalSessionId ?? session.sessionId;
4176
5239
  }
4177
5240
 
5241
+ #leaseAllows(session: SessionSocket, logicalSessionId = this.#logicalSessionId(session)): boolean {
5242
+ if (this.droppedSessions.has(session)) return false;
5243
+ const closedBinding = this.closedEndpointKeys.get(session.sessionId);
5244
+ if (
5245
+ closedBinding &&
5246
+ typeof session.endpointKey === "string" &&
5247
+ typeof session.endpointDigest === "string" &&
5248
+ closedBinding.endpointKey === session.endpointKey &&
5249
+ closedBinding.endpointDigest === session.endpointDigest &&
5250
+ closedBinding.endpointGeneration === session.hostGeneration
5251
+ )
5252
+ return false;
5253
+ if (!session.logicalSessionIdTrusted) return true;
5254
+ // Trusted leases require both the current transport socket and exact logical owner.
5255
+ if (this.sessions.get(session.sessionId) !== session) return false;
5256
+ const lease = session.recoveryLease;
5257
+ if (lease?.state !== "authorized" || lease.logicalSessionId !== logicalSessionId) return false;
5258
+ if (this.logicalSessionOwners.get(logicalSessionId) !== session) return false;
5259
+ const record = this.topics.get(logicalSessionId);
5260
+ return (
5261
+ lease.binding.endpointKey === session.endpointKey &&
5262
+ lease.binding.endpointDigest === session.endpointDigest &&
5263
+ lease.binding.endpointGeneration === session.hostGeneration &&
5264
+ (!record ||
5265
+ (record.authorityState !== "delete_pending" &&
5266
+ !record.bindingMalformed &&
5267
+ record.chatId === lease.binding.chatId &&
5268
+ record.endpointKey === lease.binding.endpointKey &&
5269
+ record.endpointDigest === lease.binding.endpointDigest &&
5270
+ record.endpointGeneration === lease.binding.endpointGeneration))
5271
+ );
5272
+ }
5273
+
5274
+ #leaseTokenAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
5275
+ return socketLease.token === 0
5276
+ ? !socketLease.session.logicalSessionIdTrusted && !this.droppedSessions.has(socketLease.session)
5277
+ : this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
5278
+ socketLease.session.recoveryLease?.token === socketLease.token &&
5279
+ this.#leaseAllows(socketLease.session, socketLease.logicalSessionId);
5280
+ }
5281
+
5282
+ #deleteLeaseAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
5283
+ if (socketLease.token === 0)
5284
+ return (
5285
+ this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
5286
+ !this.droppedSessions.has(socketLease.session)
5287
+ );
5288
+ return (
5289
+ this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
5290
+ socketLease.session.recoveryLease?.token === socketLease.token &&
5291
+ socketLease.session.recoveryLease.state === "authorized" &&
5292
+ socketLease.session.recoveryLease.logicalSessionId === socketLease.logicalSessionId &&
5293
+ this.logicalSessionOwners.get(socketLease.logicalSessionId) === socketLease.session
5294
+ );
5295
+ }
5296
+
5297
+ /**
5298
+ * An eager transport-topic create may complete after authenticated replay
5299
+ * authorizes that exact transport as its own logical session. Preserve that
5300
+ * handoff, but never revive a replaced socket or a rekeyed transport.
5301
+ */
5302
+ #isEagerCreationHandoff(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
5303
+ const { session, logicalSessionId } = socketLease;
5304
+ const lease = session.recoveryLease;
5305
+ return (
5306
+ socketLease.token === 0 &&
5307
+ logicalSessionId === session.sessionId &&
5308
+ session.logicalSessionIdTrusted &&
5309
+ this.sessions.get(session.sessionId) === session &&
5310
+ lease?.state === "authorized" &&
5311
+ lease.logicalSessionId === logicalSessionId &&
5312
+ this.logicalSessionOwners.get(logicalSessionId) === session &&
5313
+ lease.binding.chatId === String(this.opts.chatId) &&
5314
+ lease.binding.endpointKey === session.endpointKey &&
5315
+ lease.binding.endpointDigest === session.endpointDigest &&
5316
+ lease.binding.endpointGeneration === session.hostGeneration
5317
+ );
5318
+ }
5319
+
5320
+ #creationLeaseAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
5321
+ return this.#leaseTokenAllows(socketLease) || this.#isEagerCreationHandoff(socketLease);
5322
+ }
5323
+
5324
+ async #awaitCreationLeaseAuthority(socketLease: {
5325
+ session: SessionSocket;
5326
+ token: number;
5327
+ logicalSessionId: string;
5328
+ }): Promise<boolean> {
5329
+ if (this.#creationLeaseAllows(socketLease)) return true;
5330
+ if (
5331
+ socketLease.token === 0 &&
5332
+ socketLease.session.logicalSessionIdTrusted &&
5333
+ socketLease.session.recoveryLease?.state === "pending" &&
5334
+ this.sessions.get(socketLease.session.sessionId) === socketLease.session
5335
+ )
5336
+ await this.recoveryBindingClaimQueue;
5337
+ return this.#creationLeaseAllows(socketLease);
5338
+ }
5339
+ #socketLease(
5340
+ session: SessionSocket,
5341
+ logicalSessionId = this.#logicalSessionId(session),
5342
+ ): { session: SessionSocket; token: number; logicalSessionId: string } | undefined {
5343
+ const lease = session.recoveryLease;
5344
+ return lease && this.#leaseAllows(session, logicalSessionId)
5345
+ ? { session, token: lease.token, logicalSessionId }
5346
+ : session.logicalSessionIdTrusted
5347
+ ? undefined
5348
+ : { session, token: 0, logicalSessionId };
5349
+ }
5350
+
5351
+ #authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void {
5352
+ const previousSessionId = this.#logicalSessionId(session);
5353
+ if (previousSessionId !== logicalSessionId && this.logicalSessionOwners.get(previousSessionId) === session)
5354
+ this.logicalSessionOwners.delete(previousSessionId);
5355
+ const previousOwner = this.logicalSessionOwners.get(logicalSessionId);
5356
+ if (previousOwner && previousOwner !== session && previousOwner.recoveryLease)
5357
+ previousOwner.recoveryLease = { ...previousOwner.recoveryLease, state: "rejected" };
5358
+ this.logicalSessionOwners.set(logicalSessionId, session);
5359
+ this.preservedInitiatorTopics.delete(logicalSessionId);
5360
+ session.logicalSessionId = logicalSessionId;
5361
+ session.recoveryLease = { state: "authorized", logicalSessionId, binding, token: this.nextSocketLeaseToken++ };
5362
+ }
5363
+
4178
5364
  #clearModelChoiceAliases(sessionId: string): void {
4179
5365
  for (const [alias, route] of this.#modelChoiceAliases) {
4180
5366
  if (route.sessionId === sessionId) this.#modelChoiceAliases.delete(alias);
4181
5367
  }
4182
5368
  }
4183
5369
 
5370
+ async #revokeAskAuthority(sessionId: string): Promise<void> {
5371
+ for (const [alias, route] of this.aliasTable.entries()) {
5372
+ if (route.sessionId === sessionId) this.aliasTable.delete(alias);
5373
+ }
5374
+ for (const session of this.sessions.values()) {
5375
+ if (session.sessionId === sessionId || this.#logicalSessionId(session) === sessionId) {
5376
+ for (const [actionId, pending] of session.pending) {
5377
+ if (pending.sessionId === sessionId) session.pending.delete(actionId);
5378
+ }
5379
+ }
5380
+ }
5381
+ await this.persistAliases();
5382
+ }
5383
+
4184
5384
  #clearModelChoiceAliasesForSocket(session: SessionSocket): void {
4185
5385
  for (const [alias, route] of this.#modelChoiceAliases) {
4186
5386
  if (route.session === session) this.#modelChoiceAliases.delete(alias);
@@ -4224,8 +5424,8 @@ export class TelegramNotificationDaemon {
4224
5424
  "reasoning_summary",
4225
5425
  ]);
4226
5426
 
4227
- /** Rekey model controls before a valid server-threaded frame can render or route output. */
4228
- #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
5427
+ /** Rekey only after authenticated replay, except legacy config updates which are transport-local. */
5428
+ async #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): Promise<void> {
4229
5429
  if (
4230
5430
  typeof msg.type !== "string" ||
4231
5431
  !TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type) ||
@@ -4235,10 +5435,184 @@ export class TelegramNotificationDaemon {
4235
5435
  (msg.type !== "config_update" && !renderThreadedFrame(msg))
4236
5436
  )
4237
5437
  return;
4238
- void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
4239
- this.#clearModelChoiceAliasesForSocket(session);
4240
- this.#clearModelChoiceAliases(msg.sessionId);
4241
- session.logicalSessionId = msg.sessionId;
5438
+ if (!session.logicalSessionIdTrusted) return;
5439
+ await this.#recoverTopicBinding(session, msg.sessionId, msg.type === "config_update");
5440
+ }
5441
+
5442
+ #endpointBinding(session: SessionSocket): TopicEndpointBinding {
5443
+ return {
5444
+ chatId: String(this.opts.chatId),
5445
+ endpointKey: session.endpointKey,
5446
+ endpointDigest: session.endpointDigest,
5447
+ endpointGeneration: session.hostGeneration,
5448
+ };
5449
+ }
5450
+
5451
+ #endpointAuthority(binding: TopicEndpointBinding, excludedSession?: SessionSocket) {
5452
+ const tombstoned = [...this.closedEndpointKeys.values()].some(
5453
+ closed =>
5454
+ closed.chatId === binding.chatId &&
5455
+ closed.endpointKey === binding.endpointKey &&
5456
+ closed.endpointDigest === binding.endpointDigest,
5457
+ );
5458
+ if (tombstoned) return { state: "ambiguous" as const };
5459
+ const authority = this.topics.endpointAuthority(binding, excludedSession);
5460
+ const competingLiveClaim = [...this.sessions.values()].some(
5461
+ session =>
5462
+ session !== excludedSession &&
5463
+ session.endpointKey === binding.endpointKey &&
5464
+ session.endpointDigest === binding.endpointDigest,
5465
+ );
5466
+ const competingRecoveryClaim = [...this.sessions.values()].some(
5467
+ session =>
5468
+ session !== excludedSession &&
5469
+ session.recoveryLease?.state === "pending" &&
5470
+ session.recoveryLease.binding.chatId === binding.chatId &&
5471
+ session.recoveryLease.binding.endpointKey === binding.endpointKey &&
5472
+ session.recoveryLease.binding.endpointDigest === binding.endpointDigest,
5473
+ );
5474
+ return competingLiveClaim || competingRecoveryClaim ? { state: "ambiguous" as const } : authority;
5475
+ }
5476
+
5477
+ #ownsLiveOpenEndpoint(session: SessionSocket, binding: TopicEndpointBinding): boolean {
5478
+ return (
5479
+ this.sessions.get(session.sessionId) === session &&
5480
+ session.ws.readyState === WebSocket.OPEN &&
5481
+ binding.endpointKey === session.endpointKey &&
5482
+ binding.endpointDigest === session.endpointDigest &&
5483
+ binding.endpointGeneration === session.hostGeneration
5484
+ );
5485
+ }
5486
+
5487
+ #activeEndpointKeysFor(logicalSessionId: string, claimant: SessionSocket): Set<string> {
5488
+ const keys = new Set<string>();
5489
+ for (const session of this.sessions.values()) {
5490
+ if (
5491
+ session !== claimant &&
5492
+ this.#logicalSessionId(session) === logicalSessionId &&
5493
+ this.#leaseAllows(session, logicalSessionId)
5494
+ )
5495
+ keys.add(session.endpointKey);
5496
+ }
5497
+ return keys;
5498
+ }
5499
+
5500
+ #withRecoveryBindingClaim<T>(claim: () => Promise<T>): Promise<T> {
5501
+ const pending = this.recoveryBindingClaimQueue.then(claim, claim);
5502
+ this.recoveryBindingClaimQueue = pending.then(
5503
+ () => undefined,
5504
+ () => undefined,
5505
+ );
5506
+ return pending;
5507
+ }
5508
+
5509
+ async #recoverTopicBinding(
5510
+ session: SessionSocket,
5511
+ candidateSessionId = this.#logicalSessionId(session),
5512
+ preserveTransportTopic = false,
5513
+ allowEndpointRotation = false,
5514
+ identitylessAdmission: "bootstrap" | "resume" | undefined = undefined,
5515
+ ): Promise<boolean> {
5516
+ if (!session.logicalSessionIdTrusted) return false;
5517
+ const binding = this.#endpointBinding(session);
5518
+ const pendingToken = this.nextSocketLeaseToken++;
5519
+ session.recoveryLease = { state: "pending", logicalSessionId: candidateSessionId, binding, token: pendingToken };
5520
+ const claim = await this.#withRecoveryBindingClaim(async () => {
5521
+ const existing = this.topics.get(candidateSessionId);
5522
+ const hadDurableTopic = existing !== undefined;
5523
+ const previousBinding = existing
5524
+ ? {
5525
+ chatId: existing.chatId,
5526
+ endpointKey: existing.endpointKey,
5527
+ endpointDigest: existing.endpointDigest,
5528
+ endpointGeneration: existing.endpointGeneration,
5529
+ endpointIncarnation: existing.endpointIncarnation,
5530
+ }
5531
+ : undefined;
5532
+ const outcome = await this.#persistTopicMutation(
5533
+ () =>
5534
+ this.topics.get(candidateSessionId)
5535
+ ? this.topics.bindEndpoint(
5536
+ candidateSessionId,
5537
+ binding,
5538
+ this.#activeEndpointKeysFor(candidateSessionId, session),
5539
+ allowEndpointRotation,
5540
+ )
5541
+ : "unchanged",
5542
+ () => {
5543
+ if (previousBinding) this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
5544
+ },
5545
+ ).catch(() => "rejected" as const);
5546
+ if (outcome === "rejected") {
5547
+ if (session.recoveryLease?.token === pendingToken)
5548
+ session.recoveryLease = {
5549
+ state: "rejected",
5550
+ logicalSessionId: candidateSessionId,
5551
+ binding,
5552
+ token: pendingToken,
5553
+ };
5554
+ return undefined;
5555
+ }
5556
+ const endpointAuthority = this.#endpointAuthority(binding, session);
5557
+ const identitylessAdmissionAllows =
5558
+ identitylessAdmission === undefined ||
5559
+ (identitylessAdmission === "bootstrap"
5560
+ ? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
5561
+ (endpointAuthority.state === "unique" &&
5562
+ endpointAuthority.sessionId === candidateSessionId &&
5563
+ this.topics.matchesEndpoint(candidateSessionId, binding))
5564
+ : endpointAuthority.state === "unique" &&
5565
+ endpointAuthority.sessionId === candidateSessionId &&
5566
+ this.topics.matchesEndpoint(candidateSessionId, binding));
5567
+ if (
5568
+ session.recoveryLease?.token !== pendingToken ||
5569
+ session.recoveryLease.state !== "pending" ||
5570
+ !this.#ownsLiveOpenEndpoint(session, binding) ||
5571
+ !identitylessAdmissionAllows
5572
+ ) {
5573
+ if (session.recoveryLease?.token === pendingToken)
5574
+ session.recoveryLease = {
5575
+ state: "rejected",
5576
+ logicalSessionId: candidateSessionId,
5577
+ binding,
5578
+ token: pendingToken,
5579
+ };
5580
+ return undefined;
5581
+ }
5582
+ const previousSessionId = this.#logicalSessionId(session);
5583
+ if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
5584
+ if (!this.#ownsLiveOpenEndpoint(session, binding)) {
5585
+ if (session.recoveryLease?.token === pendingToken)
5586
+ session.recoveryLease = {
5587
+ state: "rejected",
5588
+ logicalSessionId: candidateSessionId,
5589
+ binding,
5590
+ token: pendingToken,
5591
+ };
5592
+ return undefined;
5593
+ }
5594
+ this.#authorizeLease(session, candidateSessionId, binding);
5595
+ return { previousSessionId, hadDurableTopic };
5596
+ });
5597
+ if (!claim) return false;
5598
+ const { previousSessionId } = claim;
5599
+ if (preserveTransportTopic && previousSessionId !== candidateSessionId) {
5600
+ this.legacyTopicOwners.set(previousSessionId, session);
5601
+ this.preservedInitiatorTopics.add(previousSessionId);
5602
+ }
5603
+ if (candidateSessionId === session.sessionId)
5604
+ void (async () => {
5605
+ const topic = this.topics.get(candidateSessionId);
5606
+ if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
5607
+ const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
5608
+ if (topicLease?.topicId === topic.topicId)
5609
+ await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
5610
+ })().catch(err =>
5611
+ logger.warn(
5612
+ `notifications: Telegram recovered topic reattach flush failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
5613
+ ),
5614
+ );
5615
+ return true;
4242
5616
  }
4243
5617
 
4244
5618
  private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
@@ -4320,13 +5694,6 @@ export class TelegramNotificationDaemon {
4320
5694
  return undefined;
4321
5695
  }
4322
5696
 
4323
- private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
4324
- const current = this.sessions.get(session.sessionId);
4325
- if (current) return current === session;
4326
- const ownerId = this.topicOwnerForIdentity(msg);
4327
- return !ownerId || ownerId === session.sessionId;
4328
- }
4329
-
4330
5697
  private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
4331
5698
  if (msg.type !== "tool_activity") return undefined;
4332
5699
  const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
@@ -4334,7 +5701,7 @@ export class TelegramNotificationDaemon {
4334
5701
  const phase = typeof msg.phase === "string" ? msg.phase : undefined;
4335
5702
  if (!toolCallId || !toolName || !phase) return undefined;
4336
5703
  return {
4337
- sessionId: session.sessionId,
5704
+ sessionId: this.#logicalSessionId(session),
4338
5705
  toolCallId,
4339
5706
  toolName,
4340
5707
  endpointDigest: session.endpointDigest,
@@ -4345,7 +5712,8 @@ export class TelegramNotificationDaemon {
4345
5712
 
4346
5713
  private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
4347
5714
  if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
4348
- const session = this.sessions.get(toolActivity.sessionId);
5715
+ const session =
5716
+ this.logicalSessionOwners.get(toolActivity.sessionId) ?? this.sessions.get(toolActivity.sessionId);
4349
5717
  if (toolActivity.endpointDigest === undefined) return session === undefined;
4350
5718
  return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
4351
5719
  }
@@ -4354,12 +5722,18 @@ export class TelegramNotificationDaemon {
4354
5722
  send: ThreadedSend,
4355
5723
  topicLease: TopicAuthorityLease,
4356
5724
  toolActivity?: ToolActivityOwner,
5725
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
4357
5726
  ): Promise<void> {
4358
5727
  this.submitPool({
4359
5728
  sessionId,
4360
5729
  lane: send.lane,
4361
5730
  coalesceKey: send.coalesceKey,
4362
- payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
5731
+ payload: {
5732
+ send,
5733
+ topicLease,
5734
+ ...(socketLease ? { socketLease } : {}),
5735
+ ...(toolActivity ? { toolActivity } : {}),
5736
+ },
4363
5737
  });
4364
5738
  await this.flushPool();
4365
5739
  }
@@ -4375,14 +5749,15 @@ export class TelegramNotificationDaemon {
4375
5749
 
4376
5750
  private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
4377
5751
  const topic = this.topics.get(sessionId);
4378
- if (!topic || topic.authorityState === "delete_pending") return undefined;
5752
+ if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined;
4379
5753
  return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
4380
5754
  }
4381
5755
 
4382
5756
  private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
4383
5757
  const topic = this.topics.get(lease.sessionId);
4384
5758
  return (
4385
- topic?.authorityState !== "delete_pending" &&
5759
+ this.topics.isActiveUnambiguous(lease.sessionId) &&
5760
+ !topic?.bindingMalformed &&
4386
5761
  topic?.topicId === lease.topicId &&
4387
5762
  (topic.authorityEpoch ?? 0) === lease.authorityEpoch
4388
5763
  );
@@ -4427,26 +5802,29 @@ export class TelegramNotificationDaemon {
4427
5802
  msg: Record<string, unknown>,
4428
5803
  toolActivity?: ToolActivityOwner,
4429
5804
  ): void {
4430
- const frames = this.pendingThreadedFrames.get(session.sessionId) ?? [];
5805
+ const logicalSessionId = this.#logicalSessionId(session);
5806
+ const socketLease = this.#socketLease(session, logicalSessionId);
5807
+ if (!socketLease) return;
5808
+ const frames = this.pendingThreadedFrames.get(logicalSessionId) ?? [];
4431
5809
  frames.push({
4432
5810
  send,
4433
5811
  msg,
4434
- endpointDigest: session.endpointDigest,
5812
+ logicalSessionId,
5813
+ socketLease,
4435
5814
  ...(toolActivity ? { toolActivity } : {}),
4436
5815
  });
4437
5816
  if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
4438
- this.pendingThreadedFrames.set(session.sessionId, frames);
5817
+ this.pendingThreadedFrames.set(logicalSessionId, frames);
4439
5818
  }
4440
5819
 
4441
5820
  private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
4442
5821
  const frames = this.pendingThreadedFrames.get(sessionId);
4443
5822
  if (!frames || frames.length === 0) return;
4444
5823
  this.pendingThreadedFrames.delete(sessionId);
4445
- const session = this.sessions.get(sessionId);
4446
5824
  for (const frame of frames) {
4447
- if (session && session.endpointDigest !== frame.endpointDigest) continue;
5825
+ if (frame.logicalSessionId !== sessionId || !this.#leaseTokenAllows(frame.socketLease)) continue;
4448
5826
  if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
4449
- await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
5827
+ await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
4450
5828
  }
4451
5829
  }
4452
5830
 
@@ -4456,36 +5834,163 @@ export class TelegramNotificationDaemon {
4456
5834
  * `undefined`; callers then flat-deliver to a private paired chat (with a
4457
5835
  * one-time nudge) or drop fail-closed for a non-private chat.
4458
5836
  */
4459
- private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
5837
+ private async ensureTopic(
5838
+ sessionId: string,
5839
+ name: string,
5840
+ session?: SessionSocket,
5841
+ creationLease?: { session: SessionSocket; token: number; logicalSessionId: string },
5842
+ ): Promise<string | undefined> {
4460
5843
  if (!(await this.pairedChatIsPrivate())) return undefined;
5844
+ if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
5845
+ const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
5846
+ if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
4461
5847
  const existing = this.topics.get(sessionId);
4462
- if (existing?.authorityState === "delete_pending") return undefined;
5848
+ if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
4463
5849
  if (existing) return existing.topicId;
5850
+ if (
5851
+ session &&
5852
+ sessionId !== session.sessionId &&
5853
+ (!session.logicalSessionIdTrusted || sessionId !== this.#logicalSessionId(session))
5854
+ )
5855
+ return undefined;
5856
+ if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease)) return undefined;
5857
+ const creationBinding = session ? this.#endpointBinding(session) : undefined;
5858
+ const creationLeaseEpoch = this.topics.authorityEpoch(sessionId);
5859
+ let acceptedTopicId: string | undefined;
5860
+ let acceptedTopicCompensated = false;
5861
+ let acceptedTopicDeleteAttempted = false;
4464
5862
  try {
4465
5863
  const rec = await this.topics.getOrCreateTopic(
4466
5864
  sessionId,
4467
5865
  async () => {
4468
- const res = await this.botApi.call("createForumTopic", {
4469
- chat_id: this.opts.chatId,
4470
- name,
4471
- });
5866
+ if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
5867
+ throw new Error("topic authority was revoked during creation");
5868
+ const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
4472
5869
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
4473
5870
  const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
4474
5871
  if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
4475
5872
  throw new Error("createForumTopic: invalid message_thread_id");
4476
- return String(tid);
5873
+ acceptedTopicId = String(tid);
5874
+ if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
5875
+ if (
5876
+ !this.topics.fenceAcceptedCreateForLease(
5877
+ sessionId,
5878
+ acceptedTopicId,
5879
+ creationLeaseEpoch,
5880
+ this.opts.now,
5881
+ name,
5882
+ creationBinding,
5883
+ )
5884
+ )
5885
+ throw new Error("topic authority was revoked during creation");
5886
+ try {
5887
+ await this.persistTopics();
5888
+ } finally {
5889
+ acceptedTopicDeleteAttempted = true;
5890
+ const deletion = await this.botApi.call("deleteForumTopic", {
5891
+ chat_id: this.opts.chatId,
5892
+ message_thread_id: tid,
5893
+ });
5894
+ acceptedTopicCompensated = topicDeleteSettled(deletion);
5895
+
5896
+ if (topicDeleteSettled(deletion)) {
5897
+ this.topics.settleDelete(sessionId, acceptedTopicId);
5898
+ await this.persistTopics();
5899
+ } else {
5900
+ this.#superviseCompensationFence(sessionId);
5901
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5902
+ }
5903
+ }
5904
+ throw new Error("topic authority was revoked during creation");
5905
+ }
5906
+ return acceptedTopicId;
4477
5907
  },
4478
5908
  this.opts.now,
4479
- // The create winner records the name it actually used; callers that
4480
- // merely JOIN an in-flight create must not overwrite it locally, or a
4481
- // later identity rename would be wrongly skipped (topic stuck at the
4482
- // provisional name on Telegram).
4483
5909
  name,
5910
+ creationBinding,
5911
+ () => this.persistTopics(),
5912
+ session,
4484
5913
  );
4485
- await this.persistTopics();
5914
+ // getOrCreateTopic deduplicates callers, so an accepted create can be
5915
+ // observed by a successor after its initiating socket was revoked. Check
5916
+ // the immutable lease again before exposing that record to frame delivery.
5917
+ if (capturedCreationLease && this.#isEagerCreationHandoff(capturedCreationLease)) {
5918
+ const binding = this.#endpointBinding(capturedCreationLease.session);
5919
+ if (
5920
+ this.topics.bindEndpoint(
5921
+ sessionId,
5922
+ binding,
5923
+ this.#activeEndpointKeysFor(sessionId, capturedCreationLease.session),
5924
+ true,
5925
+ ) !== "unchanged"
5926
+ )
5927
+ await this.persistTopics();
5928
+ }
5929
+ if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
5930
+ if (
5931
+ this.topics.fenceAcceptedCreateForLease(
5932
+ sessionId,
5933
+ rec.topicId,
5934
+ creationLeaseEpoch,
5935
+ this.opts.now,
5936
+ name,
5937
+ creationBinding,
5938
+ )
5939
+ )
5940
+ await this.deleteTopic(sessionId, undefined, true);
5941
+ return undefined;
5942
+ }
4486
5943
  return rec.topicId;
4487
5944
  } catch (err) {
4488
5945
  if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
5946
+ if (
5947
+ acceptedTopicId &&
5948
+ !acceptedTopicCompensated &&
5949
+ !acceptedTopicDeleteAttempted &&
5950
+ this.topics.get(sessionId)?.authorityState !== "delete_pending"
5951
+ ) {
5952
+ // A failed initial commit must never make compensation conditional on
5953
+ // successfully publishing its fence.
5954
+ if (
5955
+ this.topics.fenceAcceptedCreateForLease(
5956
+ sessionId,
5957
+ acceptedTopicId,
5958
+ creationLeaseEpoch,
5959
+ this.opts.now,
5960
+ name,
5961
+ creationBinding,
5962
+ )
5963
+ ) {
5964
+ try {
5965
+ await this.#persistTopicsWithRetry();
5966
+ } catch {
5967
+ this.#superviseCompensationFence(sessionId);
5968
+ }
5969
+
5970
+ try {
5971
+ const deletion = await this.botApi.call("deleteForumTopic", {
5972
+ chat_id: this.opts.chatId,
5973
+ message_thread_id: Number(acceptedTopicId),
5974
+ });
5975
+ if (topicDeleteSettled(deletion)) {
5976
+ this.topics.settleDelete(sessionId, acceptedTopicId);
5977
+ await this.persistTopics();
5978
+ } else {
5979
+ this.#superviseCompensationFence(sessionId);
5980
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5981
+ }
5982
+ } catch {
5983
+ this.#superviseCompensationFence(sessionId);
5984
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5985
+ }
5986
+ }
5987
+ }
5988
+ if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicDeleteAttempted) {
5989
+ this.#superviseCompensationFence(sessionId);
5990
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5991
+ }
5992
+ if (session && err instanceof Error && err.message === "topic authority was revoked during creation")
5993
+ return undefined;
4489
5994
  logger.warn(
4490
5995
  `notifications: Telegram topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
4491
5996
  );
@@ -4499,24 +6004,39 @@ export class TelegramNotificationDaemon {
4499
6004
  }
4500
6005
 
4501
6006
  private async observeOrphanedTopic(sessionId: string): Promise<void> {
4502
- if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
4503
- if (!this.topicPastOrphanGrace(sessionId)) return;
4504
- await this.deleteTopic(sessionId);
6007
+ await this.#withRecoveryBindingClaim(async () => {
6008
+ const owner = this.logicalSessionOwners.get(sessionId);
6009
+ if (owner && this.#leaseAllows(owner, sessionId)) {
6010
+ if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
6011
+ return;
6012
+ }
6013
+ if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
6014
+ if (!this.topicPastOrphanGrace(sessionId)) return;
6015
+ const currentOwner = this.logicalSessionOwners.get(sessionId);
6016
+ if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return;
6017
+ await this.deleteTopic(sessionId);
6018
+ });
4505
6019
  }
4506
6020
 
4507
6021
  /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
4508
- private async deleteTopic(sessionId: string): Promise<void> {
4509
- let record = this.topics.beginDelete(sessionId);
4510
- // Persist even an absent-record fence: it revokes a create that was admitted
4511
- // before close but has not yet committed its topic record.
6022
+ private async deleteTopic(
6023
+ sessionId: string,
6024
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
6025
+ deleteFenceAlreadyPublished = false,
6026
+ ): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
6027
+ const deleteSnapshot = this.topics.captureDeleteAuthority(sessionId);
6028
+ let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId);
6029
+ if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
4512
6030
  await this.persistTopics();
6031
+ if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
6032
+ await this.#revokeAskAuthority(sessionId);
6033
+ this.deleteMessageRoutes(sessionId);
6034
+ this.#clearModelChoiceAliases(sessionId);
4513
6035
  if (!record) {
4514
6036
  await this.topics.awaitInflight(sessionId);
4515
6037
  record = this.topics.get(sessionId);
4516
- // A remotely accepted revoked create installs a delete_pending record while
4517
- // its caller rejects; durably retain it before compensating remotely.
4518
6038
  await this.persistTopics();
4519
- if (!record) return;
6039
+ if (!record) return "settled";
4520
6040
  }
4521
6041
  const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
4522
6042
  for (const item of removed) {
@@ -4526,11 +6046,12 @@ export class TelegramNotificationDaemon {
4526
6046
  }
4527
6047
  try {
4528
6048
  await this.flushPool();
6049
+ if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
4529
6050
  const res = (await this.botApi.call("deleteForumTopic", {
4530
6051
  chat_id: this.opts.chatId,
4531
6052
  message_thread_id: Number(record.topicId),
4532
6053
  })) as { ok?: boolean };
4533
- if (!topicDeleteSettled(res)) return;
6054
+ if (!topicDeleteSettled(res)) return "post_dispatch_pending";
4534
6055
  this.topics.settleDelete(sessionId, record.topicId);
4535
6056
  for (const k of [...this.liveMessages.keys()])
4536
6057
  if (k.startsWith(`${sessionId}:`)) {
@@ -4541,32 +6062,119 @@ export class TelegramNotificationDaemon {
4541
6062
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
4542
6063
  });
4543
6064
  this.pendingThreadedFrames.delete(sessionId);
6065
+ try {
6066
+ await this.persistTopics();
6067
+ return "settled";
6068
+ } catch {
6069
+ this.topics.restoreDeleteFence(deleteSnapshot);
6070
+ await this.#persistTopicsWithRetry().catch(() => undefined);
6071
+ return "post_dispatch_pending";
6072
+ }
6073
+ } catch {
6074
+ // Once Telegram dispatch starts, retain the persisted deletion fence: the
6075
+ // remote result is ambiguous and may not restore stale routing authority.
6076
+ return "post_dispatch_pending";
6077
+ }
6078
+ }
6079
+
6080
+ /** Serialize a mutation and its durable snapshot so rollback precedes later writers. */
6081
+ #persistTopicMutation<T>(mutation: () => T, rollback: () => void): Promise<T> {
6082
+ const pending = this.topicsPersistQueue.then(async () => {
6083
+ const result = mutation();
6084
+ try {
6085
+ const snapshot = this.#topicStateForPersistence();
6086
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
6087
+ await ensureDir(this.fsImpl, paths.dir);
6088
+ await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
6089
+ return result;
6090
+ } catch (error) {
6091
+ rollback();
6092
+ throw error;
6093
+ }
6094
+ });
6095
+ this.topicsPersistQueue = pending.then(
6096
+ () => undefined,
6097
+ () => undefined,
6098
+ );
6099
+ return pending;
6100
+ }
6101
+
6102
+ async #persistTopicsWithRetry(): Promise<void> {
6103
+ try {
4544
6104
  await this.persistTopics();
4545
6105
  } catch {
4546
- // Retain the persisted delete fence on transport ambiguity.
6106
+ await this.persistTopics();
4547
6107
  }
4548
6108
  }
4549
6109
 
6110
+ #superviseCompensationFence(sessionId: string): void {
6111
+ if (this.compensationFenceRetries.has(sessionId)) return;
6112
+ const retry = this.effects.track(
6113
+ (async () => {
6114
+ for (;;) {
6115
+ try {
6116
+ await this.persistTopics();
6117
+ return;
6118
+ } catch {
6119
+ await this.runtime.sleep(250);
6120
+ }
6121
+ }
6122
+ })(),
6123
+ );
6124
+ this.compensationFenceRetries.set(sessionId, retry);
6125
+ void retry.finally(() => this.compensationFenceRetries.delete(sessionId));
6126
+ }
6127
+
4550
6128
  private persistTopics(): Promise<void> {
4551
6129
  const pending = this.topicsPersistQueue.then(async () => {
6130
+ // Resolve implicit snapshots inside the serialization queue. Callers that
6131
+ // mutate the registry before waiting cannot overwrite a newer authority
6132
+ // binding with an invocation-time snapshot.
6133
+ const snapshot = this.#topicStateForPersistence();
4552
6134
  const paths = daemonPaths(this.opts.settings.getAgentDir());
4553
6135
  await ensureDir(this.fsImpl, paths.dir);
4554
- await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), this.topics.serialize());
6136
+ await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
4555
6137
  });
4556
6138
  this.topicsPersistQueue = pending.catch(() => undefined);
4557
6139
  return pending;
4558
6140
  }
4559
6141
 
6142
+ #topicStateForPersistence(): TopicRegistryState {
6143
+ return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) };
6144
+ }
6145
+
4560
6146
  async loadTopics(): Promise<void> {
4561
6147
  const paths = daemonPaths(this.opts.settings.getAgentDir());
4562
6148
  const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
4563
6149
  // Restore the full serialized registry (topicId + identitySent + name) so a
4564
6150
  // fresh daemon after reload does not resend identity headers or lose renames.
4565
- if (raw && typeof raw === "object") this.topics.load(raw);
6151
+ if (raw && typeof raw === "object") {
6152
+ this.topics.load(raw);
6153
+ for (const [sessionId, binding] of Object.entries(raw.closedEndpoints ?? {})) {
6154
+ if (
6155
+ binding &&
6156
+ typeof binding.chatId === "string" &&
6157
+ typeof binding.endpointKey === "string" &&
6158
+ typeof binding.endpointDigest === "string" &&
6159
+ (binding.endpointGeneration === undefined ||
6160
+ (Number.isSafeInteger(binding.endpointGeneration) && binding.endpointGeneration >= 0))
6161
+ )
6162
+ this.closedEndpointKeys.set(sessionId, binding);
6163
+ }
6164
+ }
6165
+ }
6166
+
6167
+ /** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */
6168
+ private async reconcilePendingTopicDeletes(): Promise<void> {
6169
+ for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId);
4566
6170
  }
4567
6171
 
4568
- /** Download a Telegram file by its file_path (from getFile) into memory. */
4569
- private async downloadTelegramFile(filePath: string): Promise<Buffer | undefined> {
6172
+ /** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */
6173
+ private async downloadTelegramFile(
6174
+ filePath: string,
6175
+ maxBytes = TELEGRAM_ATTACHMENT_MAX_BYTES,
6176
+ deadlineController?: AbortController,
6177
+ ): Promise<TelegramFileDownload> {
4570
6178
  const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
4571
6179
  const fetchImpl = this.opts.fetchImpl ?? fetch;
4572
6180
  // `filePath` is remote metadata from getFile; reject suspicious segments
@@ -4574,17 +6182,68 @@ export class TelegramNotificationDaemon {
4574
6182
  // composing the download URL.
4575
6183
  if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
4576
6184
  logger.warn("notifications: rejecting suspicious Telegram file_path");
4577
- return undefined;
6185
+ return { failure: "download_failed" };
4578
6186
  }
4579
6187
  const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
4580
6188
  const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
6189
+ const controller = deadlineController ?? new AbortController();
6190
+ let cancelActiveReader: (() => Promise<void>) | undefined;
6191
+ const setTimeoutImpl = this.opts.setTimeoutImpl ?? setTimeout;
6192
+ const clearTimeoutImpl = this.opts.clearTimeoutImpl ?? clearTimeout;
6193
+ const timeout = deadlineController
6194
+ ? undefined
6195
+ : setTimeoutImpl(
6196
+ () => controller.abort(new Error("Telegram attachment download timed out")),
6197
+ TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
6198
+ );
6199
+ const cancelReader = () => void cancelActiveReader?.();
6200
+ controller.signal.addEventListener("abort", cancelReader, { once: true });
4581
6201
  try {
4582
- const res = await fetchImpl(url);
4583
- if (!res.ok) return undefined;
4584
- return Buffer.from(await res.arrayBuffer());
6202
+ const res = await fetchImpl(url, { signal: controller.signal });
6203
+ if (!res.ok) {
6204
+ await res.body?.cancel().catch(() => undefined);
6205
+ controller.abort();
6206
+ return { failure: "download_failed" };
6207
+ }
6208
+ const declaredLength = res.headers.get("content-length");
6209
+ if (declaredLength !== null) {
6210
+ const declaredBytes = Number(declaredLength);
6211
+ if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0 || declaredBytes > maxBytes) {
6212
+ await res.body?.cancel().catch(() => undefined);
6213
+ controller.abort();
6214
+ return { failure: "too_large" };
6215
+ }
6216
+ }
6217
+ if (!res.body) {
6218
+ controller.abort();
6219
+ return { failure: "download_failed" };
6220
+ }
6221
+ const reader = res.body.getReader();
6222
+ cancelActiveReader = () => reader.cancel().catch(() => undefined);
6223
+ controller.signal.throwIfAborted();
6224
+ const chunks: Buffer[] = [];
6225
+ let total = 0;
6226
+ while (true) {
6227
+ const { done, value } = await reader.read();
6228
+ if (done) break;
6229
+ total += value.byteLength;
6230
+ if (total > maxBytes) {
6231
+ await reader.cancel().catch(() => undefined);
6232
+ controller.abort();
6233
+ return { failure: "too_large" };
6234
+ }
6235
+ chunks.push(Buffer.from(value));
6236
+ }
6237
+ controller.signal.throwIfAborted();
6238
+ return { bytes: Buffer.concat(chunks, total) };
4585
6239
  } catch (e) {
6240
+ await cancelActiveReader?.();
6241
+ controller.abort();
4586
6242
  logger.warn(`notifications: file download failed: ${sanitizeDiagnostic(String(e))}`);
4587
- return undefined;
6243
+ return { failure: "download_failed" };
6244
+ } finally {
6245
+ controller.signal.removeEventListener("abort", cancelReader);
6246
+ if (timeout !== undefined) clearTimeoutImpl(timeout);
4588
6247
  }
4589
6248
  }
4590
6249
 
@@ -4594,6 +6253,8 @@ export class TelegramNotificationDaemon {
4594
6253
  * removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
4595
6254
  */
4596
6255
  private readonly attachmentDirs = new Map<string, string>();
6256
+ readonly #attachmentUsage = new Map<string, { count: number; bytes: number }>();
6257
+ readonly #attachmentChains = new Map<string, Promise<void>>();
4597
6258
 
4598
6259
  /** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
4599
6260
  private async ensureAttachmentDir(sessionId: string): Promise<string> {
@@ -4611,6 +6272,8 @@ export class TelegramNotificationDaemon {
4611
6272
  private async cleanupAllAttachmentDirs(): Promise<void> {
4612
6273
  const dirs = [...this.attachmentDirs.values()];
4613
6274
  this.attachmentDirs.clear();
6275
+ this.#attachmentUsage.clear();
6276
+ this.#attachmentChains.clear();
4614
6277
  await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
4615
6278
  }
4616
6279
 
@@ -4626,22 +6289,83 @@ export class TelegramNotificationDaemon {
4626
6289
  private async resolveInboundAttachment(
4627
6290
  att: InboundAttachment,
4628
6291
  sessionId: string,
6292
+ ): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
6293
+ const previous = this.#attachmentChains.get(sessionId) ?? Promise.resolve();
6294
+ const task = previous.then(() => this.resolveInboundAttachmentSerial(att, sessionId));
6295
+ const chain = task.then(
6296
+ () => undefined,
6297
+ () => undefined,
6298
+ );
6299
+ this.#attachmentChains.set(sessionId, chain);
6300
+ return task.finally(() => {
6301
+ if (this.#attachmentChains.get(sessionId) === chain) this.#attachmentChains.delete(sessionId);
6302
+ });
6303
+ }
6304
+
6305
+ private async resolveInboundAttachmentSerial(
6306
+ att: InboundAttachment,
6307
+ sessionId: string,
4629
6308
  ): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
4630
6309
  const images: { data: string; mime?: string }[] = [];
4631
6310
  const fileNotes: string[] = [];
4632
6311
  const label = att.fileName ?? att.kind;
6312
+ let timeout: NodeJS.Timeout | undefined;
4633
6313
  try {
4634
- const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
4635
- result?: { file_path?: unknown };
6314
+ const usage = this.#attachmentUsage.get(sessionId) ?? { count: 0, bytes: 0 };
6315
+ if (
6316
+ usage.count >= TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT ||
6317
+ usage.bytes >= TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES
6318
+ ) {
6319
+ fileNotes.push(`[attachment rejected: ${label}; session attachment limit reached]`);
6320
+ return { images, fileNotes };
6321
+ }
6322
+ const controller = new AbortController();
6323
+ timeout = (this.opts.setTimeoutImpl ?? setTimeout)(
6324
+ () => controller.abort(new Error("Telegram attachment download timed out")),
6325
+ TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
6326
+ );
6327
+ const got = (await this.botApi.call("getFile", { file_id: att.fileId }, { signal: controller.signal })) as {
6328
+ result?: { file_path?: unknown; file_size?: unknown };
4636
6329
  };
4637
6330
  const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
4638
6331
  if (!filePath) {
4639
6332
  fileNotes.push(`[attachment unavailable: ${label}]`);
4640
6333
  return { images, fileNotes };
4641
6334
  }
4642
- const bytes = await this.downloadTelegramFile(filePath);
4643
- if (!bytes) {
4644
- fileNotes.push(`[attachment download failed: ${label}]`);
6335
+ const declaredBytes = got.result?.file_size;
6336
+ const remainingBytes = TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES - usage.bytes;
6337
+ if (
6338
+ (typeof declaredBytes === "number" &&
6339
+ (!Number.isSafeInteger(declaredBytes) ||
6340
+ declaredBytes < 0 ||
6341
+ declaredBytes > TELEGRAM_ATTACHMENT_MAX_BYTES ||
6342
+ declaredBytes > remainingBytes)) ||
6343
+ remainingBytes <= 0
6344
+ ) {
6345
+ fileNotes.push(`[attachment rejected: ${label}; size limit exceeded]`);
6346
+ return { images, fileNotes };
6347
+ }
6348
+ const downloaded = await this.downloadTelegramFile(
6349
+ filePath,
6350
+ Math.min(TELEGRAM_ATTACHMENT_MAX_BYTES, remainingBytes),
6351
+ controller,
6352
+ );
6353
+ if ("failure" in downloaded) {
6354
+ fileNotes.push(
6355
+ downloaded.failure === "too_large"
6356
+ ? `[attachment rejected: ${label}; size limit exceeded]`
6357
+ : `[attachment download failed: ${label}]`,
6358
+ );
6359
+ return { images, fileNotes };
6360
+ }
6361
+ const bytes = downloaded.bytes;
6362
+ const accountedBytes = Math.max(bytes.byteLength, typeof declaredBytes === "number" ? declaredBytes : 0);
6363
+ const current = this.#attachmentUsage.get(sessionId) ?? { count: 0, bytes: 0 };
6364
+ if (
6365
+ current.count >= TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT ||
6366
+ current.bytes + accountedBytes > TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES
6367
+ ) {
6368
+ fileNotes.push(`[attachment rejected: ${label}; session attachment limit reached]`);
4645
6369
  return { images, fileNotes };
4646
6370
  }
4647
6371
  const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
@@ -4658,12 +6382,20 @@ export class TelegramNotificationDaemon {
4658
6382
  // Unguessable, non-colliding name inside the private 0700 dir; the
4659
6383
  // exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
4660
6384
  const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
4661
- await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
6385
+ try {
6386
+ await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
6387
+ } catch (e) {
6388
+ await fs.promises.unlink(dest).catch(() => undefined);
6389
+ throw e;
6390
+ }
4662
6391
  fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
4663
6392
  }
6393
+ this.#attachmentUsage.set(sessionId, { count: current.count + 1, bytes: current.bytes + accountedBytes });
4664
6394
  } catch (e) {
4665
6395
  logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
4666
6396
  fileNotes.push(`[attachment error: ${label}]`);
6397
+ } finally {
6398
+ if (timeout !== undefined) (this.opts.clearTimeoutImpl ?? clearTimeout)(timeout);
4667
6399
  }
4668
6400
  return { images, fileNotes };
4669
6401
  }
@@ -4769,7 +6501,9 @@ export class TelegramNotificationDaemon {
4769
6501
  const controller = new AbortController();
4770
6502
  selectedAck.controller = controller;
4771
6503
  const routeAvailable =
4772
- (await this.pairedChatIsPrivate()) && (!topicLease || this.topicLeaseIsCurrent(topicLease));
6504
+ this.#leaseTokenAllows(selectedAck.socketLease) &&
6505
+ (await this.pairedChatIsPrivate()) &&
6506
+ (!topicLease || this.topicLeaseIsCurrent(topicLease));
4773
6507
  if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) {
4774
6508
  this.pool.settle(item.itemId!, "removed");
4775
6509
  continue;
@@ -4791,6 +6525,14 @@ export class TelegramNotificationDaemon {
4791
6525
  Math.min(8_000, remaining),
4792
6526
  );
4793
6527
  try {
6528
+ if (
6529
+ !this.#leaseTokenAllows(selectedAck.socketLease) ||
6530
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6531
+ ) {
6532
+ this.finishSelectedAck(selectedAck, { status: "failed", reason: "route_missing" });
6533
+ this.pool.settle(item.itemId!, "rejected");
6534
+ continue;
6535
+ }
4794
6536
  const response = (await this.botApi.call(
4795
6537
  "sendMessage",
4796
6538
  {
@@ -4815,8 +6557,11 @@ export class TelegramNotificationDaemon {
4815
6557
  }
4816
6558
  continue;
4817
6559
  }
4818
- const { send, topicLease } = item.payload;
4819
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
6560
+ const { send, topicLease, socketLease } = item.payload;
6561
+ if (
6562
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6563
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6564
+ ) {
4820
6565
  this.pool.settle(item.itemId!, "rejected");
4821
6566
  continue;
4822
6567
  }
@@ -4825,7 +6570,10 @@ export class TelegramNotificationDaemon {
4825
6570
  this.pool.settle(item.itemId!, "rejected");
4826
6571
  continue;
4827
6572
  }
4828
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
6573
+ if (
6574
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6575
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6576
+ ) {
4829
6577
  this.pool.settle(item.itemId!, "rejected");
4830
6578
  continue;
4831
6579
  }
@@ -4870,7 +6618,10 @@ export class TelegramNotificationDaemon {
4870
6618
  }
4871
6619
  }
4872
6620
  }
4873
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
6621
+ if (
6622
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6623
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6624
+ ) {
4874
6625
  disposition = "rejected";
4875
6626
  continue;
4876
6627
  }
@@ -4910,7 +6661,11 @@ export class TelegramNotificationDaemon {
4910
6661
  // non-editable, HTML-only pool items (rich markers stripped) — same
4911
6662
  // per-token discipline as the non-rich split path.
4912
6663
  const chunks = splitTelegramHtml(send.text!);
4913
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
6664
+ if (
6665
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6666
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6667
+ )
6668
+ return;
4914
6669
  await this.botApi.call("sendMessage", {
4915
6670
  chat_id: this.opts.chatId,
4916
6671
  ...threadField,
@@ -4935,6 +6690,7 @@ export class TelegramNotificationDaemon {
4935
6690
  richClass: undefined,
4936
6691
  },
4937
6692
  topicLease,
6693
+ socketLease,
4938
6694
  },
4939
6695
  });
4940
6696
  }
@@ -4969,6 +6725,11 @@ export class TelegramNotificationDaemon {
4969
6725
  // transport error) resends so the first chunk is never lost.
4970
6726
  let edited = false;
4971
6727
  try {
6728
+ if (
6729
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6730
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6731
+ )
6732
+ return;
4972
6733
  const res = (await this.botApi.call("editMessageText", {
4973
6734
  chat_id: this.opts.chatId,
4974
6735
  message_id: existingId,
@@ -4981,7 +6742,10 @@ export class TelegramNotificationDaemon {
4981
6742
  }
4982
6743
  if (edited) {
4983
6744
  firstMessageId = existingId;
4984
- } else if (!topicLease || this.topicLeaseIsCurrent(topicLease)) {
6745
+ } else if (
6746
+ (!socketLease || this.#leaseTokenAllows(socketLease)) &&
6747
+ (!topicLease || this.topicLeaseIsCurrent(topicLease))
6748
+ ) {
4985
6749
  const res = (await this.botApi.call("sendMessage", {
4986
6750
  chat_id: this.opts.chatId,
4987
6751
  ...threadField,
@@ -4993,6 +6757,11 @@ export class TelegramNotificationDaemon {
4993
6757
  } else {
4994
6758
  // No streamed message to edit: a single granted slot maps to a
4995
6759
  // single Telegram send.
6760
+ if (
6761
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6762
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6763
+ )
6764
+ return;
4996
6765
  const res = (await this.botApi.call("sendMessage", {
4997
6766
  chat_id: this.opts.chatId,
4998
6767
  ...threadField,
@@ -5026,6 +6795,7 @@ export class TelegramNotificationDaemon {
5026
6795
  richClass: undefined,
5027
6796
  },
5028
6797
  topicLease,
6798
+ socketLease,
5029
6799
  },
5030
6800
  });
5031
6801
  }
@@ -5103,17 +6873,21 @@ export class TelegramNotificationDaemon {
5103
6873
  sessionId: string,
5104
6874
  send: ThreadedSend,
5105
6875
  toolActivity?: ToolActivityOwner,
6876
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
5106
6877
  ): Promise<void> {
5107
- if (!(await this.pairedChatIsPrivate())) return;
5108
- await this.notifyThreadedFallback();
6878
+ if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
6879
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
6880
+ await this.notifyThreadedFallback(socketLease);
6881
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5109
6882
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
5110
6883
  this.submitPool({
5111
6884
  sessionId,
5112
6885
  lane: send.lane,
5113
6886
  coalesceKey: send.coalesceKey,
5114
- payload: { send, ...(toolActivity ? { toolActivity } : {}) },
6887
+ payload: { send, ...(socketLease ? { socketLease } : {}), ...(toolActivity ? { toolActivity } : {}) },
5115
6888
  });
5116
6889
  await this.flushPool();
6890
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5117
6891
  if (send.identity) this.flatIdentitySent.add(sessionId);
5118
6892
  }
5119
6893
 
@@ -5156,10 +6930,21 @@ export class TelegramNotificationDaemon {
5156
6930
  }
5157
6931
 
5158
6932
  /** Tell the user once (per daemon run) how to enable Threaded Mode. */
5159
- private async notifyThreadedFallback(): Promise<void> {
5160
- if (this.threadedFallbackNoticeSent || !(await this.pairedChatIsPrivate())) return;
6933
+ private async notifyThreadedFallback(socketLease?: {
6934
+ session: SessionSocket;
6935
+ token: number;
6936
+ logicalSessionId: string;
6937
+ }): Promise<void> {
6938
+ if (
6939
+ this.threadedFallbackNoticeSent ||
6940
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6941
+ !(await this.pairedChatIsPrivate())
6942
+ )
6943
+ return;
6944
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5161
6945
  this.threadedFallbackNoticeSent = true;
5162
6946
  try {
6947
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5163
6948
  await this.botApi.call("sendMessage", {
5164
6949
  chat_id: this.opts.chatId,
5165
6950
  text: "Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands.",
@@ -5234,14 +7019,20 @@ export class TelegramNotificationDaemon {
5234
7019
  }
5235
7020
 
5236
7021
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
5237
- private async sendTyping(sessionId: string): Promise<void> {
7022
+ private async sendTyping(
7023
+ sessionId: string,
7024
+ capturedLease?: { session: SessionSocket; token: number; logicalSessionId: string },
7025
+ ): Promise<void> {
7026
+ const session = this.logicalSessionOwners.get(sessionId) ?? this.sessions.get(sessionId);
7027
+ const socketLease = capturedLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
7028
+ if (!socketLease) return;
5238
7029
  const topicLease = await this.topicAuthorityLease(sessionId);
5239
- if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
5240
- const topicId = topicLease.topicId;
7030
+ if (!topicLease || !this.topicLeaseIsCurrent(topicLease) || !this.#leaseTokenAllows(socketLease)) return;
5241
7031
  try {
7032
+ if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
5242
7033
  await this.botApi.call("sendChatAction", {
5243
7034
  chat_id: this.opts.chatId,
5244
- message_thread_id: Number(topicId),
7035
+ message_thread_id: Number(topicLease.topicId),
5245
7036
  action: "typing",
5246
7037
  });
5247
7038
  } catch {
@@ -5250,9 +7041,14 @@ export class TelegramNotificationDaemon {
5250
7041
  }
5251
7042
 
5252
7043
  /** Set a native reaction on an inbound thread message (best-effort). */
5253
- private async setReaction(messageId: number, emoji: string): Promise<void> {
5254
- if (!(await this.pairedChatIsPrivate())) return;
7044
+ private async setReaction(
7045
+ messageId: number,
7046
+ emoji: string,
7047
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
7048
+ ): Promise<void> {
7049
+ if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
5255
7050
  try {
7051
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5256
7052
  await this.botApi.call("setMessageReaction", {
5257
7053
  chat_id: this.opts.chatId,
5258
7054
  message_id: messageId,
@@ -5276,7 +7072,10 @@ export class TelegramNotificationDaemon {
5276
7072
 
5277
7073
  /** Render successful `/model` lists as session-bound, one-shot inline choices. */
5278
7074
  async #renderModelChoices(session: SessionSocket, msg: Record<string, unknown>): Promise<boolean> {
5279
- const logicalSessionId = this.#logicalSessionId(session);
7075
+ const logicalSessionId =
7076
+ !session.logicalSessionIdTrusted && typeof msg.sessionId === "string" && msg.sessionId.trim()
7077
+ ? msg.sessionId
7078
+ : this.#logicalSessionId(session);
5280
7079
  if (
5281
7080
  msg.type !== "control_command_result" ||
5282
7081
  msg.status !== "ok" ||
@@ -5285,6 +7084,8 @@ export class TelegramNotificationDaemon {
5285
7084
  this.sessions.get(session.sessionId) !== session
5286
7085
  )
5287
7086
  return false;
7087
+ const socketLease = this.#socketLease(session, logicalSessionId);
7088
+ if (!socketLease) return false;
5288
7089
 
5289
7090
  const choices: RenderedModelChoice[] = [];
5290
7091
  for (const choice of msg.modelChoices) {
@@ -5299,21 +7100,27 @@ export class TelegramNotificationDaemon {
5299
7100
  const rendered = renderThreadedFrame({ ...msg, type: "control_command_result" });
5300
7101
  if (!rendered?.text) return false;
5301
7102
  const topicId =
5302
- (await this.existingTopicForPrivateChat(session.sessionId)) ??
5303
- (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
5304
- const topicLease = await this.topicAuthorityLease(session.sessionId);
7103
+ (await this.existingTopicForPrivateChat(logicalSessionId)) ??
7104
+ (await this.ensureTopic(
7105
+ logicalSessionId,
7106
+ this.topicNameFor(logicalSessionId, msg),
7107
+ session.logicalSessionIdTrusted ? session : undefined,
7108
+ socketLease,
7109
+ ));
7110
+ const topicLease = await this.topicAuthorityLease(logicalSessionId);
5305
7111
  if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
7112
+ if (!session.logicalSessionIdTrusted) this.legacyTopicOwners.set(logicalSessionId, session);
5306
7113
 
5307
7114
  // Each logical session owns only its most recently rendered menu.
5308
7115
  this.#clearModelChoiceAliases(logicalSessionId);
5309
7116
  const aliases = choices.map(choice =>
5310
- this.#putModelChoiceAlias({ session, sessionId: logicalSessionId, selector: choice.selector }),
7117
+ this.#putModelChoiceAlias({ session, socketLease, sessionId: logicalSessionId, selector: choice.selector }),
5311
7118
  );
5312
7119
  const inline_keyboard = buildButtonGrid(
5313
7120
  choices.map(choice => choice.label),
5314
7121
  index => aliases[index]!,
5315
7122
  );
5316
- if (!this.topicLeaseIsCurrent(topicLease)) return false;
7123
+ if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
5317
7124
  try {
5318
7125
  const response = await this.botApi.call("sendMessage", {
5319
7126
  chat_id: this.opts.chatId,
@@ -5336,6 +7143,18 @@ export class TelegramNotificationDaemon {
5336
7143
  }
5337
7144
 
5338
7145
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
7146
+ if (msg?.type === "hello") {
7147
+ const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : [];
7148
+ if (capabilities.includes("ephemeral_turn_v1")) {
7149
+ session.ephemeralCapable = true;
7150
+ this.#resumeBtwTurnsForSession(session);
7151
+ }
7152
+ if (capabilities.includes(CLIENT_PING_PONG_CAPABILITY)) {
7153
+ session.capable = true;
7154
+ this.startLiveness(session);
7155
+ }
7156
+ return;
7157
+ }
5339
7158
  if (session.replayPending) {
5340
7159
  if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
5341
7160
  return;
@@ -5346,34 +7165,85 @@ export class TelegramNotificationDaemon {
5346
7165
  session.replayQueue.push(frame);
5347
7166
  return;
5348
7167
  }
5349
- session.replayPending = false;
5350
7168
  const replayValid =
7169
+ msg.ok === true &&
5351
7170
  Number.isSafeInteger(msg.generation) &&
5352
7171
  msg.generation >= 1 &&
5353
7172
  Number.isSafeInteger(msg.lastSeq) &&
5354
7173
  msg.lastSeq >= 0 &&
5355
- Array.isArray(msg.events);
5356
- if (replayValid) {
5357
- session.hostGeneration = msg.generation;
5358
- }
7174
+ msg.gap === undefined &&
7175
+ Array.isArray(msg.events) &&
7176
+ msg.events.every((event: unknown) => event !== null && typeof event === "object" && !Array.isArray(event));
5359
7177
  const replayed: Record<string, unknown>[] = replayValid
5360
- ? (msg.events as unknown[]).flatMap((event: unknown): Record<string, unknown>[] => {
5361
- if (!event || typeof event !== "object" || Array.isArray(event)) return [];
5362
- const envelope = event as Record<string, unknown>;
5363
- const payload = envelope.payload;
5364
- return [
5365
- payload && typeof payload === "object" && !Array.isArray(payload)
5366
- ? (payload as Record<string, unknown>)
5367
- : envelope,
5368
- ];
7178
+ ? (msg.events as Record<string, unknown>[]).map(event => {
7179
+ const payload = event.payload;
7180
+ return payload && typeof payload === "object" && !Array.isArray(payload)
7181
+ ? (payload as Record<string, unknown>)
7182
+ : event;
5369
7183
  })
5370
7184
  : [];
7185
+ const identityFrames = replayed.filter(frame => frame.type === "identity_header");
7186
+ const identities = identityFrames.flatMap(frame =>
7187
+ typeof frame.sessionId === "string" && frame.sessionId.trim() ? [frame.sessionId] : [],
7188
+ );
7189
+ const malformedIdentity = identityFrames.some(
7190
+ frame => typeof frame.sessionId !== "string" || !frame.sessionId.trim(),
7191
+ );
7192
+ const identityConflict = new Set(identities).size > 1;
7193
+ if (!replayValid || malformedIdentity || identityConflict) {
7194
+ // A replay result is the admission proof. Never clear its barrier or drain
7195
+ // queued frames after malformed/conflicting proof; the socket cannot fall
7196
+ // back to transport-local config rekeying.
7197
+ this.dropSession(session, "invalid_replay");
7198
+ return;
7199
+ }
7200
+ session.hostGeneration = msg.generation;
7201
+ session.logicalSessionIdTrusted = true;
7202
+ const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
7203
+ const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
7204
+ const replayIdentitySessionId = latestIdentity?.sessionId as string | undefined;
7205
+ const endpointBinding = this.#endpointBinding(session);
7206
+ // Identity-less replay may resume only the exact transport owner. A
7207
+ // rekeyed A→B transport remains denied unless replay proves B.
7208
+ const endpointAuthority = this.#endpointAuthority(endpointBinding, session);
7209
+ const ownsLiveOpenEndpoint = this.#ownsLiveOpenEndpoint(session, endpointBinding);
7210
+ const canResumeTransport =
7211
+ endpointAuthority.state === "unique" &&
7212
+ endpointAuthority.sessionId === session.sessionId &&
7213
+ ownsLiveOpenEndpoint &&
7214
+ this.topics.matchesEndpoint(session.sessionId, endpointBinding);
7215
+ const canBootstrapTransport =
7216
+ endpointAuthority.state === "none" &&
7217
+ ownsLiveOpenEndpoint &&
7218
+ !this.topics.get(session.sessionId) &&
7219
+ !this.preservedInitiatorTopics.has(session.sessionId);
7220
+ const replayCandidateSessionId =
7221
+ replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined);
7222
+ if (!replayCandidateSessionId) return;
7223
+ const recovered = await this.#recoverTopicBinding(
7224
+ session,
7225
+ replayCandidateSessionId ?? session.sessionId,
7226
+ true,
7227
+ true,
7228
+ replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume",
7229
+ );
7230
+ if (!recovered) {
7231
+ if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
7232
+ this.dropSession(session, "recovery_rejected");
7233
+ return;
7234
+ }
7235
+ if (
7236
+ !this.#ownsLiveOpenEndpoint(session, endpointBinding) ||
7237
+ !this.#leaseAllows(session, this.#logicalSessionId(session))
7238
+ ) {
7239
+ this.dropSession(session, "recovery_rejected");
7240
+ return;
7241
+ }
7242
+ session.replayPending = false;
5371
7243
  // Replay restores durable attachment state only. Live notification effects
5372
7244
  // (turn streams, context updates, lifecycle messages) may already have been
5373
7245
  // delivered before a reconnect and must never be rendered a second time.
5374
- const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
5375
7246
  const currentGeneration = identityIndex < 0 ? replayed : replayed.slice(identityIndex);
5376
- const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
5377
7247
  const latestActions = new Map<string, Record<string, unknown>>();
5378
7248
  for (const frame of currentGeneration) {
5379
7249
  if ((frame.type === "action_needed" || frame.type === "action_resolved") && typeof frame.id === "string")
@@ -5384,7 +7254,15 @@ export class TelegramNotificationDaemon {
5384
7254
  for (const frame of replayState) {
5385
7255
  const fingerprint = JSON.stringify(frame);
5386
7256
  replayCounts.set(fingerprint, (replayCounts.get(fingerprint) ?? 0) + 1);
5387
- await this.handleSessionMessage(session, frame);
7257
+ try {
7258
+ await this.handleSessionMessage(session, frame);
7259
+ } catch (error) {
7260
+ logger.warn(
7261
+ `notifications: Telegram replay admission failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
7262
+ );
7263
+ this.dropSession(session, "replay_admission_failed");
7264
+ return;
7265
+ }
5388
7266
  }
5389
7267
  const queued = session.replayQueue.splice(0);
5390
7268
  for (const frame of queued) {
@@ -5402,16 +7280,19 @@ export class TelegramNotificationDaemon {
5402
7280
  }
5403
7281
  await this.handleSessionMessage(session, frame);
5404
7282
  }
5405
- if (replayValid) {
5406
- this.#terminalizeBtwTurnsForGenerationChange(session);
5407
- this.#resumeBtwTurnsForSession(session);
5408
- if (this.topics.markReplayCursor(session.sessionId, msg.generation, msg.lastSeq))
5409
- await this.persistTopics();
5410
- }
7283
+ this.#terminalizeBtwTurnsForGenerationChange(session);
7284
+ this.#resumeBtwTurnsForSession(session);
7285
+ const recoveredSessionId = this.#logicalSessionId(session);
7286
+ if (
7287
+ this.#leaseAllows(session, recoveredSessionId) &&
7288
+ this.topics.markReplayCursor(recoveredSessionId, msg.generation, msg.lastSeq)
7289
+ )
7290
+ await this.persistTopics();
5411
7291
  return;
5412
7292
  }
5413
7293
  if (msg?.type === "event_replay_result") return;
5414
- if (msg && typeof msg === "object") this.#updateLogicalSessionForThreadedFrame(session, msg);
7294
+ if (msg && typeof msg === "object") await this.#updateLogicalSessionForThreadedFrame(session, msg);
7295
+ if (session.logicalSessionIdTrusted && !this.#leaseAllows(session)) return;
5415
7296
  if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
5416
7297
  if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
5417
7298
 
@@ -5445,7 +7326,7 @@ export class TelegramNotificationDaemon {
5445
7326
  msg.threadId !== pending.threadId ||
5446
7327
  msg.updateId !== pending.updateId ||
5447
7328
  msg.messageId !== pending.messageId ||
5448
- this.topics.sessionForTopic(pending.threadId) !== pending.transportSessionId
7329
+ this.topics.sessionForTopic(pending.threadId) !== pending.logicalSessionId
5449
7330
  )
5450
7331
  return;
5451
7332
  if (
@@ -5459,6 +7340,21 @@ export class TelegramNotificationDaemon {
5459
7340
  return;
5460
7341
  if (msg.status === "ok" && typeof msg.text !== "string") return;
5461
7342
  if (this.#stoppingBtw || this.#btwTerminalDeliveries.has(requestId)) return;
7343
+ const isAuthoritative = (): boolean =>
7344
+ !this.#stoppingBtw &&
7345
+ this.#leaseTokenAllows(pending.socketLease) &&
7346
+ this.sessions.get(session.sessionId) === session &&
7347
+ session.ws.readyState === WebSocket.OPEN &&
7348
+ pending.endpointDigest === session.endpointDigest &&
7349
+ pending.generation === session.hostGeneration &&
7350
+ pending.logicalSessionId === this.#logicalSessionId(session) &&
7351
+ pending.transportSessionId === session.sessionId &&
7352
+ requestId === msg.requestId &&
7353
+ pending.logicalSessionId === msg.sessionId &&
7354
+ pending.messageId === msg.messageId &&
7355
+ pending.threadId === msg.threadId &&
7356
+ pending.updateId === msg.updateId &&
7357
+ this.topics.sessionForTopic(pending.threadId) === pending.logicalSessionId;
5462
7358
  const finished = Promise.withResolvers<void>();
5463
7359
  const terminalDelivery: PendingBtwDelivery = {
5464
7360
  pending,
@@ -5487,6 +7383,7 @@ export class TelegramNotificationDaemon {
5487
7383
  messageId: pending.messageId,
5488
7384
  text,
5489
7385
  signal: terminalDelivery.controller.signal,
7386
+ isAuthoritative,
5490
7387
  });
5491
7388
  deliveryOutcome =
5492
7389
  response && typeof response === "object" && (response as { ok?: unknown }).ok === true
@@ -5501,20 +7398,6 @@ export class TelegramNotificationDaemon {
5501
7398
  return;
5502
7399
  }
5503
7400
  const markdown = msg.text;
5504
- const isAuthoritative = (): boolean =>
5505
- !this.#stoppingBtw &&
5506
- this.sessions.get(session.sessionId) === session &&
5507
- session.ws.readyState === WebSocket.OPEN &&
5508
- pending.endpointDigest === session.endpointDigest &&
5509
- pending.generation === session.hostGeneration &&
5510
- pending.logicalSessionId === this.#logicalSessionId(session) &&
5511
- pending.transportSessionId === session.sessionId &&
5512
- requestId === msg.requestId &&
5513
- pending.logicalSessionId === msg.sessionId &&
5514
- pending.messageId === msg.messageId &&
5515
- pending.threadId === msg.threadId &&
5516
- pending.updateId === msg.updateId &&
5517
- this.topics.sessionForTopic(pending.threadId) === pending.transportSessionId;
5518
7401
  const signal = AbortSignal.any([
5519
7402
  this.#btwDeliveryAbort.signal,
5520
7403
  terminalDelivery.controller.signal,
@@ -5641,13 +7524,13 @@ export class TelegramNotificationDaemon {
5641
7524
  toolAdmissionEpoch === this.toolActivityPolicyEpoch);
5642
7525
  const abandonStaleToolStart = (): void => {
5643
7526
  if (toolActivity?.phase !== "started") return;
5644
- const key = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
7527
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
5645
7528
  const owner = this.toolActivityOwners.get(key);
5646
7529
  if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
5647
7530
  this.toolActivityOwners.delete(key);
5648
7531
  };
5649
7532
  if (toolActivity) {
5650
- const liveKey = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
7533
+ const liveKey = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
5651
7534
  const currentOwner = this.toolActivityOwners.get(liveKey);
5652
7535
  if (toolActivity.phase === "started") {
5653
7536
  if (!toolStartIsCurrent()) return;
@@ -5672,102 +7555,106 @@ export class TelegramNotificationDaemon {
5672
7555
  }
5673
7556
  const send = renderThreadedFrame(msg);
5674
7557
  if (!send) return;
5675
- const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
7558
+ const transportLogicalSessionId = this.#logicalSessionId(session);
7559
+ // Preserve legacy identity routing for direct/non-replay session callers.
7560
+ // Authenticated transports never infer topic ownership from display identity:
7561
+ // a resume must bind only its proven logical session.
7562
+ const logicalSessionId =
7563
+ send.identity && !session.logicalSessionIdTrusted && this.sessions.get(session.sessionId) !== session
7564
+ ? (this.topicOwnerForIdentity(msg) ?? transportLogicalSessionId)
7565
+ : transportLogicalSessionId;
7566
+ if (!this.#leaseAllows(session, logicalSessionId)) return;
7567
+ const socketLease = this.#socketLease(session, logicalSessionId);
7568
+ if (!socketLease) return;
7569
+ const existingTopic = await this.existingTopicForPrivateChat(logicalSessionId);
5676
7570
  if (!toolStartIsCurrent()) {
5677
7571
  abandonStaleToolStart();
5678
7572
  return;
5679
7573
  }
5680
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5681
- if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
5682
- if (!toolStartIsCurrent()) {
5683
- abandonStaleToolStart();
5684
- return;
5685
- }
7574
+ if (
7575
+ this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
7576
+ this.topics.get(logicalSessionId)?.bindingMalformed
7577
+ )
7578
+ return;
7579
+ if (!send.identity && !existingTopic && !this.flatIdentitySent.has(logicalSessionId)) {
5686
7580
  this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
5687
7581
  return;
5688
7582
  }
5689
- if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
5690
- const ownerId = this.topicOwnerForIdentity(msg);
5691
- const ownerLease = ownerId ? await this.topicAuthorityLease(ownerId) : undefined;
5692
- if (ownerId && ownerId !== session.sessionId && ownerLease) {
5693
- await this.flushPendingThreadedFrames(session.sessionId, ownerLease);
5694
- return;
5695
- }
5696
- }
5697
7583
  const topicId =
5698
- existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
5699
- const topicLease = await this.topicAuthorityLease(session.sessionId);
5700
- if (!toolStartIsCurrent()) {
5701
- abandonStaleToolStart();
5702
- return;
5703
- }
7584
+ existingTopic ??
7585
+ (await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
7586
+ const topicLease = await this.topicAuthorityLease(logicalSessionId);
5704
7587
  if (!topicId || !topicLease || topicLease.topicId !== topicId) {
5705
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5706
- await this.deliverFlatFallback(session.sessionId, send, toolActivity);
7588
+ if (
7589
+ this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
7590
+ this.topics.get(logicalSessionId)?.bindingMalformed
7591
+ )
7592
+ return;
7593
+ await this.deliverFlatFallback(logicalSessionId, send, toolActivity, socketLease);
5707
7594
  return;
5708
7595
  }
5709
7596
  if (send.identity) {
5710
7597
  const identityKey = this.topicIdentityKey(msg);
5711
7598
  if (identityKey) {
5712
- this.topicOwnerByIdentity.set(identityKey, session.sessionId);
5713
- if (this.topics.markIdentityKey(session.sessionId, identityKey)) await this.persistTopics();
7599
+ this.topicOwnerByIdentity.set(identityKey, logicalSessionId);
7600
+ if (this.topics.markIdentityKey(logicalSessionId, identityKey)) await this.persistTopics();
5714
7601
  }
5715
- // Explicit Telegram-side user renames own the topic title. Pending user
5716
- // reconciliation runs before daemon identity naming, so retries and daemon
5717
- // restarts cannot silently replace the preserved name.
5718
7602
  await this.reconcileUserTopicName(topicLease);
5719
- const name = this.topicNameFor(session.sessionId, msg);
5720
- if (this.topics.needsRename(session.sessionId, name)) {
5721
- this.daemonRenameAttempts.set(
5722
- session.sessionId,
5723
- (this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
5724
- );
7603
+ const name = this.topicNameFor(logicalSessionId, msg);
7604
+ if (this.topics.needsRename(logicalSessionId, name)) {
5725
7605
  try {
5726
- if (!this.topicLeaseIsCurrent(topicLease)) return;
7606
+ if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
7607
+ this.daemonRenameAttempts.set(
7608
+ logicalSessionId,
7609
+ (this.daemonRenameAttempts.get(logicalSessionId) ?? 0) + 1,
7610
+ );
5727
7611
  const response = await this.botApi.call("editForumTopic", {
5728
7612
  chat_id: this.opts.chatId,
5729
7613
  message_thread_id: Number(topicId),
5730
7614
  name,
5731
7615
  });
5732
- if (topicRenameApplied(response)) this.topics.markNameApplied(session.sessionId, name);
7616
+ if (topicRenameApplied(response)) this.topics.markNameApplied(logicalSessionId, name);
5733
7617
  } catch {
5734
- // Best-effort rename; leave daemon-owned names unchanged so a
5735
- // later identity frame retries.
7618
+ // A later identity frame retries a transient daemon rename failure.
5736
7619
  } finally {
5737
- const remaining = (this.daemonRenameAttempts.get(session.sessionId) ?? 1) - 1;
5738
- if (remaining > 0) this.daemonRenameAttempts.set(session.sessionId, remaining);
5739
- else {
5740
- this.daemonRenameAttempts.delete(session.sessionId);
5741
- await this.reconcileUserTopicName(topicLease);
5742
- }
7620
+ const attempts = (this.daemonRenameAttempts.get(logicalSessionId) ?? 1) - 1;
7621
+ if (attempts > 0) this.daemonRenameAttempts.set(logicalSessionId, attempts);
7622
+ else this.daemonRenameAttempts.delete(logicalSessionId);
5743
7623
  }
5744
7624
  }
5745
- // Send the full bulleted identity header EXACTLY ONCE per topic.
5746
- if (this.topics.needsIdentity(session.sessionId)) {
5747
- await this.submitThreadedFrame(session.sessionId, send, topicLease);
5748
- this.topics.markIdentitySent(session.sessionId);
7625
+ if (this.topics.needsIdentity(logicalSessionId)) {
7626
+ await this.submitThreadedFrame(logicalSessionId, send, topicLease, undefined, socketLease);
7627
+ this.topics.markIdentitySent(logicalSessionId);
5749
7628
  }
5750
- await this.flushPendingThreadedFrames(session.sessionId, topicLease);
5751
7629
  await this.persistTopics();
7630
+ await this.flushPendingThreadedFrames(logicalSessionId, topicLease);
7631
+ await this.reconcileUserTopicName(topicLease);
5752
7632
  return;
5753
7633
  }
5754
7634
  if (!toolStartIsCurrent()) {
5755
7635
  abandonStaleToolStart();
5756
7636
  return;
5757
7637
  }
5758
- await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
7638
+ await this.submitThreadedFrame(logicalSessionId, send, topicLease, toolActivity, socketLease);
5759
7639
  return;
5760
7640
  }
5761
7641
  if (msg.type === "action_needed" && msg.id) {
5762
- if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
5763
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5764
- const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
5765
- const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(session.sessionId) : undefined;
7642
+ const logicalSessionId = this.#logicalSessionId(session);
7643
+ const socketLease = this.#socketLease(session, logicalSessionId);
7644
+ if (!socketLease) return;
7645
+ if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: logicalSessionId, actionId: msg.id });
7646
+ if (
7647
+ this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
7648
+ this.topics.get(logicalSessionId)?.bindingMalformed
7649
+ )
7650
+ return;
7651
+ const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session);
7652
+ const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined;
5766
7653
  if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
5767
7654
  if (!topicId) {
5768
7655
  // Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
5769
7656
  if (!(await this.pairedChatIsPrivate())) return;
5770
- await this.notifyThreadedFallback();
7657
+ await this.notifyThreadedFallback(socketLease);
5771
7658
  }
5772
7659
  const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
5773
7660
  const controls: Array<{
@@ -5799,19 +7686,20 @@ export class TelegramNotificationDaemon {
5799
7686
  id: msg.id,
5800
7687
  question: msg.question,
5801
7688
  options: msg.options,
7689
+ recommendedIndex: msg.recommendedIndex,
5802
7690
  controls,
5803
7691
  summary: msg.summary,
5804
7692
  });
5805
7693
  const options = Array.isArray(msg.options) ? msg.options : [];
5806
7694
  const inline_keyboard = [
5807
7695
  ...buildCompactChoiceGrid(options, (i: number) =>
5808
- this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
7696
+ this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
5809
7697
  ),
5810
7698
  ...controls.map(control => [
5811
7699
  {
5812
7700
  text: control.label,
5813
7701
  callback_data: this.aliasTable.put({
5814
- sessionId: session.sessionId,
7702
+ sessionId: logicalSessionId,
5815
7703
  actionId: msg.id,
5816
7704
  answer: { controlId: control.id },
5817
7705
  }),
@@ -5824,7 +7712,8 @@ export class TelegramNotificationDaemon {
5824
7712
  const chunks = splitTelegramHtml(rendered.text);
5825
7713
  let result: { result?: { message_id?: number } } = {};
5826
7714
  for (let i = 0; i < chunks.length; i++) {
5827
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return undefined;
7715
+ if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
7716
+ return undefined;
5828
7717
  result = (await this.botApi.call("sendMessage", {
5829
7718
  chat_id: this.opts.chatId,
5830
7719
  ...threadField,
@@ -5840,7 +7729,7 @@ export class TelegramNotificationDaemon {
5840
7729
  // Rich (default on): promote to sendRichMessage with a top-level
5841
7730
  // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
5842
7731
 
5843
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
7732
+ if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
5844
7733
  const outcome = await deliverRichActionWithFallback(
5845
7734
  this.botApi,
5846
7735
  { chat_id: this.opts.chatId, ...threadField },
@@ -5849,6 +7738,7 @@ export class TelegramNotificationDaemon {
5849
7738
  kind,
5850
7739
  question: msg.question,
5851
7740
  options: msg.options,
7741
+ recommendedIndex: msg.recommendedIndex,
5852
7742
  summary: msg.summary,
5853
7743
  }),
5854
7744
  replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
@@ -5859,21 +7749,22 @@ export class TelegramNotificationDaemon {
5859
7749
  );
5860
7750
  // Only asks are reply-routable; idle pings register no route.
5861
7751
  if (kind === "ask" && outcome.messageId !== undefined)
5862
- this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
7752
+ this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
5863
7753
  } else {
5864
7754
  // Off: byte-identical to the pre-rich HTML path.
5865
7755
  const messageId = await sendHtmlChunks();
5866
7756
  // Only asks are reply-routable; idle pings register no route (parity
5867
7757
  // with the rich branch and correct even in the byte-identical off path).
5868
7758
  if (kind === "ask" && messageId !== undefined)
5869
- this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
7759
+ this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
5870
7760
  }
5871
7761
  await this.persistAliases();
5872
7762
  } else if (msg.type === "action_resolved" && msg.id) {
5873
7763
  session.pending.delete(msg.id);
5874
- this.deleteMessageRoutes(session.sessionId, msg.id);
7764
+ this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
5875
7765
  for (const [alias, route] of this.aliasTable.entries()) {
5876
- if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
7766
+ if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
7767
+ this.aliasTable.delete(alias);
5877
7768
  }
5878
7769
  await this.persistAliases();
5879
7770
  }
@@ -5920,7 +7811,9 @@ export class TelegramNotificationDaemon {
5920
7811
  if (
5921
7812
  this.sessions.get(session.sessionId) !== session ||
5922
7813
  this.#logicalSessionId(session) !== route.sessionId ||
5923
- session.ws.readyState !== WebSocket.OPEN
7814
+ session.ws.readyState !== WebSocket.OPEN ||
7815
+ !this.#leaseTokenAllows(route.socketLease) ||
7816
+ (this.topics.get(route.sessionId) !== undefined && !this.topicAuthorityLeaseFromRegistry(route.sessionId))
5924
7817
  ) {
5925
7818
  this.#modelChoiceAliases.delete(alias);
5926
7819
  await this.#sendModelStaleGuidance(callbackId);
@@ -5932,6 +7825,13 @@ export class TelegramNotificationDaemon {
5932
7825
  const updateId = (update as { update_id?: unknown }).update_id;
5933
7826
  const safeUpdateId =
5934
7827
  typeof updateId === "number" && Number.isSafeInteger(updateId) && updateId >= 0 ? updateId : undefined;
7828
+ if (
7829
+ !this.#leaseTokenAllows(route.socketLease) ||
7830
+ (this.topics.get(route.sessionId) !== undefined && !this.topicAuthorityLeaseFromRegistry(route.sessionId))
7831
+ ) {
7832
+ await this.#sendModelStaleGuidance(callbackId);
7833
+ return true;
7834
+ }
5935
7835
  try {
5936
7836
  session.ws.send(
5937
7837
  JSON.stringify({
@@ -6205,7 +8105,40 @@ export class TelegramNotificationDaemon {
6205
8105
  if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
6206
8106
  const inbound = decideThreadedInbound(update as never, {
6207
8107
  pairedChatId: this.opts.chatId,
6208
- topicToSession: t => this.topics.sessionForTopic(t),
8108
+ topicToSession: t => {
8109
+ const topicSessionId = this.topics.sessionForTopic(t);
8110
+ if (!topicSessionId) return undefined;
8111
+ const legacyOwner = this.legacyTopicOwners.get(topicSessionId);
8112
+ if (this.preservedInitiatorTopics.has(topicSessionId)) return undefined;
8113
+ const owner =
8114
+ this.logicalSessionOwners.get(topicSessionId) ??
8115
+ legacyOwner ??
8116
+ [...this.sessions.values()].find(
8117
+ session =>
8118
+ (session.sessionId === topicSessionId || this.#logicalSessionId(session) === topicSessionId) &&
8119
+ this.#leaseAllows(session, topicSessionId),
8120
+ );
8121
+ if (owner) {
8122
+ if (owner.replayPending || owner.recoveryLease?.state === "pending") return undefined;
8123
+ return this.#leaseAllows(owner, topicSessionId) ? owner.logicalSessionId : undefined;
8124
+ }
8125
+ const transportOwner = [...this.sessions.values()].find(
8126
+ candidate => candidate.sessionId === topicSessionId && this.#leaseAllows(candidate),
8127
+ );
8128
+ if (transportOwner) {
8129
+ if (transportOwner.replayPending || transportOwner.recoveryLease?.state === "pending")
8130
+ return undefined;
8131
+ return transportOwner.logicalSessionId;
8132
+ }
8133
+ return [...this.sessions.values()].some(
8134
+ session =>
8135
+ session.sessionId === topicSessionId ||
8136
+ this.#logicalSessionId(session) === topicSessionId ||
8137
+ session.recoveryLease?.logicalSessionId === topicSessionId,
8138
+ )
8139
+ ? undefined
8140
+ : topicSessionId;
8141
+ },
6209
8142
  isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
6210
8143
  });
6211
8144
  if (inbound.kind === "duplicate") return;
@@ -6215,10 +8148,45 @@ export class TelegramNotificationDaemon {
6215
8148
  ? { kind: "none" as const }
6216
8149
  : parseTelegramControlCommand(inbound.text, this.botUsername);
6217
8150
  if (preliminaryControl.kind === "ignored") return;
6218
- const session = this.sessions.get(inbound.sessionId);
8151
+ const session =
8152
+ this.logicalSessionOwners.get(inbound.sessionId) ??
8153
+ this.sessions.get(inbound.sessionId) ??
8154
+ [...this.sessions.values()].find(
8155
+ candidate =>
8156
+ this.#logicalSessionId(candidate) === inbound.sessionId &&
8157
+ this.#leaseAllows(candidate, inbound.sessionId),
8158
+ );
8159
+ if (session && !this.#leaseAllows(session, inbound.sessionId)) return;
8160
+ const topicSessionId = this.topics.sessionForTopic(inbound.threadId);
8161
+ const topicLease = topicSessionId ? this.topicAuthorityLeaseFromRegistry(topicSessionId) : undefined;
8162
+ const topicLeaseAllows = (): boolean => {
8163
+ const current = topicSessionId ? this.topicAuthorityLeaseFromRegistry(topicSessionId) : undefined;
8164
+ return (
8165
+ !!topicLease &&
8166
+ current?.topicId === topicLease.topicId &&
8167
+ current.authorityEpoch === topicLease.authorityEpoch
8168
+ );
8169
+ };
8170
+ const routeLease = session ? this.#socketLease(session, inbound.sessionId) : undefined;
8171
+ const routeAllows = (): boolean =>
8172
+ !!session &&
8173
+ !!routeLease &&
8174
+ session.ws.readyState === WebSocket.OPEN &&
8175
+ this.#leaseTokenAllows(routeLease) &&
8176
+ topicLeaseAllows();
8177
+ const routeLeaseAllows = (): boolean =>
8178
+ topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease)));
8179
+ const reserveRouteUpdate = async (): Promise<boolean> => {
8180
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false;
8181
+ if (routeLeaseAllows()) return true;
8182
+ await this.releaseSeenUpdateId(inbound.updateId);
8183
+ return false;
8184
+ };
8185
+
8186
+ if (session && !routeLease) return;
6219
8187
  if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
6220
8188
  if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
6221
- if (await this.reserveSeenUpdateId(inbound.updateId)) {
8189
+ if (await reserveRouteUpdate()) {
6222
8190
  try {
6223
8191
  await this.botApi.call("sendMessage", {
6224
8192
  chat_id: this.opts.chatId,
@@ -6237,36 +8205,41 @@ export class TelegramNotificationDaemon {
6237
8205
  return;
6238
8206
  }
6239
8207
  if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
6240
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
8208
+ if (!(await reserveRouteUpdate())) return;
6241
8209
  await this.#sendBtwMessage({
6242
8210
  threadId: inbound.threadId,
6243
8211
  messageId: inbound.messageId,
6244
8212
  text: BTW_USAGE_TEXT,
8213
+ isAuthoritative: routeLeaseAllows,
6245
8214
  });
6246
8215
  return;
6247
8216
  }
6248
8217
  if (reservedBtw?.kind === "question" && this.opts.btw?.enabled === false) {
6249
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
8218
+ if (!(await reserveRouteUpdate())) return;
6250
8219
  await this.#sendBtwMessage({
6251
8220
  threadId: inbound.threadId,
6252
8221
  messageId: inbound.messageId,
6253
8222
  text: "Telegram /btw is disabled in local settings.",
8223
+ isAuthoritative: routeLeaseAllows,
6254
8224
  });
6255
8225
  return;
6256
8226
  }
6257
8227
  if (reservedBtw?.kind === "question" && session?.ws.readyState !== WebSocket.OPEN) {
6258
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
8228
+ if (!(await reserveRouteUpdate())) return;
6259
8229
  await this.#sendBtwMessage({
6260
8230
  threadId: inbound.threadId,
6261
8231
  messageId: inbound.messageId,
6262
8232
  text: "Restart this GJC session to enable /btw.",
8233
+ isAuthoritative: routeLeaseAllows,
6263
8234
  });
6264
8235
  return;
6265
8236
  }
6266
- if (session?.ws.readyState === WebSocket.OPEN) {
8237
+ if (!session || !routeLease) return;
8238
+ if (routeAllows()) {
6267
8239
  const attachmentResult = inbound.attachment
6268
8240
  ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
6269
8241
  : undefined;
8242
+ if (!routeAllows()) return;
6270
8243
  const images = attachmentResult?.images ?? [];
6271
8244
  const fileNotes = attachmentResult?.fileNotes ?? [];
6272
8245
  const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
@@ -6289,11 +8262,12 @@ export class TelegramNotificationDaemon {
6289
8262
  if (btw?.kind === "question") {
6290
8263
  const btwQuestion = btw.question;
6291
8264
  if (!btwQuestion || hasMedia) {
6292
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
8265
+ if (!(await reserveRouteUpdate())) return;
6293
8266
  await this.#sendBtwMessage({
6294
8267
  threadId: inbound.threadId,
6295
8268
  messageId: inbound.messageId,
6296
8269
  text: BTW_USAGE_TEXT,
8270
+ isAuthoritative: routeLeaseAllows,
6297
8271
  });
6298
8272
  return;
6299
8273
  }
@@ -6303,28 +8277,31 @@ export class TelegramNotificationDaemon {
6303
8277
  threadId: inbound.threadId,
6304
8278
  messageId: inbound.messageId,
6305
8279
  text: BTW_QUESTION_LIMIT_TEXT,
8280
+ isAuthoritative: routeLeaseAllows,
6306
8281
  });
6307
8282
  } catch {
6308
8283
  logger.warn("notifications: /btw question-limit delivery failed");
6309
8284
  }
6310
- await this.reserveSeenUpdateId(inbound.updateId);
8285
+ await reserveRouteUpdate();
6311
8286
  return;
6312
8287
  }
6313
8288
  if (this.opts.btw?.enabled === false) {
6314
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
8289
+ if (!(await reserveRouteUpdate())) return;
6315
8290
  await this.#sendBtwMessage({
6316
8291
  threadId: inbound.threadId,
6317
8292
  messageId: inbound.messageId,
6318
8293
  text: "Telegram /btw is disabled in local settings.",
8294
+ isAuthoritative: routeLeaseAllows,
6319
8295
  });
6320
8296
  return;
6321
8297
  }
6322
8298
  if (!session.ephemeralCapable || session.hostGeneration < 1) {
6323
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
8299
+ if (!(await reserveRouteUpdate())) return;
6324
8300
  await this.#sendBtwMessage({
6325
8301
  threadId: inbound.threadId,
6326
8302
  messageId: inbound.messageId,
6327
8303
  text: "Restart this GJC session to enable /btw.",
8304
+ isAuthoritative: routeLeaseAllows,
6328
8305
  });
6329
8306
  return;
6330
8307
  }
@@ -6340,21 +8317,23 @@ export class TelegramNotificationDaemon {
6340
8317
  this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
6341
8318
  }
6342
8319
  if (this.#pendingBtwTurns.size >= BTW_MAX_PENDING) {
6343
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
8320
+ if (!(await reserveRouteUpdate())) return;
6344
8321
  await this.#sendBtwMessage({
6345
8322
  threadId: inbound.threadId,
6346
8323
  messageId: inbound.messageId,
6347
8324
  text: BTW_CAPACITY_TEXT,
8325
+ isAuthoritative: routeLeaseAllows,
6348
8326
  });
6349
8327
  return;
6350
8328
  }
6351
8329
  const transportSessionId = session.sessionId;
6352
8330
  const logicalSessionId = this.#logicalSessionId(session);
6353
8331
  const requestId = `btw:${crypto.randomUUID()}`;
6354
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
8332
+ if (!(await reserveRouteUpdate())) return;
6355
8333
  const pending: PendingBtwTurn = {
6356
8334
  transportSessionId,
6357
8335
  logicalSessionId,
8336
+ socketLease: routeLease,
6358
8337
  endpointDigest: session.endpointDigest,
6359
8338
  generation: session.hostGeneration,
6360
8339
  question: btwQuestion,
@@ -6370,6 +8349,7 @@ export class TelegramNotificationDaemon {
6370
8349
  threadId: inbound.threadId,
6371
8350
  messageId: inbound.messageId,
6372
8351
  text: "Unable to start /btw because this GJC session disconnected. Reopen the session and try again.",
8352
+ isAuthoritative: routeLeaseAllows,
6373
8353
  });
6374
8354
  return;
6375
8355
  }
@@ -6377,9 +8357,10 @@ export class TelegramNotificationDaemon {
6377
8357
  }
6378
8358
  const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
6379
8359
  if (control.kind !== "none") {
6380
- await this.rememberSeenUpdateId(inbound.updateId);
8360
+ if (!(await reserveRouteUpdate())) return;
6381
8361
  const sendControlNotice = async (body: string): Promise<void> => {
6382
8362
  try {
8363
+ if (!routeLeaseAllows()) return;
6383
8364
  await this.botApi.call("sendMessage", {
6384
8365
  chat_id: this.opts.chatId,
6385
8366
  message_thread_id: Number(inbound.threadId),
@@ -6398,6 +8379,7 @@ export class TelegramNotificationDaemon {
6398
8379
  await sendControlNotice("Session control unavailable: session is disconnected.");
6399
8380
  return;
6400
8381
  }
8382
+ if (!routeAllows()) return;
6401
8383
  session.ws.send(
6402
8384
  JSON.stringify({
6403
8385
  type: "control_command",
@@ -6417,6 +8399,7 @@ export class TelegramNotificationDaemon {
6417
8399
  // Telegram asks always accept custom text (the SDK maps a string answer
6418
8400
  // to the ask's custom-input slot), so route the latest pending ask here.
6419
8401
  const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
8402
+ if (!routeAllows()) return;
6420
8403
  if (pendingAsk) {
6421
8404
  session.ws.send(
6422
8405
  JSON.stringify({
@@ -6427,9 +8410,11 @@ export class TelegramNotificationDaemon {
6427
8410
  }),
6428
8411
  );
6429
8412
  await this.rememberSeenUpdateId(inbound.updateId);
6430
- if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
8413
+ if (inbound.messageId !== undefined)
8414
+ await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
6431
8415
  return;
6432
8416
  }
8417
+ if (!routeAllows()) return;
6433
8418
  session.ws.send(
6434
8419
  JSON.stringify(
6435
8420
  cfg
@@ -6450,8 +8435,12 @@ export class TelegramNotificationDaemon {
6450
8435
  // flipped to consumed when the session acks the turn that picks it
6451
8436
  // up. Config commands are not user turns and get no reaction.
6452
8437
  if (!cfg && inbound.messageId !== undefined) {
6453
- this.inboundReactions.set(inbound.updateId, { messageId: inbound.messageId });
6454
- await this.setReaction(inbound.messageId, QUEUED_REACTION);
8438
+ if (!routeAllows()) return;
8439
+ this.inboundReactions.set(inbound.updateId, {
8440
+ messageId: inbound.messageId,
8441
+ socketLease: routeLease,
8442
+ });
8443
+ await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
6455
8444
  }
6456
8445
  }
6457
8446
  return;
@@ -6465,8 +8454,14 @@ export class TelegramNotificationDaemon {
6465
8454
  pairedChatId: this.opts.chatId,
6466
8455
  });
6467
8456
  if (decision.kind === "reply") {
6468
- const session = this.sessions.get(decision.sessionId);
6469
- if (session?.ws.readyState !== WebSocket.OPEN || !session.pending.has(decision.actionId)) {
8457
+ const session = this.logicalSessionOwners.get(decision.sessionId) ?? this.sessions.get(decision.sessionId);
8458
+ if (
8459
+ session?.ws.readyState !== WebSocket.OPEN ||
8460
+ !session.pending.has(decision.actionId) ||
8461
+ !this.#leaseAllows(session, decision.sessionId) ||
8462
+ (this.topics.get(decision.sessionId) !== undefined &&
8463
+ !this.topicAuthorityLeaseFromRegistry(decision.sessionId))
8464
+ ) {
6470
8465
  await this.sendStaleGuidance(callbackId);
6471
8466
  return;
6472
8467
  }
@@ -6517,31 +8512,49 @@ export class TelegramNotificationDaemon {
6517
8512
  }
6518
8513
 
6519
8514
  async run(): Promise<void> {
6520
- // Runtime callers can bypass TypeScript's option type. Do not derive an
6521
- // ownership identity from absent credentials; lifecycle startup still owns
6522
- // cleanup of a factory-created server on this fail-closed path.
6523
- if (!validBotToken(this.opts.botToken)) {
6524
- await this.startLifecycleControl();
6525
- return;
6526
- }
6527
- this.running = await renewDaemonHeartbeat({
6528
- settings: this.opts.settings,
6529
- ownerId: this.opts.ownerId,
6530
- acquisitionId: this.opts.ownerId,
6531
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
6532
- chatId: this.opts.chatId,
6533
- fs: this.fsImpl,
6534
- now: this.opts.now,
6535
- pid: this.opts.pid ?? process.pid,
6536
- pidIncarnation: this.opts.pidIncarnation,
6537
- });
6538
- if (!this.running) return;
6539
- this.runtime.start();
6540
- this.startOwnershipHeartbeatTimer();
6541
- this.startFlushTimer();
6542
- this.startScanTimer();
6543
- this.startTypingTimer();
8515
+ // Runtime callers can bypass TypeScript's option type. Without a valid bot
8516
+ // token, there is no authenticated daemon identity or lifecycle authority.
8517
+ if (!validBotToken(this.opts.botToken)) return;
8518
+ let ownershipProved = false;
6544
8519
  try {
8520
+ const renewed = await renewDaemonHeartbeat({
8521
+ settings: this.opts.settings,
8522
+ ownerId: this.opts.ownerId,
8523
+ acquisitionId: this.opts.ownerId,
8524
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
8525
+ chatId: this.opts.chatId,
8526
+ fs: this.fsImpl,
8527
+ now: this.opts.now,
8528
+ pid: this.opts.pid ?? process.pid,
8529
+ pidIncarnation: this.opts.pidIncarnation,
8530
+ });
8531
+ if (!renewed) return;
8532
+ ownershipProved = true;
8533
+ this.running = !this.stopRequested;
8534
+ if (!this.running) return;
8535
+ // Self-heal durable notification state before any scan/poll work so
8536
+ // `daemon reload` recovers dead roots + leak artifacts (#2956).
8537
+ try {
8538
+ await healTelegramDaemonNotificationState({
8539
+ settings: this.opts.settings,
8540
+ fs: this.fsImpl,
8541
+ now: this.opts.now,
8542
+ });
8543
+ } catch (error) {
8544
+ logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
8545
+ }
8546
+ // Owner-only: start lifecycle control immediately after ownership proof,
8547
+ // before timers or pre-poll startup work can invalidate this run.
8548
+ // Best-effort; notification delivery remains available on failure.
8549
+ await this.startLifecycleControl();
8550
+ // A stop may arrive while lifecycle startup awaits its control endpoint.
8551
+ // Do not re-enable runtime work after that stop; close the partial server.
8552
+ if (!this.running) return;
8553
+ this.runtime.start();
8554
+ this.startOwnershipHeartbeatTimer();
8555
+ this.startFlushTimer();
8556
+ this.startScanTimer();
8557
+ this.startTypingTimer();
6545
8558
  await this.refreshBotIdentity();
6546
8559
  await this.registerBotCommands();
6547
8560
  await this.loadAliases();
@@ -6549,9 +8562,6 @@ export class TelegramNotificationDaemon {
6549
8562
  await this.loadSeenUpdateIds();
6550
8563
  await this.replyStore.load();
6551
8564
  await this.runScan();
6552
- // Owner-only: start the session-lifecycle control server now that
6553
- // ownership is confirmed (singleton-safe). Best-effort; degrades.
6554
- await this.startLifecycleControl();
6555
8565
  let idleSince = this.runtime.now();
6556
8566
  while (this.running) {
6557
8567
  if (await this.controlStopRequested()) break;
@@ -6603,61 +8613,64 @@ export class TelegramNotificationDaemon {
6603
8613
  await this.runtime.sleep(10);
6604
8614
  }
6605
8615
  } finally {
6606
- let toolShutdownError: unknown;
6607
- try {
6608
- await this.beginToolActivityShutdown();
6609
- } catch (error) {
6610
- toolShutdownError = error;
6611
- }
6612
- this.effects.beginShutdown();
6613
- this.#deliveryAbort.abort();
6614
- let persisted = false;
6615
- const shutdown = this.effects.allowTerminal(async () => {
6616
- if (toolShutdownError) throw toolShutdownError;
6617
- await this.#drainBtwTurns();
6618
- await this.toolTerminalizationChain;
6619
- this.runtime.stop();
6620
- this.stopOwnershipHeartbeatTimer();
6621
- this.stopFlushTimer();
6622
- this.stopScanTimer();
6623
- this.stopTypingTimer();
6624
- this.stopLifecycleControl();
6625
- await this.cleanupAllAttachmentDirs();
6626
- await this.persistAliases();
6627
- await this.persistTopics();
6628
- await this.persistSeenUpdateIds();
6629
- await this.opts.control?.clear?.(this.opts.ownerId);
6630
- persisted = true;
6631
- });
6632
- const deadline = Promise.withResolvers<boolean>();
6633
- const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
6634
- const completed = await Promise.race([
6635
- shutdown.then(
6636
- () => true,
6637
- error => {
6638
- logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
6639
- return false;
6640
- },
6641
- ),
6642
- deadline.promise,
6643
- ]);
6644
- clearTimeout(deadlineTimer);
6645
- const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
6646
- if (!quiesced || !persisted) {
6647
- logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
6648
- } else {
6649
- await releaseDaemonOwnership({
6650
- settings: this.opts.settings,
6651
- ownerId: this.opts.ownerId,
6652
- acquisitionId: this.opts.ownerId,
6653
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
6654
- chatId: this.opts.chatId,
6655
- pid: this.opts.pid ?? process.pid,
6656
- generation: DAEMON_GENERATION,
6657
- pidIncarnation: this.opts.pidIncarnation,
6658
- fs: this.fsImpl,
6659
- now: this.opts.now,
8616
+ // A contender must not mutate durable owner state while unwinding startup.
8617
+ if (ownershipProved) {
8618
+ let toolShutdownError: unknown;
8619
+ try {
8620
+ await this.beginToolActivityShutdown();
8621
+ } catch (error) {
8622
+ toolShutdownError = error;
8623
+ }
8624
+ this.effects.beginShutdown();
8625
+ this.#deliveryAbort.abort();
8626
+ let persisted = false;
8627
+ const shutdown = this.effects.allowTerminal(async () => {
8628
+ if (toolShutdownError) throw toolShutdownError;
8629
+ await this.#drainBtwTurns();
8630
+ await this.toolTerminalizationChain;
8631
+ this.runtime.stop();
8632
+ this.stopOwnershipHeartbeatTimer();
8633
+ this.stopFlushTimer();
8634
+ this.stopScanTimer();
8635
+ this.stopTypingTimer();
8636
+ this.stopLifecycleControl();
8637
+ await this.cleanupAllAttachmentDirs();
8638
+ await this.persistAliases();
8639
+ await this.persistTopics();
8640
+ await this.persistSeenUpdateIds();
8641
+ await this.opts.control?.clear?.(this.opts.ownerId);
8642
+ persisted = true;
6660
8643
  });
8644
+ const deadline = Promise.withResolvers<boolean>();
8645
+ const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
8646
+ const completed = await Promise.race([
8647
+ shutdown.then(
8648
+ () => true,
8649
+ error => {
8650
+ logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
8651
+ return false;
8652
+ },
8653
+ ),
8654
+ deadline.promise,
8655
+ ]);
8656
+ clearTimeout(deadlineTimer);
8657
+ const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
8658
+ if (!quiesced || !persisted) {
8659
+ logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
8660
+ } else {
8661
+ await releaseDaemonOwnership({
8662
+ settings: this.opts.settings,
8663
+ ownerId: this.opts.ownerId,
8664
+ acquisitionId: this.opts.ownerId,
8665
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
8666
+ chatId: this.opts.chatId,
8667
+ pid: this.opts.pid ?? process.pid,
8668
+ generation: DAEMON_GENERATION,
8669
+ pidIncarnation: this.opts.pidIncarnation,
8670
+ fs: this.fsImpl,
8671
+ now: this.opts.now,
8672
+ });
8673
+ }
6661
8674
  }
6662
8675
  }
6663
8676
  }