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,1364 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import shutil
6
+ import subprocess
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Callable, Optional
10
+
11
+ from .....agents.registry import validate_agent_id
12
+ from .....core.config import load_repo_config
13
+ from .....core.engine import Engine
14
+ from .....core.flows import FlowController, FlowStore
15
+ from .....core.flows.models import FlowRunStatus
16
+ from .....core.flows.reconciler import reconcile_flow_run
17
+ from .....core.flows.ux_helpers import (
18
+ bootstrap_check,
19
+ build_flow_status_snapshot,
20
+ ensure_worker,
21
+ issue_md_has_content,
22
+ issue_md_path,
23
+ seed_issue_from_github,
24
+ seed_issue_from_text,
25
+ )
26
+ from .....core.flows.worker_process import (
27
+ FlowWorkerHealth,
28
+ check_worker_health,
29
+ clear_worker_metadata,
30
+ )
31
+ from .....core.state import now_iso
32
+ from .....core.utils import atomic_write, canonicalize_path
33
+ from .....flows.ticket_flow import build_ticket_flow_definition
34
+ from .....integrations.agents.wiring import (
35
+ build_agent_backend_factory,
36
+ build_app_server_supervisor_factory,
37
+ )
38
+ from .....tickets import AgentPool
39
+ from .....tickets.files import list_ticket_paths
40
+ from .....tickets.outbox import resolve_outbox_paths
41
+ from ....github.service import GitHubError, GitHubService
42
+ from ...adapter import (
43
+ FlowCallback,
44
+ InlineButton,
45
+ TelegramCallbackQuery,
46
+ TelegramMessage,
47
+ build_inline_keyboard,
48
+ encode_flow_callback,
49
+ encode_question_cancel_callback,
50
+ )
51
+ from ...config import DEFAULT_APPROVAL_TIMEOUT_SECONDS
52
+ from ...helpers import _truncate_text
53
+ from ...types import PendingQuestion, SelectionState
54
+ from .shared import SharedHelpers
55
+
56
+ _logger = logging.getLogger(__name__)
57
+
58
+
59
+ def _flow_paths(repo_root: Path) -> tuple[Path, Path]:
60
+ repo_root = repo_root.resolve()
61
+ db_path = repo_root / ".codex-autorunner" / "flows.db"
62
+ artifacts_root = repo_root / ".codex-autorunner" / "flows"
63
+ return db_path, artifacts_root
64
+
65
+
66
+ def _ticket_dir(repo_root: Path) -> Path:
67
+ return repo_root.resolve() / ".codex-autorunner" / "tickets"
68
+
69
+
70
+ def _normalize_run_id(value: str) -> Optional[str]:
71
+ try:
72
+ return str(uuid.UUID(str(value)))
73
+ except ValueError:
74
+ return None
75
+
76
+
77
+ def _split_flow_action(args: str) -> tuple[str, str]:
78
+ trimmed = (args or "").strip()
79
+ if not trimmed:
80
+ return "", ""
81
+ parts = trimmed.split(None, 1)
82
+ if len(parts) == 1:
83
+ return parts[0], ""
84
+ return parts[0], parts[1]
85
+
86
+
87
+ def _normalize_flow_action(action: str) -> str:
88
+ normalized = (action or "").strip().lower()
89
+ if not normalized:
90
+ return "help"
91
+ if normalized == "start":
92
+ return "bootstrap"
93
+ return normalized
94
+
95
+
96
+ def _flow_help_lines() -> list[str]:
97
+ return [
98
+ "Flow commands:",
99
+ "/flow status [run_id]",
100
+ "/flow runs [N]",
101
+ "/flow bootstrap [--force-new]",
102
+ "/flow issue <issue#|url>",
103
+ "/flow plan <text>",
104
+ "/flow resume [run_id]",
105
+ "/flow stop [run_id]",
106
+ "/flow recover [run_id]",
107
+ "/flow restart",
108
+ "/flow archive [run_id] [--force]",
109
+ "/flow reply <message>",
110
+ "Aliases: /flow start, /flow_status",
111
+ ]
112
+
113
+
114
+ def _get_ticket_controller(repo_root: Path) -> FlowController:
115
+ db_path, artifacts_root = _flow_paths(repo_root)
116
+ config = load_repo_config(repo_root)
117
+ engine = Engine(
118
+ repo_root,
119
+ config=config,
120
+ backend_factory=build_agent_backend_factory(repo_root, config),
121
+ app_server_supervisor_factory=build_app_server_supervisor_factory(config),
122
+ agent_id_validator=validate_agent_id,
123
+ )
124
+ agent_pool = AgentPool(engine.config)
125
+ definition = build_ticket_flow_definition(agent_pool=agent_pool)
126
+ definition.validate()
127
+ controller = FlowController(
128
+ definition=definition, db_path=db_path, artifacts_root=artifacts_root
129
+ )
130
+ controller.initialize()
131
+ return controller
132
+
133
+
134
+ def _spawn_flow_worker(repo_root: Path, run_id: str) -> None:
135
+ result = ensure_worker(repo_root, run_id)
136
+ if result["status"] == "reused":
137
+ health = result["health"]
138
+ _logger.info("Worker already active for run %s (pid=%s)", run_id, health.pid)
139
+ return
140
+ proc = result["proc"]
141
+ out = result["stdout"]
142
+ err = result["stderr"]
143
+ try:
144
+ # We don't track handles in Telegram commands, close in parent after spawn.
145
+ out.close()
146
+ err.close()
147
+ finally:
148
+ if proc.poll() is not None:
149
+ _logger.warning("Flow worker for %s exited immediately", run_id)
150
+
151
+
152
+ def _select_latest_run(
153
+ store: FlowStore, predicate: Callable[[object], bool]
154
+ ) -> Optional[object]:
155
+ for record in store.list_flow_runs(flow_type="ticket_flow"):
156
+ if predicate(record):
157
+ return record
158
+ return None
159
+
160
+
161
+ class FlowCommands(SharedHelpers):
162
+ def _github_bootstrap_status(self, repo_root: Path) -> tuple[bool, Optional[str]]:
163
+ result = bootstrap_check(repo_root, github_service_factory=GitHubService)
164
+ return bool(result.github_available), result.repo_slug
165
+
166
+ async def _prompt_flow_text_input(
167
+ self,
168
+ message: TelegramMessage,
169
+ prompt_text: str,
170
+ ) -> Optional[str]:
171
+ request_id = str(uuid.uuid4())
172
+ topic_key = await self._resolve_topic_key(message.chat_id, message.thread_id)
173
+ payload_text, parse_mode = self._prepare_outgoing_text(
174
+ prompt_text,
175
+ chat_id=message.chat_id,
176
+ thread_id=message.thread_id,
177
+ reply_to=message.message_id,
178
+ topic_key=topic_key,
179
+ )
180
+ keyboard = build_inline_keyboard(
181
+ [[InlineButton("Cancel", encode_question_cancel_callback(request_id))]]
182
+ )
183
+ response = await self._bot.send_message(
184
+ message.chat_id,
185
+ payload_text,
186
+ message_thread_id=message.thread_id,
187
+ reply_to_message_id=message.message_id,
188
+ reply_markup=keyboard,
189
+ parse_mode=parse_mode,
190
+ )
191
+ message_id = response.get("message_id") if isinstance(response, dict) else None
192
+ loop = asyncio.get_running_loop()
193
+ future: asyncio.Future[Optional[str]] = loop.create_future()
194
+ pending = PendingQuestion(
195
+ request_id=request_id,
196
+ turn_id=f"flow-bootstrap:{request_id}",
197
+ codex_thread_id=None,
198
+ chat_id=message.chat_id,
199
+ thread_id=message.thread_id,
200
+ topic_key=topic_key,
201
+ message_id=message_id if isinstance(message_id, int) else None,
202
+ created_at=now_iso(),
203
+ question_index=0,
204
+ prompt=prompt_text,
205
+ options=[],
206
+ future=future,
207
+ multiple=False,
208
+ custom=True,
209
+ selected_indices=set(),
210
+ awaiting_custom_input=True,
211
+ )
212
+ self._pending_questions[request_id] = pending
213
+ self._touch_cache_timestamp("pending_questions", request_id)
214
+ try:
215
+ result = await asyncio.wait_for(
216
+ future, timeout=DEFAULT_APPROVAL_TIMEOUT_SECONDS
217
+ )
218
+ except asyncio.TimeoutError:
219
+ self._pending_questions.pop(request_id, None)
220
+ if pending.message_id is not None:
221
+ await self._edit_message_text(
222
+ pending.chat_id,
223
+ pending.message_id,
224
+ "Question timed out.",
225
+ reply_markup={"inline_keyboard": []},
226
+ )
227
+ return None
228
+ if not result:
229
+ return None
230
+ return result.strip() or None
231
+
232
+ async def _seed_issue_from_ref(
233
+ self, repo_root: Path, issue_ref: str
234
+ ) -> tuple[int, str]:
235
+ seed = seed_issue_from_github(
236
+ repo_root, issue_ref, github_service_factory=GitHubService
237
+ )
238
+ atomic_write(issue_md_path(repo_root), seed.content)
239
+ return seed.issue_number, seed.repo_slug
240
+
241
+ def _seed_issue_from_plan(self, repo_root: Path, plan_text: str) -> None:
242
+ content = seed_issue_from_text(plan_text)
243
+ atomic_write(issue_md_path(repo_root), content)
244
+
245
+ async def _handle_flow_status(self, message: TelegramMessage, args: str) -> None:
246
+ text = args.strip()
247
+ if text:
248
+ await self._handle_flow(message, f"status {text}")
249
+ else:
250
+ await self._handle_flow(message, "status")
251
+
252
+ async def _handle_flow(self, message: TelegramMessage, args: str) -> None:
253
+ argv = self._parse_command_args(args)
254
+ action_raw = argv[0] if argv else ""
255
+ action = _normalize_flow_action(action_raw)
256
+ _, remainder = _split_flow_action(args)
257
+ rest_argv = argv[1:]
258
+
259
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
260
+ record = await self._store.get_topic(key)
261
+
262
+ if action == "help":
263
+ await self._send_flow_overview(message, record)
264
+ return
265
+
266
+ if not record or not record.workspace_path:
267
+ await self._send_message(
268
+ message.chat_id,
269
+ "No workspace bound. Use /bind to bind this topic to a repo first.",
270
+ thread_id=message.thread_id,
271
+ reply_to=message.message_id,
272
+ )
273
+ return
274
+
275
+ repo_root = canonicalize_path(Path(record.workspace_path))
276
+
277
+ if action == "status":
278
+ await self._handle_flow_status_action(message, repo_root, rest_argv)
279
+ return
280
+ if action == "runs":
281
+ await self._handle_flow_runs(message, repo_root, rest_argv)
282
+ return
283
+ if action == "bootstrap":
284
+ await self._handle_flow_bootstrap(message, repo_root, rest_argv)
285
+ return
286
+ if action == "issue":
287
+ await self._handle_flow_issue(message, repo_root, remainder)
288
+ return
289
+ if action == "plan":
290
+ await self._handle_flow_plan(message, repo_root, remainder)
291
+ return
292
+ if action == "resume":
293
+ await self._handle_flow_resume(message, repo_root, rest_argv)
294
+ return
295
+ if action == "stop":
296
+ await self._handle_flow_stop(message, repo_root, rest_argv)
297
+ return
298
+ if action == "recover":
299
+ await self._handle_flow_recover(message, repo_root, rest_argv)
300
+ return
301
+ if action == "restart":
302
+ await self._handle_flow_restart(message, repo_root, rest_argv)
303
+ return
304
+ if action == "archive":
305
+ await self._handle_flow_archive(message, repo_root, rest_argv)
306
+ return
307
+ if action == "reply":
308
+ await self._handle_reply(message, remainder)
309
+ return
310
+
311
+ await self._send_message(
312
+ message.chat_id,
313
+ f"Unknown /flow command: {action_raw or action}. Use /flow help.",
314
+ thread_id=message.thread_id,
315
+ reply_to=message.message_id,
316
+ )
317
+ await self._send_flow_help_block(message)
318
+ return
319
+
320
+ async def _render_flow_status_callback(
321
+ self,
322
+ callback: TelegramCallbackQuery,
323
+ repo_root: Path,
324
+ run_id_raw: Optional[str],
325
+ ) -> None:
326
+ store = FlowStore(_flow_paths(repo_root)[0])
327
+ try:
328
+ store.initialize()
329
+ record, error = self._resolve_status_record(store, run_id_raw)
330
+ if error:
331
+ await self._edit_callback_message(
332
+ callback, error, reply_markup={"inline_keyboard": []}
333
+ )
334
+ return
335
+ text, keyboard = self._build_flow_status_card(repo_root, record, store)
336
+ finally:
337
+ store.close()
338
+ await self._edit_callback_message(callback, text, reply_markup=keyboard)
339
+
340
+ async def _handle_flow_callback(
341
+ self, callback: TelegramCallbackQuery, parsed: FlowCallback
342
+ ) -> None:
343
+ if callback.chat_id is None:
344
+ return
345
+ key = await self._resolve_topic_key(callback.chat_id, callback.thread_id)
346
+ record = await self._store.get_topic(key)
347
+ if not record or not record.workspace_path:
348
+ await self._answer_callback(callback, "No workspace bound")
349
+ await self._edit_callback_message(
350
+ callback,
351
+ "No workspace bound. Use /bind to bind this topic to a repo first.",
352
+ reply_markup={"inline_keyboard": []},
353
+ )
354
+ return
355
+
356
+ repo_root = canonicalize_path(Path(record.workspace_path))
357
+ action = (parsed.action or "").strip().lower()
358
+ run_id_raw = parsed.run_id
359
+
360
+ if action in {"refresh", "status"}:
361
+ await self._answer_callback(callback, "Refreshing...")
362
+ await self._render_flow_status_callback(callback, repo_root, run_id_raw)
363
+ return
364
+
365
+ error = None
366
+ notice = None
367
+ if action == "resume":
368
+ store = FlowStore(_flow_paths(repo_root)[0])
369
+ try:
370
+ store.initialize()
371
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
372
+ record = store.get_flow_run(run_id) if run_id else None
373
+ if run_id_raw and error:
374
+ record = None
375
+ if error is None and record is None:
376
+ record = _select_latest_run(
377
+ store, lambda run: run.status == FlowRunStatus.PAUSED
378
+ )
379
+ if error is None and record is None:
380
+ error = "No paused ticket flow run found."
381
+ if error is None and record.status != FlowRunStatus.PAUSED:
382
+ error = f"Run {record.id} is {record.status.value}, not paused."
383
+ finally:
384
+ store.close()
385
+ if error is None:
386
+ controller = _get_ticket_controller(repo_root)
387
+ updated = await controller.resume_flow(record.id)
388
+ _spawn_flow_worker(repo_root, updated.id)
389
+ notice = "Resumed."
390
+ elif action == "stop":
391
+ store = FlowStore(_flow_paths(repo_root)[0])
392
+ try:
393
+ store.initialize()
394
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
395
+ record = store.get_flow_run(run_id) if run_id else None
396
+ if run_id_raw and error:
397
+ record = None
398
+ if error is None and record is None:
399
+ record = _select_latest_run(
400
+ store, lambda run: run.status.is_active()
401
+ )
402
+ if error is None and record is None:
403
+ error = "No active ticket flow run found."
404
+ if error is None and record.status.is_terminal():
405
+ error = f"Run {record.id} is already {record.status.value}."
406
+ finally:
407
+ store.close()
408
+ if error is None:
409
+ controller = _get_ticket_controller(repo_root)
410
+ self._stop_flow_worker(repo_root, record.id)
411
+ await controller.stop_flow(record.id)
412
+ notice = "Stopped."
413
+ elif action == "recover":
414
+ store = FlowStore(_flow_paths(repo_root)[0])
415
+ try:
416
+ store.initialize()
417
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
418
+ record = store.get_flow_run(run_id) if run_id else None
419
+ if run_id_raw and error:
420
+ record = None
421
+ if error is None and record is None:
422
+ record = _select_latest_run(
423
+ store, lambda run: run.status.is_active()
424
+ )
425
+ if error is None and record is None:
426
+ error = "No active ticket flow run found."
427
+ if error is None:
428
+ record, updated, locked = reconcile_flow_run(
429
+ repo_root, record, store
430
+ )
431
+ if locked:
432
+ error = f"Run {record.id} is locked for reconcile; try again."
433
+ else:
434
+ notice = "Recovered." if updated else "No changes needed."
435
+ finally:
436
+ store.close()
437
+ elif action == "archive":
438
+ store = FlowStore(_flow_paths(repo_root)[0])
439
+ record = None
440
+ try:
441
+ store.initialize()
442
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
443
+ record = store.get_flow_run(run_id) if run_id else None
444
+ if run_id_raw and error:
445
+ record = None
446
+ if error is None and record is None:
447
+ record = _select_latest_run(
448
+ store,
449
+ lambda run: run.status.is_terminal()
450
+ or run.status == FlowRunStatus.PAUSED,
451
+ )
452
+ if error is None and record is None:
453
+ error = "No paused or terminal ticket flow run found."
454
+ if error is None and not record.status.is_terminal():
455
+ if record.status in (FlowRunStatus.STOPPING, FlowRunStatus.PAUSED):
456
+ self._stop_flow_worker(repo_root, record.id)
457
+ else:
458
+ error = "Can only archive completed/stopped/failed runs (use --force for stuck flows)."
459
+ finally:
460
+ store.close()
461
+
462
+ if error is None:
463
+ _, artifacts_root = _flow_paths(repo_root)
464
+ archive_dir = artifacts_root / record.id / "archived_tickets"
465
+ archive_dir.mkdir(parents=True, exist_ok=True)
466
+ ticket_dir = _ticket_dir(repo_root)
467
+ for ticket_path in list_ticket_paths(ticket_dir):
468
+ dest = archive_dir / ticket_path.name
469
+ shutil.move(str(ticket_path), str(dest))
470
+
471
+ runs_dir = Path(
472
+ record.input_data.get("runs_dir") or ".codex-autorunner/runs"
473
+ )
474
+ outbox_paths = resolve_outbox_paths(
475
+ workspace_root=repo_root, runs_dir=runs_dir, run_id=record.id
476
+ )
477
+ run_dir = outbox_paths.run_dir
478
+ if run_dir.exists() and run_dir.is_dir():
479
+ archived_runs_dir = artifacts_root / record.id / "archived_runs"
480
+ shutil.move(str(run_dir), str(archived_runs_dir))
481
+
482
+ store = FlowStore(_flow_paths(repo_root)[0])
483
+ try:
484
+ store.initialize()
485
+ store.delete_flow_run(record.id)
486
+ finally:
487
+ store.close()
488
+ notice = "Archived."
489
+ elif action == "restart":
490
+ message = TelegramMessage(
491
+ update_id=callback.update_id,
492
+ message_id=callback.message_id or 0,
493
+ chat_id=callback.chat_id,
494
+ thread_id=callback.thread_id,
495
+ from_user_id=callback.from_user_id,
496
+ text=None,
497
+ date=None,
498
+ is_topic_message=callback.thread_id is not None,
499
+ )
500
+ argv = [run_id_raw] if run_id_raw else []
501
+ await self._handle_flow_restart(message, repo_root, argv)
502
+ notice = "Restarted."
503
+ else:
504
+ await self._answer_callback(callback, "Unknown action")
505
+ return
506
+
507
+ if error:
508
+ await self._answer_callback(callback, error)
509
+ elif notice:
510
+ await self._answer_callback(callback, notice)
511
+ await self._render_flow_status_callback(callback, repo_root, run_id_raw)
512
+
513
+ def _resolve_run_id_input(
514
+ self, store: FlowStore, raw_run_id: Optional[str]
515
+ ) -> tuple[Optional[str], Optional[str]]:
516
+ if not raw_run_id:
517
+ return None, None
518
+ normalized = _normalize_run_id(raw_run_id)
519
+ if normalized:
520
+ return normalized, None
521
+ matches = [
522
+ record.id
523
+ for record in store.list_flow_runs(flow_type="ticket_flow")
524
+ if record.id.startswith(raw_run_id)
525
+ ]
526
+ if len(matches) == 1:
527
+ return matches[0], None
528
+ if len(matches) > 1:
529
+ return None, "Run ID prefix is ambiguous. Use the full run_id."
530
+ return None, "Invalid run_id."
531
+
532
+ def _first_non_flag(self, argv: list[str]) -> Optional[str]:
533
+ for part in argv:
534
+ if not part.startswith("--"):
535
+ return part
536
+ return None
537
+
538
+ def _has_flag(self, argv: list[str], name: str) -> bool:
539
+ prefix = f"{name}="
540
+ return any(part == name or part.startswith(prefix) for part in argv)
541
+
542
+ def _resolve_status_record(
543
+ self, store: FlowStore, run_id_raw: Optional[str]
544
+ ) -> tuple[Optional[object], Optional[str]]:
545
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
546
+ if run_id_raw and error:
547
+ return None, error
548
+ record = store.get_flow_run(run_id) if run_id else None
549
+ if record is None:
550
+ runs = store.list_flow_runs(flow_type="ticket_flow")
551
+ record = runs[0] if runs else None
552
+ if record is None:
553
+ return None, "No ticket flow run found. Use /flow bootstrap to start."
554
+ return record, None
555
+
556
+ def _format_flow_status_lines(
557
+ self,
558
+ repo_root: Path,
559
+ record: Optional[object],
560
+ store: Optional[FlowStore],
561
+ *,
562
+ health: Optional[FlowWorkerHealth] = None,
563
+ snapshot: Optional[dict] = None,
564
+ ) -> list[str]:
565
+ if record is None:
566
+ return ["Run: none"]
567
+ if snapshot is None:
568
+ snapshot = build_flow_status_snapshot(repo_root, record, store)
569
+ run = record
570
+ status = getattr(run, "status", None)
571
+ status_value = status.value if status else "unknown"
572
+ lines = [f"Run: {run.id}", f"Status: {status_value}"]
573
+ flow_type = getattr(run, "flow_type", None)
574
+ if flow_type:
575
+ lines.append(f"Flow: {flow_type}")
576
+ created_at = getattr(run, "created_at", None)
577
+ if created_at:
578
+ lines.append(f"Created: {created_at}")
579
+ started_at = getattr(run, "started_at", None)
580
+ if started_at:
581
+ lines.append(f"Started: {started_at}")
582
+ finished_at = getattr(run, "finished_at", None)
583
+ if finished_at:
584
+ lines.append(f"Finished: {finished_at}")
585
+ current_step = getattr(run, "current_step", None)
586
+ if current_step:
587
+ lines.append(f"Step: {current_step}")
588
+ state = run.state or {}
589
+ engine = state.get("ticket_engine") if isinstance(state, dict) else None
590
+ engine = engine if isinstance(engine, dict) else {}
591
+ current = snapshot.get("effective_current_ticket") if snapshot else None
592
+ if isinstance(current, str) and current.strip():
593
+ lines.append(f"Current: {current.strip()}")
594
+ reason_summary = None
595
+ if isinstance(state, dict):
596
+ value = state.get("reason_summary")
597
+ if isinstance(value, str) and value.strip():
598
+ reason_summary = value.strip()
599
+ if reason_summary:
600
+ lines.append(f"Summary: {_truncate_text(reason_summary, 300)}")
601
+ reason = engine.get("reason") if isinstance(engine, dict) else None
602
+ if isinstance(reason, str) and reason.strip():
603
+ if reason_summary and reason.strip() == reason_summary:
604
+ pass
605
+ else:
606
+ lines.append(f"Reason: {_truncate_text(reason.strip(), 300)}")
607
+ error_message = getattr(run, "error_message", None)
608
+ if isinstance(error_message, str) and error_message.strip():
609
+ lines.append(f"Error: {_truncate_text(error_message.strip(), 300)}")
610
+ if snapshot:
611
+ last_seq = snapshot.get("last_event_seq")
612
+ last_at = snapshot.get("last_event_at")
613
+ if last_seq or last_at:
614
+ seq_label = str(last_seq) if last_seq is not None else "?"
615
+ at_label = last_at or "unknown time"
616
+ lines.append(f"Last event: {seq_label} @ {at_label}")
617
+ if health is None:
618
+ health = snapshot.get("worker_health") if snapshot else None
619
+ if health is None:
620
+ return lines
621
+ worker_line = f"Worker: {health.status}"
622
+ if health.pid:
623
+ worker_line += f" (pid {health.pid})"
624
+ if health.message and health.status not in {"alive"}:
625
+ worker_line += f" - {health.message}"
626
+ lines.append(worker_line)
627
+ if status == FlowRunStatus.PAUSED:
628
+ lines.append("Paused: use /flow reply <message>, then /flow resume.")
629
+ return lines
630
+
631
+ def _build_flow_status_keyboard(
632
+ self, record: Optional[object], *, health: Optional[FlowWorkerHealth]
633
+ ) -> Optional[dict[str, object]]:
634
+ if record is None or health is None:
635
+ return None
636
+ status = getattr(record, "status", None)
637
+ if status is None:
638
+ return None
639
+ run_id = record.id
640
+ rows: list[list[InlineButton]] = []
641
+ if status == FlowRunStatus.PAUSED:
642
+ rows.append(
643
+ [
644
+ InlineButton("Resume", encode_flow_callback("resume", run_id)),
645
+ InlineButton("Restart", encode_flow_callback("restart", run_id)),
646
+ ]
647
+ )
648
+ rows.append(
649
+ [InlineButton("Archive", encode_flow_callback("archive", run_id))]
650
+ )
651
+ elif status.is_terminal():
652
+ rows.append(
653
+ [
654
+ InlineButton("Restart", encode_flow_callback("restart", run_id)),
655
+ InlineButton("Archive", encode_flow_callback("archive", run_id)),
656
+ ]
657
+ )
658
+ rows.append(
659
+ [InlineButton("Refresh", encode_flow_callback("refresh", run_id))]
660
+ )
661
+ else:
662
+ if health.status in {"dead", "mismatch", "invalid", "absent"}:
663
+ rows.append(
664
+ [
665
+ InlineButton(
666
+ "Recover", encode_flow_callback("recover", run_id)
667
+ ),
668
+ InlineButton(
669
+ "Refresh", encode_flow_callback("refresh", run_id)
670
+ ),
671
+ ]
672
+ )
673
+ elif status == FlowRunStatus.RUNNING:
674
+ rows.append(
675
+ [
676
+ InlineButton("Stop", encode_flow_callback("stop", run_id)),
677
+ InlineButton(
678
+ "Refresh", encode_flow_callback("refresh", run_id)
679
+ ),
680
+ ]
681
+ )
682
+ else:
683
+ rows.append(
684
+ [InlineButton("Refresh", encode_flow_callback("refresh", run_id))]
685
+ )
686
+ return build_inline_keyboard(rows) if rows else None
687
+
688
+ def _build_flow_status_card(
689
+ self, repo_root: Path, record: Optional[object], store: Optional[FlowStore]
690
+ ) -> tuple[str, Optional[dict[str, object]]]:
691
+ if record is None:
692
+ return (
693
+ "\n".join(self._format_flow_status_lines(repo_root, record, store)),
694
+ None,
695
+ )
696
+ snapshot = build_flow_status_snapshot(repo_root, record, store)
697
+ health = snapshot.get("worker_health")
698
+ lines = self._format_flow_status_lines(
699
+ repo_root, record, store, health=health, snapshot=snapshot
700
+ )
701
+ keyboard = self._build_flow_status_keyboard(record, health=health)
702
+ return "\n".join(lines), keyboard
703
+
704
+ async def _send_flow_help_block(self, message: TelegramMessage) -> None:
705
+ await self._send_message(
706
+ message.chat_id,
707
+ "\n".join(_flow_help_lines()),
708
+ thread_id=message.thread_id,
709
+ reply_to=message.message_id,
710
+ )
711
+
712
+ async def _send_flow_overview(
713
+ self, message: TelegramMessage, record: Optional[object]
714
+ ) -> None:
715
+ repo_root = (
716
+ canonicalize_path(Path(record.workspace_path))
717
+ if record and record.workspace_path
718
+ else None
719
+ )
720
+ lines = [
721
+ f"Workspace: {repo_root}" if repo_root else "Workspace: unbound",
722
+ ]
723
+ if repo_root:
724
+ store = FlowStore(_flow_paths(repo_root)[0])
725
+ try:
726
+ store.initialize()
727
+ runs = store.list_flow_runs(flow_type="ticket_flow")
728
+ latest = runs[0] if runs else None
729
+ lines.extend(self._format_flow_status_lines(repo_root, latest, store))
730
+ finally:
731
+ store.close()
732
+ else:
733
+ lines.append("Run: none")
734
+ lines.append("Use /bind <repo_id> or /bind <path>.")
735
+ lines.append("")
736
+ lines.extend(_flow_help_lines())
737
+ await self._send_message(
738
+ message.chat_id,
739
+ "\n".join(lines),
740
+ thread_id=message.thread_id,
741
+ reply_to=message.message_id,
742
+ )
743
+
744
+ async def _handle_flow_status_action(
745
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
746
+ ) -> None:
747
+ store = FlowStore(_flow_paths(repo_root)[0])
748
+ try:
749
+ store.initialize()
750
+ run_id_raw = self._first_non_flag(argv)
751
+ record, error = self._resolve_status_record(store, run_id_raw)
752
+ if error:
753
+ await self._send_message(
754
+ message.chat_id,
755
+ error,
756
+ thread_id=message.thread_id,
757
+ reply_to=message.message_id,
758
+ )
759
+ return
760
+ text, keyboard = self._build_flow_status_card(repo_root, record, store)
761
+ finally:
762
+ store.close()
763
+ await self._send_message(
764
+ message.chat_id,
765
+ text,
766
+ thread_id=message.thread_id,
767
+ reply_to=message.message_id,
768
+ reply_markup=keyboard,
769
+ )
770
+
771
+ async def _handle_flow_runs(
772
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
773
+ ) -> None:
774
+ limit = 5
775
+ limit_raw = self._first_non_flag(argv)
776
+ if limit_raw:
777
+ limit_value = self._coerce_int(limit_raw)
778
+ if limit_value is None or limit_value <= 0:
779
+ await self._send_message(
780
+ message.chat_id,
781
+ "Provide a positive integer for /flow runs [N].",
782
+ thread_id=message.thread_id,
783
+ reply_to=message.message_id,
784
+ )
785
+ return
786
+ limit = min(limit_value, 50)
787
+
788
+ store = FlowStore(_flow_paths(repo_root)[0])
789
+ try:
790
+ store.initialize()
791
+ runs = store.list_flow_runs(flow_type="ticket_flow")
792
+ finally:
793
+ store.close()
794
+
795
+ if not runs:
796
+ await self._send_message(
797
+ message.chat_id,
798
+ "No ticket flow runs found. Use /flow bootstrap to start.",
799
+ thread_id=message.thread_id,
800
+ reply_to=message.message_id,
801
+ )
802
+ return
803
+
804
+ items: list[tuple[str, str]] = []
805
+ button_labels: dict[str, str] = {}
806
+ for run in runs[:limit]:
807
+ created_at = getattr(run, "created_at", None) or "unknown"
808
+ status = getattr(run, "status", None)
809
+ status_label = status.value if status is not None else "unknown"
810
+ items.append((run.id, f"{status_label} • {created_at}"))
811
+ short_id = run.id.split("-")[0]
812
+ button_label = f"{short_id} {status_label}"
813
+ button_labels[run.id] = _truncate_text(button_label, 32)
814
+
815
+ state = SelectionState(items=items, button_labels=button_labels)
816
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
817
+ self._flow_run_options[key] = state
818
+ self._touch_cache_timestamp("flow_run_options", key)
819
+ prompt = self._flow_runs_prompt(state)
820
+ keyboard = self._build_flow_runs_keyboard(state)
821
+ await self._send_message(
822
+ message.chat_id,
823
+ prompt,
824
+ thread_id=message.thread_id,
825
+ reply_to=message.message_id,
826
+ reply_markup=keyboard,
827
+ )
828
+
829
+ async def _handle_flow_bootstrap(
830
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
831
+ ) -> None:
832
+ force_new = self._has_flag(argv, "--force-new") or self._has_flag(
833
+ argv, "--force"
834
+ )
835
+ ticket_dir = _ticket_dir(repo_root)
836
+ ticket_dir.mkdir(parents=True, exist_ok=True)
837
+ existing_tickets = list_ticket_paths(ticket_dir)
838
+ tickets_exist = bool(existing_tickets)
839
+ issue_exists = issue_md_has_content(repo_root)
840
+
841
+ store = FlowStore(_flow_paths(repo_root)[0])
842
+ active_run = None
843
+ try:
844
+ store.initialize()
845
+ runs = store.list_flow_runs(flow_type="ticket_flow")
846
+ for record in runs:
847
+ if record.status in (FlowRunStatus.RUNNING, FlowRunStatus.PAUSED):
848
+ active_run = record
849
+ break
850
+ finally:
851
+ store.close()
852
+
853
+ if not force_new and active_run:
854
+ _spawn_flow_worker(repo_root, active_run.id)
855
+ await self._send_message(
856
+ message.chat_id,
857
+ f"Reusing ticket flow run {active_run.id} ({active_run.status.value}).",
858
+ thread_id=message.thread_id,
859
+ reply_to=message.message_id,
860
+ )
861
+ return
862
+
863
+ if not tickets_exist and not issue_exists:
864
+ gh_available, repo_slug = self._github_bootstrap_status(repo_root)
865
+ if gh_available:
866
+ repo_label = f" for {repo_slug}" if repo_slug else ""
867
+ prompt = (
868
+ "Enter GitHub issue number or URL" f"{repo_label} to seed ISSUE.md:"
869
+ )
870
+ issue_ref = await self._prompt_flow_text_input(message, prompt)
871
+ if not issue_ref:
872
+ await self._send_message(
873
+ message.chat_id,
874
+ "Bootstrap cancelled (no issue provided).",
875
+ thread_id=message.thread_id,
876
+ reply_to=message.message_id,
877
+ )
878
+ return
879
+ try:
880
+ number, _repo = await self._seed_issue_from_ref(
881
+ repo_root, issue_ref
882
+ )
883
+ except GitHubError as exc:
884
+ await self._send_message(
885
+ message.chat_id,
886
+ f"GitHub error: {exc}",
887
+ thread_id=message.thread_id,
888
+ reply_to=message.message_id,
889
+ )
890
+ return
891
+ except Exception as exc:
892
+ await self._send_message(
893
+ message.chat_id,
894
+ f"Failed to fetch issue: {exc}",
895
+ thread_id=message.thread_id,
896
+ reply_to=message.message_id,
897
+ )
898
+ return
899
+ await self._send_message(
900
+ message.chat_id,
901
+ f"Seeded ISSUE.md from GitHub issue {number}.",
902
+ thread_id=message.thread_id,
903
+ reply_to=message.message_id,
904
+ )
905
+ issue_exists = True
906
+ else:
907
+ prompt = "Describe the work to seed ISSUE.md:"
908
+ plan_text = await self._prompt_flow_text_input(message, prompt)
909
+ if not plan_text:
910
+ await self._send_message(
911
+ message.chat_id,
912
+ "Bootstrap cancelled (no description provided).",
913
+ thread_id=message.thread_id,
914
+ reply_to=message.message_id,
915
+ )
916
+ return
917
+ self._seed_issue_from_plan(repo_root, plan_text)
918
+ await self._send_message(
919
+ message.chat_id,
920
+ "Seeded ISSUE.md from your plan.",
921
+ thread_id=message.thread_id,
922
+ reply_to=message.message_id,
923
+ )
924
+ issue_exists = True
925
+
926
+ seeded = False
927
+ if not tickets_exist:
928
+ first_ticket = ticket_dir / "TICKET-001.md"
929
+ if not first_ticket.exists():
930
+ template = """---
931
+ agent: codex
932
+ done: false
933
+ title: Bootstrap ticket plan
934
+ goal: Capture scope and seed follow-up tickets
935
+ ---
936
+
937
+ You are the first ticket in a new ticket_flow run.
938
+
939
+ - Read `.codex-autorunner/ISSUE.md`. If it is missing:
940
+ - If GitHub is available, ask the user for the issue/PR URL or number and create `.codex-autorunner/ISSUE.md` from it.
941
+ - If GitHub is not available, write `DISPATCH.md` with `mode: pause` asking the user to describe the work (or share a doc). After the reply, create `.codex-autorunner/ISSUE.md` with their input.
942
+ - If helpful, create or update workspace docs under `.codex-autorunner/workspace/`:
943
+ - `active_context.md` for current context and links
944
+ - `decisions.md` for decisions/rationale
945
+ - `spec.md` for requirements and constraints
946
+ - Break the work into additional `TICKET-00X.md` files with clear owners/goals; keep this ticket open until they exist.
947
+ - Place any supporting artifacts in `.codex-autorunner/runs/<run_id>/dispatch/` if needed.
948
+ - Write `DISPATCH.md` to dispatch a message to the user:
949
+ - Use `mode: pause` (handoff) to wait for user response. This pauses execution.
950
+ - Use `mode: notify` (informational) to message the user but keep running.
951
+ """
952
+ first_ticket.write_text(template, encoding="utf-8")
953
+ seeded = True
954
+
955
+ controller = _get_ticket_controller(repo_root)
956
+ flow_record = await controller.start_flow(
957
+ input_data={},
958
+ metadata={"seeded_ticket": seeded, "origin": "telegram"},
959
+ )
960
+ _spawn_flow_worker(repo_root, flow_record.id)
961
+
962
+ if not issue_exists and not tickets_exist:
963
+ await self._send_flow_issue_hint(message, repo_root)
964
+
965
+ await self._send_message(
966
+ message.chat_id,
967
+ f"Started ticket flow run {flow_record.id}.",
968
+ thread_id=message.thread_id,
969
+ reply_to=message.message_id,
970
+ )
971
+
972
+ async def _send_flow_issue_hint(
973
+ self, message: TelegramMessage, repo_root: Path
974
+ ) -> None:
975
+ gh_status = (
976
+ "No ISSUE.md found. Use /flow plan <text> to seed it from a short plan."
977
+ )
978
+ gh_available, repo_slug = self._github_bootstrap_status(repo_root)
979
+ if gh_available:
980
+ repo_label = repo_slug or "your repo"
981
+ gh_status = (
982
+ f"No ISSUE.md found. Use /flow issue <issue#|url> for {repo_label}, "
983
+ "or /flow plan <text>."
984
+ )
985
+ await self._send_message(
986
+ message.chat_id,
987
+ gh_status,
988
+ thread_id=message.thread_id,
989
+ reply_to=message.message_id,
990
+ )
991
+
992
+ async def _handle_flow_issue(
993
+ self, message: TelegramMessage, repo_root: Path, issue_ref: str
994
+ ) -> None:
995
+ issue_ref = issue_ref.strip()
996
+ if not issue_ref:
997
+ await self._send_message(
998
+ message.chat_id,
999
+ "Provide an issue reference: /flow issue <issue#|url>",
1000
+ thread_id=message.thread_id,
1001
+ reply_to=message.message_id,
1002
+ )
1003
+ return
1004
+ try:
1005
+ number, _repo = await self._seed_issue_from_ref(repo_root, issue_ref)
1006
+ except GitHubError as exc:
1007
+ await self._send_message(
1008
+ message.chat_id,
1009
+ f"GitHub error: {exc}",
1010
+ thread_id=message.thread_id,
1011
+ reply_to=message.message_id,
1012
+ )
1013
+ return
1014
+ except RuntimeError as exc:
1015
+ await self._send_message(
1016
+ message.chat_id,
1017
+ str(exc),
1018
+ thread_id=message.thread_id,
1019
+ reply_to=message.message_id,
1020
+ )
1021
+ return
1022
+ except Exception as exc:
1023
+ await self._send_message(
1024
+ message.chat_id,
1025
+ f"Failed to fetch issue: {exc}",
1026
+ thread_id=message.thread_id,
1027
+ reply_to=message.message_id,
1028
+ )
1029
+ return
1030
+ await self._send_message(
1031
+ message.chat_id,
1032
+ f"Seeded ISSUE.md from GitHub issue {number}.",
1033
+ thread_id=message.thread_id,
1034
+ reply_to=message.message_id,
1035
+ )
1036
+
1037
+ async def _handle_flow_plan(
1038
+ self, message: TelegramMessage, repo_root: Path, plan_text: str
1039
+ ) -> None:
1040
+ plan_text = plan_text.strip()
1041
+ if not plan_text:
1042
+ await self._send_message(
1043
+ message.chat_id,
1044
+ "Provide a plan: /flow plan <text>",
1045
+ thread_id=message.thread_id,
1046
+ reply_to=message.message_id,
1047
+ )
1048
+ return
1049
+ self._seed_issue_from_plan(repo_root, plan_text)
1050
+ await self._send_message(
1051
+ message.chat_id,
1052
+ "Seeded ISSUE.md from your plan.",
1053
+ thread_id=message.thread_id,
1054
+ reply_to=message.message_id,
1055
+ )
1056
+
1057
+ async def _handle_flow_resume(
1058
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1059
+ ) -> None:
1060
+ store = FlowStore(_flow_paths(repo_root)[0])
1061
+ try:
1062
+ store.initialize()
1063
+ run_id_raw = self._first_non_flag(argv)
1064
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1065
+ record = store.get_flow_run(run_id) if run_id else None
1066
+ if run_id_raw and error:
1067
+ await self._send_message(
1068
+ message.chat_id,
1069
+ error,
1070
+ thread_id=message.thread_id,
1071
+ reply_to=message.message_id,
1072
+ )
1073
+ return
1074
+ if record is None:
1075
+ record = _select_latest_run(
1076
+ store, lambda run: run.status == FlowRunStatus.PAUSED
1077
+ )
1078
+ if record is None:
1079
+ await self._send_message(
1080
+ message.chat_id,
1081
+ "No paused ticket flow run found.",
1082
+ thread_id=message.thread_id,
1083
+ reply_to=message.message_id,
1084
+ )
1085
+ return
1086
+ if record.status != FlowRunStatus.PAUSED:
1087
+ await self._send_message(
1088
+ message.chat_id,
1089
+ f"Run {record.id} is {record.status.value}, not paused.",
1090
+ thread_id=message.thread_id,
1091
+ reply_to=message.message_id,
1092
+ )
1093
+ return
1094
+ finally:
1095
+ store.close()
1096
+
1097
+ controller = _get_ticket_controller(repo_root)
1098
+ updated = await controller.resume_flow(record.id)
1099
+ _spawn_flow_worker(repo_root, updated.id)
1100
+ await self._send_message(
1101
+ message.chat_id,
1102
+ f"Resumed run {updated.id}.",
1103
+ thread_id=message.thread_id,
1104
+ reply_to=message.message_id,
1105
+ )
1106
+
1107
+ def _stop_flow_worker(self, repo_root: Path, run_id: str) -> None:
1108
+ health = check_worker_health(repo_root, run_id)
1109
+ if health.is_alive and health.pid:
1110
+ try:
1111
+ subprocess.run(["kill", str(health.pid)], check=False)
1112
+ except Exception as exc:
1113
+ _logger.warning("Failed to stop worker %s: %s", run_id, exc)
1114
+ if health.status in {"dead", "mismatch", "invalid"}:
1115
+ clear_worker_metadata(health.artifact_path.parent)
1116
+
1117
+ async def _handle_flow_stop(
1118
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1119
+ ) -> None:
1120
+ store = FlowStore(_flow_paths(repo_root)[0])
1121
+ try:
1122
+ store.initialize()
1123
+ run_id_raw = self._first_non_flag(argv)
1124
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1125
+ record = store.get_flow_run(run_id) if run_id else None
1126
+ if run_id_raw and error:
1127
+ await self._send_message(
1128
+ message.chat_id,
1129
+ error,
1130
+ thread_id=message.thread_id,
1131
+ reply_to=message.message_id,
1132
+ )
1133
+ return
1134
+ if record is None:
1135
+ record = _select_latest_run(store, lambda run: run.status.is_active())
1136
+ if record is None:
1137
+ await self._send_message(
1138
+ message.chat_id,
1139
+ "No active ticket flow run found.",
1140
+ thread_id=message.thread_id,
1141
+ reply_to=message.message_id,
1142
+ )
1143
+ return
1144
+ if record.status.is_terminal():
1145
+ await self._send_message(
1146
+ message.chat_id,
1147
+ f"Run {record.id} is already {record.status.value}.",
1148
+ thread_id=message.thread_id,
1149
+ reply_to=message.message_id,
1150
+ )
1151
+ return
1152
+ finally:
1153
+ store.close()
1154
+
1155
+ controller = _get_ticket_controller(repo_root)
1156
+ self._stop_flow_worker(repo_root, record.id)
1157
+ updated = await controller.stop_flow(record.id)
1158
+ await self._send_message(
1159
+ message.chat_id,
1160
+ f"Stopped run {updated.id} ({updated.status.value}).",
1161
+ thread_id=message.thread_id,
1162
+ reply_to=message.message_id,
1163
+ )
1164
+
1165
+ async def _handle_flow_recover(
1166
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1167
+ ) -> None:
1168
+ store = FlowStore(_flow_paths(repo_root)[0])
1169
+ try:
1170
+ store.initialize()
1171
+ run_id_raw = self._first_non_flag(argv)
1172
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1173
+ record = store.get_flow_run(run_id) if run_id else None
1174
+ if run_id_raw and error:
1175
+ await self._send_message(
1176
+ message.chat_id,
1177
+ error,
1178
+ thread_id=message.thread_id,
1179
+ reply_to=message.message_id,
1180
+ )
1181
+ return
1182
+ if record is None:
1183
+ record = _select_latest_run(store, lambda run: run.status.is_active())
1184
+ if record is None:
1185
+ await self._send_message(
1186
+ message.chat_id,
1187
+ "No active ticket flow run found.",
1188
+ thread_id=message.thread_id,
1189
+ reply_to=message.message_id,
1190
+ )
1191
+ return
1192
+ record, updated, locked = reconcile_flow_run(repo_root, record, store)
1193
+ if locked:
1194
+ await self._send_message(
1195
+ message.chat_id,
1196
+ f"Run {record.id} is locked for reconcile; try again.",
1197
+ thread_id=message.thread_id,
1198
+ reply_to=message.message_id,
1199
+ )
1200
+ return
1201
+ hint = "Recovered" if updated else "No changes needed"
1202
+ lines = [f"{hint} for run {record.id}."]
1203
+ lines.extend(self._format_flow_status_lines(repo_root, record, store))
1204
+ finally:
1205
+ store.close()
1206
+
1207
+ await self._send_message(
1208
+ message.chat_id,
1209
+ "\n".join(lines),
1210
+ thread_id=message.thread_id,
1211
+ reply_to=message.message_id,
1212
+ )
1213
+
1214
+ async def _handle_flow_restart(
1215
+ self,
1216
+ message: TelegramMessage,
1217
+ repo_root: Path,
1218
+ argv: Optional[list[str]] = None,
1219
+ ) -> None:
1220
+ argv = argv or []
1221
+ store = FlowStore(_flow_paths(repo_root)[0])
1222
+ record = None
1223
+ try:
1224
+ store.initialize()
1225
+ run_id_raw = self._first_non_flag(argv)
1226
+ if run_id_raw:
1227
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1228
+ if error is None and run_id:
1229
+ record = store.get_flow_run(run_id)
1230
+ else:
1231
+ record = _select_latest_run(store, lambda run: run.status.is_active())
1232
+ finally:
1233
+ store.close()
1234
+ if record and not record.status.is_terminal():
1235
+ controller = _get_ticket_controller(repo_root)
1236
+ self._stop_flow_worker(repo_root, record.id)
1237
+ await controller.stop_flow(record.id)
1238
+ await self._handle_flow_bootstrap(message, repo_root, argv=["--force-new"])
1239
+
1240
+ async def _handle_flow_archive(
1241
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1242
+ ) -> None:
1243
+ force = self._has_flag(argv, "--force")
1244
+ store = FlowStore(_flow_paths(repo_root)[0])
1245
+ record = None
1246
+ try:
1247
+ store.initialize()
1248
+ run_id_raw = self._first_non_flag(argv)
1249
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1250
+ record = store.get_flow_run(run_id) if run_id else None
1251
+ if run_id_raw and error:
1252
+ await self._send_message(
1253
+ message.chat_id,
1254
+ error,
1255
+ thread_id=message.thread_id,
1256
+ reply_to=message.message_id,
1257
+ )
1258
+ return
1259
+ if record is None:
1260
+ record = _select_latest_run(
1261
+ store,
1262
+ lambda run: run.status.is_terminal()
1263
+ or run.status == FlowRunStatus.PAUSED
1264
+ or (force and run.status == FlowRunStatus.STOPPING),
1265
+ )
1266
+ if record is None:
1267
+ await self._send_message(
1268
+ message.chat_id,
1269
+ "No paused or terminal ticket flow run found.",
1270
+ thread_id=message.thread_id,
1271
+ reply_to=message.message_id,
1272
+ )
1273
+ return
1274
+ if not record.status.is_terminal():
1275
+ if record.status in (FlowRunStatus.STOPPING, FlowRunStatus.PAUSED):
1276
+ self._stop_flow_worker(repo_root, record.id)
1277
+ else:
1278
+ await self._send_message(
1279
+ message.chat_id,
1280
+ "Can only archive completed/stopped/failed runs (use --force for stuck flows).",
1281
+ thread_id=message.thread_id,
1282
+ reply_to=message.message_id,
1283
+ )
1284
+ return
1285
+ finally:
1286
+ store.close()
1287
+
1288
+ _, artifacts_root = _flow_paths(repo_root)
1289
+ archive_dir = artifacts_root / record.id / "archived_tickets"
1290
+ archive_dir.mkdir(parents=True, exist_ok=True)
1291
+ ticket_dir = _ticket_dir(repo_root)
1292
+ archived_count = 0
1293
+ for ticket_path in list_ticket_paths(ticket_dir):
1294
+ dest = archive_dir / ticket_path.name
1295
+ shutil.move(str(ticket_path), str(dest))
1296
+ archived_count += 1
1297
+
1298
+ runs_dir = Path(record.input_data.get("runs_dir") or ".codex-autorunner/runs")
1299
+ outbox_paths = resolve_outbox_paths(
1300
+ workspace_root=repo_root, runs_dir=runs_dir, run_id=record.id
1301
+ )
1302
+ run_dir = outbox_paths.run_dir
1303
+ if run_dir.exists() and run_dir.is_dir():
1304
+ archived_runs_dir = artifacts_root / record.id / "archived_runs"
1305
+ shutil.move(str(run_dir), str(archived_runs_dir))
1306
+
1307
+ store = FlowStore(_flow_paths(repo_root)[0])
1308
+ try:
1309
+ store.initialize()
1310
+ store.delete_flow_run(record.id)
1311
+ finally:
1312
+ store.close()
1313
+
1314
+ await self._send_message(
1315
+ message.chat_id,
1316
+ f"Archived run {record.id} ({archived_count} tickets).",
1317
+ thread_id=message.thread_id,
1318
+ reply_to=message.message_id,
1319
+ )
1320
+
1321
+ async def _handle_reply(self, message: TelegramMessage, args: str) -> None:
1322
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
1323
+ record = await self._store.get_topic(key)
1324
+ if not record or not record.workspace_path:
1325
+ await self._send_message(
1326
+ message.chat_id,
1327
+ "No workspace bound. Use /bind to bind this topic to a repo first.",
1328
+ thread_id=message.thread_id,
1329
+ reply_to=message.message_id,
1330
+ )
1331
+ return
1332
+
1333
+ repo_root = canonicalize_path(Path(record.workspace_path))
1334
+ text = args.strip()
1335
+ if not text:
1336
+ await self._send_message(
1337
+ message.chat_id,
1338
+ "Provide a reply: /flow reply <message> (or /reply <message>).",
1339
+ thread_id=message.thread_id,
1340
+ reply_to=message.message_id,
1341
+ )
1342
+ return
1343
+
1344
+ target_run_id = self._ticket_flow_pause_targets.get(str(repo_root))
1345
+ paused = self._get_paused_ticket_flow(repo_root, preferred_run_id=target_run_id)
1346
+ if not paused:
1347
+ await self._send_message(
1348
+ message.chat_id,
1349
+ "No paused ticket flow run found for this workspace.",
1350
+ thread_id=message.thread_id,
1351
+ reply_to=message.message_id,
1352
+ )
1353
+ return
1354
+
1355
+ run_id, run_record = paused
1356
+ success, result = await self._write_user_reply_from_telegram(
1357
+ repo_root, run_id, run_record, message, text
1358
+ )
1359
+ await self._send_message(
1360
+ message.chat_id,
1361
+ result,
1362
+ thread_id=message.thread_id,
1363
+ reply_to=message.message_id,
1364
+ )