codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,60 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import logging
5
+ import shutil
6
+ import subprocess
7
+ import uuid
4
8
  from pathlib import Path
9
+ from typing import Callable, Optional
5
10
 
6
- from .....core.engine import Engine
11
+ from .....agents.registry import validate_agent_id
12
+ from .....core.config import load_hub_config, load_repo_config
7
13
  from .....core.flows import FlowController, FlowStore
8
14
  from .....core.flows.models import FlowRunStatus
15
+ from .....core.flows.reconciler import reconcile_flow_run
16
+ from .....core.flows.ux_helpers import (
17
+ bootstrap_check,
18
+ build_flow_status_snapshot,
19
+ ensure_worker,
20
+ issue_md_has_content,
21
+ issue_md_path,
22
+ seed_issue_from_github,
23
+ seed_issue_from_text,
24
+ ticket_progress,
25
+ )
9
26
  from .....core.flows.worker_process import (
27
+ FlowWorkerHealth,
10
28
  check_worker_health,
11
- spawn_flow_worker,
29
+ clear_worker_metadata,
12
30
  )
13
- from .....core.utils import canonicalize_path
31
+ from .....core.logging_utils import log_event
32
+ from .....core.runtime import RuntimeContext
33
+ from .....core.state import now_iso
34
+ from .....core.utils import atomic_write, canonicalize_path
14
35
  from .....flows.ticket_flow import build_ticket_flow_definition
36
+ from .....integrations.agents import build_backend_orchestrator
37
+ from .....integrations.agents.wiring import (
38
+ build_agent_backend_factory,
39
+ build_app_server_supervisor_factory,
40
+ )
41
+ from .....manifest import load_manifest
15
42
  from .....tickets import AgentPool
16
- from ...adapter import TelegramMessage
43
+ from .....tickets.files import list_ticket_paths
44
+ from .....tickets.outbox import resolve_outbox_paths
45
+ from ....github.service import GitHubError, GitHubService
46
+ from ...adapter import (
47
+ FlowCallback,
48
+ InlineButton,
49
+ TelegramCallbackQuery,
50
+ TelegramMessage,
51
+ build_inline_keyboard,
52
+ encode_flow_callback,
53
+ encode_question_cancel_callback,
54
+ )
55
+ from ...config import DEFAULT_APPROVAL_TIMEOUT_SECONDS
17
56
  from ...helpers import _truncate_text
57
+ from ...types import PendingQuestion, SelectionState
18
58
  from .shared import SharedHelpers
19
59
 
20
60
  _logger = logging.getLogger(__name__)
@@ -27,9 +67,123 @@ def _flow_paths(repo_root: Path) -> tuple[Path, Path]:
27
67
  return db_path, artifacts_root
28
68
 
29
69
 
70
+ def _ticket_dir(repo_root: Path) -> Path:
71
+ return repo_root.resolve() / ".codex-autorunner" / "tickets"
72
+
73
+
74
+ def _load_flow_store(repo_root: Path, hub_root: Optional[Path] = None) -> FlowStore:
75
+ config = load_repo_config(repo_root, hub_root)
76
+ return FlowStore(_flow_paths(repo_root)[0], durable=config.durable_writes)
77
+
78
+
79
+ def _normalize_run_id(value: str) -> Optional[str]:
80
+ try:
81
+ return str(uuid.UUID(str(value)))
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ def _split_flow_action(args: str) -> tuple[str, str]:
87
+ trimmed = (args or "").strip()
88
+ if not trimmed:
89
+ return "", ""
90
+ parts = trimmed.split(None, 1)
91
+ if len(parts) == 1:
92
+ return parts[0], ""
93
+ return parts[0], parts[1]
94
+
95
+
96
+ def _normalize_flow_action(action: str) -> str:
97
+ normalized = (action or "").strip().lower()
98
+ if not normalized:
99
+ return "help"
100
+ if normalized == "start":
101
+ return "bootstrap"
102
+ return normalized
103
+
104
+
105
+ def _flow_help_lines() -> list[str]:
106
+ return [
107
+ "Flow commands:",
108
+ "/flow status [run_id]",
109
+ "/flow runs [N]",
110
+ "/flow bootstrap [--force-new]",
111
+ "/flow issue <issue#|url>",
112
+ "/flow plan <text>",
113
+ "/flow resume [run_id]",
114
+ "/flow stop [run_id]",
115
+ "/flow recover [run_id]",
116
+ "/flow restart",
117
+ "/flow archive [run_id] [--force]",
118
+ "/flow reply <message>",
119
+ "Alias: /flow start",
120
+ ]
121
+
122
+
123
+ def _discover_unregistered_worktrees(
124
+ manifest, hub_root: Optional[Path]
125
+ ) -> list[dict[str, object]]:
126
+ if not hub_root:
127
+ return []
128
+ try:
129
+ hub_config = load_hub_config(hub_root)
130
+ except Exception:
131
+ return []
132
+ worktrees_root = hub_config.worktrees_root
133
+ if not worktrees_root.exists() or not worktrees_root.is_dir():
134
+ return []
135
+
136
+ known_paths = {(hub_root / repo.path).resolve() for repo in manifest.repos}
137
+ known_ids = {repo.id for repo in manifest.repos}
138
+ extras: list[dict[str, object]] = []
139
+ for child in sorted(worktrees_root.iterdir()):
140
+ if not child.is_dir():
141
+ continue
142
+ if not (child / ".git").exists():
143
+ continue
144
+ resolved = child.resolve()
145
+ if resolved in known_paths:
146
+ continue
147
+
148
+ flows_root = child / ".codex-autorunner" / "flows"
149
+ flows_db = child / ".codex-autorunner" / "flows.db"
150
+ if not flows_root.exists() and not flows_db.exists():
151
+ continue
152
+
153
+ repo_id = child.name
154
+ label = repo_id
155
+ indent = ""
156
+ if "--" in repo_id:
157
+ _, suffix = repo_id.split("--", 1)
158
+ label = suffix or repo_id
159
+ indent = " - "
160
+ label = f"{label} (unregistered)"
161
+ if repo_id in known_ids:
162
+ label = f"{label} (duplicate id)"
163
+ extras.append(
164
+ {
165
+ "repo_id": repo_id,
166
+ "repo_root": resolved,
167
+ "label": label,
168
+ "indent": indent,
169
+ "unregistered": True,
170
+ }
171
+ )
172
+ return extras
173
+
174
+
30
175
  def _get_ticket_controller(repo_root: Path) -> FlowController:
31
176
  db_path, artifacts_root = _flow_paths(repo_root)
32
- engine = Engine(repo_root)
177
+ config = load_repo_config(repo_root)
178
+ backend_orchestrator = build_backend_orchestrator(repo_root, config)
179
+ engine = RuntimeContext(
180
+ repo_root,
181
+ config=config,
182
+ backend_orchestrator=backend_orchestrator,
183
+ backend_factory=build_agent_backend_factory(repo_root, config),
184
+ app_server_supervisor_factory=build_app_server_supervisor_factory(config),
185
+ agent_id_validator=validate_agent_id,
186
+ )
33
187
  agent_pool = AgentPool(engine.config)
34
188
  definition = build_ticket_flow_definition(agent_pool=agent_pool)
35
189
  definition.validate()
@@ -41,12 +195,14 @@ def _get_ticket_controller(repo_root: Path) -> FlowController:
41
195
 
42
196
 
43
197
  def _spawn_flow_worker(repo_root: Path, run_id: str) -> None:
44
- health = check_worker_health(repo_root, run_id)
45
- if health.is_alive:
198
+ result = ensure_worker(repo_root, run_id)
199
+ if result["status"] == "reused":
200
+ health = result["health"]
46
201
  _logger.info("Worker already active for run %s (pid=%s)", run_id, health.pid)
47
202
  return
48
-
49
- proc, out, err = spawn_flow_worker(repo_root, run_id)
203
+ proc = result["proc"]
204
+ out = result["stdout"]
205
+ err = result["stderr"]
50
206
  try:
51
207
  # We don't track handles in Telegram commands, close in parent after spawn.
52
208
  out.close()
@@ -56,127 +212,1396 @@ def _spawn_flow_worker(repo_root: Path, run_id: str) -> None:
56
212
  _logger.warning("Flow worker for %s exited immediately", run_id)
57
213
 
58
214
 
215
+ def _select_latest_run(
216
+ store: FlowStore, predicate: Callable[[object], bool]
217
+ ) -> Optional[object]:
218
+ for record in store.list_flow_runs(flow_type="ticket_flow"):
219
+ if predicate(record):
220
+ return record
221
+ return None
222
+
223
+
59
224
  class FlowCommands(SharedHelpers):
225
+ def _github_bootstrap_status(self, repo_root: Path) -> tuple[bool, Optional[str]]:
226
+ result = bootstrap_check(repo_root, github_service_factory=GitHubService)
227
+ return bool(result.github_available), result.repo_slug
228
+
229
+ async def _prompt_flow_text_input(
230
+ self,
231
+ message: TelegramMessage,
232
+ prompt_text: str,
233
+ ) -> Optional[str]:
234
+ request_id = str(uuid.uuid4())
235
+ topic_key = await self._resolve_topic_key(message.chat_id, message.thread_id)
236
+ payload_text, parse_mode = self._prepare_outgoing_text(
237
+ prompt_text,
238
+ chat_id=message.chat_id,
239
+ thread_id=message.thread_id,
240
+ reply_to=message.message_id,
241
+ topic_key=topic_key,
242
+ )
243
+ keyboard = build_inline_keyboard(
244
+ [[InlineButton("Cancel", encode_question_cancel_callback(request_id))]]
245
+ )
246
+ response = await self._bot.send_message(
247
+ message.chat_id,
248
+ payload_text,
249
+ message_thread_id=message.thread_id,
250
+ reply_to_message_id=message.message_id,
251
+ reply_markup=keyboard,
252
+ parse_mode=parse_mode,
253
+ )
254
+ message_id = response.get("message_id") if isinstance(response, dict) else None
255
+ loop = asyncio.get_running_loop()
256
+ future: asyncio.Future[Optional[str]] = loop.create_future()
257
+ pending = PendingQuestion(
258
+ request_id=request_id,
259
+ turn_id=f"flow-bootstrap:{request_id}",
260
+ codex_thread_id=None,
261
+ chat_id=message.chat_id,
262
+ thread_id=message.thread_id,
263
+ topic_key=topic_key,
264
+ message_id=message_id if isinstance(message_id, int) else None,
265
+ created_at=now_iso(),
266
+ question_index=0,
267
+ prompt=prompt_text,
268
+ options=[],
269
+ future=future,
270
+ multiple=False,
271
+ custom=True,
272
+ selected_indices=set(),
273
+ awaiting_custom_input=True,
274
+ )
275
+ self._pending_questions[request_id] = pending
276
+ self._touch_cache_timestamp("pending_questions", request_id)
277
+ try:
278
+ result = await asyncio.wait_for(
279
+ future, timeout=DEFAULT_APPROVAL_TIMEOUT_SECONDS
280
+ )
281
+ except asyncio.TimeoutError:
282
+ self._pending_questions.pop(request_id, None)
283
+ if pending.message_id is not None:
284
+ await self._edit_message_text(
285
+ pending.chat_id,
286
+ pending.message_id,
287
+ "Question timed out.",
288
+ reply_markup={"inline_keyboard": []},
289
+ )
290
+ return None
291
+ if not result:
292
+ return None
293
+ return result.strip() or None
294
+
295
+ async def _seed_issue_from_ref(
296
+ self, repo_root: Path, issue_ref: str
297
+ ) -> tuple[int, str]:
298
+ seed = seed_issue_from_github(
299
+ repo_root, issue_ref, github_service_factory=GitHubService
300
+ )
301
+ atomic_write(issue_md_path(repo_root), seed.content)
302
+ return seed.issue_number, seed.repo_slug
303
+
304
+ def _seed_issue_from_plan(self, repo_root: Path, plan_text: str) -> None:
305
+ content = seed_issue_from_text(plan_text)
306
+ atomic_write(issue_md_path(repo_root), content)
307
+
308
+ async def _handle_flow_status(self, message: TelegramMessage, args: str) -> None:
309
+ text = args.strip()
310
+ if text:
311
+ await self._handle_flow(message, f"status {text}")
312
+ else:
313
+ await self._handle_flow(message, "status")
314
+
60
315
  async def _handle_flow(self, message: TelegramMessage, args: str) -> None:
61
- """
62
- /flow start - seed tickets if missing and start ticket_flow
63
- /flow resume - resume latest paused ticket_flow run
64
- /flow status - show latest ticket_flow run status
65
- """
316
+ argv = self._parse_command_args(args)
317
+
318
+ target_repo_root = None
319
+ effective_args = args
320
+
321
+ if argv:
322
+ resolved = self._resolve_workspace(argv[0])
323
+ if resolved:
324
+ target_repo_root = Path(resolved[0])
325
+ argv = argv[1:]
326
+ # Reconstruct args for remainder logic (imperfect but sufficient for text commands)
327
+ effective_args = " ".join(argv)
328
+
329
+ action_raw = argv[0] if argv else ""
330
+ if target_repo_root and not action_raw:
331
+ action_raw = "status"
332
+ argv = ["status"]
333
+ effective_args = "status"
334
+ action = _normalize_flow_action(action_raw)
335
+ _, remainder = _split_flow_action(effective_args)
336
+ rest_argv = argv[1:]
337
+
66
338
  key = await self._resolve_topic_key(message.chat_id, message.thread_id)
67
339
  record = await self._store.get_topic(key)
68
- if not record or not record.workspace_path:
340
+ is_pma = bool(record and getattr(record, "pma_enabled", False))
341
+ is_unbound = bool(not record or not getattr(record, "workspace_path", None))
342
+
343
+ if not target_repo_root and not action_raw:
344
+ # Check if we should show Hub Overview
345
+ if is_pma or is_unbound:
346
+ await self._send_flow_hub_overview(message)
347
+ return
348
+ action = "status"
349
+ rest_argv = []
350
+
351
+ if action == "help":
352
+ await self._send_flow_overview(message, record)
353
+ return
354
+
355
+ if target_repo_root:
356
+ repo_root = canonicalize_path(target_repo_root)
357
+ elif record and record.workspace_path:
358
+ repo_root = canonicalize_path(Path(record.workspace_path))
359
+ else:
360
+ if action == "status" and (is_pma or is_unbound):
361
+ await self._send_flow_hub_overview(message)
362
+ return
69
363
  await self._send_message(
70
364
  message.chat_id,
71
- "No workspace bound. Use /bind to bind this topic to a repo first.",
365
+ "No workspace bound. Use /flow <repo-id> status to inspect a repo without binding, or /bind <repo-id> to attach this topic.",
72
366
  thread_id=message.thread_id,
73
367
  reply_to=message.message_id,
74
368
  )
75
369
  return
76
370
 
77
- repo_root = canonicalize_path(Path(record.workspace_path))
78
- cmd = (args or "").strip().lower().split()
79
- action = cmd[0] if cmd else "status"
371
+ try:
372
+ if action == "status":
373
+ await self._handle_flow_status_action(message, repo_root, rest_argv)
374
+ return
375
+ if action == "runs":
376
+ await self._handle_flow_runs(message, repo_root, rest_argv)
377
+ return
378
+ if action == "bootstrap":
379
+ await self._handle_flow_bootstrap(message, repo_root, rest_argv)
380
+ return
381
+ if action == "issue":
382
+ await self._handle_flow_issue(message, repo_root, remainder)
383
+ return
384
+ if action == "plan":
385
+ await self._handle_flow_plan(message, repo_root, remainder)
386
+ return
387
+ if action == "resume":
388
+ await self._handle_flow_resume(message, repo_root, rest_argv)
389
+ return
390
+ if action == "stop":
391
+ await self._handle_flow_stop(message, repo_root, rest_argv)
392
+ return
393
+ if action == "recover":
394
+ await self._handle_flow_recover(message, repo_root, rest_argv)
395
+ return
396
+ if action == "restart":
397
+ await self._handle_flow_restart(message, repo_root, rest_argv)
398
+ return
399
+ if action == "archive":
400
+ await self._handle_flow_archive(message, repo_root, rest_argv)
401
+ return
402
+ if action == "reply":
403
+ await self._handle_reply(message, remainder)
404
+ return
405
+ except (asyncio.CancelledError, KeyboardInterrupt):
406
+ # Let cancellations propagate so shutdowns/timeouts are not masked.
407
+ raise
408
+ except Exception as exc: # pragma: no cover - defensive
409
+ log_event(
410
+ _logger,
411
+ logging.WARNING,
412
+ "telegram.flow.command_failed",
413
+ chat_id=message.chat_id,
414
+ thread_id=message.thread_id,
415
+ action=action or "unknown",
416
+ exc=exc,
417
+ )
418
+ format_msg = getattr(self, "_with_conversation_id", None)
419
+ error_text = (
420
+ format_msg(
421
+ "Flow command failed; check logs for details.",
422
+ chat_id=message.chat_id,
423
+ thread_id=message.thread_id,
424
+ )
425
+ if callable(format_msg)
426
+ else "Flow command failed; check logs for details."
427
+ )
428
+ await self._send_message(
429
+ message.chat_id,
430
+ error_text,
431
+ thread_id=message.thread_id,
432
+ reply_to=message.message_id,
433
+ )
434
+ return
80
435
 
81
- controller = _get_ticket_controller(repo_root)
436
+ await self._send_message(
437
+ message.chat_id,
438
+ f"Unknown /flow command: {action_raw or action}. Use /flow help.",
439
+ thread_id=message.thread_id,
440
+ reply_to=message.message_id,
441
+ )
442
+ await self._send_flow_help_block(message)
443
+ return
82
444
 
83
- store = FlowStore(_flow_paths(repo_root)[0])
445
+ async def _render_flow_status_callback(
446
+ self,
447
+ callback: TelegramCallbackQuery,
448
+ repo_root: Path,
449
+ run_id_raw: Optional[str],
450
+ ) -> None:
451
+ store = _load_flow_store(repo_root)
84
452
  try:
85
453
  store.initialize()
86
- runs = store.list_flow_runs(flow_type="ticket_flow")
87
- latest = runs[0] if runs else None
454
+ record, error = self._resolve_status_record(store, run_id_raw)
455
+ if error:
456
+ await self._edit_callback_message(
457
+ callback, error, reply_markup={"inline_keyboard": []}
458
+ )
459
+ return
460
+ text, keyboard = self._build_flow_status_card(repo_root, record, store)
88
461
  finally:
89
462
  store.close()
463
+ await self._edit_callback_message(callback, text, reply_markup=keyboard)
90
464
 
91
- if action == "start":
92
- if latest and latest.status.is_active():
465
+ async def _handle_flow_callback(
466
+ self, callback: TelegramCallbackQuery, parsed: FlowCallback
467
+ ) -> None:
468
+ if callback.chat_id is None:
469
+ return
470
+ key = await self._resolve_topic_key(callback.chat_id, callback.thread_id)
471
+ record = await self._store.get_topic(key)
472
+ if not record or not record.workspace_path:
473
+ await self._answer_callback(callback, "No workspace bound")
474
+ await self._edit_callback_message(
475
+ callback,
476
+ "No workspace bound. Use /bind to bind this topic to a repo first.",
477
+ reply_markup={"inline_keyboard": []},
478
+ )
479
+ return
480
+
481
+ repo_root = canonicalize_path(Path(record.workspace_path))
482
+ action = (parsed.action or "").strip().lower()
483
+ run_id_raw = parsed.run_id
484
+
485
+ if action in {"refresh", "status"}:
486
+ await self._answer_callback(callback, "Refreshing...")
487
+ await self._render_flow_status_callback(callback, repo_root, run_id_raw)
488
+ return
489
+
490
+ error = None
491
+ notice = None
492
+ if action == "resume":
493
+ store = _load_flow_store(repo_root)
494
+ try:
495
+ store.initialize()
496
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
497
+ record = store.get_flow_run(run_id) if run_id else None
498
+ if run_id_raw and error:
499
+ record = None
500
+ if error is None and record is None:
501
+ record = _select_latest_run(
502
+ store, lambda run: run.status == FlowRunStatus.PAUSED
503
+ )
504
+ if error is None and record is None:
505
+ error = "No paused ticket flow run found."
506
+ if error is None and record.status != FlowRunStatus.PAUSED:
507
+ error = f"Run {record.id} is {record.status.value}, not paused."
508
+ finally:
509
+ store.close()
510
+ if error is None:
511
+ controller = _get_ticket_controller(repo_root)
512
+ updated = await controller.resume_flow(record.id)
513
+ _spawn_flow_worker(repo_root, updated.id)
514
+ notice = "Resumed."
515
+ elif action == "stop":
516
+ store = _load_flow_store(repo_root)
517
+ try:
518
+ store.initialize()
519
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
520
+ record = store.get_flow_run(run_id) if run_id else None
521
+ if run_id_raw and error:
522
+ record = None
523
+ if error is None and record is None:
524
+ record = _select_latest_run(
525
+ store, lambda run: run.status.is_active()
526
+ )
527
+ if error is None and record is None:
528
+ error = "No active ticket flow run found."
529
+ if error is None and record.status.is_terminal():
530
+ error = f"Run {record.id} is already {record.status.value}."
531
+ finally:
532
+ store.close()
533
+ if error is None:
534
+ controller = _get_ticket_controller(repo_root)
535
+ self._stop_flow_worker(repo_root, record.id)
536
+ await controller.stop_flow(record.id)
537
+ notice = "Stopped."
538
+ elif action == "recover":
539
+ store = _load_flow_store(repo_root)
540
+ try:
541
+ store.initialize()
542
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
543
+ record = store.get_flow_run(run_id) if run_id else None
544
+ if run_id_raw and error:
545
+ record = None
546
+ if error is None and record is None:
547
+ record = _select_latest_run(
548
+ store, lambda run: run.status.is_active()
549
+ )
550
+ if error is None and record is None:
551
+ error = "No active ticket flow run found."
552
+ if error is None:
553
+ record, updated, locked = reconcile_flow_run(
554
+ repo_root, record, store
555
+ )
556
+ if locked:
557
+ error = f"Run {record.id} is locked for reconcile; try again."
558
+ else:
559
+ notice = "Recovered." if updated else "No changes needed."
560
+ finally:
561
+ store.close()
562
+ elif action == "archive":
563
+ store = _load_flow_store(repo_root)
564
+ record = None
565
+ try:
566
+ store.initialize()
567
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
568
+ record = store.get_flow_run(run_id) if run_id else None
569
+ if run_id_raw and error:
570
+ record = None
571
+ if error is None and record is None:
572
+ record = _select_latest_run(
573
+ store,
574
+ lambda run: run.status.is_terminal()
575
+ or run.status == FlowRunStatus.PAUSED,
576
+ )
577
+ if error is None and record is None:
578
+ error = "No paused or terminal ticket flow run found."
579
+ if error is None and not record.status.is_terminal():
580
+ if record.status in (FlowRunStatus.STOPPING, FlowRunStatus.PAUSED):
581
+ self._stop_flow_worker(repo_root, record.id)
582
+ else:
583
+ error = "Can only archive completed/stopped/failed runs (use --force for stuck flows)."
584
+ finally:
585
+ store.close()
586
+
587
+ if error is None:
588
+ _, artifacts_root = _flow_paths(repo_root)
589
+ archive_dir = artifacts_root / record.id / "archived_tickets"
590
+ archive_dir.mkdir(parents=True, exist_ok=True)
591
+ ticket_dir = _ticket_dir(repo_root)
592
+ for ticket_path in list_ticket_paths(ticket_dir):
593
+ dest = archive_dir / ticket_path.name
594
+ shutil.move(str(ticket_path), str(dest))
595
+
596
+ runs_dir = Path(
597
+ record.input_data.get("runs_dir") or ".codex-autorunner/runs"
598
+ )
599
+ outbox_paths = resolve_outbox_paths(
600
+ workspace_root=repo_root, runs_dir=runs_dir, run_id=record.id
601
+ )
602
+ run_dir = outbox_paths.run_dir
603
+ if run_dir.exists() and run_dir.is_dir():
604
+ archived_runs_dir = artifacts_root / record.id / "archived_runs"
605
+ shutil.move(str(run_dir), str(archived_runs_dir))
606
+
607
+ store = _load_flow_store(repo_root)
608
+ try:
609
+ store.initialize()
610
+ store.delete_flow_run(record.id)
611
+ finally:
612
+ store.close()
613
+ notice = "Archived."
614
+ elif action == "restart":
615
+ message = TelegramMessage(
616
+ update_id=callback.update_id,
617
+ message_id=callback.message_id or 0,
618
+ chat_id=callback.chat_id,
619
+ thread_id=callback.thread_id,
620
+ from_user_id=callback.from_user_id,
621
+ text=None,
622
+ date=None,
623
+ is_topic_message=callback.thread_id is not None,
624
+ )
625
+ argv = [run_id_raw] if run_id_raw else []
626
+ await self._handle_flow_restart(message, repo_root, argv)
627
+ notice = "Restarted."
628
+ else:
629
+ await self._answer_callback(callback, "Unknown action")
630
+ return
631
+
632
+ if error:
633
+ await self._answer_callback(callback, error)
634
+ elif notice:
635
+ await self._answer_callback(callback, notice)
636
+ await self._render_flow_status_callback(callback, repo_root, run_id_raw)
637
+
638
+ def _resolve_run_id_input(
639
+ self, store: FlowStore, raw_run_id: Optional[str]
640
+ ) -> tuple[Optional[str], Optional[str]]:
641
+ if not raw_run_id:
642
+ return None, None
643
+ normalized = _normalize_run_id(raw_run_id)
644
+ if normalized:
645
+ return normalized, None
646
+ matches = [
647
+ record.id
648
+ for record in store.list_flow_runs(flow_type="ticket_flow")
649
+ if record.id.startswith(raw_run_id)
650
+ ]
651
+ if len(matches) == 1:
652
+ return matches[0], None
653
+ if len(matches) > 1:
654
+ return None, "Run ID prefix is ambiguous. Use the full run_id."
655
+ return None, "Invalid run_id."
656
+
657
+ def _first_non_flag(self, argv: list[str]) -> Optional[str]:
658
+ for part in argv:
659
+ if not part.startswith("--"):
660
+ return part
661
+ return None
662
+
663
+ def _has_flag(self, argv: list[str], name: str) -> bool:
664
+ prefix = f"{name}="
665
+ return any(part == name or part.startswith(prefix) for part in argv)
666
+
667
+ def _resolve_status_record(
668
+ self, store: FlowStore, run_id_raw: Optional[str]
669
+ ) -> tuple[Optional[object], Optional[str]]:
670
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
671
+ if run_id_raw and error:
672
+ return None, error
673
+ record = store.get_flow_run(run_id) if run_id else None
674
+ if record is None:
675
+ runs = store.list_flow_runs(flow_type="ticket_flow")
676
+ record = runs[0] if runs else None
677
+ if record is None:
678
+ return None, "No ticket flow run found. Use /flow bootstrap to start."
679
+ return record, None
680
+
681
+ def _format_flow_status_lines(
682
+ self,
683
+ repo_root: Path,
684
+ record: Optional[object],
685
+ store: Optional[FlowStore],
686
+ *,
687
+ health: Optional[FlowWorkerHealth] = None,
688
+ snapshot: Optional[dict] = None,
689
+ ) -> list[str]:
690
+ if record is None:
691
+ return ["Run: none"]
692
+ if snapshot is None:
693
+ snapshot = build_flow_status_snapshot(repo_root, record, store)
694
+ run = record
695
+ status = getattr(run, "status", None)
696
+ status_value = status.value if status else "unknown"
697
+ progress = snapshot.get("ticket_progress") if snapshot else None
698
+ progress_label = None
699
+ if isinstance(progress, dict):
700
+ done = progress.get("done")
701
+ total = progress.get("total")
702
+ if isinstance(done, int) and isinstance(total, int) and total >= 0:
703
+ progress_label = f"{done}/{total}"
704
+ lines = [f"Run: {run.id}", f"Status: {status_value}"]
705
+ if progress_label:
706
+ lines.append(f"Tickets: {progress_label}")
707
+ flow_type = getattr(run, "flow_type", None)
708
+ if flow_type:
709
+ lines.append(f"Flow: {flow_type}")
710
+ created_at = getattr(run, "created_at", None)
711
+ if created_at:
712
+ lines.append(f"Created: {created_at}")
713
+ started_at = getattr(run, "started_at", None)
714
+ if started_at:
715
+ lines.append(f"Started: {started_at}")
716
+ finished_at = getattr(run, "finished_at", None)
717
+ if finished_at:
718
+ lines.append(f"Finished: {finished_at}")
719
+ current_step = getattr(run, "current_step", None)
720
+ if current_step:
721
+ lines.append(f"Step: {current_step}")
722
+ state = run.state or {}
723
+ engine = state.get("ticket_engine") if isinstance(state, dict) else None
724
+ engine = engine if isinstance(engine, dict) else {}
725
+ current = snapshot.get("effective_current_ticket") if snapshot else None
726
+ if isinstance(current, str) and current.strip():
727
+ lines.append(f"Current: {current.strip()}")
728
+ reason_summary = None
729
+ if isinstance(state, dict):
730
+ value = state.get("reason_summary")
731
+ if isinstance(value, str) and value.strip():
732
+ reason_summary = value.strip()
733
+ if reason_summary:
734
+ lines.append(f"Summary: {_truncate_text(reason_summary, 300)}")
735
+ reason = engine.get("reason") if isinstance(engine, dict) else None
736
+ if isinstance(reason, str) and reason.strip():
737
+ if reason_summary and reason.strip() == reason_summary:
738
+ pass
739
+ else:
740
+ lines.append(f"Reason: {_truncate_text(reason.strip(), 300)}")
741
+ error_message = getattr(run, "error_message", None)
742
+ if isinstance(error_message, str) and error_message.strip():
743
+ lines.append(f"Error: {_truncate_text(error_message.strip(), 300)}")
744
+ if snapshot:
745
+ last_seq = snapshot.get("last_event_seq")
746
+ last_at = snapshot.get("last_event_at")
747
+ if last_seq or last_at:
748
+ seq_label = str(last_seq) if last_seq is not None else "?"
749
+ at_label = last_at or "unknown time"
750
+ lines.append(f"Last event: {seq_label} @ {at_label}")
751
+ if health is None:
752
+ health = snapshot.get("worker_health") if snapshot else None
753
+ if health is None:
754
+ return lines
755
+ worker_line = f"Worker: {health.status}"
756
+ if health.pid:
757
+ worker_line += f" (pid {health.pid})"
758
+ if health.message and health.status not in {"alive"}:
759
+ worker_line += f" - {health.message}"
760
+ lines.append(worker_line)
761
+ if status == FlowRunStatus.PAUSED:
762
+ lines.append("Paused: use /flow reply <message>, then /flow resume.")
763
+ return lines
764
+
765
+ def _build_flow_status_keyboard(
766
+ self, record: Optional[object], *, health: Optional[FlowWorkerHealth]
767
+ ) -> Optional[dict[str, object]]:
768
+ if record is None or health is None:
769
+ return None
770
+ status = getattr(record, "status", None)
771
+ if status is None:
772
+ return None
773
+ run_id = record.id
774
+ rows: list[list[InlineButton]] = []
775
+ if status == FlowRunStatus.PAUSED:
776
+ rows.append(
777
+ [
778
+ InlineButton("Resume", encode_flow_callback("resume", run_id)),
779
+ InlineButton("Restart", encode_flow_callback("restart", run_id)),
780
+ ]
781
+ )
782
+ rows.append(
783
+ [InlineButton("Archive", encode_flow_callback("archive", run_id))]
784
+ )
785
+ elif status.is_terminal():
786
+ rows.append(
787
+ [
788
+ InlineButton("Restart", encode_flow_callback("restart", run_id)),
789
+ InlineButton("Archive", encode_flow_callback("archive", run_id)),
790
+ ]
791
+ )
792
+ rows.append(
793
+ [InlineButton("Refresh", encode_flow_callback("refresh", run_id))]
794
+ )
795
+ else:
796
+ if health.status in {"dead", "mismatch", "invalid", "absent"}:
797
+ rows.append(
798
+ [
799
+ InlineButton(
800
+ "Recover", encode_flow_callback("recover", run_id)
801
+ ),
802
+ InlineButton(
803
+ "Refresh", encode_flow_callback("refresh", run_id)
804
+ ),
805
+ ]
806
+ )
807
+ elif status == FlowRunStatus.RUNNING:
808
+ rows.append(
809
+ [
810
+ InlineButton("Stop", encode_flow_callback("stop", run_id)),
811
+ InlineButton(
812
+ "Refresh", encode_flow_callback("refresh", run_id)
813
+ ),
814
+ ]
815
+ )
816
+ else:
817
+ rows.append(
818
+ [InlineButton("Refresh", encode_flow_callback("refresh", run_id))]
819
+ )
820
+ return build_inline_keyboard(rows) if rows else None
821
+
822
+ def _build_flow_status_card(
823
+ self, repo_root: Path, record: Optional[object], store: Optional[FlowStore]
824
+ ) -> tuple[str, Optional[dict[str, object]]]:
825
+ if record is None:
826
+ return (
827
+ "\n".join(self._format_flow_status_lines(repo_root, record, store)),
828
+ None,
829
+ )
830
+ snapshot = build_flow_status_snapshot(repo_root, record, store)
831
+ health = snapshot.get("worker_health")
832
+ lines = self._format_flow_status_lines(
833
+ repo_root, record, store, health=health, snapshot=snapshot
834
+ )
835
+ keyboard = self._build_flow_status_keyboard(record, health=health)
836
+ return "\n".join(lines), keyboard
837
+
838
+ async def _send_flow_help_block(self, message: TelegramMessage) -> None:
839
+ await self._send_message(
840
+ message.chat_id,
841
+ "\n".join(_flow_help_lines()),
842
+ thread_id=message.thread_id,
843
+ reply_to=message.message_id,
844
+ )
845
+
846
+ async def _send_flow_overview(
847
+ self, message: TelegramMessage, record: Optional[object]
848
+ ) -> None:
849
+ repo_root = (
850
+ canonicalize_path(Path(record.workspace_path))
851
+ if record and record.workspace_path
852
+ else None
853
+ )
854
+ lines = [
855
+ f"Workspace: {repo_root}" if repo_root else "Workspace: unbound",
856
+ ]
857
+ if repo_root:
858
+ store = _load_flow_store(repo_root)
859
+ try:
860
+ store.initialize()
861
+ runs = store.list_flow_runs(flow_type="ticket_flow")
862
+ latest = runs[0] if runs else None
863
+ lines.extend(self._format_flow_status_lines(repo_root, latest, store))
864
+ finally:
865
+ store.close()
866
+ else:
867
+ lines.append("Run: none")
868
+ lines.append("Use /bind <repo_id> or /bind <path>.")
869
+ lines.append("")
870
+ lines.extend(_flow_help_lines())
871
+ await self._send_message(
872
+ message.chat_id,
873
+ "\n".join(lines),
874
+ thread_id=message.thread_id,
875
+ reply_to=message.message_id,
876
+ )
877
+
878
+ async def _send_flow_hub_overview(self, message: TelegramMessage) -> None:
879
+ if not self._manifest_path or not self._hub_root:
880
+ await self._send_message(
881
+ message.chat_id,
882
+ "Hub manifest not configured.",
883
+ thread_id=message.thread_id,
884
+ reply_to=message.message_id,
885
+ )
886
+ return
887
+
888
+ try:
889
+ manifest = load_manifest(self._manifest_path, self._hub_root)
890
+ except Exception:
891
+ await self._send_message(
892
+ message.chat_id,
893
+ "Failed to load manifest.",
894
+ thread_id=message.thread_id,
895
+ reply_to=message.message_id,
896
+ )
897
+ return
898
+
899
+ def _group_key(repo_id: str) -> tuple[str, Optional[str]]:
900
+ parts = repo_id.split("--", 1)
901
+ if len(parts) == 1:
902
+ return repo_id, None
903
+ return parts[0], parts[1]
904
+
905
+ def _format_status_line(
906
+ label: str,
907
+ *,
908
+ status_icon: str,
909
+ status_value: str,
910
+ progress_label: str,
911
+ run_id: Optional[str],
912
+ indent: str = "",
913
+ ) -> str:
914
+ run_suffix = f" run {run_id}" if run_id else ""
915
+ return f"{indent}{status_icon} {label}: {status_value} {progress_label}{run_suffix}"
916
+
917
+ lines = ["Hub Flow Overview:"]
918
+ groups: dict[str, list[tuple[str, str]]] = {}
919
+ group_order: list[str] = []
920
+
921
+ entries: list[dict[str, object]] = []
922
+ for repo in manifest.repos:
923
+ if not repo.enabled:
924
+ continue
925
+ repo_root = (self._hub_root / repo.path).resolve()
926
+ group, suffix = _group_key(repo.id)
927
+ label = suffix or repo.id
928
+ indent = " - " if suffix else ""
929
+ entries.append(
930
+ {
931
+ "repo_id": repo.id,
932
+ "repo_root": repo_root,
933
+ "label": label,
934
+ "indent": indent,
935
+ "group": group,
936
+ "unregistered": False,
937
+ }
938
+ )
939
+
940
+ extras = _discover_unregistered_worktrees(manifest, self._hub_root)
941
+ for extra in extras:
942
+ repo_id = str(extra["repo_id"])
943
+ group, _ = _group_key(repo_id)
944
+ extra["group"] = group
945
+ entries.append(extra)
946
+
947
+ for entry in entries:
948
+ repo_id = str(entry["repo_id"])
949
+ repo_root = Path(entry["repo_root"])
950
+ label = str(entry["label"])
951
+ indent = str(entry.get("indent", ""))
952
+ group = str(entry.get("group", repo_id))
953
+ if group not in groups:
954
+ groups[group] = []
955
+ group_order.append(group)
956
+
957
+ store = _load_flow_store(repo_root)
958
+ try:
959
+ store.initialize()
960
+ progress = ticket_progress(repo_root)
961
+ done = progress.get("done", 0)
962
+ total = progress.get("total", 0)
963
+ progress_label = f"{done}/{total}"
964
+ active = _select_latest_run(store, lambda run: run.status.is_active())
965
+ if active:
966
+ status_icon = (
967
+ "🟢" if active.status == FlowRunStatus.RUNNING else "🟡"
968
+ )
969
+ status_line = _format_status_line(
970
+ label,
971
+ status_icon=status_icon,
972
+ status_value=active.status.value,
973
+ progress_label=progress_label,
974
+ run_id=active.id,
975
+ indent=indent,
976
+ )
977
+ else:
978
+ paused = _select_latest_run(
979
+ store, lambda run: run.status == FlowRunStatus.PAUSED
980
+ )
981
+ if paused:
982
+ status_line = _format_status_line(
983
+ label,
984
+ status_icon="🔴",
985
+ status_value="PAUSED",
986
+ progress_label=progress_label,
987
+ run_id=paused.id,
988
+ indent=indent,
989
+ )
990
+ else:
991
+ status_line = _format_status_line(
992
+ label,
993
+ status_icon="⚪",
994
+ status_value="Idle",
995
+ progress_label=progress_label,
996
+ run_id=None,
997
+ indent=indent,
998
+ )
999
+ except Exception:
1000
+ status_line = f"{indent}❓ {label}: Error reading state"
1001
+ finally:
1002
+ store.close()
1003
+
1004
+ groups[group].append((label, status_line))
1005
+
1006
+ for group in group_order:
1007
+ entries = groups.get(group, [])
1008
+ if not entries:
1009
+ continue
1010
+ entries.sort(key=lambda pair: (0 if pair[0] == group else 1, pair[0]))
1011
+ lines.extend([line for _label, line in entries])
1012
+ lines.append("")
1013
+
1014
+ if lines and lines[-1] == "":
1015
+ lines.pop()
1016
+ if extras:
1017
+ lines.append("")
1018
+ lines.append(
1019
+ "Note: Unregistered worktrees detected. Run 'car hub scan' to register them."
1020
+ )
1021
+ lines.append("")
1022
+ lines.append("Tip: use /flow <repo-id> status for repo details.")
1023
+
1024
+ await self._send_message(
1025
+ message.chat_id,
1026
+ "\n".join(lines),
1027
+ thread_id=message.thread_id,
1028
+ reply_to=message.message_id,
1029
+ parse_mode=None,
1030
+ )
1031
+
1032
+ async def _handle_flow_status_action(
1033
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1034
+ ) -> None:
1035
+ store = _load_flow_store(repo_root)
1036
+ try:
1037
+ store.initialize()
1038
+ run_id_raw = self._first_non_flag(argv)
1039
+ record, error = self._resolve_status_record(store, run_id_raw)
1040
+ if error:
93
1041
  await self._send_message(
94
1042
  message.chat_id,
95
- f"Ticket flow already active (run {latest.id}, status {latest.status.value}).",
1043
+ error,
96
1044
  thread_id=message.thread_id,
97
1045
  reply_to=message.message_id,
98
1046
  )
99
1047
  return
100
- # seed ticket if missing
101
- ticket_dir = repo_root / ".codex-autorunner" / "tickets"
102
- ticket_dir.mkdir(parents=True, exist_ok=True)
103
- first_ticket = ticket_dir / "TICKET-001.md"
104
- seeded = False
105
- if not first_ticket.exists():
106
- first_ticket.write_text(
107
- """---
108
- agent: codex
109
- done: false
110
- title: Bootstrap ticket flow
111
- goal: Create SPEC.md and additional tickets, then pause for review
112
- ---
1048
+ text, keyboard = self._build_flow_status_card(repo_root, record, store)
1049
+ finally:
1050
+ store.close()
1051
+ await self._send_message(
1052
+ message.chat_id,
1053
+ text,
1054
+ thread_id=message.thread_id,
1055
+ reply_to=message.message_id,
1056
+ reply_markup=keyboard,
1057
+ )
113
1058
 
114
- Create SPEC.md and additional tickets under .codex-autorunner/tickets/. Then write a pause DISPATCH.md for review.
115
- """,
116
- encoding="utf-8",
1059
+ async def _handle_flow_runs(
1060
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1061
+ ) -> None:
1062
+ limit = 5
1063
+ limit_raw = self._first_non_flag(argv)
1064
+ if limit_raw:
1065
+ limit_value = self._coerce_int(limit_raw)
1066
+ if limit_value is None or limit_value <= 0:
1067
+ await self._send_message(
1068
+ message.chat_id,
1069
+ "Provide a positive integer for /flow runs [N].",
1070
+ thread_id=message.thread_id,
1071
+ reply_to=message.message_id,
117
1072
  )
118
- seeded = True
1073
+ return
1074
+ limit = min(limit_value, 50)
1075
+
1076
+ store = _load_flow_store(repo_root)
1077
+ try:
1078
+ store.initialize()
1079
+ runs = store.list_flow_runs(flow_type="ticket_flow")
1080
+ finally:
1081
+ store.close()
119
1082
 
120
- flow_record = await controller.start_flow(
121
- input_data={},
122
- metadata={"seeded_ticket": seeded, "origin": "telegram"},
1083
+ if not runs:
1084
+ await self._send_message(
1085
+ message.chat_id,
1086
+ "No ticket flow runs found. Use /flow bootstrap to start.",
1087
+ thread_id=message.thread_id,
1088
+ reply_to=message.message_id,
123
1089
  )
124
- _spawn_flow_worker(repo_root, flow_record.id)
1090
+ return
1091
+
1092
+ items: list[tuple[str, str]] = []
1093
+ button_labels: dict[str, str] = {}
1094
+ for run in runs[:limit]:
1095
+ created_at = getattr(run, "created_at", None) or "unknown"
1096
+ status = getattr(run, "status", None)
1097
+ status_label = status.value if status is not None else "unknown"
1098
+ items.append((run.id, f"{status_label} • {created_at}"))
1099
+ short_id = run.id.split("-")[0]
1100
+ button_label = f"{short_id} {status_label}"
1101
+ button_labels[run.id] = _truncate_text(button_label, 32)
1102
+
1103
+ state = SelectionState(items=items, button_labels=button_labels)
1104
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
1105
+ self._flow_run_options[key] = state
1106
+ self._touch_cache_timestamp("flow_run_options", key)
1107
+ prompt = self._flow_runs_prompt(state)
1108
+ keyboard = self._build_flow_runs_keyboard(state)
1109
+ await self._send_message(
1110
+ message.chat_id,
1111
+ prompt,
1112
+ thread_id=message.thread_id,
1113
+ reply_to=message.message_id,
1114
+ reply_markup=keyboard,
1115
+ )
1116
+
1117
+ async def _handle_flow_bootstrap(
1118
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1119
+ ) -> None:
1120
+ force_new = self._has_flag(argv, "--force-new") or self._has_flag(
1121
+ argv, "--force"
1122
+ )
1123
+ ticket_dir = _ticket_dir(repo_root)
1124
+ ticket_dir.mkdir(parents=True, exist_ok=True)
1125
+ existing_tickets = list_ticket_paths(ticket_dir)
1126
+ tickets_exist = bool(existing_tickets)
1127
+ issue_exists = issue_md_has_content(repo_root)
1128
+
1129
+ store = _load_flow_store(repo_root)
1130
+ active_run = None
1131
+ try:
1132
+ store.initialize()
1133
+ runs = store.list_flow_runs(flow_type="ticket_flow")
1134
+ for record in runs:
1135
+ if record.status in (FlowRunStatus.RUNNING, FlowRunStatus.PAUSED):
1136
+ active_run = record
1137
+ break
1138
+ finally:
1139
+ store.close()
1140
+
1141
+ if not force_new and active_run:
1142
+ _spawn_flow_worker(repo_root, active_run.id)
125
1143
  await self._send_message(
126
1144
  message.chat_id,
127
- f"Started ticket flow run {flow_record.id}.",
1145
+ f"Reusing ticket flow run {active_run.id} ({active_run.status.value}).",
128
1146
  thread_id=message.thread_id,
129
1147
  reply_to=message.message_id,
130
1148
  )
131
1149
  return
132
1150
 
133
- if action == "resume":
134
- if not latest:
1151
+ if not tickets_exist and not issue_exists:
1152
+ gh_available, repo_slug = self._github_bootstrap_status(repo_root)
1153
+ if gh_available:
1154
+ repo_label = f" for {repo_slug}" if repo_slug else ""
1155
+ prompt = (
1156
+ f"Enter GitHub issue number or URL{repo_label} to seed ISSUE.md:"
1157
+ )
1158
+ issue_ref = await self._prompt_flow_text_input(message, prompt)
1159
+ if not issue_ref:
1160
+ await self._send_message(
1161
+ message.chat_id,
1162
+ "Bootstrap cancelled (no issue provided).",
1163
+ thread_id=message.thread_id,
1164
+ reply_to=message.message_id,
1165
+ )
1166
+ return
1167
+ try:
1168
+ number, _repo = await self._seed_issue_from_ref(
1169
+ repo_root, issue_ref
1170
+ )
1171
+ except GitHubError as exc:
1172
+ await self._send_message(
1173
+ message.chat_id,
1174
+ f"GitHub error: {exc}",
1175
+ thread_id=message.thread_id,
1176
+ reply_to=message.message_id,
1177
+ )
1178
+ return
1179
+ except Exception as exc:
1180
+ await self._send_message(
1181
+ message.chat_id,
1182
+ f"Failed to fetch issue: {exc}",
1183
+ thread_id=message.thread_id,
1184
+ reply_to=message.message_id,
1185
+ )
1186
+ return
135
1187
  await self._send_message(
136
1188
  message.chat_id,
137
- "No ticket flow run found.",
1189
+ f"Seeded ISSUE.md from GitHub issue {number}.",
138
1190
  thread_id=message.thread_id,
139
1191
  reply_to=message.message_id,
140
1192
  )
141
- return
142
- if latest.status != FlowRunStatus.PAUSED:
1193
+ issue_exists = True
1194
+ else:
1195
+ prompt = "Describe the work to seed ISSUE.md:"
1196
+ plan_text = await self._prompt_flow_text_input(message, prompt)
1197
+ if not plan_text:
1198
+ await self._send_message(
1199
+ message.chat_id,
1200
+ "Bootstrap cancelled (no description provided).",
1201
+ thread_id=message.thread_id,
1202
+ reply_to=message.message_id,
1203
+ )
1204
+ return
1205
+ self._seed_issue_from_plan(repo_root, plan_text)
143
1206
  await self._send_message(
144
1207
  message.chat_id,
145
- f"Latest run is {latest.status.value}, not paused.",
1208
+ "Seeded ISSUE.md from your plan.",
146
1209
  thread_id=message.thread_id,
147
1210
  reply_to=message.message_id,
148
1211
  )
149
- return
150
- updated = await controller.resume_flow(latest.id)
151
- _spawn_flow_worker(repo_root, updated.id)
1212
+ issue_exists = True
1213
+
1214
+ seeded = False
1215
+ if not tickets_exist:
1216
+ first_ticket = ticket_dir / "TICKET-001.md"
1217
+ if not first_ticket.exists():
1218
+ template = """---
1219
+ agent: codex
1220
+ done: false
1221
+ title: Bootstrap ticket plan
1222
+ goal: Capture scope and seed follow-up tickets
1223
+ ---
1224
+
1225
+ You are the first ticket in a new ticket_flow run.
1226
+
1227
+ - Read `.codex-autorunner/ISSUE.md`. If it is missing:
1228
+ - If GitHub is available, ask the user for the issue/PR URL or number and create `.codex-autorunner/ISSUE.md` from it.
1229
+ - 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.
1230
+ - If helpful, create or update workspace docs under `.codex-autorunner/workspace/`:
1231
+ - `active_context.md` for current context and links
1232
+ - `decisions.md` for decisions/rationale
1233
+ - `spec.md` for requirements and constraints
1234
+ - Break the work into additional `TICKET-00X.md` files with clear owners/goals; keep this ticket open until they exist.
1235
+ - Place any supporting artifacts in `.codex-autorunner/runs/<run_id>/dispatch/` if needed.
1236
+ - Write `DISPATCH.md` to dispatch a message to the user:
1237
+ - Use `mode: pause` (handoff) to wait for user response. This pauses execution.
1238
+ - Use `mode: notify` (informational) to message the user but keep running.
1239
+ """
1240
+ first_ticket.write_text(template, encoding="utf-8")
1241
+ seeded = True
1242
+
1243
+ controller = _get_ticket_controller(repo_root)
1244
+ flow_record = await controller.start_flow(
1245
+ input_data={},
1246
+ metadata={"seeded_ticket": seeded, "origin": "telegram"},
1247
+ )
1248
+ _spawn_flow_worker(repo_root, flow_record.id)
1249
+
1250
+ if not issue_exists and not tickets_exist:
1251
+ await self._send_flow_issue_hint(message, repo_root)
1252
+
1253
+ await self._send_message(
1254
+ message.chat_id,
1255
+ f"Started ticket flow run {flow_record.id}.",
1256
+ thread_id=message.thread_id,
1257
+ reply_to=message.message_id,
1258
+ )
1259
+
1260
+ async def _send_flow_issue_hint(
1261
+ self, message: TelegramMessage, repo_root: Path
1262
+ ) -> None:
1263
+ gh_status = (
1264
+ "No ISSUE.md found. Use /flow plan <text> to seed it from a short plan."
1265
+ )
1266
+ gh_available, repo_slug = self._github_bootstrap_status(repo_root)
1267
+ if gh_available:
1268
+ repo_label = repo_slug or "your repo"
1269
+ gh_status = (
1270
+ f"No ISSUE.md found. Use /flow issue <issue#|url> for {repo_label}, "
1271
+ "or /flow plan <text>."
1272
+ )
1273
+ await self._send_message(
1274
+ message.chat_id,
1275
+ gh_status,
1276
+ thread_id=message.thread_id,
1277
+ reply_to=message.message_id,
1278
+ )
1279
+
1280
+ async def _handle_flow_issue(
1281
+ self, message: TelegramMessage, repo_root: Path, issue_ref: str
1282
+ ) -> None:
1283
+ issue_ref = issue_ref.strip()
1284
+ if not issue_ref:
152
1285
  await self._send_message(
153
1286
  message.chat_id,
154
- f"Resumed run {updated.id}.",
1287
+ "Provide an issue reference: /flow issue <issue#|url>",
155
1288
  thread_id=message.thread_id,
156
1289
  reply_to=message.message_id,
157
1290
  )
158
1291
  return
1292
+ try:
1293
+ number, _repo = await self._seed_issue_from_ref(repo_root, issue_ref)
1294
+ except GitHubError as exc:
1295
+ await self._send_message(
1296
+ message.chat_id,
1297
+ f"GitHub error: {exc}",
1298
+ thread_id=message.thread_id,
1299
+ reply_to=message.message_id,
1300
+ )
1301
+ return
1302
+ except RuntimeError as exc:
1303
+ await self._send_message(
1304
+ message.chat_id,
1305
+ str(exc),
1306
+ thread_id=message.thread_id,
1307
+ reply_to=message.message_id,
1308
+ )
1309
+ return
1310
+ except Exception as exc:
1311
+ await self._send_message(
1312
+ message.chat_id,
1313
+ f"Failed to fetch issue: {exc}",
1314
+ thread_id=message.thread_id,
1315
+ reply_to=message.message_id,
1316
+ )
1317
+ return
1318
+ await self._send_message(
1319
+ message.chat_id,
1320
+ f"Seeded ISSUE.md from GitHub issue {number}.",
1321
+ thread_id=message.thread_id,
1322
+ reply_to=message.message_id,
1323
+ )
159
1324
 
160
- # status (default)
161
- if not latest:
1325
+ async def _handle_flow_plan(
1326
+ self, message: TelegramMessage, repo_root: Path, plan_text: str
1327
+ ) -> None:
1328
+ plan_text = plan_text.strip()
1329
+ if not plan_text:
162
1330
  await self._send_message(
163
1331
  message.chat_id,
164
- "No ticket flow run found. Use /flow start to start.",
1332
+ "Provide a plan: /flow plan <text>",
165
1333
  thread_id=message.thread_id,
166
1334
  reply_to=message.message_id,
167
1335
  )
168
1336
  return
169
- state = latest.state or {}
170
- engine = state.get("ticket_engine") or {}
171
- current = engine.get("current_ticket") or "–"
172
- reason = engine.get("reason") or latest.error_message or ""
173
- text = f"Run {latest.id}\nStatus: {latest.status.value}\nCurrent: {current}"
174
- if reason:
175
- text += f"\nReason: {_truncate_text(str(reason), 400)}"
176
- text += "\n\nUse /flow resume to resume a paused run."
1337
+ self._seed_issue_from_plan(repo_root, plan_text)
177
1338
  await self._send_message(
178
1339
  message.chat_id,
179
- text,
1340
+ "Seeded ISSUE.md from your plan.",
1341
+ thread_id=message.thread_id,
1342
+ reply_to=message.message_id,
1343
+ )
1344
+
1345
+ async def _handle_flow_resume(
1346
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1347
+ ) -> None:
1348
+ store = _load_flow_store(repo_root)
1349
+ try:
1350
+ store.initialize()
1351
+ run_id_raw = self._first_non_flag(argv)
1352
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1353
+ record = store.get_flow_run(run_id) if run_id else None
1354
+ if run_id_raw and error:
1355
+ await self._send_message(
1356
+ message.chat_id,
1357
+ error,
1358
+ thread_id=message.thread_id,
1359
+ reply_to=message.message_id,
1360
+ )
1361
+ return
1362
+ if record is None:
1363
+ record = _select_latest_run(
1364
+ store, lambda run: run.status == FlowRunStatus.PAUSED
1365
+ )
1366
+ if record is None:
1367
+ await self._send_message(
1368
+ message.chat_id,
1369
+ "No paused ticket flow run found.",
1370
+ thread_id=message.thread_id,
1371
+ reply_to=message.message_id,
1372
+ )
1373
+ return
1374
+ if record.status != FlowRunStatus.PAUSED:
1375
+ await self._send_message(
1376
+ message.chat_id,
1377
+ f"Run {record.id} is {record.status.value}, not paused.",
1378
+ thread_id=message.thread_id,
1379
+ reply_to=message.message_id,
1380
+ )
1381
+ return
1382
+ finally:
1383
+ store.close()
1384
+
1385
+ controller = _get_ticket_controller(repo_root)
1386
+ updated = await controller.resume_flow(record.id)
1387
+ _spawn_flow_worker(repo_root, updated.id)
1388
+ await self._send_message(
1389
+ message.chat_id,
1390
+ f"Resumed run {updated.id}.",
1391
+ thread_id=message.thread_id,
1392
+ reply_to=message.message_id,
1393
+ )
1394
+
1395
+ def _stop_flow_worker(self, repo_root: Path, run_id: str) -> None:
1396
+ health = check_worker_health(repo_root, run_id)
1397
+ if health.is_alive and health.pid:
1398
+ try:
1399
+ subprocess.run(["kill", str(health.pid)], check=False)
1400
+ except Exception as exc:
1401
+ _logger.warning("Failed to stop worker %s: %s", run_id, exc)
1402
+ if health.status in {"dead", "mismatch", "invalid"}:
1403
+ clear_worker_metadata(health.artifact_path.parent)
1404
+
1405
+ async def _handle_flow_stop(
1406
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1407
+ ) -> None:
1408
+ store = _load_flow_store(repo_root)
1409
+ try:
1410
+ store.initialize()
1411
+ run_id_raw = self._first_non_flag(argv)
1412
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1413
+ record = store.get_flow_run(run_id) if run_id else None
1414
+ if run_id_raw and error:
1415
+ await self._send_message(
1416
+ message.chat_id,
1417
+ error,
1418
+ thread_id=message.thread_id,
1419
+ reply_to=message.message_id,
1420
+ )
1421
+ return
1422
+ if record is None:
1423
+ record = _select_latest_run(store, lambda run: run.status.is_active())
1424
+ if record is None:
1425
+ await self._send_message(
1426
+ message.chat_id,
1427
+ "No active ticket flow run found.",
1428
+ thread_id=message.thread_id,
1429
+ reply_to=message.message_id,
1430
+ )
1431
+ return
1432
+ if record.status.is_terminal():
1433
+ await self._send_message(
1434
+ message.chat_id,
1435
+ f"Run {record.id} is already {record.status.value}.",
1436
+ thread_id=message.thread_id,
1437
+ reply_to=message.message_id,
1438
+ )
1439
+ return
1440
+ finally:
1441
+ store.close()
1442
+
1443
+ controller = _get_ticket_controller(repo_root)
1444
+ self._stop_flow_worker(repo_root, record.id)
1445
+ updated = await controller.stop_flow(record.id)
1446
+ await self._send_message(
1447
+ message.chat_id,
1448
+ f"Stopped run {updated.id} ({updated.status.value}).",
1449
+ thread_id=message.thread_id,
1450
+ reply_to=message.message_id,
1451
+ )
1452
+
1453
+ async def _handle_flow_recover(
1454
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1455
+ ) -> None:
1456
+ store = _load_flow_store(repo_root)
1457
+ try:
1458
+ store.initialize()
1459
+ run_id_raw = self._first_non_flag(argv)
1460
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1461
+ record = store.get_flow_run(run_id) if run_id else None
1462
+ if run_id_raw and error:
1463
+ await self._send_message(
1464
+ message.chat_id,
1465
+ error,
1466
+ thread_id=message.thread_id,
1467
+ reply_to=message.message_id,
1468
+ )
1469
+ return
1470
+ if record is None:
1471
+ record = _select_latest_run(store, lambda run: run.status.is_active())
1472
+ if record is None:
1473
+ await self._send_message(
1474
+ message.chat_id,
1475
+ "No active ticket flow run found.",
1476
+ thread_id=message.thread_id,
1477
+ reply_to=message.message_id,
1478
+ )
1479
+ return
1480
+ record, updated, locked = reconcile_flow_run(repo_root, record, store)
1481
+ if locked:
1482
+ await self._send_message(
1483
+ message.chat_id,
1484
+ f"Run {record.id} is locked for reconcile; try again.",
1485
+ thread_id=message.thread_id,
1486
+ reply_to=message.message_id,
1487
+ )
1488
+ return
1489
+ hint = "Recovered" if updated else "No changes needed"
1490
+ lines = [f"{hint} for run {record.id}."]
1491
+ lines.extend(self._format_flow_status_lines(repo_root, record, store))
1492
+ finally:
1493
+ store.close()
1494
+
1495
+ await self._send_message(
1496
+ message.chat_id,
1497
+ "\n".join(lines),
1498
+ thread_id=message.thread_id,
1499
+ reply_to=message.message_id,
1500
+ )
1501
+
1502
+ async def _handle_flow_restart(
1503
+ self,
1504
+ message: TelegramMessage,
1505
+ repo_root: Path,
1506
+ argv: Optional[list[str]] = None,
1507
+ ) -> None:
1508
+ argv = argv or []
1509
+ store = _load_flow_store(repo_root)
1510
+ record = None
1511
+ try:
1512
+ store.initialize()
1513
+ run_id_raw = self._first_non_flag(argv)
1514
+ if run_id_raw:
1515
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1516
+ if error is None and run_id:
1517
+ record = store.get_flow_run(run_id)
1518
+ else:
1519
+ record = _select_latest_run(store, lambda run: run.status.is_active())
1520
+ finally:
1521
+ store.close()
1522
+ if record and not record.status.is_terminal():
1523
+ controller = _get_ticket_controller(repo_root)
1524
+ self._stop_flow_worker(repo_root, record.id)
1525
+ await controller.stop_flow(record.id)
1526
+ await self._handle_flow_bootstrap(message, repo_root, argv=["--force-new"])
1527
+
1528
+ async def _handle_flow_archive(
1529
+ self, message: TelegramMessage, repo_root: Path, argv: list[str]
1530
+ ) -> None:
1531
+ force = self._has_flag(argv, "--force")
1532
+ store = _load_flow_store(repo_root)
1533
+ record = None
1534
+ try:
1535
+ store.initialize()
1536
+ run_id_raw = self._first_non_flag(argv)
1537
+ run_id, error = self._resolve_run_id_input(store, run_id_raw)
1538
+ record = store.get_flow_run(run_id) if run_id else None
1539
+ if run_id_raw and error:
1540
+ await self._send_message(
1541
+ message.chat_id,
1542
+ error,
1543
+ thread_id=message.thread_id,
1544
+ reply_to=message.message_id,
1545
+ )
1546
+ return
1547
+ if record is None:
1548
+ record = _select_latest_run(
1549
+ store,
1550
+ lambda run: run.status.is_terminal()
1551
+ or run.status == FlowRunStatus.PAUSED
1552
+ or (force and run.status == FlowRunStatus.STOPPING),
1553
+ )
1554
+ if record is None:
1555
+ await self._send_message(
1556
+ message.chat_id,
1557
+ "No paused or terminal ticket flow run found.",
1558
+ thread_id=message.thread_id,
1559
+ reply_to=message.message_id,
1560
+ )
1561
+ return
1562
+ if not record.status.is_terminal():
1563
+ if record.status in (FlowRunStatus.STOPPING, FlowRunStatus.PAUSED):
1564
+ self._stop_flow_worker(repo_root, record.id)
1565
+ else:
1566
+ await self._send_message(
1567
+ message.chat_id,
1568
+ "Can only archive completed/stopped/failed runs (use --force for stuck flows).",
1569
+ thread_id=message.thread_id,
1570
+ reply_to=message.message_id,
1571
+ )
1572
+ return
1573
+ finally:
1574
+ store.close()
1575
+
1576
+ _, artifacts_root = _flow_paths(repo_root)
1577
+ archive_dir = artifacts_root / record.id / "archived_tickets"
1578
+ archive_dir.mkdir(parents=True, exist_ok=True)
1579
+ ticket_dir = _ticket_dir(repo_root)
1580
+ archived_count = 0
1581
+ for ticket_path in list_ticket_paths(ticket_dir):
1582
+ dest = archive_dir / ticket_path.name
1583
+ shutil.move(str(ticket_path), str(dest))
1584
+ archived_count += 1
1585
+
1586
+ runs_dir = Path(record.input_data.get("runs_dir") or ".codex-autorunner/runs")
1587
+ outbox_paths = resolve_outbox_paths(
1588
+ workspace_root=repo_root, runs_dir=runs_dir, run_id=record.id
1589
+ )
1590
+ run_dir = outbox_paths.run_dir
1591
+ if run_dir.exists() and run_dir.is_dir():
1592
+ archived_runs_dir = artifacts_root / record.id / "archived_runs"
1593
+ shutil.move(str(run_dir), str(archived_runs_dir))
1594
+
1595
+ store = _load_flow_store(repo_root)
1596
+ try:
1597
+ store.initialize()
1598
+ store.delete_flow_run(record.id)
1599
+ finally:
1600
+ store.close()
1601
+
1602
+ await self._send_message(
1603
+ message.chat_id,
1604
+ f"Archived run {record.id} ({archived_count} tickets).",
180
1605
  thread_id=message.thread_id,
181
1606
  reply_to=message.message_id,
182
1607
  )
@@ -198,7 +1623,7 @@ Create SPEC.md and additional tickets under .codex-autorunner/tickets/. Then wri
198
1623
  if not text:
199
1624
  await self._send_message(
200
1625
  message.chat_id,
201
- "Provide a reply: `/reply <message>`",
1626
+ "Provide a reply: /flow reply <message> (or /reply <message>).",
202
1627
  thread_id=message.thread_id,
203
1628
  reply_to=message.message_id,
204
1629
  )