codex-autorunner 0.1.2__py3-none-any.whl → 1.1.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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -2,7 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import math
5
6
  from contextlib import contextmanager
7
+ from datetime import datetime, timedelta, timezone
6
8
  from typing import Any, Awaitable, Callable, Optional
7
9
 
8
10
  from ...core.logging_utils import log_event
@@ -13,13 +15,40 @@ from .constants import (
13
15
  OUTBOX_MAX_ATTEMPTS,
14
16
  OUTBOX_RETRY_INTERVAL_SECONDS,
15
17
  )
18
+ from .retry import _extract_retry_after_seconds
16
19
  from .state import OutboxRecord, TelegramStateStore, topic_key
17
20
 
21
+ __all__ = ["_outbox_key", "TelegramOutboxManager"]
22
+
18
23
  SendMessageFn = Callable[..., Awaitable[None]]
19
24
  EditMessageFn = Callable[..., Awaitable[bool]]
20
25
  DeleteMessageFn = Callable[..., Awaitable[bool]]
21
26
 
22
27
 
28
+ def _outbox_key(
29
+ chat_id: int,
30
+ thread_id: Optional[int],
31
+ message_id: Optional[int],
32
+ operation: Optional[str],
33
+ ) -> str:
34
+ return f"{chat_id}:{thread_id if thread_id is not None else 'root'}:{message_id if message_id is not None else 'new'}:{operation or 'send'}"
35
+
36
+
37
+ # Keep a module-level reference so static analysis sees this helper as used in production.
38
+ OUTBOX_KEY_HELPER = _outbox_key
39
+
40
+
41
+ def _parse_next_attempt_at(next_at_str: Optional[str]) -> Optional[datetime]:
42
+ if not next_at_str:
43
+ return None
44
+ try:
45
+ return datetime.strptime(next_at_str, "%Y-%m-%dT%H:%M:%SZ").replace(
46
+ tzinfo=timezone.utc
47
+ )
48
+ except (ValueError, TypeError):
49
+ return None
50
+
51
+
23
52
  class TelegramOutboxManager:
24
53
  def __init__(
25
54
  self,
@@ -36,27 +65,61 @@ class TelegramOutboxManager:
36
65
  self._delete_message = delete_message
37
66
  self._logger = logger
38
67
  self._inflight: set[str] = set()
68
+ self._inflight_outbox_keys: set[str] = set()
39
69
  self._lock: Optional[asyncio.Lock] = None
40
70
 
41
71
  def start(self) -> None:
42
72
  self._inflight = set()
73
+ self._inflight_outbox_keys = set()
43
74
  self._lock = asyncio.Lock()
44
75
 
45
76
  async def restore(self) -> None:
46
77
  records = await self._store.list_outbox()
47
78
  if not records:
48
79
  return
49
- log_event(
50
- self._logger,
51
- logging.INFO,
52
- "telegram.outbox.restore",
53
- count=len(records),
54
- )
80
+ for record in records:
81
+ conversation_id = None
82
+ try:
83
+ from .state import topic_key as build_topic_key
84
+
85
+ conversation_id = build_topic_key(record.chat_id, record.thread_id)
86
+ except Exception:
87
+ pass
88
+ if conversation_id:
89
+ from ...core.request_context import set_conversation_id
90
+
91
+ token = set_conversation_id(conversation_id)
92
+ try:
93
+ log_event(
94
+ self._logger,
95
+ logging.INFO,
96
+ "telegram.outbox.restore",
97
+ record_id=record.record_id,
98
+ chat_id=record.chat_id,
99
+ thread_id=record.thread_id,
100
+ message_id=record.message_id,
101
+ conversation_id=conversation_id,
102
+ )
103
+ finally:
104
+ from ...core.request_context import reset_conversation_id
105
+
106
+ reset_conversation_id(token)
107
+ else:
108
+ log_event(
109
+ self._logger,
110
+ logging.INFO,
111
+ "telegram.outbox.restore",
112
+ record_id=record.record_id,
113
+ chat_id=record.chat_id,
114
+ thread_id=record.thread_id,
115
+ message_id=record.message_id,
116
+ )
55
117
  await self._flush(records)
56
118
 
57
119
  async def run_loop(self) -> None:
58
120
  while True:
59
121
  await asyncio.sleep(OUTBOX_RETRY_INTERVAL_SECONDS)
122
+ records = []
60
123
  try:
61
124
  records = await self._store.list_outbox()
62
125
  if records:
@@ -67,6 +130,7 @@ class TelegramOutboxManager:
67
130
  logging.WARNING,
68
131
  "telegram.outbox.flush_failed",
69
132
  exc=exc,
133
+ record_count=len(records) if records else 0,
70
134
  )
71
135
 
72
136
  async def send_message_with_outbox(
@@ -74,6 +138,11 @@ class TelegramOutboxManager:
74
138
  record: OutboxRecord,
75
139
  ) -> bool:
76
140
  await self._store.enqueue_outbox(record)
141
+ conversation_id = None
142
+ try:
143
+ conversation_id = topic_key(record.chat_id, record.thread_id)
144
+ except Exception:
145
+ pass
77
146
  log_event(
78
147
  self._logger,
79
148
  logging.INFO,
@@ -81,48 +150,120 @@ class TelegramOutboxManager:
81
150
  record_id=record.record_id,
82
151
  chat_id=record.chat_id,
83
152
  thread_id=record.thread_id,
153
+ message_id=record.message_id,
154
+ conversation_id=conversation_id,
84
155
  )
85
- for delay in OUTBOX_IMMEDIATE_RETRY_DELAYS:
86
- if await self._attempt_send(record):
156
+ immediate_delays_iter = iter(OUTBOX_IMMEDIATE_RETRY_DELAYS)
157
+ immediate_delays_exhausted = False
158
+ while True:
159
+ current = await self._store.get_outbox(record.record_id)
160
+ if current is None:
161
+ return False
162
+ next_at = _parse_next_attempt_at(current.next_attempt_at)
163
+ if next_at is not None:
164
+ now = datetime.now(timezone.utc)
165
+ sleep_duration = (next_at - now).total_seconds()
166
+ if sleep_duration > 0.01:
167
+ await asyncio.sleep(sleep_duration)
168
+ if await self._attempt_send(current):
87
169
  return True
88
170
  current = await self._store.get_outbox(record.record_id)
89
171
  if current is None:
90
172
  return False
91
173
  if current.attempts >= OUTBOX_MAX_ATTEMPTS:
92
174
  return False
93
- await asyncio.sleep(delay)
175
+ next_at = _parse_next_attempt_at(current.next_attempt_at)
176
+ if next_at is not None:
177
+ now = datetime.now(timezone.utc)
178
+ sleep_duration = (next_at - now).total_seconds()
179
+ if sleep_duration > 0.01:
180
+ await asyncio.sleep(sleep_duration)
181
+ continue
182
+ if immediate_delays_exhausted:
183
+ break
184
+ try:
185
+ delay = next(immediate_delays_iter)
186
+ except StopIteration:
187
+ immediate_delays_exhausted = True
188
+ has_next = await self._store.get_outbox(record.record_id)
189
+ if has_next is not None and has_next.next_attempt_at is None:
190
+ break
191
+ continue
192
+ if delay > 0:
193
+ await asyncio.sleep(delay)
94
194
  return False
95
195
 
96
196
  async def _flush(self, records: list[OutboxRecord]) -> None:
197
+ now = datetime.now(timezone.utc)
198
+ ready_records: list[OutboxRecord] = []
97
199
  for record in records:
98
- with self._conversation_context(record.chat_id, record.thread_id):
99
- if record.attempts >= OUTBOX_MAX_ATTEMPTS:
100
- log_event(
101
- self._logger,
102
- logging.WARNING,
103
- "telegram.outbox.gave_up",
104
- record_id=record.record_id,
105
- chat_id=record.chat_id,
106
- thread_id=record.thread_id,
107
- attempts=record.attempts,
108
- )
200
+ next_at = _parse_next_attempt_at(record.next_attempt_at)
201
+ if next_at is None or now >= next_at:
202
+ ready_records.append(record)
203
+
204
+ # Keep only the last ready record per outbox_key, but do not drop deferred
205
+ # future records; we leave them for later flush cycles. Latest wins to avoid
206
+ # delivering stale edits.
207
+ coalesced_ready: dict[str, OutboxRecord] = {}
208
+ for record in ready_records:
209
+ if record.outbox_key is not None:
210
+ coalesced_ready[record.outbox_key] = record
211
+ else:
212
+ await self._process_record(record)
213
+
214
+ for record in coalesced_ready.values():
215
+ await self._process_record(record)
216
+
217
+ async def _process_record(self, record: OutboxRecord) -> None:
218
+ with self._conversation_context(record.chat_id, record.thread_id):
219
+ conversation_id = None
220
+ try:
221
+ conversation_id = topic_key(record.chat_id, record.thread_id)
222
+ except Exception:
223
+ pass
224
+ if record.attempts >= OUTBOX_MAX_ATTEMPTS:
225
+ log_event(
226
+ self._logger,
227
+ logging.WARNING,
228
+ "telegram.outbox.gave_up",
229
+ record_id=record.record_id,
230
+ chat_id=record.chat_id,
231
+ thread_id=record.thread_id,
232
+ message_id=record.message_id,
233
+ attempts=record.attempts,
234
+ conversation_id=conversation_id,
235
+ )
236
+ if record.outbox_key:
237
+ records = await self._store.list_outbox()
238
+ for r in records:
239
+ if r.outbox_key == record.outbox_key:
240
+ await self._store.delete_outbox(r.record_id)
241
+ else:
109
242
  await self._store.delete_outbox(record.record_id)
110
- if record.placeholder_message_id is not None:
111
- await self._edit_message_text(
112
- record.chat_id,
113
- record.placeholder_message_id,
114
- "Delivery failed after retries. Please resend.",
115
- )
116
- continue
117
- await self._attempt_send(record)
243
+ if record.placeholder_message_id is not None:
244
+ await self._edit_message_text(
245
+ record.chat_id,
246
+ record.placeholder_message_id,
247
+ "Delivery failed after retries. Please resend.",
248
+ message_thread_id=record.thread_id,
249
+ )
250
+ return
251
+ await self._attempt_send(record)
118
252
 
119
253
  async def _attempt_send(self, record: OutboxRecord) -> bool:
120
254
  current = await self._store.get_outbox(record.record_id)
121
255
  if current is None:
122
256
  return False
123
257
  record = current
124
- if not await self._mark_inflight(record.record_id):
258
+ if not await self._mark_inflight(
259
+ record.outbox_key if record.outbox_key else record.record_id
260
+ ):
125
261
  return False
262
+ conversation_id = None
263
+ try:
264
+ conversation_id = topic_key(record.chat_id, record.thread_id)
265
+ except Exception:
266
+ pass
126
267
  with self._conversation_context(record.chat_id, record.thread_id):
127
268
  try:
128
269
  await self._send_message(
@@ -132,9 +273,19 @@ class TelegramOutboxManager:
132
273
  reply_to=record.reply_to_message_id,
133
274
  )
134
275
  except Exception as exc:
276
+ retry_after = _extract_retry_after_seconds(exc)
135
277
  record.attempts += 1
136
278
  record.last_error = str(exc)[:500]
137
279
  record.last_attempt_at = now_iso()
280
+ if retry_after is not None:
281
+ now = datetime.now(timezone.utc)
282
+ delay_seconds = max(1, math.ceil(retry_after))
283
+ next_at = now.replace(microsecond=0) + timedelta(
284
+ seconds=delay_seconds
285
+ )
286
+ if next_at <= now:
287
+ next_at = now + timedelta(seconds=delay_seconds)
288
+ record.next_attempt_at = next_at.strftime("%Y-%m-%dT%H:%M:%SZ")
138
289
  await self._store.update_outbox(record)
139
290
  log_event(
140
291
  self._logger,
@@ -143,16 +294,34 @@ class TelegramOutboxManager:
143
294
  record_id=record.record_id,
144
295
  chat_id=record.chat_id,
145
296
  thread_id=record.thread_id,
297
+ message_id=record.message_id,
146
298
  attempts=record.attempts,
299
+ retry_after=retry_after,
147
300
  exc=exc,
301
+ conversation_id=conversation_id,
148
302
  )
149
303
  return False
150
304
  finally:
151
- await self._clear_inflight(record.record_id)
152
- await self._store.delete_outbox(record.record_id)
305
+ await self._clear_inflight(
306
+ record.outbox_key if record.outbox_key else record.record_id
307
+ )
308
+ if record.outbox_key:
309
+ # Only delete records up to (and including) this record's created_at to
310
+ # avoid dropping newer queued messages for the same key.
311
+ records = await self._store.list_outbox()
312
+ for r in records:
313
+ if (
314
+ r.outbox_key == record.outbox_key
315
+ and r.created_at <= record.created_at
316
+ ):
317
+ await self._store.delete_outbox(r.record_id)
318
+ else:
319
+ await self._store.delete_outbox(record.record_id)
153
320
  if record.placeholder_message_id is not None:
154
321
  await self._delete_message(
155
- record.chat_id, record.placeholder_message_id
322
+ record.chat_id,
323
+ record.placeholder_message_id,
324
+ record.thread_id,
156
325
  )
157
326
  log_event(
158
327
  self._logger,
@@ -161,23 +330,25 @@ class TelegramOutboxManager:
161
330
  record_id=record.record_id,
162
331
  chat_id=record.chat_id,
163
332
  thread_id=record.thread_id,
333
+ message_id=record.message_id,
334
+ conversation_id=conversation_id,
164
335
  )
165
336
  return True
166
337
 
167
- async def _mark_inflight(self, record_id: str) -> bool:
338
+ async def _mark_inflight(self, key: str) -> bool:
168
339
  if self._lock is None:
169
340
  self._lock = asyncio.Lock()
170
341
  async with self._lock:
171
- if record_id in self._inflight:
342
+ if key in self._inflight_outbox_keys:
172
343
  return False
173
- self._inflight.add(record_id)
344
+ self._inflight_outbox_keys.add(key)
174
345
  return True
175
346
 
176
- async def _clear_inflight(self, record_id: str) -> None:
347
+ async def _clear_inflight(self, key: str) -> None:
177
348
  if self._lock is None:
178
349
  return
179
350
  async with self._lock:
180
- self._inflight.discard(record_id)
351
+ self._inflight_outbox_keys.discard(key)
181
352
 
182
353
  @contextmanager
183
354
  def _conversation_context(self, chat_id: int, thread_id: Optional[int]) -> Any:
@@ -4,16 +4,9 @@ import time
4
4
  from dataclasses import dataclass, field
5
5
  from typing import Optional
6
6
 
7
+ from .constants import COMPACT_MAX_ACTIONS, COMPACT_MAX_TEXT_LENGTH, STATUS_ICONS
7
8
  from .helpers import _truncate_text
8
9
 
9
- STATUS_ICONS = {
10
- "running": "▸",
11
- "update": "↻",
12
- "done": "✓",
13
- "fail": "✗",
14
- "warn": "⚠",
15
- }
16
-
17
10
 
18
11
  def format_elapsed(seconds: float) -> str:
19
12
  total = max(int(seconds), 0)
@@ -45,8 +38,8 @@ class TurnProgressTracker:
45
38
  agent: str
46
39
  model: str
47
40
  label: str
48
- max_actions: int
49
- max_output_chars: int
41
+ max_actions: int = COMPACT_MAX_ACTIONS
42
+ max_output_chars: int = COMPACT_MAX_TEXT_LENGTH
50
43
  actions: list[ProgressAction] = field(default_factory=list)
51
44
  step: int = 0
52
45
  last_output_index: Optional[int] = None