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
@@ -0,0 +1,611 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Awaitable, Callable, Optional
8
+
9
+ from ...core.flows import FlowStore
10
+ from ...core.flows.controller import FlowController
11
+ from ...core.flows.models import FlowRunRecord, FlowRunStatus
12
+ from ...core.flows.worker_process import spawn_flow_worker
13
+ from ...core.logging_utils import log_event
14
+ from ...core.utils import canonicalize_path
15
+ from ...flows.ticket_flow import build_ticket_flow_definition
16
+ from ...manifest import load_manifest
17
+ from ...tickets import AgentPool
18
+ from .adapter import chunk_message
19
+ from .constants import TELEGRAM_MAX_MESSAGE_LENGTH
20
+ from .state import parse_topic_key
21
+
22
+
23
+ class TelegramTicketFlowBridge:
24
+ """Encapsulate ticket_flow pause/resume plumbing for Telegram service."""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ logger: logging.Logger,
30
+ store,
31
+ pause_targets: dict[str, str],
32
+ send_message_with_outbox,
33
+ send_document: Callable[..., Awaitable[bool]],
34
+ pause_config,
35
+ default_notification_chat_id: Optional[int],
36
+ hub_root: Optional[Path] = None,
37
+ manifest_path: Optional[Path] = None,
38
+ config_root: Optional[Path] = None,
39
+ ) -> None:
40
+ self._logger = logger
41
+ self._store = store
42
+ self._pause_targets = pause_targets
43
+ self._send_message_with_outbox = send_message_with_outbox
44
+ self._send_document = send_document
45
+ self._pause_config = pause_config
46
+ self._default_notification_chat_id = default_notification_chat_id
47
+ self._hub_root = hub_root
48
+ self._manifest_path = manifest_path
49
+ self._config_root = config_root
50
+ self._last_default_notification: dict[Path, str] = {}
51
+
52
+ @staticmethod
53
+ def _select_ticket_flow_topic(
54
+ entries: list[tuple[str, object]],
55
+ ) -> Optional[tuple[str, object]]:
56
+ if not entries:
57
+ return None
58
+
59
+ def score(entry: tuple[str, object]) -> tuple[int, float, str]:
60
+ key, record = entry
61
+ thread_id = None
62
+ try:
63
+ _chat_id, thread_id, _scope = parse_topic_key(key)
64
+ except Exception:
65
+ thread_id = None
66
+ active_raw = getattr(record, "active_thread_id", None)
67
+ try:
68
+ active_thread = int(active_raw) if active_raw is not None else None
69
+ except (TypeError, ValueError):
70
+ active_thread = None
71
+ active_match = (
72
+ int(thread_id) == active_thread if thread_id is not None else False
73
+ )
74
+ last_active_at = getattr(record, "last_active_at", None)
75
+ last_active = TelegramTicketFlowBridge._parse_last_active(last_active_at)
76
+ return (1 if active_match else 0, last_active, key)
77
+
78
+ return max(entries, key=score)
79
+
80
+ @staticmethod
81
+ def _parse_last_active(raw: Optional[str]) -> float:
82
+ if not isinstance(raw, str):
83
+ return float("-inf")
84
+ try:
85
+ return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").timestamp()
86
+ except ValueError:
87
+ return float("-inf")
88
+
89
+ async def watch_ticket_flow_pauses(self, interval_seconds: float) -> None:
90
+ interval = max(interval_seconds, 1.0)
91
+ while True:
92
+ try:
93
+ await self._scan_and_notify_pauses()
94
+ except Exception as exc:
95
+ log_event(
96
+ self._logger,
97
+ logging.WARNING,
98
+ "telegram.ticket_flow.watch_failed",
99
+ exc=exc,
100
+ )
101
+ await asyncio.sleep(interval)
102
+
103
+ async def _scan_and_notify_pauses(self) -> None:
104
+ if not self._pause_config.enabled:
105
+ return
106
+ topics = await self._store.list_topics()
107
+ workspace_topics = self._get_all_workspaces(topics or {})
108
+
109
+ tasks = []
110
+ for workspace_root, entries in workspace_topics.items():
111
+ if entries:
112
+ tasks.append(
113
+ asyncio.create_task(
114
+ self._notify_ticket_flow_pause(workspace_root, entries)
115
+ )
116
+ )
117
+ else:
118
+ tasks.append(
119
+ asyncio.create_task(self._notify_via_default_chat(workspace_root))
120
+ )
121
+ if tasks:
122
+ await asyncio.gather(*tasks, return_exceptions=True)
123
+
124
+ async def _notify_ticket_flow_pause(
125
+ self,
126
+ workspace_root: Path,
127
+ entries: list[tuple[str, object]],
128
+ ) -> None:
129
+ try:
130
+ pause = await asyncio.to_thread(
131
+ self._load_ticket_flow_pause, workspace_root
132
+ )
133
+ except Exception as exc:
134
+ log_event(
135
+ self._logger,
136
+ logging.WARNING,
137
+ "telegram.ticket_flow.scan_failed",
138
+ exc=exc,
139
+ workspace_root=str(workspace_root),
140
+ )
141
+ return
142
+ if pause is None:
143
+ return
144
+ run_id, seq, content, archived_dir = pause
145
+ marker = f"{run_id}:{seq}"
146
+ pending = [
147
+ (key, record)
148
+ for key, record in entries
149
+ if getattr(record, "last_ticket_dispatch_seq", None) != marker
150
+ ]
151
+ if not pending:
152
+ return
153
+ primary = self._select_ticket_flow_topic(pending)
154
+ if not primary:
155
+ return
156
+ updates: list[tuple[str, Optional[str]]] = [
157
+ (key, getattr(record, "last_ticket_dispatch_seq", None))
158
+ for key, record in pending
159
+ ]
160
+ for key, _previous in updates:
161
+ await self._store.update_topic(
162
+ key, self._set_ticket_dispatch_marker(marker)
163
+ )
164
+
165
+ primary_key, _primary_record = primary
166
+ try:
167
+ chat_id, thread_id, _scope = parse_topic_key(primary_key)
168
+ except Exception as exc:
169
+ self._logger.debug("Failed to parse topic key: %s", exc)
170
+ for key, previous in updates:
171
+ await self._store.update_topic(
172
+ key, self._set_ticket_dispatch_marker(previous)
173
+ )
174
+ return
175
+
176
+ try:
177
+ await self._send_full_dispatch(
178
+ chat_id,
179
+ thread_id,
180
+ run_id=run_id,
181
+ seq=seq,
182
+ content=content,
183
+ archived_dir=archived_dir,
184
+ )
185
+ self._pause_targets[str(workspace_root)] = run_id
186
+ except Exception as exc:
187
+ log_event(
188
+ self._logger,
189
+ logging.WARNING,
190
+ "telegram.ticket_flow.notify_failed",
191
+ exc=exc,
192
+ topic_key=primary_key,
193
+ run_id=run_id,
194
+ seq=seq,
195
+ )
196
+ for key, previous in updates:
197
+ await self._store.update_topic(
198
+ key, self._set_ticket_dispatch_marker(previous)
199
+ )
200
+
201
+ @staticmethod
202
+ def _set_ticket_dispatch_marker(
203
+ value: Optional[str],
204
+ ):
205
+ def apply(topic) -> None:
206
+ topic.last_ticket_dispatch_seq = value
207
+
208
+ return apply
209
+
210
+ def _get_all_workspaces(
211
+ self, topics: dict[str, object]
212
+ ) -> dict[Path, list[tuple[str, object]]]:
213
+ workspace_topics: dict[Path, list[tuple[str, object]]] = {}
214
+ for key, record in topics.items():
215
+ if not isinstance(record.workspace_path, str) or not record.workspace_path:
216
+ continue
217
+ workspace_root = canonicalize_path(Path(record.workspace_path))
218
+ workspace_topics.setdefault(workspace_root, []).append((key, record))
219
+
220
+ # Include config root
221
+ if self._config_root:
222
+ workspace_topics.setdefault(self._config_root.resolve(), [])
223
+
224
+ # Include hub manifest worktrees (for web-originated flows)
225
+ if self._hub_root and self._manifest_path and self._manifest_path.exists():
226
+ try:
227
+ manifest = load_manifest(self._manifest_path, self._hub_root)
228
+ for repo in manifest.repos:
229
+ path = canonicalize_path((self._hub_root / repo.path).resolve())
230
+ workspace_topics.setdefault(path, [])
231
+ except Exception as exc:
232
+ self._logger.debug(
233
+ "telegram.ticket_flow.manifest_load_failed", exc_info=exc
234
+ )
235
+
236
+ return workspace_topics
237
+
238
+ def _load_ticket_flow_pause(
239
+ self, workspace_root: Path
240
+ ) -> Optional[tuple[str, str, str, Optional[Path]]]:
241
+ db_path = workspace_root / ".codex-autorunner" / "flows.db"
242
+ if not db_path.exists():
243
+ return None
244
+ store = FlowStore(db_path)
245
+ try:
246
+ store.initialize()
247
+ runs = store.list_flow_runs(
248
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
249
+ )
250
+ if not runs:
251
+ return None
252
+ latest = runs[0]
253
+ runs_dir_raw = latest.input_data.get("runs_dir")
254
+ runs_dir = (
255
+ Path(runs_dir_raw)
256
+ if isinstance(runs_dir_raw, str) and runs_dir_raw
257
+ else Path(".codex-autorunner/runs")
258
+ )
259
+ from ...tickets.outbox import resolve_outbox_paths
260
+
261
+ paths = resolve_outbox_paths(
262
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=latest.id
263
+ )
264
+ history_dir = paths.dispatch_history_dir
265
+ seq = self._latest_dispatch_seq(history_dir)
266
+ if not seq:
267
+ reason = self._format_ticket_flow_pause_reason(latest)
268
+ return latest.id, "paused", reason, None
269
+ message_path = history_dir / seq / "DISPATCH.md"
270
+ try:
271
+ content = message_path.read_text(encoding="utf-8")
272
+ except OSError:
273
+ return None
274
+ return latest.id, seq, content, history_dir / seq
275
+ finally:
276
+ store.close()
277
+
278
+ @staticmethod
279
+ def _latest_dispatch_seq(history_dir: Path) -> Optional[str]:
280
+ if not history_dir.exists() or not history_dir.is_dir():
281
+ return None
282
+ seqs = [
283
+ child.name
284
+ for child in history_dir.iterdir()
285
+ if child.is_dir()
286
+ and not child.name.startswith(".")
287
+ and child.name.isdigit()
288
+ ]
289
+ if not seqs:
290
+ return None
291
+ return max(seqs)
292
+
293
+ @staticmethod
294
+ def _format_ticket_flow_pause_reason(record: FlowRunRecord) -> str:
295
+ state = record.state or {}
296
+ engine = state.get("ticket_engine") or {}
297
+ reason = (
298
+ engine.get("reason") or record.error_message or "Paused without details."
299
+ )
300
+ return f"Reason: {reason}"
301
+
302
+ def get_paused_ticket_flow(
303
+ self, workspace_root: Path, preferred_run_id: Optional[str] = None
304
+ ) -> Optional[tuple[str, FlowRunRecord]]:
305
+ db_path = workspace_root / ".codex-autorunner" / "flows.db"
306
+ if not db_path.exists():
307
+ return None
308
+ store = FlowStore(db_path)
309
+ try:
310
+ store.initialize()
311
+ if preferred_run_id:
312
+ preferred = store.get_flow_run(preferred_run_id)
313
+ if preferred and preferred.status == FlowRunStatus.PAUSED:
314
+ return preferred.id, preferred
315
+ runs = store.list_flow_runs(
316
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
317
+ )
318
+ if not runs:
319
+ return None
320
+ latest = runs[0]
321
+ return latest.id, latest
322
+ finally:
323
+ store.close()
324
+
325
+ async def auto_resume_run(self, workspace_root: Path, run_id: str) -> None:
326
+ """Best-effort resume + worker spawn; failures are logged only."""
327
+ try:
328
+ controller = _ticket_controller_for(workspace_root)
329
+ updated = await controller.resume_flow(run_id)
330
+ if updated:
331
+ _spawn_ticket_worker(workspace_root, updated.id, self._logger)
332
+ except Exception as exc:
333
+ log_event(
334
+ self._logger,
335
+ logging.WARNING,
336
+ "telegram.ticket_flow.auto_resume_failed",
337
+ exc=exc,
338
+ run_id=run_id,
339
+ workspace_root=str(workspace_root),
340
+ )
341
+
342
+ async def _notify_via_default_chat(self, workspace_root: Path) -> None:
343
+ if not self._pause_config.enabled or self._default_notification_chat_id is None:
344
+ return
345
+ try:
346
+ pause = await asyncio.to_thread(
347
+ self._load_ticket_flow_pause, workspace_root
348
+ )
349
+ except Exception as exc:
350
+ log_event(
351
+ self._logger,
352
+ logging.WARNING,
353
+ "telegram.ticket_flow.scan_failed",
354
+ exc=exc,
355
+ workspace_root=str(workspace_root),
356
+ )
357
+ return
358
+ if pause is None:
359
+ return
360
+ run_id, seq, content, archived_dir = pause
361
+ marker = f"{run_id}:{seq}"
362
+ previous = self._last_default_notification.get(workspace_root)
363
+ if previous == marker:
364
+ return
365
+ try:
366
+ await self._send_full_dispatch(
367
+ self._default_notification_chat_id,
368
+ None,
369
+ run_id=run_id,
370
+ seq=seq,
371
+ content=content,
372
+ archived_dir=archived_dir,
373
+ )
374
+ self._last_default_notification[workspace_root] = marker
375
+ self._pause_targets[str(workspace_root)] = run_id
376
+ except Exception as exc:
377
+ log_event(
378
+ self._logger,
379
+ logging.WARNING,
380
+ "telegram.ticket_flow.notify_default_failed",
381
+ exc=exc,
382
+ chat_id=self._default_notification_chat_id,
383
+ run_id=run_id,
384
+ seq=seq,
385
+ )
386
+
387
+ async def _send_full_dispatch(
388
+ self,
389
+ chat_id: int,
390
+ thread_id: Optional[int],
391
+ *,
392
+ run_id: str,
393
+ seq: str,
394
+ content: str,
395
+ archived_dir: Optional[Path],
396
+ ) -> None:
397
+ await self._send_dispatch_text(
398
+ chat_id,
399
+ thread_id,
400
+ run_id=run_id,
401
+ seq=seq,
402
+ content=content,
403
+ )
404
+ if self._pause_config.send_attachments and archived_dir:
405
+ await self._send_dispatch_attachments(
406
+ chat_id,
407
+ thread_id,
408
+ run_id=run_id,
409
+ seq=seq,
410
+ archived_dir=archived_dir,
411
+ )
412
+
413
+ async def _send_dispatch_text(
414
+ self,
415
+ chat_id: int,
416
+ thread_id: Optional[int],
417
+ *,
418
+ run_id: str,
419
+ seq: str,
420
+ content: str,
421
+ ) -> None:
422
+ body = content.strip() or "(no dispatch message)"
423
+ header = f"Ticket flow paused (run {run_id}). Latest dispatch #{seq}:\n\n"
424
+ footer = "\n\nUse /flow resume to continue."
425
+ full_text = f"{header}{body}{footer}"
426
+
427
+ if self._pause_config.chunk_long_messages:
428
+ chunks = chunk_message(
429
+ full_text,
430
+ max_len=TELEGRAM_MAX_MESSAGE_LENGTH,
431
+ with_numbering=True,
432
+ )
433
+ else:
434
+ chunks = [full_text]
435
+
436
+ for idx, chunk in enumerate(chunks):
437
+ await self._send_message_with_outbox(
438
+ chat_id,
439
+ chunk,
440
+ thread_id=thread_id,
441
+ reply_to=None,
442
+ )
443
+ if idx == 0:
444
+ await asyncio.sleep(0)
445
+
446
+ async def _send_dispatch_attachments(
447
+ self,
448
+ chat_id: int,
449
+ thread_id: Optional[int],
450
+ *,
451
+ run_id: str,
452
+ seq: str,
453
+ archived_dir: Path,
454
+ ) -> None:
455
+ try:
456
+ items = sorted(
457
+ [
458
+ child
459
+ for child in archived_dir.iterdir()
460
+ if child.is_file()
461
+ and child.name != "DISPATCH.md"
462
+ and not child.name.startswith(".")
463
+ ],
464
+ key=lambda p: p.name,
465
+ )
466
+ except OSError as exc:
467
+ log_event(
468
+ self._logger,
469
+ logging.WARNING,
470
+ "telegram.ticket_flow.attachments_list_failed",
471
+ exc=exc,
472
+ run_id=run_id,
473
+ seq=seq,
474
+ dir=str(archived_dir),
475
+ )
476
+ return
477
+
478
+ for item in items:
479
+ await self._send_single_attachment(
480
+ chat_id,
481
+ thread_id,
482
+ run_id=run_id,
483
+ seq=seq,
484
+ path=item,
485
+ )
486
+
487
+ async def _send_single_attachment(
488
+ self,
489
+ chat_id: int,
490
+ thread_id: Optional[int],
491
+ *,
492
+ run_id: str,
493
+ seq: str,
494
+ path: Path,
495
+ ) -> None:
496
+ try:
497
+ size = path.stat().st_size
498
+ except OSError:
499
+ size = None
500
+ if size is not None and size > self._pause_config.max_file_size_bytes:
501
+ warning = (
502
+ f"Skipped attachment {path.name} "
503
+ f"({size} bytes > {self._pause_config.max_file_size_bytes} limit)."
504
+ )
505
+ await self._send_message_with_outbox(
506
+ chat_id,
507
+ warning,
508
+ thread_id=thread_id,
509
+ reply_to=None,
510
+ )
511
+ return
512
+ try:
513
+ data = path.read_bytes()
514
+ except OSError as exc:
515
+ log_event(
516
+ self._logger,
517
+ logging.WARNING,
518
+ "telegram.ticket_flow.attachment_read_failed",
519
+ exc=exc,
520
+ file=str(path),
521
+ run_id=run_id,
522
+ seq=seq,
523
+ )
524
+ await self._send_message_with_outbox(
525
+ chat_id,
526
+ f"Failed to read attachment {path.name}.",
527
+ thread_id=thread_id,
528
+ reply_to=None,
529
+ )
530
+ return
531
+ caption = f"[run {run_id} dispatch #{seq}] {path.name}"
532
+ send_ok = False
533
+ try:
534
+ send_ok = await self._send_document(
535
+ chat_id,
536
+ data,
537
+ filename=path.name,
538
+ thread_id=thread_id,
539
+ reply_to=None,
540
+ caption=caption[:1024],
541
+ )
542
+ if not send_ok:
543
+ log_event(
544
+ self._logger,
545
+ logging.WARNING,
546
+ "telegram.ticket_flow.attachment_send_failed",
547
+ file=str(path),
548
+ run_id=run_id,
549
+ seq=seq,
550
+ )
551
+ except Exception as exc:
552
+ log_event(
553
+ self._logger,
554
+ logging.WARNING,
555
+ "telegram.ticket_flow.attachment_send_failed",
556
+ exc=exc,
557
+ file=str(path),
558
+ run_id=run_id,
559
+ seq=seq,
560
+ )
561
+ if not send_ok:
562
+ await self._send_message_with_outbox(
563
+ chat_id,
564
+ f"Failed to send attachment {path.name}.",
565
+ thread_id=thread_id,
566
+ reply_to=None,
567
+ )
568
+
569
+
570
+ def _ticket_controller_for(repo_root: Path) -> FlowController:
571
+ repo_root = repo_root.resolve()
572
+ db_path = repo_root / ".codex-autorunner" / "flows.db"
573
+ artifacts_root = repo_root / ".codex-autorunner" / "flows"
574
+ from ...agents.registry import validate_agent_id
575
+ from ...core.config import load_repo_config
576
+ from ...core.engine import Engine
577
+ from ...integrations.agents.wiring import (
578
+ build_agent_backend_factory,
579
+ build_app_server_supervisor_factory,
580
+ )
581
+
582
+ config = load_repo_config(repo_root)
583
+ engine = Engine(
584
+ repo_root,
585
+ config=config,
586
+ backend_factory=build_agent_backend_factory(repo_root, config),
587
+ app_server_supervisor_factory=build_app_server_supervisor_factory(config),
588
+ agent_id_validator=validate_agent_id,
589
+ )
590
+ agent_pool = AgentPool(engine.config)
591
+ definition = build_ticket_flow_definition(agent_pool=agent_pool)
592
+ definition.validate()
593
+ controller = FlowController(
594
+ definition=definition, db_path=db_path, artifacts_root=artifacts_root
595
+ )
596
+ controller.initialize()
597
+ return controller
598
+
599
+
600
+ def _spawn_ticket_worker(repo_root: Path, run_id: str, logger: logging.Logger) -> None:
601
+ try:
602
+ proc, out, err = spawn_flow_worker(repo_root, run_id)
603
+ out.close()
604
+ err.close()
605
+ logger.info("Started ticket_flow worker for %s (pid=%s)", run_id, proc.pid)
606
+ except Exception as exc:
607
+ logger.warning(
608
+ "ticket_flow.worker.spawn_failed",
609
+ exc_info=exc,
610
+ extra={"run_id": run_id},
611
+ )