codex-autorunner 0.1.1__py3-none-any.whl → 1.0.0__py3-none-any.whl

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 (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -5,24 +5,27 @@ import collections
5
5
  import json
6
6
  import logging
7
7
  import os
8
- import shlex
9
8
  import socket
10
9
  import time
10
+ from datetime import datetime
11
11
  from pathlib import Path
12
- from typing import TYPE_CHECKING, Any, Coroutine, Optional, Sequence
12
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Sequence
13
13
 
14
14
  if TYPE_CHECKING:
15
15
  from .progress_stream import TurnProgressTracker
16
16
  from .state import TelegramTopicRecord
17
17
 
18
18
  from ...agents.opencode.supervisor import OpenCodeSupervisor
19
+ from ...core.flows.models import FlowRunRecord
19
20
  from ...core.locks import process_alive
20
21
  from ...core.logging_utils import log_event
21
22
  from ...core.request_context import reset_conversation_id, set_conversation_id
22
23
  from ...core.state import now_iso
23
- from ...core.utils import resolve_executable, resolve_opencode_binary
24
+ from ...core.text_delta_coalescer import TextDeltaCoalescer
25
+ from ...core.utils import build_opencode_supervisor
24
26
  from ...housekeeping import HousekeepingConfig, run_housekeeping_for_roots
25
27
  from ...manifest import load_manifest
28
+ from ...tickets.replies import dispatch_reply, ensure_reply_dirs, resolve_reply_paths
26
29
  from ...voice import VoiceConfig, VoiceService
27
30
  from ..app_server.supervisor import WorkspaceAppServerSupervisor
28
31
  from .adapter import (
@@ -36,25 +39,16 @@ from .adapter import (
36
39
  )
37
40
  from .commands_registry import build_command_payloads, diff_command_lists
38
41
  from .config import (
42
+ AppServerUnavailableError,
39
43
  TelegramBotConfig,
40
44
  TelegramBotConfigError,
41
45
  TelegramBotLockError,
42
46
  TelegramMediaCandidate,
43
47
  )
44
48
  from .constants import (
45
- CACHE_CLEANUP_INTERVAL_SECONDS,
46
- COALESCE_BUFFER_TTL_SECONDS,
47
49
  DEFAULT_INTERRUPT_TIMEOUT_SECONDS,
48
50
  DEFAULT_WORKSPACE_STATE_ROOT,
49
- MEDIA_BATCH_BUFFER_TTL_SECONDS,
50
- MODEL_PENDING_TTL_SECONDS,
51
- OVERSIZE_WARNING_TTL_SECONDS,
52
- PENDING_APPROVAL_TTL_SECONDS,
53
- PROGRESS_STREAM_TTL_SECONDS,
54
- REASONING_BUFFER_TTL_SECONDS,
55
- SELECTION_STATE_TTL_SECONDS,
56
- TURN_PREVIEW_TTL_SECONDS,
57
- UPDATE_ID_PERSIST_INTERVAL_SECONDS,
51
+ QUEUED_PLACEHOLDER_TEXT,
58
52
  TurnKey,
59
53
  )
60
54
  from .dispatch import dispatch_update
@@ -64,6 +58,7 @@ from .handlers.approvals import TelegramApprovalHandlers
64
58
  from .handlers.commands import build_command_specs
65
59
  from .handlers.commands_runtime import TelegramCommandHandlers
66
60
  from .handlers.messages import _CoalescedBuffer
61
+ from .handlers.questions import TelegramQuestionHandlers
67
62
  from .handlers.selections import TelegramSelectionHandlers
68
63
  from .helpers import (
69
64
  ModelOption,
@@ -82,39 +77,20 @@ from .state import (
82
77
  parse_topic_key,
83
78
  topic_key,
84
79
  )
80
+ from .ticket_flow_bridge import TelegramTicketFlowBridge
85
81
  from .transport import TelegramMessageTransport
86
82
  from .types import (
87
83
  CompactState,
88
84
  ModelPickerState,
89
85
  PendingApproval,
86
+ PendingQuestion,
90
87
  ReviewCommitSelectionState,
91
88
  SelectionState,
92
89
  TurnContext,
93
90
  )
94
91
  from .voice import TelegramVoiceManager
95
92
 
96
-
97
- def _parse_command(raw: Optional[str]) -> list[str]:
98
- if not raw:
99
- return []
100
- try:
101
- return [part for part in shlex.split(raw) if part]
102
- except ValueError:
103
- return []
104
-
105
-
106
- def _command_available(command: list[str], *, workspace_root: Path) -> bool:
107
- if not command:
108
- return False
109
- entry = str(command[0]).strip()
110
- if not entry:
111
- return False
112
- if os.path.sep in entry or (os.path.altsep and os.path.altsep in entry):
113
- path = Path(entry)
114
- if not path.is_absolute():
115
- path = workspace_root / path
116
- return path.is_file() and os.access(path, os.X_OK)
117
- return resolve_executable(entry) is not None
93
+ TICKET_FLOW_WATCH_INTERVAL_SECONDS = 20
118
94
 
119
95
 
120
96
  def _build_opencode_supervisor(
@@ -122,38 +98,22 @@ def _build_opencode_supervisor(
122
98
  *,
123
99
  logger: logging.Logger,
124
100
  ) -> Optional[OpenCodeSupervisor]:
125
- raw_command = os.environ.get("CAR_OPENCODE_COMMAND")
126
- command = _parse_command(raw_command)
101
+ opencode_command = config.opencode_command or None
127
102
  opencode_binary = config.agent_binaries.get("opencode")
128
- if not command and opencode_binary:
129
- command = [
130
- opencode_binary,
131
- "serve",
132
- "--hostname",
133
- "127.0.0.1",
134
- "--port",
135
- "0",
136
- ]
137
- resolved_source = None
138
- if command:
139
- resolved_source = command[0]
140
- elif opencode_binary:
141
- resolved_source = opencode_binary
142
- resolved_binary = resolve_opencode_binary(resolved_source)
143
- if command:
144
- if resolved_binary:
145
- command[0] = resolved_binary
146
- else:
147
- if resolved_binary:
148
- command = [
149
- resolved_binary,
150
- "serve",
151
- "--hostname",
152
- "127.0.0.1",
153
- "--port",
154
- "0",
155
- ]
156
- if not command or not _command_available(command, workspace_root=config.root):
103
+
104
+ supervisor = build_opencode_supervisor(
105
+ opencode_command=opencode_command,
106
+ opencode_binary=opencode_binary,
107
+ workspace_root=config.root,
108
+ logger=logger,
109
+ request_timeout=None,
110
+ max_handles=config.app_server_max_handles,
111
+ idle_ttl_seconds=config.app_server_idle_ttl_seconds,
112
+ base_env=None,
113
+ subagent_models=None,
114
+ )
115
+
116
+ if supervisor is None:
157
117
  log_event(
158
118
  logger,
159
119
  logging.INFO,
@@ -161,16 +121,28 @@ def _build_opencode_supervisor(
161
121
  reason="command_missing",
162
122
  )
163
123
  return None
164
- username = os.environ.get("OPENCODE_SERVER_USERNAME")
165
- password = os.environ.get("OPENCODE_SERVER_PASSWORD")
166
- return OpenCodeSupervisor(
167
- command,
168
- logger=logger,
169
- max_handles=config.app_server_max_handles,
170
- idle_ttl_seconds=config.app_server_idle_ttl_seconds,
171
- username=username if username and password else None,
172
- password=password if username and password else None,
173
- )
124
+
125
+ return supervisor
126
+
127
+
128
+ def _next_reply_seq_sync(reply_history_dir: Any) -> int:
129
+ from pathlib import Path
130
+
131
+ path = Path(reply_history_dir)
132
+ if not path.exists() or not path.is_dir():
133
+ return 1
134
+ existing: list[int] = []
135
+ _SEQ_RE = __import__("re").compile(r"^[0-9]{4}$")
136
+ for child in path.iterdir():
137
+ try:
138
+ if not child.is_dir():
139
+ continue
140
+ if not _SEQ_RE.fullmatch(child.name):
141
+ continue
142
+ existing.append(int(child.name))
143
+ except OSError:
144
+ continue
145
+ return (max(existing) + 1) if existing else 1
174
146
 
175
147
 
176
148
  class TelegramBotService(
@@ -178,6 +150,7 @@ class TelegramBotService(
178
150
  TelegramMessageTransport,
179
151
  TelegramNotificationHandlers,
180
152
  TelegramApprovalHandlers,
153
+ TelegramQuestionHandlers,
181
154
  TelegramSelectionHandlers,
182
155
  TelegramCommandHandlers,
183
156
  ):
@@ -193,6 +166,8 @@ class TelegramBotService(
193
166
  housekeeping_config: Optional[HousekeepingConfig] = None,
194
167
  update_repo_url: Optional[str] = None,
195
168
  update_repo_ref: Optional[str] = None,
169
+ update_skip_checks: bool = False,
170
+ app_server_auto_restart: Optional[bool] = None,
196
171
  ) -> None:
197
172
  self._config = config
198
173
  self._logger = logger or logging.getLogger(__name__)
@@ -200,6 +175,8 @@ class TelegramBotService(
200
175
  self._manifest_path = manifest_path
201
176
  self._update_repo_url = update_repo_url
202
177
  self._update_repo_ref = update_repo_ref
178
+ self._update_skip_checks = update_skip_checks
179
+ self._app_server_auto_restart = app_server_auto_restart
203
180
  self._allowlist = config.allowlist()
204
181
  self._store = TelegramStateStore(
205
182
  config.state_file, default_approval_mode=config.defaults.approval_mode
@@ -213,6 +190,7 @@ class TelegramBotService(
213
190
  approval_handler=self._handle_approval_request,
214
191
  notification_handler=self._handle_app_server_notification,
215
192
  logger=self._logger,
193
+ auto_restart=self._app_server_auto_restart,
216
194
  max_handles=config.app_server_max_handles,
217
195
  idle_ttl_seconds=config.app_server_idle_ttl_seconds,
218
196
  )
@@ -220,7 +198,16 @@ class TelegramBotService(
220
198
  config,
221
199
  logger=self._logger,
222
200
  )
223
- self._bot = TelegramBotClient(config.bot_token or "", logger=self._logger)
201
+ poll_timeout = float(config.poll_timeout_seconds)
202
+ request_timeout = config.poll_request_timeout_seconds
203
+ if request_timeout is None:
204
+ # Keep HTTP timeout above long-poll timeout to avoid ReadTimeout churn.
205
+ request_timeout = max(poll_timeout + 5.0, 10.0)
206
+ self._bot = TelegramBotClient(
207
+ config.bot_token or "",
208
+ logger=self._logger,
209
+ timeout_seconds=float(request_timeout),
210
+ )
224
211
  self._poller = TelegramUpdatePoller(
225
212
  self._bot, allowed_updates=config.poll_allowed_updates
226
213
  )
@@ -242,15 +229,25 @@ class TelegramBotService(
242
229
  )
243
230
  self._turn_semaphore: Optional[asyncio.Semaphore] = None
244
231
  self._turn_contexts: dict[TurnKey, TurnContext] = {}
245
- self._reasoning_buffers: dict[str, str] = {}
232
+ self._reasoning_buffers: dict[str, TextDeltaCoalescer] = {}
246
233
  self._turn_preview_text: dict[TurnKey, str] = {}
247
234
  self._turn_preview_updated_at: dict[TurnKey, float] = {}
248
235
  self._turn_progress_trackers: dict[TurnKey, "TurnProgressTracker"] = {}
249
236
  self._turn_progress_rendered: dict[TurnKey, str] = {}
250
237
  self._turn_progress_updated_at: dict[TurnKey, float] = {}
251
238
  self._turn_progress_tasks: dict[TurnKey, asyncio.Task[None]] = {}
239
+ self._turn_progress_heartbeat_tasks: dict[TurnKey, asyncio.Task[None]] = {}
240
+ self._turn_progress_locks: dict[TurnKey, asyncio.Lock] = {}
252
241
  self._oversize_warnings: set[TurnKey] = set()
253
242
  self._pending_approvals: dict[str, PendingApproval] = {}
243
+ self._pending_questions: dict[str, PendingQuestion] = {}
244
+ self._ticket_flow_pause_targets: dict[str, str] = {}
245
+ self._ticket_flow_bridge = TelegramTicketFlowBridge(
246
+ logger=self._logger,
247
+ store=self._store,
248
+ pause_targets=self._ticket_flow_pause_targets,
249
+ send_message_with_outbox=self._send_message_with_outbox,
250
+ )
254
251
  self._resume_options: dict[str, SelectionState] = {}
255
252
  self._bind_options: dict[str, SelectionState] = {}
256
253
  self._update_options: dict[str, SelectionState] = {}
@@ -265,6 +262,8 @@ class TelegramBotService(
265
262
  self._media_batch_locks: dict[str, asyncio.Lock] = {}
266
263
  self._outbox_inflight: set[str] = set()
267
264
  self._outbox_lock: Optional[asyncio.Lock] = None
265
+ self._queued_placeholder_map: dict[tuple[int, int], int] = {}
266
+ self._queued_placeholder_timestamps: dict[tuple[int, int], float] = {}
268
267
  self._bot_username: Optional[str] = None
269
268
  self._token_usage_by_thread: "collections.OrderedDict[str, dict[str, Any]]" = (
270
269
  collections.OrderedDict()
@@ -274,6 +273,7 @@ class TelegramBotService(
274
273
  )
275
274
  self._outbox_task: Optional[asyncio.Task[None]] = None
276
275
  self._cache_cleanup_task: Optional[asyncio.Task[None]] = None
276
+ self._ticket_flow_watch_task: Optional[asyncio.Task[None]] = None
277
277
  self._cache_timestamps: dict[str, dict[object, float]] = {}
278
278
  self._last_update_ids: dict[str, int] = {}
279
279
  self._last_update_persisted_at: dict[str, float] = {}
@@ -302,10 +302,10 @@ class TelegramBotService(
302
302
  self._command_specs = build_command_specs(self)
303
303
  self._instance_lock_path: Optional[Path] = None
304
304
 
305
- def _housekeeping_roots(self) -> list[Path]:
305
+ async def _housekeeping_roots(self) -> list[Path]:
306
306
  roots: set[Path] = set()
307
307
  try:
308
- state = self._store.load()
308
+ state = await self._store.load()
309
309
  for record in state.topics.values():
310
310
  if isinstance(record.workspace_path, str) and record.workspace_path:
311
311
  roots.add(Path(record.workspace_path).expanduser().resolve())
@@ -332,6 +332,81 @@ class TelegramBotService(
332
332
  roots.add(self._config.root.resolve())
333
333
  return sorted(roots)
334
334
 
335
+ async def _gather_workspace_roots(self) -> list[Path]:
336
+ roots: set[Path] = set()
337
+ try:
338
+ state = await self._store.load()
339
+ for record in state.topics.values():
340
+ if isinstance(record.workspace_path, str) and record.workspace_path:
341
+ roots.add(Path(record.workspace_path).expanduser().resolve())
342
+ except Exception as exc:
343
+ log_event(
344
+ self._logger,
345
+ logging.WARNING,
346
+ "telegram.prewarm.state_failed",
347
+ exc=exc,
348
+ )
349
+ return sorted(roots)
350
+
351
+ async def _prewarm_workspace_clients(self) -> None:
352
+ workspace_roots = await self._gather_workspace_roots()
353
+ if not workspace_roots:
354
+ log_event(
355
+ self._logger,
356
+ logging.INFO,
357
+ "telegram.prewarm.skipped",
358
+ reason="no_workspaces",
359
+ )
360
+ return
361
+
362
+ log_event(
363
+ self._logger,
364
+ logging.INFO,
365
+ "telegram.prewarm.started",
366
+ workspace_count=len(workspace_roots),
367
+ workspaces=[str(p) for p in workspace_roots],
368
+ )
369
+
370
+ sem = asyncio.Semaphore(3)
371
+ prewarmed_count = 0
372
+ failed_count = 0
373
+
374
+ async def prewarm_one(workspace_root: Path) -> None:
375
+ nonlocal prewarmed_count, failed_count
376
+ async with sem:
377
+ try:
378
+ await self._app_server_supervisor.get_client(workspace_root)
379
+ prewarmed_count += 1
380
+ log_event(
381
+ self._logger,
382
+ logging.INFO,
383
+ "telegram.prewarm.client_ready",
384
+ workspace_root=str(workspace_root),
385
+ )
386
+ except Exception as exc:
387
+ failed_count += 1
388
+ log_event(
389
+ self._logger,
390
+ logging.WARNING,
391
+ "telegram.prewarm.client_failed",
392
+ workspace_root=str(workspace_root),
393
+ exc=exc,
394
+ )
395
+
396
+ await asyncio.gather(
397
+ *[prewarm_one(root) for root in workspace_roots],
398
+ return_exceptions=True,
399
+ )
400
+
401
+ log_event(
402
+ self._logger,
403
+ logging.INFO,
404
+ "telegram.prewarm.completed",
405
+ workspace_count=len(workspace_roots),
406
+ prewarmed_count=prewarmed_count,
407
+ failed_count=failed_count,
408
+ )
409
+
335
410
  async def _housekeeping_loop(self) -> None:
336
411
  config = self._housekeeping_config
337
412
  if config is None or not config.enabled:
@@ -339,7 +414,7 @@ class TelegramBotService(
339
414
  interval = max(config.interval_seconds, 1)
340
415
  while True:
341
416
  try:
342
- roots = self._housekeeping_roots()
417
+ roots = await self._housekeeping_roots()
343
418
  if roots:
344
419
  await asyncio.to_thread(
345
420
  run_housekeeping_for_roots,
@@ -485,11 +560,17 @@ class TelegramBotService(
485
560
  await self._restore_pending_approvals()
486
561
  await self._outbox_manager.restore()
487
562
  await self._voice_manager.restore()
488
- self._prime_poller_offset()
563
+ await self._prime_poller_offset()
489
564
  self._outbox_task = asyncio.create_task(self._outbox_manager.run_loop())
490
565
  self._voice_task = asyncio.create_task(self._voice_manager.run_loop())
491
566
  self._housekeeping_task = asyncio.create_task(self._housekeeping_loop())
492
567
  self._cache_cleanup_task = asyncio.create_task(self._cache_cleanup_loop())
568
+ self._ticket_flow_watch_task = asyncio.create_task(
569
+ self._ticket_flow_bridge.watch_ticket_flow_pauses(
570
+ TICKET_FLOW_WATCH_INTERVAL_SECONDS
571
+ )
572
+ )
573
+ self._spawn_task(self._prewarm_workspace_clients())
493
574
  log_event(
494
575
  self._logger,
495
576
  logging.INFO,
@@ -505,6 +586,10 @@ class TelegramBotService(
505
586
  media_enabled=self._config.media.enabled,
506
587
  media_images=self._config.media.images,
507
588
  media_voice=self._config.media.voice,
589
+ app_server_turn_timeout_seconds=self._config.app_server_turn_timeout_seconds,
590
+ agent_turn_timeout_seconds=dict(
591
+ self._config.agent_turn_timeout_seconds
592
+ ),
508
593
  poller_offset=self._poller.offset,
509
594
  )
510
595
  try:
@@ -532,7 +617,7 @@ class TelegramBotService(
532
617
  timeout=self._config.poll_timeout_seconds
533
618
  )
534
619
  if self._poller.offset is not None:
535
- self._record_poll_offset(updates)
620
+ await self._record_poll_offset(updates)
536
621
  except Exception as exc:
537
622
  log_event(
538
623
  self._logger,
@@ -570,6 +655,12 @@ class TelegramBotService(
570
655
  await self._cache_cleanup_task
571
656
  except asyncio.CancelledError:
572
657
  pass
658
+ if self._ticket_flow_watch_task is not None:
659
+ self._ticket_flow_watch_task.cancel()
660
+ try:
661
+ await self._ticket_flow_watch_task
662
+ except asyncio.CancelledError:
663
+ pass
573
664
  if self._spawned_tasks:
574
665
  for task in list(self._spawned_tasks):
575
666
  task.cancel()
@@ -593,6 +684,15 @@ class TelegramBotService(
593
684
  "telegram.app_server.close_failed",
594
685
  exc=exc,
595
686
  )
687
+ try:
688
+ await self._store.close()
689
+ except Exception as exc:
690
+ log_event(
691
+ self._logger,
692
+ logging.WARNING,
693
+ "telegram.state.close_failed",
694
+ exc=exc,
695
+ )
596
696
  self._release_instance_lock()
597
697
 
598
698
  async def _prime_bot_identity(self) -> None:
@@ -694,8 +794,8 @@ class TelegramBotService(
694
794
  order_changed=diff.order_changed,
695
795
  )
696
796
 
697
- def _prime_poller_offset(self) -> None:
698
- last_update_id = self._store.get_last_update_id_global()
797
+ async def _prime_poller_offset(self) -> None:
798
+ last_update_id = await self._store.get_last_update_id_global()
699
799
  if not isinstance(last_update_id, int) or isinstance(last_update_id, bool):
700
800
  return
701
801
  offset = last_update_id + 1
@@ -708,16 +808,15 @@ class TelegramBotService(
708
808
  poller_offset=offset,
709
809
  )
710
810
 
711
- def _record_poll_offset(self, updates: Sequence[TelegramUpdate]) -> None:
811
+ async def _record_poll_offset(self, updates: Sequence[TelegramUpdate]) -> None:
712
812
  offset = self._poller.offset
713
813
  if offset is None:
714
814
  return
715
815
  last_update_id = offset - 1
716
816
  if last_update_id < 0:
717
817
  return
718
- stored = self._store.update_last_update_id_global(last_update_id)
719
- if updates:
720
- max_update_id = max(update.update_id for update in updates)
818
+ stored = await self._store.update_last_update_id_global(last_update_id)
819
+ max_update_id = max((update.update_id for update in updates), default=None)
721
820
  log_event(
722
821
  self._logger,
723
822
  logging.INFO,
@@ -772,6 +871,9 @@ class TelegramBotService(
772
871
  task = self._turn_progress_tasks.pop(key, None)
773
872
  if task and not task.done():
774
873
  task.cancel()
874
+ heartbeat_task = self._turn_progress_heartbeat_tasks.pop(key, None)
875
+ if heartbeat_task and not heartbeat_task.done():
876
+ heartbeat_task.cancel()
775
877
  elif cache_name == "oversize_warnings":
776
878
  self._oversize_warnings.discard(key)
777
879
  elif cache_name == "coalesced_buffers":
@@ -804,63 +906,209 @@ class TelegramBotService(
804
906
  self._model_pending.pop(key, None)
805
907
  elif cache_name == "pending_approvals":
806
908
  self._pending_approvals.pop(key, None)
909
+ elif cache_name == "pending_questions":
910
+ self._pending_questions.pop(key, None)
807
911
 
808
912
  async def _cache_cleanup_loop(self) -> None:
809
- interval = max(CACHE_CLEANUP_INTERVAL_SECONDS, 1.0)
913
+ interval = max(self._config.cache.cleanup_interval_seconds, 1.0)
810
914
  while True:
811
915
  await asyncio.sleep(interval)
812
916
  self._evict_expired_cache_entries(
813
- "reasoning_buffers", REASONING_BUFFER_TTL_SECONDS
917
+ "reasoning_buffers", self._config.cache.reasoning_buffer_ttl_seconds
918
+ )
919
+ self._evict_expired_cache_entries(
920
+ "turn_preview", self._config.cache.turn_preview_ttl_seconds
814
921
  )
815
- self._evict_expired_cache_entries("turn_preview", TURN_PREVIEW_TTL_SECONDS)
816
922
  self._evict_expired_cache_entries(
817
- "progress_trackers", PROGRESS_STREAM_TTL_SECONDS
923
+ "progress_trackers", self._config.cache.progress_stream_ttl_seconds
818
924
  )
819
925
  self._evict_expired_cache_entries(
820
- "oversize_warnings", OVERSIZE_WARNING_TTL_SECONDS
926
+ "oversize_warnings", self._config.cache.oversize_warning_ttl_seconds
821
927
  )
822
928
  self._evict_expired_cache_entries(
823
- "coalesced_buffers", COALESCE_BUFFER_TTL_SECONDS
929
+ "coalesced_buffers", self._config.cache.coalesce_buffer_ttl_seconds
824
930
  )
825
931
  self._evict_expired_cache_entries(
826
- "media_batch_buffers", MEDIA_BATCH_BUFFER_TTL_SECONDS
932
+ "media_batch_buffers",
933
+ self._config.cache.media_batch_buffer_ttl_seconds,
827
934
  )
828
935
  self._evict_expired_cache_entries(
829
- "resume_options", SELECTION_STATE_TTL_SECONDS
936
+ "resume_options", self._config.cache.selection_state_ttl_seconds
830
937
  )
831
938
  self._evict_expired_cache_entries(
832
- "bind_options", SELECTION_STATE_TTL_SECONDS
939
+ "bind_options", self._config.cache.selection_state_ttl_seconds
833
940
  )
834
941
  self._evict_expired_cache_entries(
835
- "agent_options", SELECTION_STATE_TTL_SECONDS
942
+ "agent_options", self._config.cache.selection_state_ttl_seconds
836
943
  )
837
944
  self._evict_expired_cache_entries(
838
- "update_options", SELECTION_STATE_TTL_SECONDS
945
+ "update_options", self._config.cache.selection_state_ttl_seconds
839
946
  )
840
947
  self._evict_expired_cache_entries(
841
- "update_confirm_options", SELECTION_STATE_TTL_SECONDS
948
+ "update_confirm_options",
949
+ self._config.cache.selection_state_ttl_seconds,
842
950
  )
843
951
  self._evict_expired_cache_entries(
844
- "review_commit_options", SELECTION_STATE_TTL_SECONDS
952
+ "review_commit_options",
953
+ self._config.cache.selection_state_ttl_seconds,
845
954
  )
846
955
  self._evict_expired_cache_entries(
847
- "review_commit_subjects", SELECTION_STATE_TTL_SECONDS
956
+ "review_commit_subjects",
957
+ self._config.cache.selection_state_ttl_seconds,
848
958
  )
849
959
  self._evict_expired_cache_entries(
850
- "pending_review_custom", SELECTION_STATE_TTL_SECONDS
960
+ "pending_review_custom",
961
+ self._config.cache.selection_state_ttl_seconds,
851
962
  )
852
963
  self._evict_expired_cache_entries(
853
- "compact_pending", SELECTION_STATE_TTL_SECONDS
964
+ "compact_pending", self._config.cache.selection_state_ttl_seconds
854
965
  )
855
966
  self._evict_expired_cache_entries(
856
- "model_options", SELECTION_STATE_TTL_SECONDS
967
+ "model_options", self._config.cache.selection_state_ttl_seconds
857
968
  )
858
969
  self._evict_expired_cache_entries(
859
- "model_pending", MODEL_PENDING_TTL_SECONDS
970
+ "model_pending", self._config.cache.model_pending_ttl_seconds
860
971
  )
861
972
  self._evict_expired_cache_entries(
862
- "pending_approvals", PENDING_APPROVAL_TTL_SECONDS
973
+ "pending_approvals", self._config.cache.pending_approval_ttl_seconds
974
+ )
975
+ self._evict_expired_cache_entries(
976
+ "pending_questions", self._config.cache.pending_question_ttl_seconds
977
+ )
978
+ now = time.monotonic()
979
+ expired_placeholders = []
980
+ for key, timestamp in self._queued_placeholder_timestamps.items():
981
+ if (now - timestamp) > self._config.cache.pending_approval_ttl_seconds:
982
+ expired_placeholders.append(key)
983
+ for key in expired_placeholders:
984
+ self._queued_placeholder_map.pop(key, None)
985
+ self._queued_placeholder_timestamps.pop(key, None)
986
+
987
+ @staticmethod
988
+ def _parse_last_active(record: "TelegramTopicRecord") -> float:
989
+ raw = getattr(record, "last_active_at", None)
990
+ if isinstance(raw, str):
991
+ try:
992
+ return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").timestamp()
993
+ except ValueError:
994
+ return float("-inf")
995
+ return float("-inf")
996
+
997
+ def _select_ticket_flow_topic(
998
+ self, entries: list[tuple[str, "TelegramTopicRecord"]]
999
+ ) -> Optional[tuple[str, "TelegramTopicRecord"]]:
1000
+ return self._ticket_flow_bridge._select_ticket_flow_topic(entries)
1001
+
1002
+ @staticmethod
1003
+ def _set_ticket_dispatch_marker(
1004
+ value: Optional[str],
1005
+ ) -> "Callable[[TelegramTopicRecord], None]":
1006
+ def apply(topic: "TelegramTopicRecord") -> None:
1007
+ topic.last_ticket_dispatch_seq = value
1008
+
1009
+ return apply
1010
+
1011
+ async def _ticket_flow_watch_loop(self) -> None:
1012
+ await self._ticket_flow_bridge.watch_ticket_flow_pauses(
1013
+ TICKET_FLOW_WATCH_INTERVAL_SECONDS
1014
+ )
1015
+
1016
+ async def _watch_ticket_flow_pauses(self) -> None:
1017
+ await self._ticket_flow_bridge._scan_and_notify_pauses()
1018
+
1019
+ async def _notify_ticket_flow_pause(
1020
+ self,
1021
+ workspace_root: Path,
1022
+ entries: list[tuple[str, "TelegramTopicRecord"]],
1023
+ ) -> None:
1024
+ await self._ticket_flow_bridge._notify_ticket_flow_pause(
1025
+ workspace_root, entries
1026
+ )
1027
+
1028
+ def _load_ticket_flow_pause(
1029
+ self, workspace_root: Path
1030
+ ) -> Optional[tuple[str, str, str]]:
1031
+ return self._ticket_flow_bridge._load_ticket_flow_pause(workspace_root)
1032
+
1033
+ def _latest_dispatch_seq(self, history_dir: Path) -> Optional[str]:
1034
+ return self._ticket_flow_bridge._latest_dispatch_seq(history_dir)
1035
+
1036
+ def _format_ticket_flow_pause_reason(self, record: "FlowRunRecord") -> str:
1037
+ return self._ticket_flow_bridge._format_ticket_flow_pause_reason(record)
1038
+
1039
+ def _format_ticket_flow_pause_message(
1040
+ self, run_id: str, seq: str, content: str
1041
+ ) -> str:
1042
+ return self._ticket_flow_bridge._format_ticket_flow_pause_message(
1043
+ run_id, seq, content
1044
+ )
1045
+
1046
+ def _get_paused_ticket_flow(
1047
+ self, workspace_root: Path, preferred_run_id: Optional[str] = None
1048
+ ) -> Optional[tuple[str, FlowRunRecord]]:
1049
+ return self._ticket_flow_bridge.get_paused_ticket_flow(
1050
+ workspace_root, preferred_run_id=preferred_run_id
1051
+ )
1052
+
1053
+ async def _write_user_reply_from_telegram(
1054
+ self,
1055
+ workspace_root: Path,
1056
+ run_id: str,
1057
+ run_record: FlowRunRecord,
1058
+ message: TelegramMessage,
1059
+ text: str,
1060
+ files: Optional[list[tuple[str, bytes]]] = None,
1061
+ ) -> tuple[bool, str]:
1062
+ try:
1063
+ input_data = dict(run_record.input_data or {})
1064
+ runs_dir_raw = input_data.get("runs_dir")
1065
+ runs_dir = (
1066
+ Path(runs_dir_raw)
1067
+ if isinstance(runs_dir_raw, str) and runs_dir_raw
1068
+ else Path(".codex-autorunner/runs")
863
1069
  )
1070
+ reply_paths = resolve_reply_paths(
1071
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
1072
+ )
1073
+ ensure_reply_dirs(reply_paths)
1074
+
1075
+ cleaned_text = text.strip()
1076
+ raw = cleaned_text
1077
+ if raw and not raw.endswith("\n"):
1078
+ raw += "\n"
1079
+
1080
+ await asyncio.to_thread(
1081
+ reply_paths.user_reply_path.write_text, raw, encoding="utf-8"
1082
+ )
1083
+
1084
+ if files:
1085
+ for filename, data in files:
1086
+ dest = reply_paths.reply_dir / filename
1087
+ dest.parent.mkdir(parents=True, exist_ok=True)
1088
+ await asyncio.to_thread(dest.write_bytes, data)
1089
+
1090
+ seq = await asyncio.to_thread(
1091
+ lambda: _next_reply_seq_sync(reply_paths.reply_history_dir)
1092
+ )
1093
+ dispatch, errors = await asyncio.to_thread(
1094
+ dispatch_reply, reply_paths, next_seq=seq
1095
+ )
1096
+ if errors:
1097
+ return False, "\n".join(errors)
1098
+ if dispatch is None:
1099
+ return False, "Failed to archive reply"
1100
+ return (
1101
+ True,
1102
+ f"Reply archived (seq {dispatch.seq}). Use /flow resume to continue.",
1103
+ )
1104
+ except Exception as exc:
1105
+ self._logger.warning(
1106
+ "Failed to write USER_REPLY.md from Telegram",
1107
+ exc=exc,
1108
+ workspace_root=str(workspace_root),
1109
+ run_id=run_id,
1110
+ )
1111
+ return False, f"Failed to write reply: {exc}"
864
1112
 
865
1113
  async def _interrupt_timeout_check(
866
1114
  self, key: str, turn_id: str, message_id: int
@@ -890,8 +1138,8 @@ class TelegramBotService(
890
1138
  chat_id: int,
891
1139
  thread_id: Optional[int],
892
1140
  ) -> None:
893
- key = self._resolve_topic_key(chat_id, thread_id)
894
- record = self._router.get_topic(key)
1141
+ key = await self._resolve_topic_key(chat_id, thread_id)
1142
+ record = await self._router.get_topic(key)
895
1143
  if record and record.agent == "opencode":
896
1144
  session_id = record.active_thread_id
897
1145
  workspace_path = record.workspace_path
@@ -943,9 +1191,21 @@ class TelegramBotService(
943
1191
  runtime.interrupt_turn_id = None
944
1192
  runtime.interrupt_requested = False
945
1193
  return
946
- client = await self._client_for_workspace(
947
- record.workspace_path if record else None
948
- )
1194
+ try:
1195
+ client = await self._client_for_workspace(
1196
+ record.workspace_path if record else None
1197
+ )
1198
+ except AppServerUnavailableError:
1199
+ runtime.interrupt_requested = False
1200
+ if runtime.interrupt_message_id is not None:
1201
+ await self._edit_message_text(
1202
+ chat_id,
1203
+ runtime.interrupt_message_id,
1204
+ "Interrupt failed (app-server unavailable).",
1205
+ )
1206
+ runtime.interrupt_message_id = None
1207
+ runtime.interrupt_turn_id = None
1208
+ return
949
1209
  if client is None:
950
1210
  runtime.interrupt_requested = False
951
1211
  if runtime.interrupt_message_id is not None:
@@ -992,23 +1252,36 @@ class TelegramBotService(
992
1252
  await message_handlers.handle_edited_message(self, message)
993
1253
 
994
1254
  async def _handle_message_inner(
995
- self, message: TelegramMessage, *, topic_key: Optional[str] = None
1255
+ self,
1256
+ message: TelegramMessage,
1257
+ *,
1258
+ topic_key: Optional[str] = None,
1259
+ placeholder_id: Optional[int] = None,
996
1260
  ) -> None:
997
- await message_handlers.handle_message_inner(self, message, topic_key=topic_key)
1261
+ await message_handlers.handle_message_inner(
1262
+ self, message, topic_key=topic_key, placeholder_id=placeholder_id
1263
+ )
998
1264
 
999
1265
  def _coalesce_key_for_topic(self, key: str, user_id: Optional[int]) -> str:
1000
1266
  return message_handlers.coalesce_key_for_topic(self, key, user_id)
1001
1267
 
1002
- def _coalesce_key(self, message: TelegramMessage) -> str:
1003
- return message_handlers.coalesce_key(self, message)
1268
+ async def _coalesce_key(self, message: TelegramMessage) -> str:
1269
+ return await message_handlers.coalesce_key(self, message)
1004
1270
 
1005
1271
  async def _buffer_coalesced_message(
1006
- self, message: TelegramMessage, text: str
1272
+ self,
1273
+ message: TelegramMessage,
1274
+ text: str,
1275
+ *,
1276
+ placeholder_id: Optional[int] = None,
1007
1277
  ) -> None:
1008
- await message_handlers.buffer_coalesced_message(self, message, text)
1278
+ await message_handlers.buffer_coalesced_message(
1279
+ self, message, text, placeholder_id=placeholder_id
1280
+ )
1009
1281
 
1010
1282
  async def _coalesce_flush_after(self, key: str) -> None:
1011
- await message_handlers.coalesce_flush_after(self, key)
1283
+ window_seconds = self._config.coalesce_window_seconds
1284
+ await message_handlers.coalesce_flush_after(self, key, window_seconds)
1012
1285
 
1013
1286
  async def _flush_coalesced_message(self, message: TelegramMessage) -> None:
1014
1287
  await message_handlers.flush_coalesced_message(self, message)
@@ -1041,10 +1314,19 @@ class TelegramBotService(
1041
1314
  return message_handlers.select_voice_candidate(message)
1042
1315
 
1043
1316
  async def _handle_media_message(
1044
- self, message: TelegramMessage, runtime: Any, caption_text: str
1317
+ self,
1318
+ message: TelegramMessage,
1319
+ runtime: Any,
1320
+ caption_text: str,
1321
+ *,
1322
+ placeholder_id: Optional[int] = None,
1045
1323
  ) -> None:
1046
1324
  await message_handlers.handle_media_message(
1047
- self, message, runtime, caption_text
1325
+ self,
1326
+ message,
1327
+ runtime,
1328
+ caption_text,
1329
+ placeholder_id=placeholder_id,
1048
1330
  )
1049
1331
 
1050
1332
  def _with_conversation_id(
@@ -1052,14 +1334,25 @@ class TelegramBotService(
1052
1334
  ) -> str:
1053
1335
  return _with_conversation_id(message, chat_id=chat_id, thread_id=thread_id)
1054
1336
 
1055
- def _should_process_update(self, key: str, update_id: int) -> bool:
1337
+ async def _should_process_update(self, key: str, update_id: int) -> bool:
1056
1338
  if not isinstance(update_id, int):
1057
1339
  return True
1058
1340
  if isinstance(update_id, bool):
1059
1341
  return True
1060
1342
  last_id = self._last_update_ids.get(key)
1061
1343
  if last_id is None:
1062
- record = self._store.get_topic(key)
1344
+ record = None
1345
+ try:
1346
+ record = await self._store.get_topic(key)
1347
+ except Exception as exc:
1348
+ log_event(
1349
+ self._logger,
1350
+ logging.WARNING,
1351
+ "telegram.update_id.load.failed",
1352
+ exc=exc,
1353
+ topic_key=key,
1354
+ update_id=update_id,
1355
+ )
1063
1356
  last_id = record.last_update_id if record else None
1064
1357
  if isinstance(last_id, int) and not isinstance(last_id, bool):
1065
1358
  self._last_update_ids[key] = last_id
@@ -1068,19 +1361,31 @@ class TelegramBotService(
1068
1361
  if isinstance(last_id, int) and update_id <= last_id:
1069
1362
  return False
1070
1363
  self._last_update_ids[key] = update_id
1071
- self._maybe_persist_update_id(key, update_id)
1364
+ try:
1365
+ await self._maybe_persist_update_id(key, update_id)
1366
+ except Exception as exc:
1367
+ log_event(
1368
+ self._logger,
1369
+ logging.WARNING,
1370
+ "telegram.update_id.persist.failed",
1371
+ exc=exc,
1372
+ topic_key=key,
1373
+ update_id=update_id,
1374
+ )
1072
1375
  return True
1073
1376
 
1074
- def _maybe_persist_update_id(self, key: str, update_id: int) -> None:
1377
+ async def _maybe_persist_update_id(self, key: str, update_id: int) -> None:
1075
1378
  now = time.monotonic()
1076
1379
  last_persisted = self._last_update_persisted_at.get(key, 0.0)
1077
- if (now - last_persisted) < UPDATE_ID_PERSIST_INTERVAL_SECONDS:
1380
+ if (
1381
+ now - last_persisted
1382
+ ) < self._config.cache.update_id_persist_interval_seconds:
1078
1383
  return
1079
1384
 
1080
1385
  def apply(record: "TelegramTopicRecord") -> None:
1081
1386
  record.last_update_id = update_id
1082
1387
 
1083
- self._store.update_topic(key, apply)
1388
+ await self._store.update_topic(key, apply)
1084
1389
  self._last_update_persisted_at[key] = now
1085
1390
 
1086
1391
  async def _handle_callback(self, callback: TelegramCallbackQuery) -> None:
@@ -1096,6 +1401,72 @@ class TelegramBotService(
1096
1401
  else:
1097
1402
  self._spawn_task(wrapped())
1098
1403
 
1404
+ async def _maybe_send_queued_placeholder(
1405
+ self, message: TelegramMessage, *, topic_key: str
1406
+ ) -> Optional[int]:
1407
+ runtime = self._router.runtime_for(topic_key)
1408
+ is_busy = runtime.current_turn_id is not None or runtime.queue.pending() > 0
1409
+ if not is_busy:
1410
+ return None
1411
+ placeholder_id = await self._send_placeholder(
1412
+ message.chat_id,
1413
+ thread_id=message.thread_id,
1414
+ reply_to=message.message_id,
1415
+ text=QUEUED_PLACEHOLDER_TEXT,
1416
+ )
1417
+ if placeholder_id is None:
1418
+ return None
1419
+ self._set_queued_placeholder(
1420
+ message.chat_id, message.message_id, placeholder_id
1421
+ )
1422
+ log_event(
1423
+ self._logger,
1424
+ logging.INFO,
1425
+ "telegram.placeholder.queued",
1426
+ topic_key=topic_key,
1427
+ chat_id=message.chat_id,
1428
+ thread_id=message.thread_id,
1429
+ message_id=message.message_id,
1430
+ placeholder_id=placeholder_id,
1431
+ )
1432
+ return placeholder_id
1433
+
1434
+ def _wrap_placeholder_work(
1435
+ self,
1436
+ *,
1437
+ chat_id: int,
1438
+ placeholder_id: Optional[int],
1439
+ work: Any,
1440
+ ) -> Any:
1441
+ if placeholder_id is None:
1442
+ return work
1443
+
1444
+ async def wrapped() -> Any:
1445
+ try:
1446
+ return await work()
1447
+ finally:
1448
+ await self._delete_message(chat_id, placeholder_id)
1449
+
1450
+ return wrapped
1451
+
1452
+ def _claim_queued_placeholder(self, chat_id: int, message_id: int) -> Optional[int]:
1453
+ placeholder_id = self._queued_placeholder_map.pop((chat_id, message_id), None)
1454
+ self._queued_placeholder_timestamps.pop((chat_id, message_id), None)
1455
+ return placeholder_id
1456
+
1457
+ def _get_queued_placeholder(self, chat_id: int, message_id: int) -> Optional[int]:
1458
+ return self._queued_placeholder_map.get((chat_id, message_id))
1459
+
1460
+ def _set_queued_placeholder(
1461
+ self, chat_id: int, message_id: int, placeholder_id: int
1462
+ ) -> None:
1463
+ self._queued_placeholder_map[(chat_id, message_id)] = placeholder_id
1464
+ self._queued_placeholder_timestamps[(chat_id, message_id)] = time.monotonic()
1465
+
1466
+ def _clear_queued_placeholder(self, chat_id: int, message_id: int) -> None:
1467
+ self._queued_placeholder_map.pop((chat_id, message_id), None)
1468
+ self._queued_placeholder_timestamps.pop((chat_id, message_id), None)
1469
+
1099
1470
  def _wrap_topic_work(self, key: str, work: Any) -> Any:
1100
1471
  conversation_id = None
1101
1472
  try: