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
@@ -1,6 +1,9 @@
1
1
  import asyncio
2
2
  import contextlib
3
3
  import dataclasses
4
+ import hashlib
5
+ import importlib
6
+ import inspect
4
7
  import json
5
8
  import logging
6
9
  import os
@@ -8,52 +11,45 @@ import signal
8
11
  import threading
9
12
  import time
10
13
  import traceback
14
+ import uuid
11
15
  from collections import Counter
12
16
  from datetime import datetime, timezone
13
17
  from logging.handlers import RotatingFileHandler
14
18
  from pathlib import Path
15
- from typing import IO, Any, Dict, Iterator, Optional, Union
19
+ from typing import IO, Any, Awaitable, Callable, Iterator, Optional
16
20
 
17
21
  import yaml
18
22
 
19
- from ..agents.factory import create_orchestrator
20
- from ..agents.opencode.logging import OpenCodeEventFormatter
21
- from ..agents.opencode.runtime import (
22
- OpenCodeTurnOutput,
23
- build_turn_id,
24
- collect_opencode_output,
25
- extract_session_id,
26
- map_approval_policy_to_permission,
27
- opencode_missing_env,
28
- parse_message_response,
29
- split_model_id,
30
- )
31
- from ..agents.opencode.supervisor import OpenCodeSupervisor, OpenCodeSupervisorError
32
23
  from ..agents.registry import validate_agent_id
33
- from ..integrations.app_server.client import (
34
- CodexAppServerError,
35
- _extract_thread_id,
36
- _extract_thread_id_for_turn,
37
- _extract_turn_id,
38
- )
39
- from ..integrations.app_server.env import build_app_server_env
40
- from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
41
24
  from ..manifest import MANIFEST_VERSION
42
- from ..web.static_assets import missing_static_assets, resolve_static_dir
25
+ from ..tickets.files import list_ticket_paths, ticket_is_done
43
26
  from .about_car import ensure_about_car_file
44
- from .app_server_events import AppServerEventBuffer
27
+ from .adapter_utils import handle_agent_output
28
+ from .app_server_ids import (
29
+ extract_thread_id,
30
+ extract_thread_id_for_turn,
31
+ extract_turn_id,
32
+ )
45
33
  from .app_server_logging import AppServerEventFormatter
46
34
  from .app_server_prompts import build_autorunner_prompt
47
35
  from .app_server_threads import AppServerThreadRegistry, default_app_server_threads_path
48
36
  from .config import (
37
+ CONFIG_FILENAME,
38
+ CONFIG_VERSION,
39
+ DEFAULT_REPO_CONFIG,
49
40
  ConfigError,
50
41
  RepoConfig,
42
+ _build_repo_config,
51
43
  _is_loopback_host,
44
+ _load_yaml_dict,
45
+ _merge_defaults,
46
+ _validate_repo_config,
52
47
  derive_repo_config,
53
48
  load_hub_config,
54
49
  load_repo_config,
55
50
  )
56
51
  from .docs import DocsManager, parse_todos
52
+ from .flows.models import FlowEventType
57
53
  from .git_utils import GitError, run_git
58
54
  from .locks import (
59
55
  DEFAULT_RUNNER_CMD_HINTS,
@@ -66,14 +62,29 @@ from .locks import (
66
62
  )
67
63
  from .notifications import NotificationManager
68
64
  from .optional_dependencies import missing_optional_dependencies
65
+ from .ports.agent_backend import AgentBackend
66
+ from .ports.run_event import (
67
+ ApprovalRequested,
68
+ Completed,
69
+ Failed,
70
+ OutputDelta,
71
+ RunEvent,
72
+ RunNotice,
73
+ Started,
74
+ TokenUsage,
75
+ ToolCall,
76
+ )
69
77
  from .prompt import build_final_summary_prompt
78
+ from .redaction import redact_text
70
79
  from .review_context import build_spec_progress_review_context
71
80
  from .run_index import RunIndexStore
72
81
  from .state import RunnerState, load_state, now_iso, save_state, state_lock
82
+ from .state_roots import resolve_global_state_root, resolve_repo_state_root
83
+ from .ticket_linter_cli import ensure_ticket_linter
84
+ from .ticket_manager_cli import ensure_ticket_manager
73
85
  from .utils import (
74
86
  RepoNotFoundError,
75
87
  atomic_write,
76
- build_opencode_supervisor,
77
88
  ensure_executable,
78
89
  find_repo_root,
79
90
  )
@@ -106,13 +117,11 @@ class RunTelemetry:
106
117
  diff: Optional[Any] = None
107
118
 
108
119
 
109
- @dataclasses.dataclass
110
- class ActiveOpencodeRun:
111
- session_id: str
112
- turn_id: str
113
- client: Any
114
- interrupted: bool
115
- interrupt_event: asyncio.Event
120
+ NotificationHandler = Callable[[dict[str, Any]], Awaitable[None]]
121
+ BackendFactory = Callable[
122
+ [str, RunnerState, Optional[NotificationHandler]], AgentBackend
123
+ ]
124
+ AppServerSupervisorFactory = Callable[[str, Optional[NotificationHandler]], Any]
116
125
 
117
126
 
118
127
  class Engine:
@@ -122,6 +131,10 @@ class Engine:
122
131
  *,
123
132
  config: Optional[RepoConfig] = None,
124
133
  hub_path: Optional[Path] = None,
134
+ backend_factory: Optional[BackendFactory] = None,
135
+ app_server_supervisor_factory: Optional[AppServerSupervisorFactory] = None,
136
+ backend_orchestrator: Optional[Any] = None,
137
+ agent_id_validator: Optional[Callable[[str], str]] = None,
125
138
  ):
126
139
  if config is None:
127
140
  config = load_repo_config(repo_root, hub_path=hub_path)
@@ -134,21 +147,42 @@ class Engine:
134
147
  self._run_index_store = RunIndexStore(self.state_path)
135
148
  self.lock_path = self.repo_root / ".codex-autorunner" / "lock"
136
149
  self.stop_path = self.repo_root / ".codex-autorunner" / "stop"
150
+ self._hub_path = hub_path
137
151
  self._active_global_handler: Optional[RotatingFileHandler] = None
138
152
  self._active_run_log: Optional[IO[str]] = None
139
153
  self._app_server_threads = AppServerThreadRegistry(
140
154
  default_app_server_threads_path(self.repo_root)
141
155
  )
142
156
  self._app_server_threads_lock = threading.Lock()
143
- self._app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None
157
+ self._backend_factory = backend_factory
158
+ self._app_server_supervisor_factory = app_server_supervisor_factory
159
+ self._app_server_supervisor: Optional[Any] = None
160
+ self._backend_orchestrator: Optional[Any] = None
144
161
  self._app_server_logger = logging.getLogger("codex_autorunner.app_server")
145
- self._app_server_event_formatter = AppServerEventFormatter()
146
- self._app_server_events = AppServerEventBuffer()
147
- self._opencode_event_formatter = OpenCodeEventFormatter()
148
- self._opencode_supervisor: Optional[OpenCodeSupervisor] = None
162
+ self._agent_id_validator = agent_id_validator or validate_agent_id
163
+ redact_enabled = self.config.security.get("redact_run_logs", True)
164
+ self._app_server_event_formatter = AppServerEventFormatter(
165
+ redact_enabled=redact_enabled
166
+ )
167
+ self._opencode_supervisor: Optional[Any] = None
168
+
169
+ # Backend orchestrator for protocol-agnostic backend management
170
+ # Use provided orchestrator if available (for testing), otherwise create it
171
+ self._backend_orchestrator = None
172
+ if backend_orchestrator is not None:
173
+ self._backend_orchestrator = backend_orchestrator
174
+ elif backend_factory is None and app_server_supervisor_factory is None:
175
+ self._backend_orchestrator = self._build_backend_orchestrator()
176
+ else:
177
+ self._app_server_logger.debug(
178
+ "Skipping BackendOrchestrator creation because backend_factory or app_server_supervisor_factory is set",
179
+ )
180
+ self._backend_orchestrator = None
149
181
  self._run_telemetry_lock = threading.Lock()
150
182
  self._run_telemetry: Optional[RunTelemetry] = None
151
183
  self._last_telemetry_update_time: float = 0.0
184
+ self._canonical_event_lock = threading.Lock()
185
+ self._canonical_event_seq: dict[int, int] = {}
152
186
  self._last_run_interrupted = False
153
187
  self._lock_handle: Optional[FileLock] = None
154
188
  # Ensure the interactive TUI briefing doc exists (for web Terminal "New").
@@ -159,6 +193,44 @@ class Engine:
159
193
  self._app_server_logger.debug(
160
194
  "Best-effort ABOUT_CAR.md creation failed: %s", exc
161
195
  )
196
+ try:
197
+ ensure_ticket_linter(self.config.root)
198
+ except (OSError, IOError) as exc:
199
+ self._app_server_logger.debug(
200
+ "Best-effort lint_tickets.py creation failed: %s", exc
201
+ )
202
+ try:
203
+ ensure_ticket_manager(self.config.root)
204
+ except (OSError, IOError) as exc:
205
+ self._app_server_logger.debug(
206
+ "Best-effort ticket_tool.py creation failed: %s", exc
207
+ )
208
+
209
+ def _build_backend_orchestrator(self) -> Optional[Any]:
210
+ """
211
+ Dynamically construct BackendOrchestrator without introducing a core -> integrations
212
+ import-time dependency. Keeps import-boundary checks satisfied.
213
+ """
214
+ try:
215
+ module = importlib.import_module(
216
+ "codex_autorunner.integrations.agents.backend_orchestrator"
217
+ )
218
+ orchestrator_cls = getattr(module, "BackendOrchestrator", None)
219
+ if orchestrator_cls is None:
220
+ raise AttributeError("BackendOrchestrator not found in module")
221
+ return orchestrator_cls(
222
+ repo_root=self.repo_root,
223
+ config=self.config,
224
+ notification_handler=self._handle_app_server_notification,
225
+ logger=self._app_server_logger,
226
+ )
227
+ except Exception as exc:
228
+ self._app_server_logger.warning(
229
+ "Failed to create BackendOrchestrator: %s\n%s",
230
+ exc,
231
+ traceback.format_exc(),
232
+ )
233
+ return None
162
234
 
163
235
  @staticmethod
164
236
  def from_cwd(repo: Optional[Path] = None) -> "Engine":
@@ -262,41 +334,21 @@ class Engine:
262
334
  return None
263
335
 
264
336
  def todos_done(self) -> bool:
265
- return self.docs.todos_done()
337
+ # Ticket-first mode: completion is determined by ticket files, not TODO.md.
338
+ ticket_dir = self.repo_root / ".codex-autorunner" / "tickets"
339
+ ticket_paths = list_ticket_paths(ticket_dir)
340
+ if not ticket_paths:
341
+ return False
342
+ return all(ticket_is_done(path) for path in ticket_paths)
266
343
 
267
344
  def summary_finalized(self) -> bool:
268
- """Return True if SUMMARY.md contains the finalization marker."""
269
- try:
270
- text = self.docs.read_doc("summary")
271
- except (FileNotFoundError, OSError) as exc:
272
- self._app_server_logger.debug("Failed to read SUMMARY.md: %s", exc)
273
- return False
274
- return SUMMARY_FINALIZED_MARKER in (text or "")
345
+ # Legacy docs finalization no longer applies (no SUMMARY doc).
346
+ return True
275
347
 
276
348
  def _stamp_summary_finalized(self, run_id: int) -> None:
277
- """
278
- Append an idempotency marker to SUMMARY.md so the final summary job runs only once.
279
- Users may remove the marker to force regeneration.
280
- """
281
- path = self.config.doc_path("summary")
282
- try:
283
- existing = path.read_text(encoding="utf-8") if path.exists() else ""
284
- except (FileNotFoundError, OSError) as exc:
285
- self._app_server_logger.debug(
286
- "Failed to read SUMMARY.md for stamping: %s", exc
287
- )
288
- existing = ""
289
- if SUMMARY_FINALIZED_MARKER in existing:
290
- return
291
- stamp = f"{SUMMARY_FINALIZED_MARKER_PREFIX} run_id={int(run_id)} -->\n"
292
- new_text = existing
293
- if new_text and not new_text.endswith("\n"):
294
- new_text += "\n"
295
- # Keep a blank line before the marker for readability.
296
- if new_text and not new_text.endswith("\n\n"):
297
- new_text += "\n"
298
- new_text += stamp
299
- atomic_write(path, new_text)
349
+ # No-op: summary file no longer exists.
350
+ _ = run_id
351
+ return
300
352
 
301
353
  async def _execute_run_step(
302
354
  self,
@@ -317,43 +369,56 @@ class Engine:
317
369
  try:
318
370
  todo_before = self.docs.read_doc("todo")
319
371
  except (FileNotFoundError, OSError) as exc:
320
- self._app_server_logger.debug("Failed to read TODO.md before run: %s", exc)
372
+ self._app_server_logger.debug(
373
+ "Failed to read TODO.md before run %s: %s", run_id, exc
374
+ )
321
375
  todo_before = ""
322
376
  state = load_state(self.state_path)
323
- selected_agent = (state.autorunner_agent_override or "codex").strip().lower()
324
377
  try:
325
- validated_agent = validate_agent_id(selected_agent)
378
+ validated_agent = self._agent_id_validator(
379
+ state.autorunner_agent_override or "codex"
380
+ )
326
381
  except ValueError:
327
382
  validated_agent = "codex"
328
383
  self.log_line(
329
384
  run_id,
330
- f"info: unknown agent '{selected_agent}', defaulting to codex",
385
+ f"info: unknown agent '{state.autorunner_agent_override}', defaulting to codex",
331
386
  )
332
387
  self._update_state("running", run_id, None, started=True)
333
388
  self._last_run_interrupted = False
334
389
  self._start_run_telemetry(run_id)
390
+
391
+ actor: dict[str, Any] = {
392
+ "backend": "codex_app_server",
393
+ "agent_id": validated_agent,
394
+ "surface": "hub" if self._hub_path else "cli",
395
+ }
396
+ mode: dict[str, Any] = {
397
+ "approval_policy": state.autorunner_approval_policy or "never",
398
+ "sandbox": state.autorunner_sandbox_mode or "dangerFullAccess",
399
+ }
400
+ runner_cfg = self.config.raw.get("runner") or {}
401
+ review_cfg = runner_cfg.get("review")
402
+ if isinstance(review_cfg, dict):
403
+ mode["review_enabled"] = bool(review_cfg.get("enabled"))
404
+
335
405
  with self._run_log_context(run_id):
336
- self._write_run_marker(run_id, "start")
337
- if validated_agent == "opencode":
338
- exit_code = await self._run_opencode_app_server_async(
339
- prompt,
340
- run_id,
341
- model=state.autorunner_model_override,
342
- reasoning=state.autorunner_effort_override,
343
- external_stop_flag=external_stop_flag,
344
- )
345
- else:
346
- exit_code = await self._run_codex_app_server_async(
347
- prompt,
348
- run_id,
349
- external_stop_flag=external_stop_flag,
350
- )
406
+ self._write_run_marker(run_id, "start", actor=actor, mode=mode)
407
+ exit_code = await self._run_agent_async(
408
+ agent_id=validated_agent,
409
+ prompt=prompt,
410
+ run_id=run_id,
411
+ state=state,
412
+ external_stop_flag=external_stop_flag,
413
+ )
351
414
  self._write_run_marker(run_id, "end", exit_code=exit_code)
352
415
 
353
416
  try:
354
417
  todo_after = self.docs.read_doc("todo")
355
418
  except (FileNotFoundError, OSError) as exc:
356
- self._app_server_logger.debug("Failed to read TODO.md after run: %s", exc)
419
+ self._app_server_logger.debug(
420
+ "Failed to read TODO.md after run %s: %s", run_id, exc
421
+ )
357
422
  todo_after = ""
358
423
  todo_delta = self._compute_todo_attribution(todo_before, todo_after)
359
424
  todo_snapshot = self._build_todo_snapshot(todo_before, todo_after)
@@ -362,6 +427,7 @@ class Engine:
362
427
  "todo_snapshot": todo_snapshot,
363
428
  }
364
429
  telemetry = self._snapshot_run_telemetry(run_id)
430
+ usage_payload: Optional[dict[str, Any]] = None
365
431
  if (
366
432
  telemetry
367
433
  and telemetry.thread_id
@@ -374,42 +440,51 @@ class Engine:
374
440
  thread_id=telemetry.thread_id, run_id=run_id
375
441
  )
376
442
  delta = self._compute_token_delta(baseline, telemetry.token_total)
377
- run_updates["token_usage"] = {
443
+ token_usage_payload = {
378
444
  "delta": delta,
379
445
  "thread_total_before": baseline,
380
446
  "thread_total_after": telemetry.token_total,
381
447
  }
448
+ run_updates["token_usage"] = token_usage_payload
449
+ usage_payload = {
450
+ "run_id": run_id,
451
+ "captured_at": timestamp(),
452
+ "agent": validated_agent,
453
+ "thread_id": telemetry.thread_id,
454
+ "turn_id": telemetry.turn_id,
455
+ "token_usage": token_usage_payload,
456
+ # Use getattr() for optional config attributes that may not exist in all config versions
457
+ "cache_scope": getattr(self.config.usage, "cache_scope", "global"),
458
+ }
382
459
  artifacts: dict[str, str] = {}
460
+ if usage_payload is not None:
461
+ usage_path = self._write_run_usage_artifact(run_id, usage_payload)
462
+ if usage_path is not None:
463
+ artifacts["usage_path"] = str(usage_path)
464
+ redact_enabled = self.config.security.get("redact_run_logs", True)
383
465
  if telemetry and telemetry.plan is not None:
384
- try:
385
- plan_content = (
386
- telemetry.plan
387
- if isinstance(telemetry.plan, str)
388
- else json.dumps(
389
- telemetry.plan, ensure_ascii=True, indent=2, default=str
390
- )
391
- )
392
- except (TypeError, ValueError) as exc:
393
- self._app_server_logger.debug(
394
- "Failed to serialize plan to JSON: %s", exc
395
- )
396
- plan_content = json.dumps(
397
- {"plan": str(telemetry.plan)}, ensure_ascii=True, indent=2
398
- )
466
+ plan_content = self._serialize_plan_content(
467
+ telemetry.plan, redact_enabled=redact_enabled, run_id=run_id
468
+ )
399
469
  plan_path = self._write_run_artifact(run_id, "plan.json", plan_content)
400
470
  artifacts["plan_path"] = str(plan_path)
401
471
  if telemetry and telemetry.diff is not None:
402
- diff_content = (
403
- telemetry.diff
404
- if isinstance(telemetry.diff, str)
405
- else json.dumps(
406
- telemetry.diff, ensure_ascii=True, indent=2, default=str
407
- )
472
+ diff_content = self._serialize_diff_content(
473
+ telemetry.diff, redact_enabled=redact_enabled
408
474
  )
409
- diff_path = self._write_run_artifact(run_id, "diff.patch", diff_content)
410
- artifacts["diff_path"] = str(diff_path)
475
+ if diff_content is not None:
476
+ diff_path = self._write_run_artifact(run_id, "diff.patch", diff_content)
477
+ artifacts["diff_path"] = str(diff_path)
411
478
  if artifacts:
412
479
  run_updates["artifacts"] = artifacts
480
+ if redact_enabled:
481
+ from .redaction import get_redaction_patterns
482
+
483
+ run_updates["security"] = {
484
+ "redaction_enabled": True,
485
+ "redaction_version": "1.0",
486
+ "redaction_patterns": get_redaction_patterns(),
487
+ }
413
488
  if run_updates:
414
489
  self._merge_run_index_entry(run_id, run_updates)
415
490
  self._clear_run_telemetry(run_id)
@@ -457,7 +532,7 @@ class Engine:
457
532
  text = run_log.read_text(encoding="utf-8")
458
533
  except (FileNotFoundError, OSError) as exc:
459
534
  self._app_server_logger.debug(
460
- "Failed to read previous run log: %s", exc
535
+ "Failed to read previous run log for run %s: %s", run_id, exc
461
536
  )
462
537
  text = ""
463
538
  if text:
@@ -508,10 +583,12 @@ class Engine:
508
583
  try:
509
584
  return run_log.read_text(encoding="utf-8")
510
585
  except (FileNotFoundError, OSError) as exc:
511
- self._app_server_logger.debug("Failed to read run log block: %s", exc)
586
+ self._app_server_logger.debug(
587
+ "Failed to read run log block for run %s: %s", run_id, exc
588
+ )
512
589
  return None
513
590
  if index_entry:
514
- block = self._read_log_range(index_entry)
591
+ block = self._read_log_range(run_id, index_entry)
515
592
  if block is not None:
516
593
  return block
517
594
  if not self.log_path.exists():
@@ -555,7 +632,7 @@ class Engine:
555
632
  return "\n".join(buf) if buf else None
556
633
  except (FileNotFoundError, OSError, ValueError) as exc:
557
634
  self._app_server_logger.debug(
558
- "Failed to read full log for run block: %s", exc
635
+ "Failed to read full log for run %s block: %s", run_id, exc
559
636
  )
560
637
  return None
561
638
  return None
@@ -582,7 +659,7 @@ class Engine:
582
659
  self._active_run_log.flush()
583
660
  except (OSError, IOError) as exc:
584
661
  self._app_server_logger.warning(
585
- "Failed to write to active run log: %s", exc
662
+ "Failed to write to active run log for run %s: %s", run_id, exc
586
663
  )
587
664
  else:
588
665
  run_log = self._run_log_path(run_id)
@@ -607,7 +684,69 @@ class Engine:
607
684
  f.write(_json.dumps(event_data) + "\n")
608
685
  except (OSError, IOError) as exc:
609
686
  self._app_server_logger.warning(
610
- "Failed to write event to events log: %s", exc
687
+ "Failed to write event to events log for run %s: %s", run_id, exc
688
+ )
689
+ event_type = {
690
+ "run.started": FlowEventType.RUN_STARTED,
691
+ "run.finished": FlowEventType.RUN_FINISHED,
692
+ "run.state_changed": FlowEventType.RUN_STATE_CHANGED,
693
+ "run.no_progress": FlowEventType.RUN_NO_PROGRESS,
694
+ "token.updated": FlowEventType.TOKEN_USAGE,
695
+ "plan.updated": FlowEventType.PLAN_UPDATED,
696
+ "diff.updated": FlowEventType.DIFF_UPDATED,
697
+ }.get(event)
698
+ if event_type is not None:
699
+ self._emit_canonical_event(run_id, event_type, payload)
700
+
701
+ def _emit_canonical_event(
702
+ self,
703
+ run_id: int,
704
+ event_type: FlowEventType,
705
+ data: Optional[dict[str, Any]] = None,
706
+ *,
707
+ step_id: Optional[str] = None,
708
+ timestamp_override: Optional[str] = None,
709
+ ) -> None:
710
+ event_payload: dict[str, Any] = {
711
+ "id": uuid.uuid4().hex,
712
+ "run_id": str(run_id),
713
+ "event_type": event_type.value,
714
+ "timestamp": timestamp_override or now_iso(),
715
+ "data": data or {},
716
+ }
717
+ if step_id is not None:
718
+ event_payload["step_id"] = step_id
719
+ self._ensure_run_log_dir()
720
+ with self._canonical_event_lock:
721
+ seq = self._canonical_event_seq.get(run_id, 0) + 1
722
+ self._canonical_event_seq[run_id] = seq
723
+ event_payload["seq"] = seq
724
+ events_path = self._canonical_events_log_path(run_id)
725
+ try:
726
+ with events_path.open("a", encoding="utf-8") as f:
727
+ f.write(json.dumps(event_payload, ensure_ascii=True) + "\n")
728
+ except (OSError, IOError) as exc:
729
+ self._app_server_logger.warning(
730
+ "Failed to write canonical event for run %s: %s", run_id, exc
731
+ )
732
+
733
+ async def _cancel_task_with_notice(
734
+ self,
735
+ run_id: int,
736
+ task: asyncio.Task[Any],
737
+ *,
738
+ name: str,
739
+ ) -> None:
740
+ if task.done():
741
+ return
742
+ task.cancel()
743
+ try:
744
+ await task
745
+ except asyncio.CancelledError:
746
+ self._emit_canonical_event(
747
+ run_id,
748
+ FlowEventType.RUN_CANCELLED,
749
+ {"task": name},
611
750
  )
612
751
 
613
752
  def _ensure_log_path(self) -> None:
@@ -619,18 +758,32 @@ class Engine:
619
758
  def _events_log_path(self, run_id: int) -> Path:
620
759
  return self.log_path.parent / "runs" / f"run-{run_id}.events.jsonl"
621
760
 
761
+ def _canonical_events_log_path(self, run_id: int) -> Path:
762
+ return self.log_path.parent / "runs" / f"run-{run_id}.events.canonical.jsonl"
763
+
622
764
  def _ensure_run_log_dir(self) -> None:
623
765
  (self.log_path.parent / "runs").mkdir(parents=True, exist_ok=True)
624
766
 
625
767
  def _write_run_marker(
626
- self, run_id: int, marker: str, exit_code: Optional[int] = None
768
+ self,
769
+ run_id: int,
770
+ marker: str,
771
+ exit_code: Optional[int] = None,
772
+ *,
773
+ actor: Optional[dict[str, Any]] = None,
774
+ mode: Optional[dict[str, Any]] = None,
627
775
  ) -> None:
628
776
  suffix = ""
629
777
  if marker == "end":
630
778
  suffix = f" (code {exit_code})"
631
779
  self._emit_event(run_id, "run.finished", exit_code=exit_code)
632
780
  elif marker == "start":
633
- self._emit_event(run_id, "run.started")
781
+ payload: dict[str, Any] = {}
782
+ if actor is not None:
783
+ payload["actor"] = actor
784
+ if mode is not None:
785
+ payload["mode"] = mode
786
+ self._emit_event(run_id, "run.started", **payload)
634
787
  text = f"=== run {run_id} {marker}{suffix} ==="
635
788
  offset = self._emit_global_line(text)
636
789
  if self._active_run_log is not None:
@@ -639,14 +792,18 @@ class Engine:
639
792
  self._active_run_log.flush()
640
793
  except (OSError, IOError) as exc:
641
794
  self._app_server_logger.warning(
642
- "Failed to write marker to active run log: %s", exc
795
+ "Failed to write marker to active run log for run %s: %s",
796
+ run_id,
797
+ exc,
643
798
  )
644
799
  else:
645
800
  self._ensure_run_log_dir()
646
801
  run_log = self._run_log_path(run_id)
647
802
  with run_log.open("a", encoding="utf-8") as f:
648
803
  f.write(f"{text}\n")
649
- self._update_run_index(run_id, marker, offset, exit_code)
804
+ self._update_run_index(
805
+ run_id, marker, offset, exit_code, actor=actor, mode=mode
806
+ )
650
807
 
651
808
  def _emit_global_line(self, text: str) -> Optional[tuple[int, int]]:
652
809
  if self._active_global_handler is None:
@@ -693,6 +850,7 @@ class Engine:
693
850
  def _run_log_context(self, run_id: int) -> Iterator[None]:
694
851
  self._ensure_log_path()
695
852
  self._ensure_run_log_dir()
853
+ # Use getattr() for optional config attributes that may not exist in all config versions
696
854
  max_bytes = getattr(self.config.log, "max_bytes", None) or 0
697
855
  backup_count = getattr(self.config.log, "backup_count", 0) or 0
698
856
  handler = RotatingFileHandler(
@@ -715,14 +873,13 @@ class Engine:
715
873
  handler.close()
716
874
  except (OSError, IOError) as exc:
717
875
  self._app_server_logger.debug(
718
- "Failed to close run log handler: %s", exc
876
+ "Failed to close run log handler for run %s: %s", run_id, exc
719
877
  )
720
878
 
721
879
  def _start_run_telemetry(self, run_id: int) -> None:
722
880
  with self._run_telemetry_lock:
723
881
  self._run_telemetry = RunTelemetry(run_id=run_id)
724
882
  self._app_server_event_formatter.reset()
725
- self._opencode_event_formatter.reset()
726
883
 
727
884
  def _update_run_telemetry(self, run_id: int, **updates: Any) -> None:
728
885
  with self._run_telemetry_lock:
@@ -747,6 +904,75 @@ class Engine:
747
904
  return
748
905
  self._run_telemetry = None
749
906
 
907
+ @staticmethod
908
+ def _normalize_diff_payload(diff: Any) -> Optional[Any]:
909
+ if diff is None:
910
+ return None
911
+ if isinstance(diff, str):
912
+ return diff if diff.strip() else None
913
+ if isinstance(diff, dict):
914
+ # Prefer meaningful fields if present.
915
+ for key in ("diff", "patch", "content", "value"):
916
+ if key in diff:
917
+ val = diff.get(key)
918
+ if isinstance(val, str) and val.strip():
919
+ return val
920
+ if val not in (None, "", [], {}, ()):
921
+ return diff
922
+ for val in diff.values():
923
+ if isinstance(val, str) and val.strip():
924
+ return diff
925
+ if val not in (None, "", [], {}, ()):
926
+ return diff
927
+ return None
928
+ return diff
929
+
930
+ @staticmethod
931
+ def _hash_content(content: str) -> str:
932
+ return hashlib.sha256((content or "").encode("utf-8")).hexdigest()
933
+
934
+ def _serialize_plan_content(
935
+ self,
936
+ plan: Any,
937
+ *,
938
+ redact_enabled: bool,
939
+ run_id: Optional[int] = None,
940
+ ) -> str:
941
+ try:
942
+ content = (
943
+ plan
944
+ if isinstance(plan, str)
945
+ else json.dumps(plan, ensure_ascii=True, indent=2, default=str)
946
+ )
947
+ except (TypeError, ValueError) as exc:
948
+ if run_id is not None:
949
+ self._app_server_logger.debug(
950
+ "Failed to serialize plan to JSON for run %s: %s", run_id, exc
951
+ )
952
+ else:
953
+ self._app_server_logger.debug(
954
+ "Failed to serialize plan to JSON: %s", exc
955
+ )
956
+ content = json.dumps({"plan": str(plan)}, ensure_ascii=True, indent=2)
957
+ if redact_enabled:
958
+ content = redact_text(content)
959
+ return content
960
+
961
+ def _serialize_diff_content(
962
+ self, diff: Any, *, redact_enabled: bool
963
+ ) -> Optional[str]:
964
+ normalized = self._normalize_diff_payload(diff)
965
+ if normalized is None:
966
+ return None
967
+ content = (
968
+ normalized
969
+ if isinstance(normalized, str)
970
+ else json.dumps(normalized, ensure_ascii=True, indent=2, default=str)
971
+ )
972
+ if redact_enabled:
973
+ content = redact_text(content)
974
+ return content
975
+
750
976
  def _maybe_update_run_index_telemetry(
751
977
  self, run_id: int, min_interval_seconds: float = 3.0
752
978
  ) -> None:
@@ -789,12 +1015,14 @@ class Engine:
789
1015
  params_raw = message.get("params")
790
1016
  params = params_raw if isinstance(params_raw, dict) else {}
791
1017
  thread_id = (
792
- _extract_thread_id_for_turn(params)
793
- or _extract_thread_id(params)
794
- or _extract_thread_id(message)
1018
+ extract_thread_id_for_turn(params)
1019
+ or extract_thread_id(params)
1020
+ or extract_thread_id(message)
795
1021
  )
796
- turn_id = _extract_turn_id(params) or _extract_turn_id(message)
1022
+ turn_id = extract_turn_id(params) or extract_turn_id(message)
797
1023
  run_id: Optional[int] = None
1024
+ plan_update: Any = None
1025
+ diff_update: Any = None
798
1026
  with self._run_telemetry_lock:
799
1027
  telemetry = self._run_telemetry
800
1028
  if telemetry is None:
@@ -819,17 +1047,60 @@ class Engine:
819
1047
  self._maybe_update_run_index_telemetry(run_id)
820
1048
  self._emit_event(run_id, "token.updated", token_total=total)
821
1049
  if method == "turn/plan/updated":
822
- telemetry.plan = params.get("plan") if "plan" in params else params
1050
+ plan_update = params.get("plan") if "plan" in params else params
1051
+ telemetry.plan = plan_update
823
1052
  if method == "turn/diff/updated":
824
- diff = (
825
- params.get("diff")
826
- or params.get("patch")
827
- or params.get("content")
828
- or params.get("value")
829
- )
830
- telemetry.diff = diff if diff is not None else params
1053
+ diff: Any = None
1054
+ for key in ("diff", "patch", "content", "value"):
1055
+ if key in params:
1056
+ diff = params.get(key)
1057
+ break
1058
+ diff_update = diff if diff is not None else params or None
1059
+ telemetry.diff = diff_update
831
1060
  if run_id is None:
832
1061
  return
1062
+ redact_enabled = self.config.security.get("redact_run_logs", True)
1063
+ notification_path = self._append_run_notification(
1064
+ run_id, message, redact_enabled
1065
+ )
1066
+ if notification_path is not None:
1067
+ self._merge_run_index_entry(
1068
+ run_id,
1069
+ {
1070
+ "artifacts": {
1071
+ "app_server_notifications_path": str(notification_path)
1072
+ }
1073
+ },
1074
+ )
1075
+ if plan_update is not None:
1076
+ plan_content = self._serialize_plan_content(
1077
+ plan_update, redact_enabled=redact_enabled, run_id=run_id
1078
+ )
1079
+ plan_path = self._write_run_artifact(run_id, "plan.json", plan_content)
1080
+ self._merge_run_index_entry(
1081
+ run_id, {"artifacts": {"plan_path": str(plan_path)}}
1082
+ )
1083
+ self._emit_event(
1084
+ run_id,
1085
+ "plan.updated",
1086
+ plan_hash=self._hash_content(plan_content),
1087
+ plan_path=str(plan_path),
1088
+ )
1089
+ if diff_update is not None:
1090
+ diff_content = self._serialize_diff_content(
1091
+ diff_update, redact_enabled=redact_enabled
1092
+ )
1093
+ if diff_content is not None:
1094
+ diff_path = self._write_run_artifact(run_id, "diff.patch", diff_content)
1095
+ self._merge_run_index_entry(
1096
+ run_id, {"artifacts": {"diff_path": str(diff_path)}}
1097
+ )
1098
+ self._emit_event(
1099
+ run_id,
1100
+ "diff.updated",
1101
+ diff_hash=self._hash_content(diff_content),
1102
+ diff_path=str(diff_path),
1103
+ )
833
1104
  for line in self._app_server_event_formatter.format_event(message):
834
1105
  self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
835
1106
 
@@ -847,7 +1118,10 @@ class Engine:
847
1118
  """
848
1119
  try:
849
1120
  state = load_state(self.state_path)
850
- except Exception:
1121
+ except Exception as exc:
1122
+ self._app_server_logger.warning(
1123
+ "Failed to load state during run index reconciliation: %s", exc
1124
+ )
851
1125
  return
852
1126
 
853
1127
  active_pid: Optional[int] = None
@@ -870,7 +1144,10 @@ class Engine:
870
1144
  now = now_iso()
871
1145
  try:
872
1146
  index = self._run_index_store.load_all()
873
- except Exception:
1147
+ except Exception as exc:
1148
+ self._app_server_logger.warning(
1149
+ "Failed to load run index during reconciliation: %s", exc
1150
+ )
874
1151
  return
875
1152
 
876
1153
  for key, entry in index.items():
@@ -917,7 +1194,10 @@ class Engine:
917
1194
  ),
918
1195
  },
919
1196
  )
920
- except Exception:
1197
+ except Exception as exc:
1198
+ self._app_server_logger.warning(
1199
+ "Failed to reconcile run index entry for run %d: %s", run_id, exc
1200
+ )
921
1201
  continue
922
1202
 
923
1203
  def _merge_run_index_entry(self, run_id: int, updates: dict[str, Any]) -> None:
@@ -929,6 +1209,9 @@ class Engine:
929
1209
  marker: str,
930
1210
  offset: Optional[tuple[int, int]],
931
1211
  exit_code: Optional[int],
1212
+ *,
1213
+ actor: Optional[dict[str, Any]] = None,
1214
+ mode: Optional[dict[str, Any]] = None,
932
1215
  ) -> None:
933
1216
  self._run_index_store.update_marker(
934
1217
  run_id,
@@ -937,6 +1220,8 @@ class Engine:
937
1220
  exit_code,
938
1221
  log_path=str(self.log_path),
939
1222
  run_log_path=str(self._run_log_path(run_id)),
1223
+ actor=actor,
1224
+ mode=mode,
940
1225
  )
941
1226
 
942
1227
  def _list_from_counts(self, source: list[str], counts: Counter[str]) -> list[str]:
@@ -1021,7 +1306,10 @@ class Engine:
1021
1306
  entry_id = int(key)
1022
1307
  except (TypeError, ValueError) as exc:
1023
1308
  self._app_server_logger.debug(
1024
- "Failed to parse run index key '%s': %s", key, exc
1309
+ "Failed to parse run index key '%s' while resolving run %s: %s",
1310
+ key,
1311
+ run_id,
1312
+ exc,
1025
1313
  )
1026
1314
  continue
1027
1315
  if entry_id >= run_id:
@@ -1106,7 +1394,52 @@ class Engine:
1106
1394
  atomic_write(path, content)
1107
1395
  return path
1108
1396
 
1109
- def _read_log_range(self, entry: dict) -> Optional[str]:
1397
+ def _write_run_usage_artifact(
1398
+ self, run_id: int, payload: dict[str, Any]
1399
+ ) -> Optional[Path]:
1400
+ self._ensure_run_log_dir()
1401
+ run_dir = self.log_path.parent / "runs" / str(run_id)
1402
+ try:
1403
+ run_dir.mkdir(parents=True, exist_ok=True)
1404
+ path = run_dir / "usage.json"
1405
+ atomic_write(
1406
+ path,
1407
+ json.dumps(payload, ensure_ascii=True, indent=2, default=str),
1408
+ )
1409
+ return path
1410
+ except OSError as exc:
1411
+ self._app_server_logger.warning(
1412
+ "Failed to write usage artifact for run %s: %s", run_id, exc
1413
+ )
1414
+ return None
1415
+
1416
+ def _app_server_notifications_path(self, run_id: int) -> Path:
1417
+ return (
1418
+ self.log_path.parent
1419
+ / "runs"
1420
+ / f"run-{run_id}.app_server.notifications.jsonl"
1421
+ )
1422
+
1423
+ def _append_run_notification(
1424
+ self, run_id: int, message: dict[str, Any], redact_enabled: bool
1425
+ ) -> Optional[Path]:
1426
+ self._ensure_run_log_dir()
1427
+ path = self._app_server_notifications_path(run_id)
1428
+ payload = {"ts": timestamp(), "message": message}
1429
+ try:
1430
+ line = json.dumps(payload, ensure_ascii=True, default=str)
1431
+ if redact_enabled:
1432
+ line = redact_text(line)
1433
+ with path.open("a", encoding="utf-8") as f:
1434
+ f.write(line + "\n")
1435
+ except (OSError, IOError, TypeError, ValueError) as exc:
1436
+ self._app_server_logger.warning(
1437
+ "Failed to write app-server notification for run %s: %s", run_id, exc
1438
+ )
1439
+ return None
1440
+ return path
1441
+
1442
+ def _read_log_range(self, run_id: int, entry: dict) -> Optional[str]:
1110
1443
  start = entry.get("start_offset")
1111
1444
  end = entry.get("end_offset")
1112
1445
  if start is None or end is None:
@@ -1115,7 +1448,9 @@ class Engine:
1115
1448
  start_offset = int(start)
1116
1449
  end_offset = int(end)
1117
1450
  except (TypeError, ValueError) as exc:
1118
- self._app_server_logger.debug("Failed to parse log range offsets: %s", exc)
1451
+ self._app_server_logger.debug(
1452
+ "Failed to parse log range offsets for run %s: %s", run_id, exc
1453
+ )
1119
1454
  return None
1120
1455
  if end_offset < start_offset:
1121
1456
  return None
@@ -1131,7 +1466,9 @@ class Engine:
1131
1466
  data = f.read(end_offset - start_offset)
1132
1467
  return data.decode("utf-8", errors="replace")
1133
1468
  except (FileNotFoundError, OSError) as exc:
1134
- self._app_server_logger.debug("Failed to read log range: %s", exc)
1469
+ self._app_server_logger.debug(
1470
+ "Failed to read log range for run %s: %s", run_id, exc
1471
+ )
1135
1472
  return None
1136
1473
 
1137
1474
  def _build_app_server_prompt(self, prev_output: Optional[str]) -> str:
@@ -1154,7 +1491,6 @@ class Engine:
1154
1491
  prompt,
1155
1492
  run_id,
1156
1493
  external_stop_flag=external_stop_flag,
1157
- reuse_supervisor=False,
1158
1494
  )
1159
1495
  )
1160
1496
  except RuntimeError as exc:
@@ -1166,114 +1502,312 @@ class Engine:
1166
1502
  return 1
1167
1503
  raise
1168
1504
 
1169
- async def _run_agent_turn_async(
1505
+ async def _run_agent_async(
1170
1506
  self,
1507
+ *,
1171
1508
  agent_id: str,
1172
1509
  prompt: str,
1173
1510
  run_id: int,
1174
- *,
1175
- external_stop_flag: Optional[threading.Event] = None,
1511
+ state: RunnerState,
1512
+ external_stop_flag: Optional[threading.Event],
1176
1513
  ) -> int:
1177
- orchestrator = self._get_orchestrator(agent_id)
1178
- if orchestrator is None:
1179
- self.log_line(
1180
- run_id,
1181
- f"error: agent '{agent_id}' backend is not configured",
1514
+ """
1515
+ Run an agent turn using the specified backend.
1516
+
1517
+ This method is protocol-agnostic - it determines the appropriate
1518
+ model/reasoning parameters based on the agent_id and delegates to
1519
+ either the BackendOrchestrator or _run_agent_backend_async().
1520
+ """
1521
+ # Determine model and reasoning parameters based on agent
1522
+ if agent_id == "codex":
1523
+ model = state.autorunner_model_override or self.config.codex_model
1524
+ reasoning = state.autorunner_effort_override or self.config.codex_reasoning
1525
+ elif agent_id == "opencode":
1526
+ model = state.autorunner_model_override
1527
+ reasoning = state.autorunner_effort_override
1528
+ else:
1529
+ # Fallback to codex defaults for unknown agents
1530
+ model = state.autorunner_model_override or self.config.codex_model
1531
+ reasoning = state.autorunner_effort_override or self.config.codex_reasoning
1532
+
1533
+ # Use BackendOrchestrator if available, otherwise fall back to old method
1534
+ if agent_id == "codex":
1535
+ session_key = "autorunner"
1536
+ elif agent_id == "opencode":
1537
+ session_key = "autorunner.opencode"
1538
+ else:
1539
+ session_key = "autorunner"
1540
+
1541
+ if self._backend_orchestrator is not None:
1542
+ return await self._run_agent_via_orchestrator(
1543
+ agent_id=agent_id,
1544
+ prompt=prompt,
1545
+ run_id=run_id,
1546
+ state=state,
1547
+ model=model,
1548
+ reasoning=reasoning,
1549
+ session_key=session_key,
1550
+ external_stop_flag=external_stop_flag,
1182
1551
  )
1183
- return 1
1184
1552
 
1185
- thread_key = f"autorunner.{agent_id}"
1186
- with state_lock(self.state_path):
1187
- state = load_state(self.state_path)
1188
- effective_model = state.autorunner_model_override or self.config.codex_model
1189
- effective_effort = (
1190
- state.autorunner_effort_override or self.config.codex_reasoning
1553
+ # Fallback to old method for backward compatibility (testing)
1554
+ return await self._run_agent_backend_async(
1555
+ agent_id=agent_id,
1556
+ prompt=prompt,
1557
+ run_id=run_id,
1558
+ state=state,
1559
+ session_key=session_key,
1560
+ model=model,
1561
+ reasoning=reasoning,
1562
+ external_stop_flag=external_stop_flag,
1191
1563
  )
1192
1564
 
1193
- with self._app_server_threads_lock:
1194
- conversation_id = self._app_server_threads.get_thread_id(thread_key)
1195
- if not conversation_id:
1196
- try:
1197
- conversation_info = (
1198
- await orchestrator.create_or_resume_conversation(
1199
- self.repo_root, agent_id
1200
- )
1201
- )
1202
- conversation_id = conversation_info.id
1203
- self._app_server_threads.set_thread_id(thread_key, conversation_id)
1204
- except Exception as exc:
1205
- self.log_line(
1206
- run_id, f"error: failed to create conversation: {exc}"
1207
- )
1208
- return 1
1565
+ async def _run_agent_via_orchestrator(
1566
+ self,
1567
+ *,
1568
+ agent_id: str,
1569
+ prompt: str,
1570
+ run_id: int,
1571
+ state: RunnerState,
1572
+ model: Optional[str],
1573
+ reasoning: Optional[str],
1574
+ session_key: str,
1575
+ external_stop_flag: Optional[threading.Event],
1576
+ ) -> int:
1577
+ """
1578
+ Run an agent turn using the BackendOrchestrator.
1579
+
1580
+ This method uses the orchestrator's protocol-agnostic interface to run
1581
+ a turn on the backend, handling all events and emitting canonical events.
1582
+ """
1583
+ orchestrator = self._backend_orchestrator
1584
+ assert (
1585
+ orchestrator is not None
1586
+ ), "orchestrator should be set when calling this method"
1209
1587
 
1210
- if conversation_id:
1211
- self._update_run_telemetry(run_id, thread_id=conversation_id)
1588
+ events: asyncio.Queue[Optional[RunEvent]] = asyncio.Queue()
1212
1589
 
1213
- approval_policy = state.autorunner_approval_policy or "never"
1214
- sandbox_mode = state.autorunner_sandbox_mode or "dangerFullAccess"
1215
- if sandbox_mode == "workspaceWrite":
1216
- sandbox_policy: Union[Dict[str, Any], str] = {
1217
- "type": "workspaceWrite",
1218
- "writableRoots": [str(self.repo_root)],
1219
- "networkAccess": bool(state.autorunner_workspace_write_network),
1220
- }
1221
- else:
1222
- sandbox_policy = sandbox_mode
1590
+ async def _produce_events() -> None:
1591
+ try:
1592
+ async for event in orchestrator.run_turn(
1593
+ agent_id=agent_id,
1594
+ state=state,
1595
+ prompt=prompt,
1596
+ model=model,
1597
+ reasoning=reasoning,
1598
+ session_key=session_key,
1599
+ ):
1600
+ await events.put(event)
1601
+ except Exception as exc:
1602
+ await events.put(Failed(timestamp=now_iso(), error_message=str(exc)))
1603
+ finally:
1604
+ await events.put(None)
1223
1605
 
1224
- stop_event = asyncio.Event()
1225
- stop_task: Optional[asyncio.Task] = None
1606
+ producer_task = asyncio.create_task(_produce_events())
1607
+ stop_task = asyncio.create_task(self._wait_for_stop(external_stop_flag))
1608
+ timeout_seconds = self.config.app_server.turn_timeout_seconds
1609
+ timeout_task: Optional[asyncio.Task] = (
1610
+ asyncio.create_task(asyncio.sleep(timeout_seconds))
1611
+ if timeout_seconds
1612
+ else None
1613
+ )
1226
1614
 
1227
- if external_stop_flag:
1228
- stop_task = asyncio.create_task(
1229
- self._wait_for_stop(external_stop_flag, stop_event)
1230
- )
1615
+ assistant_messages: list[str] = []
1616
+ final_message: Optional[str] = None
1617
+ failed_error: Optional[str] = None
1231
1618
 
1232
1619
  try:
1233
- result = await orchestrator.run_turn(
1234
- self.repo_root,
1235
- conversation_id,
1236
- prompt,
1237
- model=effective_model,
1238
- reasoning=effective_effort,
1239
- approval_mode=approval_policy,
1240
- sandbox_policy=sandbox_policy,
1241
- should_stop=stop_event.is_set,
1242
- )
1243
- if result.get("status") != "completed":
1244
- self.log_line(
1245
- run_id, f"error: turn failed with status {result.get('status')}"
1246
- )
1247
- return 1
1248
- output = result.get("output", "")
1249
- if output:
1250
- self._log_app_server_output(run_id, output.splitlines())
1251
- output_path = self._write_run_artifact(run_id, "output.txt", output)
1252
- self._merge_run_index_entry(
1253
- run_id, {"artifacts": {"output_path": str(output_path)}}
1620
+ while True:
1621
+ get_task = asyncio.create_task(events.get())
1622
+ tasks = {get_task, stop_task}
1623
+ if timeout_task is not None:
1624
+ tasks.add(timeout_task)
1625
+ done, pending = await asyncio.wait(
1626
+ tasks, return_when=asyncio.FIRST_COMPLETED
1254
1627
  )
1255
- return 0
1256
- except Exception as exc:
1257
- self.log_line(run_id, f"error: {exc}")
1258
- return 1
1628
+
1629
+ if get_task in done:
1630
+ event = get_task.result()
1631
+ if event is None:
1632
+ break
1633
+ if isinstance(event, Started) and event.session_id:
1634
+ self._update_run_telemetry(run_id, thread_id=event.session_id)
1635
+ elif isinstance(event, OutputDelta):
1636
+ self._emit_canonical_event(
1637
+ run_id,
1638
+ FlowEventType.AGENT_STREAM_DELTA,
1639
+ {
1640
+ "delta": event.content,
1641
+ "delta_type": event.delta_type,
1642
+ },
1643
+ timestamp_override=event.timestamp,
1644
+ )
1645
+ if event.delta_type in {
1646
+ "assistant_message",
1647
+ "assistant_stream",
1648
+ }:
1649
+ assistant_messages.append(event.content)
1650
+ elif event.delta_type == "log_line":
1651
+ self.log_line(
1652
+ run_id,
1653
+ (
1654
+ f"stdout: {event.content}"
1655
+ if event.content
1656
+ else "stdout: "
1657
+ ),
1658
+ )
1659
+ elif isinstance(event, ToolCall):
1660
+ self._emit_canonical_event(
1661
+ run_id,
1662
+ FlowEventType.TOOL_CALL,
1663
+ {
1664
+ "tool_name": event.tool_name,
1665
+ "tool_input": event.tool_input,
1666
+ },
1667
+ timestamp_override=event.timestamp,
1668
+ )
1669
+ elif isinstance(event, ApprovalRequested):
1670
+ self._emit_canonical_event(
1671
+ run_id,
1672
+ FlowEventType.APPROVAL_REQUESTED,
1673
+ {
1674
+ "request_id": event.request_id,
1675
+ "description": event.description,
1676
+ "context": event.context,
1677
+ },
1678
+ timestamp_override=event.timestamp,
1679
+ )
1680
+ elif isinstance(event, TokenUsage):
1681
+ self._emit_canonical_event(
1682
+ run_id,
1683
+ FlowEventType.TOKEN_USAGE,
1684
+ {"usage": event.usage},
1685
+ timestamp_override=event.timestamp,
1686
+ )
1687
+ elif isinstance(event, RunNotice):
1688
+ notice_type = FlowEventType.RUN_STATE_CHANGED
1689
+ if event.kind.endswith("timeout"):
1690
+ notice_type = FlowEventType.RUN_TIMEOUT
1691
+ elif "cancel" in event.kind:
1692
+ notice_type = FlowEventType.RUN_CANCELLED
1693
+ data: dict[str, Any] = {
1694
+ "kind": event.kind,
1695
+ "message": event.message,
1696
+ }
1697
+ if event.data:
1698
+ data["data"] = event.data
1699
+ self._emit_canonical_event(
1700
+ run_id,
1701
+ notice_type,
1702
+ data,
1703
+ timestamp_override=event.timestamp,
1704
+ )
1705
+ elif isinstance(event, Completed):
1706
+ if event.final_message:
1707
+ self._emit_canonical_event(
1708
+ run_id,
1709
+ FlowEventType.AGENT_MESSAGE_COMPLETE,
1710
+ {"final_message": event.final_message},
1711
+ timestamp_override=event.timestamp,
1712
+ )
1713
+ if event.final_message:
1714
+ final_message = event.final_message
1715
+ elif isinstance(event, Failed):
1716
+ self.log_line(
1717
+ run_id,
1718
+ f"error: backend run failed: {event.error_message}",
1719
+ )
1720
+ failed_error = event.error_message
1721
+
1722
+ if stop_task in done:
1723
+ self._last_run_interrupted = True
1724
+ self.log_line(run_id, "info: stop requested; interrupting backend")
1725
+ if not producer_task.done():
1726
+ producer_task.cancel()
1727
+ try:
1728
+ await producer_task
1729
+ except asyncio.CancelledError:
1730
+ pass
1731
+ if timeout_task and not timeout_task.done():
1732
+ timeout_task.cancel()
1733
+ try:
1734
+ await orchestrator.interrupt(agent_id, state)
1735
+ except Exception as exc:
1736
+ self.log_line(run_id, f"interrupt failed: {exc}")
1737
+ if not get_task.done():
1738
+ get_task.cancel()
1739
+ for task in pending:
1740
+ task.cancel()
1741
+ return 0
1742
+
1743
+ if timeout_task and timeout_task in done:
1744
+ if not producer_task.done():
1745
+ producer_task.cancel()
1746
+ try:
1747
+ await producer_task
1748
+ except asyncio.CancelledError:
1749
+ pass
1750
+ try:
1751
+ await orchestrator.interrupt(agent_id, state)
1752
+ except Exception as exc:
1753
+ self.log_line(run_id, f"interrupt failed: {exc}")
1754
+ if not get_task.done():
1755
+ get_task.cancel()
1756
+ for task in pending:
1757
+ task.cancel()
1758
+ return 1
1259
1759
  finally:
1260
- if stop_task is not None:
1760
+ if not producer_task.done():
1761
+ producer_task.cancel()
1762
+ try:
1763
+ await producer_task
1764
+ except asyncio.CancelledError:
1765
+ pass
1766
+ if timeout_task and not timeout_task.done():
1767
+ timeout_task.cancel()
1768
+ if stop_task and not stop_task.done():
1261
1769
  stop_task.cancel()
1262
- with contextlib.suppress(asyncio.CancelledError):
1263
- await stop_task
1264
- if stop_event.is_set():
1265
- await orchestrator.interrupt_turn(
1266
- self.repo_root, conversation_id, grace_seconds=30.0
1267
- )
1268
- self._last_run_interrupted = True
1269
1770
 
1270
- async def _run_codex_app_server_async(
1271
- self,
1771
+ if failed_error:
1772
+ return 1
1773
+
1774
+ output_messages: list[str] = []
1775
+ if final_message:
1776
+ self.log_line(run_id, final_message)
1777
+ output_messages = [final_message]
1778
+ elif assistant_messages:
1779
+ output_messages = assistant_messages
1780
+
1781
+ if output_messages:
1782
+ handle_agent_output(
1783
+ self._log_app_server_output,
1784
+ self._write_run_artifact,
1785
+ self._merge_run_index_entry,
1786
+ run_id,
1787
+ output_messages,
1788
+ )
1789
+
1790
+ context = orchestrator.get_context()
1791
+ if context:
1792
+ turn_id = context.turn_id or orchestrator.get_last_turn_id()
1793
+ thread_info = context.thread_info or orchestrator.get_last_thread_info()
1794
+ token_total = orchestrator.get_last_token_total()
1795
+ self._update_run_telemetry(
1796
+ run_id,
1797
+ turn_id=turn_id,
1798
+ token_total=token_total,
1799
+ )
1800
+ if thread_info:
1801
+ self._update_run_telemetry(run_id, thread_info=thread_info)
1802
+
1803
+ return 0
1804
+
1805
+ async def _run_codex_app_server_async(
1806
+ self,
1272
1807
  prompt: str,
1273
1808
  run_id: int,
1274
1809
  *,
1275
1810
  external_stop_flag: Optional[threading.Event] = None,
1276
- reuse_supervisor: bool = True,
1277
1811
  ) -> int:
1278
1812
  config = self.config
1279
1813
  if not config.app_server.command:
@@ -1282,129 +1816,306 @@ class Engine:
1282
1816
  "error: app-server backend requires app_server.command to be configured",
1283
1817
  )
1284
1818
  return 1
1285
-
1286
- def _env_builder(
1287
- workspace_root: Path, _workspace_id: str, state_dir: Path
1288
- ) -> dict[str, str]:
1289
- state_dir.mkdir(parents=True, exist_ok=True)
1290
- return build_app_server_env(
1291
- config.app_server.command,
1292
- workspace_root,
1293
- state_dir,
1294
- logger=self._app_server_logger,
1295
- event_prefix="autorunner",
1296
- )
1297
-
1298
- supervisor = (
1299
- self._ensure_app_server_supervisor(_env_builder)
1300
- if reuse_supervisor
1301
- else self._build_app_server_supervisor(_env_builder)
1302
- )
1303
1819
  with state_lock(self.state_path):
1304
1820
  state = load_state(self.state_path)
1305
1821
  effective_model = state.autorunner_model_override or config.codex_model
1306
1822
  effective_effort = state.autorunner_effort_override or config.codex_reasoning
1307
- approval_policy = state.autorunner_approval_policy or "never"
1308
- sandbox_mode = state.autorunner_sandbox_mode or "dangerFullAccess"
1309
- if sandbox_mode == "workspaceWrite":
1310
- sandbox_policy: Any = {
1311
- "type": "workspaceWrite",
1312
- "writableRoots": [str(self.repo_root)],
1313
- "networkAccess": bool(state.autorunner_workspace_write_network),
1314
- }
1315
- else:
1316
- sandbox_policy = sandbox_mode
1823
+ return await self._run_agent_backend_async(
1824
+ agent_id="codex",
1825
+ prompt=prompt,
1826
+ run_id=run_id,
1827
+ state=state,
1828
+ session_key="autorunner",
1829
+ model=effective_model,
1830
+ reasoning=effective_effort,
1831
+ external_stop_flag=external_stop_flag,
1832
+ )
1833
+
1834
+ async def _run_agent_backend_async(
1835
+ self,
1836
+ *,
1837
+ agent_id: str,
1838
+ prompt: str,
1839
+ run_id: int,
1840
+ state: RunnerState,
1841
+ session_key: str,
1842
+ model: Optional[str],
1843
+ reasoning: Optional[str],
1844
+ external_stop_flag: Optional[threading.Event],
1845
+ ) -> int:
1846
+ if self._backend_factory is None:
1847
+ self.log_line(
1848
+ run_id,
1849
+ f"error: {agent_id} backend factory is not configured for this engine",
1850
+ )
1851
+ return 1
1852
+
1317
1853
  try:
1318
- client = await supervisor.get_client(self.repo_root)
1319
- with self._app_server_threads_lock:
1320
- thread_id = self._app_server_threads.get_thread_id("autorunner")
1321
- thread_info: Optional[dict[str, Any]] = None
1322
- if thread_id:
1323
- try:
1324
- resume_result = await client.thread_resume(thread_id)
1325
- resumed = resume_result.get("id")
1326
- if isinstance(resumed, str) and resumed:
1327
- thread_id = resumed
1328
- self._app_server_threads.set_thread_id(
1329
- "autorunner", thread_id
1330
- )
1331
- if isinstance(resume_result, dict):
1332
- thread_info = resume_result
1333
- except CodexAppServerError:
1334
- self._app_server_threads.reset_thread("autorunner")
1335
- thread_id = None
1336
- if not thread_id:
1337
- thread = await client.thread_start(str(self.repo_root))
1338
- thread_id = thread.get("id")
1339
- if not isinstance(thread_id, str) or not thread_id:
1340
- self.log_line(
1341
- run_id, "error: app-server did not return a thread id"
1342
- )
1343
- return 1
1344
- self._app_server_threads.set_thread_id("autorunner", thread_id)
1345
- if isinstance(thread, dict):
1346
- thread_info = thread
1347
- if thread_id:
1348
- self._update_run_telemetry(run_id, thread_id=thread_id)
1349
- turn_kwargs: dict[str, Any] = {}
1350
- if effective_model:
1351
- turn_kwargs["model"] = str(effective_model)
1352
- if effective_effort:
1353
- turn_kwargs["effort"] = str(effective_effort)
1354
- handle = await client.turn_start(
1355
- thread_id,
1356
- prompt,
1357
- approval_policy=approval_policy,
1358
- sandbox_policy=sandbox_policy,
1359
- **turn_kwargs,
1854
+ backend = self._backend_factory(
1855
+ agent_id, state, self._handle_app_server_notification
1360
1856
  )
1361
- app_server_meta = self._build_app_server_meta(
1362
- thread_id=thread_id,
1363
- turn_id=handle.turn_id,
1364
- thread_info=thread_info,
1365
- model=turn_kwargs.get("model"),
1366
- reasoning_effort=turn_kwargs.get("effort"),
1857
+ except Exception as exc:
1858
+ self.log_line(
1859
+ run_id, f"error: failed to initialize {agent_id} backend: {exc}"
1367
1860
  )
1368
- self._merge_run_index_entry(run_id, {"app_server": app_server_meta})
1369
- self._update_run_telemetry(
1370
- run_id, thread_id=thread_id, turn_id=handle.turn_id
1861
+ return 1
1862
+
1863
+ reuse_session = bool(getattr(self.config, "autorunner_reuse_session", False))
1864
+ session_id: Optional[str] = None
1865
+ if reuse_session and self._backend_orchestrator is not None:
1866
+ session_id = self._backend_orchestrator.get_thread_id(session_key)
1867
+ elif reuse_session:
1868
+ with self._app_server_threads_lock:
1869
+ session_id = self._app_server_threads.get_thread_id(session_key)
1870
+
1871
+ try:
1872
+ session_id = await backend.start_session(
1873
+ target={"workspace": str(self.repo_root)},
1874
+ context={"workspace": str(self.repo_root), "session_id": session_id},
1371
1875
  )
1372
- turn_timeout = config.app_server.turn_timeout_seconds
1373
- turn_result, interrupted = await self._wait_for_turn_with_stop(
1374
- client,
1375
- handle,
1376
- run_id,
1377
- timeout=turn_timeout,
1378
- external_stop_flag=external_stop_flag,
1379
- supervisor=supervisor,
1876
+ except Exception as exc:
1877
+ self.log_line(
1878
+ run_id, f"error: {agent_id} backend failed to start session: {exc}"
1380
1879
  )
1381
- self._last_run_interrupted = interrupted
1382
- self._log_app_server_output(run_id, turn_result.agent_messages)
1383
- output_text = "\n\n".join(turn_result.agent_messages).strip()
1384
- if output_text:
1385
- output_path = self._write_run_artifact(
1386
- run_id, "output.txt", output_text
1880
+ return 1
1881
+
1882
+ if not session_id:
1883
+ self.log_line(
1884
+ run_id, f"error: {agent_id} backend did not return a session id"
1885
+ )
1886
+ return 1
1887
+
1888
+ if reuse_session and self._backend_orchestrator is not None:
1889
+ self._backend_orchestrator.set_thread_id(session_key, session_id)
1890
+ elif reuse_session:
1891
+ with self._app_server_threads_lock:
1892
+ self._app_server_threads.set_thread_id(session_key, session_id)
1893
+
1894
+ self._update_run_telemetry(run_id, thread_id=session_id)
1895
+
1896
+ events: asyncio.Queue[Optional[RunEvent]] = asyncio.Queue()
1897
+
1898
+ async def _produce_events() -> None:
1899
+ try:
1900
+ async for event in backend.run_turn_events(session_id, prompt):
1901
+ await events.put(event)
1902
+ except Exception as exc:
1903
+ await events.put(Failed(timestamp=now_iso(), error_message=str(exc)))
1904
+ finally:
1905
+ await events.put(None)
1906
+
1907
+ producer_task = asyncio.create_task(_produce_events())
1908
+ stop_task = asyncio.create_task(self._wait_for_stop(external_stop_flag))
1909
+ timeout_seconds = self.config.app_server.turn_timeout_seconds
1910
+ timeout_task: Optional[asyncio.Task] = (
1911
+ asyncio.create_task(asyncio.sleep(timeout_seconds))
1912
+ if timeout_seconds
1913
+ else None
1914
+ )
1915
+
1916
+ assistant_messages: list[str] = []
1917
+ final_message: Optional[str] = None
1918
+ failed_error: Optional[str] = None
1919
+
1920
+ try:
1921
+ while True:
1922
+ get_task = asyncio.create_task(events.get())
1923
+ tasks = {get_task, stop_task}
1924
+ if timeout_task is not None:
1925
+ tasks.add(timeout_task)
1926
+ done, pending = await asyncio.wait(
1927
+ tasks, return_when=asyncio.FIRST_COMPLETED
1387
1928
  )
1388
- self._merge_run_index_entry(
1389
- run_id, {"artifacts": {"output_path": str(output_path)}}
1929
+
1930
+ if get_task in done:
1931
+ event = get_task.result()
1932
+ if event is None:
1933
+ break
1934
+ if isinstance(event, Started) and event.session_id:
1935
+ self._update_run_telemetry(
1936
+ run_id, thread_id=event.session_id, turn_id=event.turn_id
1937
+ )
1938
+ elif isinstance(event, OutputDelta):
1939
+ self._emit_canonical_event(
1940
+ run_id,
1941
+ FlowEventType.AGENT_STREAM_DELTA,
1942
+ {
1943
+ "delta": event.content,
1944
+ "delta_type": event.delta_type,
1945
+ },
1946
+ timestamp_override=event.timestamp,
1947
+ )
1948
+ if event.delta_type in {
1949
+ "assistant_message",
1950
+ "assistant_stream",
1951
+ }:
1952
+ assistant_messages.append(event.content)
1953
+ elif event.delta_type == "log_line":
1954
+ self.log_line(
1955
+ run_id,
1956
+ (
1957
+ f"stdout: {event.content}"
1958
+ if event.content
1959
+ else "stdout: "
1960
+ ),
1961
+ )
1962
+ elif isinstance(event, ToolCall):
1963
+ self._emit_canonical_event(
1964
+ run_id,
1965
+ FlowEventType.TOOL_CALL,
1966
+ {
1967
+ "tool_name": event.tool_name,
1968
+ "tool_input": event.tool_input,
1969
+ },
1970
+ timestamp_override=event.timestamp,
1971
+ )
1972
+ elif isinstance(event, ApprovalRequested):
1973
+ self._emit_canonical_event(
1974
+ run_id,
1975
+ FlowEventType.APPROVAL_REQUESTED,
1976
+ {
1977
+ "request_id": event.request_id,
1978
+ "description": event.description,
1979
+ "context": event.context,
1980
+ },
1981
+ timestamp_override=event.timestamp,
1982
+ )
1983
+ elif isinstance(event, TokenUsage):
1984
+ self._emit_canonical_event(
1985
+ run_id,
1986
+ FlowEventType.TOKEN_USAGE,
1987
+ {"usage": event.usage},
1988
+ timestamp_override=event.timestamp,
1989
+ )
1990
+ elif isinstance(event, RunNotice):
1991
+ notice_type = FlowEventType.RUN_STATE_CHANGED
1992
+ if event.kind.endswith("timeout"):
1993
+ notice_type = FlowEventType.RUN_TIMEOUT
1994
+ elif "cancel" in event.kind:
1995
+ notice_type = FlowEventType.RUN_CANCELLED
1996
+ data: dict[str, Any] = {
1997
+ "kind": event.kind,
1998
+ "message": event.message,
1999
+ }
2000
+ if event.data:
2001
+ data["data"] = event.data
2002
+ self._emit_canonical_event(
2003
+ run_id,
2004
+ notice_type,
2005
+ data,
2006
+ timestamp_override=event.timestamp,
2007
+ )
2008
+ elif isinstance(event, Completed):
2009
+ if event.final_message:
2010
+ self._emit_canonical_event(
2011
+ run_id,
2012
+ FlowEventType.AGENT_MESSAGE_COMPLETE,
2013
+ {"final_message": event.final_message},
2014
+ timestamp_override=event.timestamp,
2015
+ )
2016
+ if event.final_message:
2017
+ final_message = event.final_message
2018
+ elif isinstance(event, Failed):
2019
+ self._emit_canonical_event(
2020
+ run_id,
2021
+ FlowEventType.AGENT_FAILED,
2022
+ {"error_message": event.error_message},
2023
+ timestamp_override=event.timestamp,
2024
+ )
2025
+ failed_error = event.error_message
2026
+ continue
2027
+
2028
+ timed_out = timeout_task is not None and timeout_task in done
2029
+ stopped = stop_task in done
2030
+ if timed_out:
2031
+ self.log_line(
2032
+ run_id,
2033
+ "error: app-server turn timed out; interrupting app-server",
2034
+ )
2035
+ self._emit_canonical_event(
2036
+ run_id,
2037
+ FlowEventType.RUN_TIMEOUT,
2038
+ {
2039
+ "context": "app_server_turn",
2040
+ "timeout_seconds": timeout_seconds,
2041
+ },
2042
+ )
2043
+ if stopped:
2044
+ self._last_run_interrupted = True
2045
+ self.log_line(
2046
+ run_id, "info: stop requested; interrupting app-server"
2047
+ )
2048
+ try:
2049
+ await backend.interrupt(session_id)
2050
+ except Exception as exc:
2051
+ self.log_line(run_id, f"error: app-server interrupt failed: {exc}")
2052
+
2053
+ done_after_interrupt, _pending = await asyncio.wait(
2054
+ {producer_task}, timeout=AUTORUNNER_INTERRUPT_GRACE_SECONDS
1390
2055
  )
1391
- if turn_result.errors:
1392
- for error in turn_result.errors:
1393
- self.log_line(run_id, f"error: {error}")
2056
+ if not done_after_interrupt:
2057
+ await self._cancel_task_with_notice(
2058
+ run_id, producer_task, name="producer_task"
2059
+ )
2060
+ if stopped:
2061
+ return 0
2062
+ return 1
2063
+ if stopped:
2064
+ return 0
1394
2065
  return 1
1395
- return 0
1396
- except asyncio.TimeoutError:
1397
- self.log_line(run_id, "error: app-server turn timed out")
1398
- return 1
1399
- except CodexAppServerError as exc:
1400
- self.log_line(run_id, f"error: {exc}")
1401
- return 1
1402
- except Exception as exc: # pragma: no cover - defensive
1403
- self.log_line(run_id, f"error: app-server failed: {exc}")
1404
- return 1
2066
+
2067
+ await producer_task
1405
2068
  finally:
1406
- if not reuse_supervisor:
1407
- await supervisor.close_all()
2069
+ await self._cancel_task_with_notice(run_id, stop_task, name="stop_task")
2070
+ if timeout_task is not None:
2071
+ await self._cancel_task_with_notice(
2072
+ run_id, timeout_task, name="timeout_task"
2073
+ )
2074
+
2075
+ if failed_error:
2076
+ self.log_line(run_id, f"error: {failed_error}")
2077
+ return 1
2078
+
2079
+ output_messages = []
2080
+ if final_message:
2081
+ output_messages = [final_message]
2082
+ elif assistant_messages:
2083
+ output_messages = assistant_messages
2084
+
2085
+ if output_messages:
2086
+ handle_agent_output(
2087
+ self._log_app_server_output,
2088
+ self._write_run_artifact,
2089
+ self._merge_run_index_entry,
2090
+ run_id,
2091
+ output_messages,
2092
+ )
2093
+
2094
+ token_total = getattr(backend, "last_token_total", None)
2095
+ if isinstance(token_total, dict):
2096
+ self._update_run_telemetry(run_id, token_total=token_total)
2097
+
2098
+ telemetry = self._snapshot_run_telemetry(run_id)
2099
+ turn_id = None
2100
+ if telemetry is not None:
2101
+ turn_id = telemetry.turn_id
2102
+ if not turn_id:
2103
+ turn_id = getattr(backend, "last_turn_id", None)
2104
+ thread_info = getattr(backend, "last_thread_info", None)
2105
+
2106
+ if session_id and turn_id:
2107
+ app_server_meta = self._build_app_server_meta(
2108
+ thread_id=session_id,
2109
+ turn_id=turn_id,
2110
+ thread_info=thread_info if isinstance(thread_info, dict) else None,
2111
+ model=model,
2112
+ reasoning_effort=reasoning,
2113
+ )
2114
+ if agent_id != "codex":
2115
+ app_server_meta["agent"] = agent_id
2116
+ self._merge_run_index_entry(run_id, {"app_server": app_server_meta})
2117
+
2118
+ return 0
1408
2119
 
1409
2120
  def _log_app_server_output(self, run_id: int, messages: list[str]) -> None:
1410
2121
  if not messages:
@@ -1419,13 +2130,12 @@ class Engine:
1419
2130
  msg = self.config.git_commit_message_template.replace(
1420
2131
  "{run_id}", str(run_id)
1421
2132
  ).replace("#{run_id}", str(run_id))
1422
- paths = [
1423
- self.config.doc_path("todo"),
1424
- self.config.doc_path("progress"),
1425
- self.config.doc_path("opinions"),
1426
- self.config.doc_path("spec"),
1427
- self.config.doc_path("summary"),
1428
- ]
2133
+ paths = []
2134
+ for key in ("active_context", "decisions", "spec"):
2135
+ try:
2136
+ paths.append(self.config.doc_path(key))
2137
+ except KeyError:
2138
+ pass
1429
2139
  add_paths = [str(p.relative_to(self.repo_root)) for p in paths if p.exists()]
1430
2140
  if not add_paths:
1431
2141
  return
@@ -1455,26 +2165,36 @@ class Engine:
1455
2165
  except GitError as exc:
1456
2166
  self.log_line(run_id, f"git commit failed: {exc}")
1457
2167
 
1458
- def _build_app_server_supervisor(
1459
- self, env_builder: Any
1460
- ) -> WorkspaceAppServerSupervisor:
1461
- config = self.config.app_server
1462
- return WorkspaceAppServerSupervisor(
1463
- config.command,
1464
- state_root=config.state_root,
1465
- env_builder=env_builder,
1466
- logger=self._app_server_logger,
1467
- notification_handler=self._handle_app_server_notification,
1468
- max_handles=config.max_handles,
1469
- idle_ttl_seconds=config.idle_ttl_seconds,
1470
- request_timeout=config.request_timeout,
1471
- )
2168
+ def _ensure_app_server_supervisor(self, event_prefix: str) -> Optional[Any]:
2169
+ """
2170
+ Ensure app server supervisor exists by delegating to BackendOrchestrator.
1472
2171
 
1473
- def _ensure_app_server_supervisor(
1474
- self, env_builder: Any
1475
- ) -> WorkspaceAppServerSupervisor:
2172
+ This method is kept for backward compatibility but now delegates to
2173
+ BackendOrchestrator to keep Engine protocol-agnostic.
2174
+ """
1476
2175
  if self._app_server_supervisor is None:
1477
- self._app_server_supervisor = self._build_app_server_supervisor(env_builder)
2176
+ if (
2177
+ self._backend_orchestrator is None
2178
+ and self._app_server_supervisor_factory is not None
2179
+ ):
2180
+ self._app_server_supervisor = self._app_server_supervisor_factory(
2181
+ event_prefix, self._handle_app_server_notification
2182
+ )
2183
+ elif self._backend_orchestrator is not None:
2184
+ try:
2185
+ self._app_server_supervisor = (
2186
+ self._backend_orchestrator.build_app_server_supervisor(
2187
+ event_prefix=event_prefix,
2188
+ notification_handler=self._handle_app_server_notification,
2189
+ )
2190
+ )
2191
+ except Exception:
2192
+ if self._app_server_supervisor_factory is not None:
2193
+ self._app_server_supervisor = (
2194
+ self._app_server_supervisor_factory(
2195
+ event_prefix, self._handle_app_server_notification
2196
+ )
2197
+ )
1478
2198
  return self._app_server_supervisor
1479
2199
 
1480
2200
  async def _close_app_server_supervisor(self) -> None:
@@ -1483,45 +2203,49 @@ class Engine:
1483
2203
  supervisor = self._app_server_supervisor
1484
2204
  self._app_server_supervisor = None
1485
2205
  try:
1486
- await supervisor.close_all()
2206
+ close_all = getattr(supervisor, "close_all", None)
2207
+ if close_all is None:
2208
+ return
2209
+ result = close_all()
2210
+ if inspect.isawaitable(result):
2211
+ await result
1487
2212
  except Exception as exc:
1488
2213
  self._app_server_logger.warning(
1489
2214
  "app-server supervisor close failed: %s", exc
1490
2215
  )
1491
2216
 
1492
- def _build_opencode_supervisor(self) -> Optional[OpenCodeSupervisor]:
1493
- config = self.config.app_server
1494
- opencode_command = self.config.agent_serve_command("opencode")
1495
- opencode_binary = None
2217
+ async def _close_agent_backends(self) -> None:
2218
+ if self._backend_factory is None:
2219
+ return
2220
+ close_all = getattr(self._backend_factory, "close_all", None)
2221
+ if close_all is None:
2222
+ return
1496
2223
  try:
1497
- opencode_binary = self.config.agent_binary("opencode")
1498
- except ConfigError:
1499
- opencode_binary = None
1500
-
1501
- agent_config = self.config.agents.get("opencode")
1502
- subagent_models = agent_config.subagent_models if agent_config else None
2224
+ result = close_all()
2225
+ if inspect.isawaitable(result):
2226
+ await result
2227
+ except Exception as exc:
2228
+ self._app_server_logger.warning("agent backend close failed: %s", exc)
1503
2229
 
1504
- supervisor = build_opencode_supervisor(
1505
- opencode_command=opencode_command,
1506
- opencode_binary=opencode_binary,
1507
- workspace_root=self.repo_root,
1508
- logger=self._app_server_logger,
1509
- request_timeout=config.request_timeout,
1510
- max_handles=config.max_handles,
1511
- idle_ttl_seconds=config.idle_ttl_seconds,
1512
- base_env=None,
1513
- subagent_models=subagent_models,
1514
- )
2230
+ def _build_opencode_supervisor(self) -> Optional[Any]:
2231
+ """
2232
+ Build OpenCode supervisor by delegating to BackendOrchestrator.
1515
2233
 
1516
- if supervisor is None:
1517
- self._app_server_logger.info(
1518
- "OpenCode command unavailable; skipping opencode supervisor."
1519
- )
2234
+ This method is kept for backward compatibility but now delegates to
2235
+ BackendOrchestrator to keep Engine protocol-agnostic.
2236
+ """
2237
+ if self._backend_orchestrator is None:
1520
2238
  return None
1521
2239
 
1522
- return supervisor
2240
+ return self._backend_orchestrator.ensure_opencode_supervisor()
2241
+
2242
+ def _ensure_opencode_supervisor(self) -> Optional[Any]:
2243
+ """
2244
+ Ensure OpenCode supervisor exists by delegating to BackendOrchestrator.
1523
2245
 
1524
- def _ensure_opencode_supervisor(self) -> Optional[OpenCodeSupervisor]:
2246
+ This method is kept for backward compatibility but now delegates to
2247
+ BackendOrchestrator to keep Engine protocol-agnostic.
2248
+ """
1525
2249
  if self._opencode_supervisor is None:
1526
2250
  self._opencode_supervisor = self._build_opencode_supervisor()
1527
2251
  return self._opencode_supervisor
@@ -1536,22 +2260,6 @@ class Engine:
1536
2260
  except Exception as exc:
1537
2261
  self._app_server_logger.warning("opencode supervisor close failed: %s", exc)
1538
2262
 
1539
- def _get_orchestrator(self, agent_id: str):
1540
- if agent_id == "opencode":
1541
- opencode_sup = self._ensure_opencode_supervisor()
1542
- if opencode_sup is None:
1543
- return None
1544
- return create_orchestrator(agent_id, opencode_supervisor=opencode_sup)
1545
- else:
1546
- app_server_sup = self._ensure_app_server_supervisor(
1547
- lambda workspace_root, workspace_id, state_dir: {}
1548
- )
1549
- return create_orchestrator(
1550
- agent_id,
1551
- codex_supervisor=app_server_sup,
1552
- codex_events=self._app_server_events,
1553
- )
1554
-
1555
2263
  async def _wait_for_stop(
1556
2264
  self,
1557
2265
  external_stop_flag: Optional[threading.Event],
@@ -1570,7 +2278,7 @@ class Engine:
1570
2278
  *,
1571
2279
  timeout: Optional[float],
1572
2280
  external_stop_flag: Optional[threading.Event],
1573
- supervisor: Optional[WorkspaceAppServerSupervisor] = None,
2281
+ supervisor: Optional[Any] = None,
1574
2282
  ) -> tuple[Any, bool]:
1575
2283
  stop_task = asyncio.create_task(self._wait_for_stop(external_stop_flag))
1576
2284
  turn_task = asyncio.create_task(handle.wait(timeout=None))
@@ -1594,6 +2302,11 @@ class Engine:
1594
2302
  self.log_line(
1595
2303
  run_id, "error: app-server turn timed out; interrupting app-server"
1596
2304
  )
2305
+ self._emit_canonical_event(
2306
+ run_id,
2307
+ FlowEventType.RUN_TIMEOUT,
2308
+ {"context": "app_server_turn", "timeout_seconds": timeout},
2309
+ )
1597
2310
  if stopped and not turn_task.done():
1598
2311
  interrupted = True
1599
2312
  self.log_line(run_id, "info: stop requested; interrupting app-server")
@@ -1602,7 +2315,7 @@ class Engine:
1602
2315
  await client.turn_interrupt(
1603
2316
  handle.turn_id, thread_id=handle.thread_id
1604
2317
  )
1605
- except CodexAppServerError as exc:
2318
+ except Exception as exc:
1606
2319
  self.log_line(run_id, f"error: app-server interrupt failed: {exc}")
1607
2320
  if interrupted:
1608
2321
  self.kill_running_process()
@@ -1617,7 +2330,7 @@ class Engine:
1617
2330
  )
1618
2331
  if interrupted:
1619
2332
  self.kill_running_process()
1620
- raise CodexAppServerError("App-server interrupt timed out")
2333
+ raise RuntimeError("App-server interrupt timed out")
1621
2334
  if supervisor is not None:
1622
2335
  await supervisor.close_all()
1623
2336
  raise asyncio.TimeoutError()
@@ -1626,254 +2339,11 @@ class Engine:
1626
2339
  raise asyncio.TimeoutError()
1627
2340
  return result, interrupted
1628
2341
  finally:
1629
- stop_task.cancel()
1630
- with contextlib.suppress(asyncio.CancelledError):
1631
- await stop_task
1632
- if timeout_task is not None:
1633
- timeout_task.cancel()
1634
- with contextlib.suppress(asyncio.CancelledError):
1635
- await timeout_task
1636
-
1637
- async def _abort_opencode(self, client: Any, session_id: str, run_id: int) -> None:
1638
- try:
1639
- await client.abort(session_id)
1640
- except Exception as exc:
1641
- self.log_line(run_id, f"error: opencode abort failed: {exc}")
1642
-
1643
- async def _run_opencode_app_server_async(
1644
- self,
1645
- prompt: str,
1646
- run_id: int,
1647
- *,
1648
- model: Optional[str],
1649
- reasoning: Optional[str],
1650
- external_stop_flag: Optional[threading.Event] = None,
1651
- ) -> int:
1652
- supervisor = self._ensure_opencode_supervisor()
1653
- if supervisor is None:
1654
- self.log_line(
1655
- run_id, "error: opencode backend is not configured in this repo"
1656
- )
1657
- return 1
1658
- try:
1659
- client = await supervisor.get_client(self.repo_root)
1660
- except OpenCodeSupervisorError as exc:
1661
- self.log_line(run_id, f"error: opencode backend unavailable: {exc}")
1662
- return 1
1663
-
1664
- with self._app_server_threads_lock:
1665
- key = "autorunner.opencode"
1666
- thread_id = self._app_server_threads.get_thread_id(key)
1667
- if thread_id:
1668
- try:
1669
- await client.get_session(thread_id)
1670
- except Exception as exc:
1671
- self._app_server_logger.debug(
1672
- "Failed to get existing opencode session '%s': %s",
1673
- thread_id,
1674
- exc,
1675
- )
1676
- self._app_server_threads.reset_thread(key)
1677
- thread_id = None
1678
- if not thread_id:
1679
- session = await client.create_session(directory=str(self.repo_root))
1680
- thread_id = extract_session_id(session, allow_fallback_id=True)
1681
- if not isinstance(thread_id, str) or not thread_id:
1682
- self.log_line(run_id, "error: opencode did not return a session id")
1683
- return 1
1684
- self._app_server_threads.set_thread_id(key, thread_id)
1685
-
1686
- model_payload = split_model_id(model)
1687
- missing_env = await opencode_missing_env(
1688
- client, str(self.repo_root), model_payload
1689
- )
1690
- if missing_env:
1691
- provider_id = model_payload.get("providerID") if model_payload else None
1692
- self.log_line(
1693
- run_id,
1694
- "error: opencode provider "
1695
- f"{provider_id or 'selected'} requires env vars: "
1696
- f"{', '.join(missing_env)}",
1697
- )
1698
- return 1
1699
- opencode_turn_started = False
1700
- await supervisor.mark_turn_started(self.repo_root)
1701
- opencode_turn_started = True
1702
- turn_id = build_turn_id(thread_id)
1703
- self._update_run_telemetry(run_id, thread_id=thread_id, turn_id=turn_id)
1704
- app_server_meta = self._build_app_server_meta(
1705
- thread_id=thread_id,
1706
- turn_id=turn_id,
1707
- thread_info=None,
1708
- model=model,
1709
- reasoning_effort=reasoning,
1710
- )
1711
- app_server_meta["agent"] = "opencode"
1712
- self._merge_run_index_entry(run_id, {"app_server": app_server_meta})
1713
-
1714
- active = ActiveOpencodeRun(
1715
- session_id=thread_id,
1716
- turn_id=turn_id,
1717
- client=client,
1718
- interrupted=False,
1719
- interrupt_event=asyncio.Event(),
1720
- )
1721
- with state_lock(self.state_path):
1722
- state = load_state(self.state_path)
1723
- permission_policy = map_approval_policy_to_permission(
1724
- state.autorunner_approval_policy, default="allow"
1725
- )
1726
-
1727
- async def _opencode_part_handler(
1728
- part_type: str, part: dict[str, Any], delta_text: Optional[str]
1729
- ) -> None:
1730
- if part_type == "usage" and isinstance(part, dict):
1731
- for line in self._opencode_event_formatter.format_usage(part):
1732
- self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
1733
- else:
1734
- for line in self._opencode_event_formatter.format_part(
1735
- part_type, part, delta_text
1736
- ):
1737
- self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
1738
-
1739
- ready_event = asyncio.Event()
1740
- output_task = asyncio.create_task(
1741
- collect_opencode_output(
1742
- client,
1743
- session_id=thread_id,
1744
- workspace_path=str(self.repo_root),
1745
- permission_policy=permission_policy,
1746
- question_policy="auto_first_option",
1747
- should_stop=active.interrupt_event.is_set,
1748
- part_handler=_opencode_part_handler,
1749
- ready_event=ready_event,
1750
- )
1751
- )
1752
- with contextlib.suppress(asyncio.TimeoutError):
1753
- await asyncio.wait_for(ready_event.wait(), timeout=2.0)
1754
- prompt_task = asyncio.create_task(
1755
- client.prompt_async(
1756
- thread_id,
1757
- message=prompt,
1758
- model=model_payload,
1759
- variant=reasoning,
1760
- )
1761
- )
1762
- stop_task = asyncio.create_task(self._wait_for_stop(external_stop_flag))
1763
- timeout_task = None
1764
- turn_timeout = self.config.app_server.turn_timeout_seconds
1765
- if turn_timeout:
1766
- timeout_task = asyncio.create_task(asyncio.sleep(turn_timeout))
1767
- timed_out = False
1768
- try:
1769
- try:
1770
- prompt_response = await prompt_task
1771
- prompt_info = (
1772
- prompt_response.get("info")
1773
- if isinstance(prompt_response, dict)
1774
- else {}
1775
- )
1776
- tokens = (
1777
- prompt_info.get("tokens") if isinstance(prompt_info, dict) else {}
1778
- )
1779
- if isinstance(tokens, dict):
1780
- input_tokens = int(tokens.get("input", 0) or 0)
1781
- cached_read = (
1782
- int(tokens.get("cache", {}).get("read", 0) or 0)
1783
- if isinstance(tokens.get("cache"), dict)
1784
- else 0
1785
- )
1786
- output_tokens = int(tokens.get("output", 0) or 0)
1787
- reasoning_tokens = int(tokens.get("reasoning", 0) or 0)
1788
- total_tokens = (
1789
- input_tokens + cached_read + output_tokens + reasoning_tokens
1790
- )
1791
- token_total = {
1792
- "total": total_tokens,
1793
- "input_tokens": input_tokens,
1794
- "prompt_tokens": input_tokens,
1795
- "cached_input_tokens": cached_read,
1796
- "output_tokens": output_tokens,
1797
- "completion_tokens": output_tokens,
1798
- "reasoning_tokens": reasoning_tokens,
1799
- "reasoning_output_tokens": reasoning_tokens,
1800
- }
1801
- self._update_run_telemetry(run_id, token_total=token_total)
1802
- except Exception as exc:
1803
- active.interrupt_event.set()
1804
- output_task.cancel()
1805
- with contextlib.suppress(asyncio.CancelledError):
1806
- await output_task
1807
- self.log_line(run_id, f"error: opencode prompt failed: {exc}")
1808
- return 1
1809
- tasks = {output_task, stop_task}
1810
- if timeout_task is not None:
1811
- tasks.add(timeout_task)
1812
- done, _pending = await asyncio.wait(
1813
- tasks, return_when=asyncio.FIRST_COMPLETED
1814
- )
1815
- timed_out = timeout_task is not None and timeout_task in done
1816
- stopped = stop_task in done
1817
- if timed_out:
1818
- self.log_line(
1819
- run_id, "error: opencode turn timed out; aborting session"
1820
- )
1821
- active.interrupt_event.set()
1822
- if stopped:
1823
- active.interrupted = True
1824
- active.interrupt_event.set()
1825
- self.log_line(run_id, "info: stop requested; aborting opencode")
1826
- if timed_out or stopped:
1827
- await self._abort_opencode(client, thread_id, run_id)
1828
- done, _pending = await asyncio.wait(
1829
- {output_task}, timeout=AUTORUNNER_INTERRUPT_GRACE_SECONDS
1830
- )
1831
- if not done:
1832
- output_task.cancel()
1833
- with contextlib.suppress(asyncio.CancelledError):
1834
- await output_task
1835
- if timed_out:
1836
- return 1
1837
- self._last_run_interrupted = active.interrupted
1838
- return 0
1839
- output_result = await output_task
1840
- if not output_result.text and not output_result.error:
1841
- fallback = parse_message_response(prompt_response)
1842
- if fallback.text:
1843
- output_result = OpenCodeTurnOutput(
1844
- text=fallback.text, error=fallback.error
1845
- )
1846
- finally:
1847
- stop_task.cancel()
1848
- with contextlib.suppress(asyncio.CancelledError):
1849
- await stop_task
2342
+ await self._cancel_task_with_notice(run_id, stop_task, name="stop_task")
1850
2343
  if timeout_task is not None:
1851
- timeout_task.cancel()
1852
- with contextlib.suppress(asyncio.CancelledError):
1853
- await timeout_task
1854
- if opencode_turn_started:
1855
- await supervisor.mark_turn_finished(self.repo_root)
1856
-
1857
- output = output_result.text
1858
- if output:
1859
- self._log_app_server_output(run_id, [output])
1860
- output_text = output.strip()
1861
- if output_text:
1862
- output_path = self._write_run_artifact(
1863
- run_id, "output.txt", output_text
2344
+ await self._cancel_task_with_notice(
2345
+ run_id, timeout_task, name="timeout_task"
1864
2346
  )
1865
- self._merge_run_index_entry(
1866
- run_id, {"artifacts": {"output_path": str(output_path)}}
1867
- )
1868
- if output_result.error:
1869
- self.log_line(
1870
- run_id, f"error: opencode session error: {output_result.error}"
1871
- )
1872
- return 1
1873
- self._last_run_interrupted = active.interrupted
1874
- if timed_out:
1875
- return 1
1876
- return 0
1877
2347
 
1878
2348
  async def _run_loop_async(
1879
2349
  self,
@@ -1894,8 +2364,10 @@ class Engine:
1894
2364
  )
1895
2365
  )
1896
2366
  no_progress_count = 0
1897
- last_outstanding_count = len(self.docs.todos()[0])
1898
- last_done_count = len(self.docs.todos()[1])
2367
+ ticket_dir = self.repo_root / ".codex-autorunner" / "tickets"
2368
+ initial_tickets = list_ticket_paths(ticket_dir)
2369
+ last_done_count = sum(1 for path in initial_tickets if ticket_is_done(path))
2370
+ last_outstanding_count = len(initial_tickets) - last_done_count
1899
2371
  exit_reason: Optional[str] = None
1900
2372
 
1901
2373
  try:
@@ -1949,9 +2421,11 @@ class Engine:
1949
2421
  break
1950
2422
 
1951
2423
  # Check for no progress across runs
1952
- current_outstanding, current_done = self.docs.todos()
1953
- current_outstanding_count = len(current_outstanding)
1954
- current_done_count = len(current_done)
2424
+ current_tickets = list_ticket_paths(ticket_dir)
2425
+ current_done_count = sum(
2426
+ 1 for path in current_tickets if ticket_is_done(path)
2427
+ )
2428
+ current_outstanding_count = len(current_tickets) - current_done_count
1955
2429
 
1956
2430
  # Check if there was any meaningful progress
1957
2431
  has_progress = (
@@ -1959,25 +2433,55 @@ class Engine:
1959
2433
  or current_done_count != last_done_count
1960
2434
  )
1961
2435
 
1962
- # Check if there was any meaningful output (diff, files changed, etc.)
2436
+ # Check if there was any meaningful output (diff, plan, etc.)
1963
2437
  has_output = False
1964
- try:
1965
- output_path = (
1966
- self.repo_root
1967
- / ".codex-autorunner"
1968
- / "runs"
1969
- / f"run-{run_id}"
1970
- / "output.txt"
1971
- )
1972
- if output_path.exists():
1973
- output_content = output_path.read_text(encoding="utf-8").strip()
1974
- # Consider it output if there's meaningful text (not just empty or whitespace)
1975
- has_output = len(output_content) > 100
1976
- except (OSError, IOError):
1977
- pass
2438
+ run_entry = self._run_index_store.get_entry(run_id)
2439
+ if run_entry:
2440
+ artifacts = run_entry.get("artifacts", {})
2441
+ if isinstance(artifacts, dict):
2442
+ diff_path = artifacts.get("diff_path")
2443
+ if diff_path:
2444
+ try:
2445
+ diff_content = (
2446
+ Path(diff_path).read_text(encoding="utf-8").strip()
2447
+ )
2448
+ has_output = len(diff_content) > 0
2449
+ except (OSError, IOError):
2450
+ pass
2451
+ if not has_output:
2452
+ plan_path = artifacts.get("plan_path")
2453
+ if plan_path:
2454
+ try:
2455
+ plan_content = (
2456
+ Path(plan_path)
2457
+ .read_text(encoding="utf-8")
2458
+ .strip()
2459
+ )
2460
+ has_output = len(plan_content) > 0
2461
+ except (OSError, IOError):
2462
+ pass
1978
2463
 
1979
2464
  if not has_progress and not has_output:
1980
2465
  no_progress_count += 1
2466
+
2467
+ evidence = {
2468
+ "outstanding_count": current_outstanding_count,
2469
+ "done_count": current_done_count,
2470
+ "has_diff": bool(
2471
+ run_entry
2472
+ and isinstance(run_entry.get("artifacts"), dict)
2473
+ and run_entry["artifacts"].get("diff_path")
2474
+ ),
2475
+ "has_plan": bool(
2476
+ run_entry
2477
+ and isinstance(run_entry.get("artifacts"), dict)
2478
+ and run_entry["artifacts"].get("plan_path")
2479
+ ),
2480
+ "run_id": run_id,
2481
+ }
2482
+ self._emit_event(
2483
+ run_id, "run.no_progress", count=no_progress_count, **evidence
2484
+ )
1981
2485
  self.log_line(
1982
2486
  run_id,
1983
2487
  f"info: no progress detected ({no_progress_count}/{self.config.runner_no_progress_threshold} runs without progress)",
@@ -2030,12 +2534,16 @@ class Engine:
2030
2534
  for line in tb.splitlines():
2031
2535
  self.log_line(run_id, f"traceback: {line}")
2032
2536
  except (OSError, IOError) as exc:
2033
- self._app_server_logger.error("Failed to log run_loop crash: %s", exc)
2537
+ self._app_server_logger.error(
2538
+ "Failed to log run_loop crash for run %s: %s", run_id, exc
2539
+ )
2034
2540
  try:
2035
2541
  self._update_state("error", run_id, 1, finished=True)
2036
2542
  except (OSError, IOError) as exc:
2037
2543
  self._app_server_logger.error(
2038
- "Failed to update state after run_loop crash: %s", exc
2544
+ "Failed to update state after run_loop crash for run %s: %s",
2545
+ run_id,
2546
+ exc,
2039
2547
  )
2040
2548
  finally:
2041
2549
  try:
@@ -2044,9 +2552,12 @@ class Engine:
2044
2552
  last_exit_code=last_exit_code,
2045
2553
  )
2046
2554
  except Exception as exc:
2047
- self._app_server_logger.warning("End-of-run review failed: %s", exc)
2555
+ self._app_server_logger.warning(
2556
+ "End-of-run review failed for run %s: %s", run_id, exc
2557
+ )
2048
2558
  await self._close_app_server_supervisor()
2049
2559
  await self._close_opencode_supervisor()
2560
+ await self._close_agent_backends()
2050
2561
  # IMPORTANT: lock ownership is managed by the caller (CLI/Hub/Server runner).
2051
2562
  # Engine.run_loop must never unconditionally mutate the lock file.
2052
2563
 
@@ -2116,8 +2627,8 @@ class Engine:
2116
2627
  }
2117
2628
  payload = {k: v for k, v in payload.items() if v is not None}
2118
2629
 
2119
- opencode_supervisor: Optional[OpenCodeSupervisor] = None
2120
- app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None
2630
+ opencode_supervisor: Optional[Any] = None
2631
+ app_server_supervisor: Optional[Any] = None
2121
2632
 
2122
2633
  if agent == "codex":
2123
2634
  if not self.config.app_server.command:
@@ -2125,20 +2636,12 @@ class Engine:
2125
2636
  "Skipping end-of-run review: codex backend not configured"
2126
2637
  )
2127
2638
  return
2128
-
2129
- def _env_builder(
2130
- workspace_root: Path, _workspace_id: str, state_dir: Path
2131
- ) -> dict[str, str]:
2132
- state_dir.mkdir(parents=True, exist_ok=True)
2133
- return build_app_server_env(
2134
- self.config.app_server.command,
2135
- workspace_root,
2136
- state_dir,
2137
- logger=self._app_server_logger,
2138
- event_prefix="review",
2639
+ app_server_supervisor = self._ensure_app_server_supervisor("review")
2640
+ if app_server_supervisor is None:
2641
+ self._app_server_logger.info(
2642
+ "Skipping end-of-run review: codex supervisor factory unavailable"
2139
2643
  )
2140
-
2141
- app_server_supervisor = self._ensure_app_server_supervisor(_env_builder)
2644
+ return
2142
2645
  else:
2143
2646
  opencode_supervisor = self._ensure_opencode_supervisor()
2144
2647
  if opencode_supervisor is None:
@@ -2147,7 +2650,7 @@ class Engine:
2147
2650
  )
2148
2651
  return
2149
2652
 
2150
- from .review import ReviewService
2653
+ from ..flows.review import ReviewService
2151
2654
 
2152
2655
  review_service = ReviewService(
2153
2656
  self,
@@ -2208,8 +2711,12 @@ class Engine:
2208
2711
  started: bool = False,
2209
2712
  finished: bool = False,
2210
2713
  ) -> None:
2714
+ prev_status: Optional[str] = None
2715
+ last_run_started_at: Optional[str] = None
2716
+ last_run_finished_at: Optional[str] = None
2211
2717
  with state_lock(self.state_path):
2212
2718
  current = load_state(self.state_path)
2719
+ prev_status = current.status
2213
2720
  last_run_started_at = current.last_run_started_at
2214
2721
  last_run_finished_at = current.last_run_finished_at
2215
2722
  runner_pid = current.runner_pid
@@ -2237,6 +2744,18 @@ class Engine:
2237
2744
  repo_to_session=current.repo_to_session,
2238
2745
  )
2239
2746
  save_state(self.state_path, new_state)
2747
+ if run_id > 0 and prev_status != status:
2748
+ payload: dict[str, Any] = {
2749
+ "from_status": prev_status,
2750
+ "to_status": status,
2751
+ }
2752
+ if exit_code is not None:
2753
+ payload["exit_code"] = exit_code
2754
+ if started and last_run_started_at:
2755
+ payload["started_at"] = last_run_started_at
2756
+ if finished and last_run_finished_at:
2757
+ payload["finished_at"] = last_run_finished_at
2758
+ self._emit_event(run_id, "run.state_changed", **payload)
2240
2759
 
2241
2760
 
2242
2761
  def clear_stale_lock(lock_path: Path) -> bool:
@@ -2385,253 +2904,399 @@ def _manifest_has_worktrees(manifest_path: Path) -> bool:
2385
2904
  return False
2386
2905
 
2387
2906
 
2388
- def doctor(start_path: Path) -> DoctorReport:
2389
- hub_config = load_hub_config(start_path)
2390
- repo_config: Optional[RepoConfig] = None
2391
- try:
2392
- repo_root = find_repo_root(start_path)
2393
- repo_config = derive_repo_config(hub_config, repo_root)
2394
- except RepoNotFoundError:
2395
- repo_config = None
2907
+ def _append_repo_check(
2908
+ checks: list[DoctorCheck],
2909
+ prefix: str,
2910
+ check_id: str,
2911
+ status: str,
2912
+ message: str,
2913
+ fix: Optional[str] = None,
2914
+ ) -> None:
2915
+ full_id = f"{prefix}.{check_id}" if prefix else check_id
2916
+ _append_check(checks, full_id, status, message, fix)
2917
+
2918
+
2919
+ def _load_isolated_repo_config(repo_root: Path) -> RepoConfig:
2920
+ config_path = repo_root / CONFIG_FILENAME
2921
+ raw_config = _load_yaml_dict(config_path) if config_path.exists() else {}
2922
+ raw = _merge_defaults(DEFAULT_REPO_CONFIG, raw_config or {})
2923
+ raw["mode"] = "repo"
2924
+ raw["version"] = raw.get("version") or CONFIG_VERSION
2925
+ _validate_repo_config(raw, root=repo_root)
2926
+ return _build_repo_config(config_path, raw)
2927
+
2928
+
2929
+ def _repo_checks(
2930
+ repo_config: RepoConfig,
2931
+ global_state_root: Path,
2932
+ prefix: str = "",
2933
+ ) -> list[DoctorCheck]:
2396
2934
  checks: list[DoctorCheck] = []
2397
- config = repo_config or hub_config
2398
- root = config.root
2399
-
2400
- if repo_config is not None:
2401
- missing = []
2402
- for key in ("todo", "progress", "opinions"):
2403
- path = repo_config.doc_path(key)
2404
- if not path.exists():
2405
- missing.append(path)
2406
- if missing:
2407
- names = ", ".join(str(p) for p in missing)
2408
- _append_check(
2935
+ repo_state_root = resolve_repo_state_root(repo_config.root)
2936
+ _append_repo_check(
2937
+ checks,
2938
+ prefix,
2939
+ "state.roots",
2940
+ "ok",
2941
+ f"Repo state root: {repo_state_root}; Global state root: {global_state_root}",
2942
+ )
2943
+
2944
+ missing = []
2945
+ configured_docs = repo_config.docs or {}
2946
+ for key in configured_docs:
2947
+ path = repo_config.doc_path(key)
2948
+ if not path.exists():
2949
+ missing.append(path)
2950
+ if missing:
2951
+ names = ", ".join(str(p) for p in missing)
2952
+ _append_repo_check(
2953
+ checks,
2954
+ prefix,
2955
+ "docs.required",
2956
+ "warning",
2957
+ f"Configured doc files are missing: {names}",
2958
+ "Create the missing files (workspace docs are optional but recommended).",
2959
+ )
2960
+ else:
2961
+ _append_repo_check(
2962
+ checks,
2963
+ prefix,
2964
+ "docs.required",
2965
+ "ok",
2966
+ "Configured doc files are present.",
2967
+ )
2968
+
2969
+ if ensure_executable(repo_config.codex_binary):
2970
+ _append_repo_check(
2971
+ checks,
2972
+ prefix,
2973
+ "codex.binary",
2974
+ "ok",
2975
+ f"Codex binary resolved: {repo_config.codex_binary}",
2976
+ )
2977
+ else:
2978
+ _append_repo_check(
2979
+ checks,
2980
+ prefix,
2981
+ "codex.binary",
2982
+ "error",
2983
+ f"Codex binary not found in PATH: {repo_config.codex_binary}",
2984
+ "Install Codex or set codex.binary to a full path.",
2985
+ )
2986
+
2987
+ voice_enabled = bool(repo_config.voice.get("enabled", True))
2988
+ if voice_enabled:
2989
+ missing_voice = missing_optional_dependencies(
2990
+ (
2991
+ ("httpx", "httpx"),
2992
+ (("multipart", "python_multipart"), "python-multipart"),
2993
+ )
2994
+ )
2995
+ if missing_voice:
2996
+ deps_list = ", ".join(missing_voice)
2997
+ _append_repo_check(
2409
2998
  checks,
2410
- "docs.required",
2999
+ prefix,
3000
+ "voice.dependencies",
2411
3001
  "error",
2412
- f"Missing doc files: {names}",
2413
- "Run `car init` or create the missing files.",
3002
+ f"Voice is enabled but missing optional deps: {deps_list}",
3003
+ "Install with `pip install codex-autorunner[voice]`.",
2414
3004
  )
2415
3005
  else:
2416
- _append_check(
2417
- checks,
2418
- "docs.required",
2419
- "ok",
2420
- "Required doc files are present.",
2421
- )
2422
-
2423
- if ensure_executable(repo_config.codex_binary):
2424
- _append_check(
3006
+ _append_repo_check(
2425
3007
  checks,
2426
- "codex.binary",
3008
+ prefix,
3009
+ "voice.dependencies",
2427
3010
  "ok",
2428
- f"Codex binary resolved: {repo_config.codex_binary}",
2429
- )
2430
- else:
2431
- _append_check(
2432
- checks,
2433
- "codex.binary",
2434
- "error",
2435
- f"Codex binary not found in PATH: {repo_config.codex_binary}",
2436
- "Install Codex or set codex.binary to a full path.",
3011
+ "Voice dependencies are installed.",
2437
3012
  )
2438
3013
 
2439
- voice_enabled = bool(repo_config.voice.get("enabled", True))
2440
- if voice_enabled:
2441
- missing_voice = missing_optional_dependencies(
2442
- (
2443
- ("httpx", "httpx"),
2444
- (("multipart", "python_multipart"), "python-multipart"),
2445
- )
2446
- )
2447
- if missing_voice:
2448
- deps_list = ", ".join(missing_voice)
2449
- _append_check(
2450
- checks,
2451
- "voice.dependencies",
2452
- "error",
2453
- f"Voice is enabled but missing optional deps: {deps_list}",
2454
- "Install with `pip install codex-autorunner[voice]`.",
2455
- )
2456
- else:
2457
- _append_check(
2458
- checks,
2459
- "voice.dependencies",
2460
- "ok",
2461
- "Voice dependencies are installed.",
2462
- )
2463
-
2464
3014
  env_candidates = [
2465
- root / ".env",
2466
- root / ".codex-autorunner" / ".env",
3015
+ repo_config.root / ".env",
3016
+ repo_config.root / ".codex-autorunner" / ".env",
2467
3017
  ]
2468
3018
  env_found = [str(path) for path in env_candidates if path.exists()]
2469
3019
  if env_found:
2470
- _append_check(
3020
+ _append_repo_check(
2471
3021
  checks,
3022
+ prefix,
2472
3023
  "dotenv.locations",
2473
3024
  "ok",
2474
3025
  f"Found .env files: {', '.join(env_found)}",
2475
3026
  )
2476
3027
  else:
2477
- _append_check(
3028
+ _append_repo_check(
2478
3029
  checks,
3030
+ prefix,
2479
3031
  "dotenv.locations",
2480
3032
  "warning",
2481
3033
  "No .env files found in repo root or .codex-autorunner/.env.",
2482
3034
  "Create one of these files if you rely on env vars.",
2483
3035
  )
2484
3036
 
2485
- host = str(config.server_host or "")
3037
+ host = str(repo_config.server_host or "")
2486
3038
  if not _is_loopback_host(host):
2487
- if not config.server_auth_token_env:
2488
- _append_check(
3039
+ if not repo_config.server_auth_token_env:
3040
+ _append_repo_check(
2489
3041
  checks,
3042
+ prefix,
2490
3043
  "server.auth",
2491
3044
  "error",
2492
3045
  f"Non-loopback host {host} requires server.auth_token_env.",
2493
3046
  "Set server.auth_token_env or bind to 127.0.0.1.",
2494
3047
  )
2495
3048
  else:
2496
- token_val = os.environ.get(config.server_auth_token_env)
3049
+ token_val = os.environ.get(repo_config.server_auth_token_env)
2497
3050
  if not token_val:
2498
- _append_check(
3051
+ _append_repo_check(
2499
3052
  checks,
3053
+ prefix,
2500
3054
  "server.auth",
2501
3055
  "warning",
2502
- f"Auth token env var {config.server_auth_token_env} is not set.",
3056
+ f"Auth token env var {repo_config.server_auth_token_env} is not set.",
2503
3057
  "Export the env var or add it to .env.",
2504
3058
  )
2505
3059
  else:
2506
- _append_check(
3060
+ _append_repo_check(
2507
3061
  checks,
3062
+ prefix,
2508
3063
  "server.auth",
2509
3064
  "ok",
2510
3065
  "Server auth token env var is set for non-loopback host.",
2511
3066
  )
2512
3067
 
2513
- static_dir, static_context = resolve_static_dir()
3068
+ return checks
3069
+
3070
+
3071
+ def _iter_hub_repos(hub_config) -> list[tuple[str, Path]]:
3072
+ repos: list[tuple[str, Path]] = []
3073
+ if hub_config.manifest_path.exists():
3074
+ try:
3075
+ raw = yaml.safe_load(hub_config.manifest_path.read_text(encoding="utf-8"))
3076
+ except (OSError, yaml.YAMLError):
3077
+ raw = None
3078
+ if isinstance(raw, dict):
3079
+ entries = raw.get("repos")
3080
+ if isinstance(entries, list):
3081
+ for entry in entries:
3082
+ if not isinstance(entry, dict):
3083
+ continue
3084
+ if not entry.get("enabled", True):
3085
+ continue
3086
+ path_val = entry.get("path")
3087
+ if not isinstance(path_val, str):
3088
+ continue
3089
+ repo_id = str(entry.get("id") or path_val)
3090
+ repos.append((repo_id, (hub_config.root / path_val).resolve()))
3091
+ if not repos and hub_config.repos_root.exists():
3092
+ for child in hub_config.repos_root.iterdir():
3093
+ if child.is_dir():
3094
+ repos.append((child.name, child.resolve()))
3095
+ return repos
3096
+
3097
+
3098
+ def doctor(start_path: Path) -> DoctorReport:
3099
+ checks: list[DoctorCheck] = []
3100
+ hub_config = None
2514
3101
  try:
2515
- missing_assets = missing_static_assets(static_dir)
2516
- if missing_assets:
2517
- _append_check(
2518
- checks,
2519
- "static.assets",
2520
- "error",
2521
- f"Static UI assets missing in {static_dir}: {', '.join(missing_assets)}",
2522
- "Reinstall the package or rebuild the UI assets.",
2523
- )
3102
+ hub_config = load_hub_config(start_path)
3103
+ except ConfigError:
3104
+ hub_config = None
3105
+
3106
+ repo_root: Optional[Path] = None
3107
+ try:
3108
+ repo_root = find_repo_root(start_path)
3109
+ except RepoNotFoundError:
3110
+ repo_root = None
3111
+
3112
+ repo_config: Optional[RepoConfig] = None
3113
+ if hub_config is not None and repo_root is not None:
3114
+ try:
3115
+ repo_config = derive_repo_config(hub_config, repo_root)
3116
+ except ConfigError:
3117
+ repo_config = None
3118
+ elif hub_config is None and repo_root is not None:
3119
+ try:
3120
+ repo_config = load_repo_config(start_path)
3121
+ except ConfigError:
3122
+ repo_config = _load_isolated_repo_config(repo_root)
3123
+
3124
+ if hub_config is not None:
3125
+ global_state_root = resolve_global_state_root(config=hub_config)
3126
+ _append_check(
3127
+ checks,
3128
+ "state.roots",
3129
+ "ok",
3130
+ f"Hub root: {hub_config.root}; Global state root: {global_state_root}",
3131
+ )
3132
+ elif repo_config is not None:
3133
+ global_state_root = resolve_global_state_root(config=repo_config)
3134
+ _append_check(
3135
+ checks,
3136
+ "state.roots",
3137
+ "ok",
3138
+ f"Repo state root: {resolve_repo_state_root(repo_config.root)}; Global state root: {global_state_root}",
3139
+ )
3140
+ else:
3141
+ raise ConfigError("No hub or repo configuration found for doctor check.")
3142
+
3143
+ if hub_config is not None:
3144
+ if hub_config.manifest_path.exists():
3145
+ version = _parse_manifest_version(hub_config.manifest_path)
3146
+ if version is None:
3147
+ _append_check(
3148
+ checks,
3149
+ "hub.manifest.version",
3150
+ "error",
3151
+ f"Failed to read manifest version from {hub_config.manifest_path}.",
3152
+ "Fix the manifest YAML or regenerate it with `car hub scan`.",
3153
+ )
3154
+ elif version != MANIFEST_VERSION:
3155
+ _append_check(
3156
+ checks,
3157
+ "hub.manifest.version",
3158
+ "error",
3159
+ f"Hub manifest version {version} unsupported (expected {MANIFEST_VERSION}).",
3160
+ "Regenerate the manifest (delete it and run `car hub scan`).",
3161
+ )
3162
+ else:
3163
+ _append_check(
3164
+ checks,
3165
+ "hub.manifest.version",
3166
+ "ok",
3167
+ f"Hub manifest version {version} is supported.",
3168
+ )
2524
3169
  else:
2525
3170
  _append_check(
2526
3171
  checks,
2527
- "static.assets",
2528
- "ok",
2529
- f"Static UI assets present in {static_dir}",
3172
+ "hub.manifest.exists",
3173
+ "warning",
3174
+ f"Hub manifest missing at {hub_config.manifest_path}.",
3175
+ "Run `car hub scan` or `car hub create` to generate it.",
2530
3176
  )
2531
- finally:
2532
- if static_context is not None:
2533
- static_context.close()
2534
3177
 
2535
- if hub_config.manifest_path.exists():
2536
- version = _parse_manifest_version(hub_config.manifest_path)
2537
- if version is None:
3178
+ if not hub_config.repos_root.exists():
2538
3179
  _append_check(
2539
3180
  checks,
2540
- "hub.manifest.version",
3181
+ "hub.repos_root",
2541
3182
  "error",
2542
- f"Failed to read manifest version from {hub_config.manifest_path}.",
2543
- "Fix the manifest YAML or regenerate it with `car hub scan`.",
3183
+ f"Hub repos_root does not exist: {hub_config.repos_root}",
3184
+ "Create the directory or update hub.repos_root in config.",
2544
3185
  )
2545
- elif version != MANIFEST_VERSION:
3186
+ elif not hub_config.repos_root.is_dir():
2546
3187
  _append_check(
2547
3188
  checks,
2548
- "hub.manifest.version",
3189
+ "hub.repos_root",
2549
3190
  "error",
2550
- f"Hub manifest version {version} unsupported (expected {MANIFEST_VERSION}).",
2551
- "Regenerate the manifest (delete it and run `car hub scan`).",
3191
+ f"Hub repos_root is not a directory: {hub_config.repos_root}",
3192
+ "Point hub.repos_root at a directory.",
2552
3193
  )
2553
3194
  else:
2554
3195
  _append_check(
2555
3196
  checks,
2556
- "hub.manifest.version",
3197
+ "hub.repos_root",
2557
3198
  "ok",
2558
- f"Hub manifest version {version} is supported.",
3199
+ f"Hub repos_root exists: {hub_config.repos_root}",
2559
3200
  )
2560
- else:
2561
- _append_check(
2562
- checks,
2563
- "hub.manifest.exists",
2564
- "warning",
2565
- f"Hub manifest missing at {hub_config.manifest_path}.",
2566
- "Run `car hub scan` or `car hub create` to generate it.",
2567
- )
2568
3201
 
2569
- if not hub_config.repos_root.exists():
2570
- _append_check(
2571
- checks,
2572
- "hub.repos_root",
2573
- "error",
2574
- f"Hub repos_root does not exist: {hub_config.repos_root}",
2575
- "Create the directory or update hub.repos_root in config.",
2576
- )
2577
- elif not hub_config.repos_root.is_dir():
2578
- _append_check(
2579
- checks,
2580
- "hub.repos_root",
2581
- "error",
2582
- f"Hub repos_root is not a directory: {hub_config.repos_root}",
2583
- "Point hub.repos_root at a directory.",
2584
- )
2585
- else:
2586
- _append_check(
2587
- checks,
2588
- "hub.repos_root",
2589
- "ok",
2590
- f"Hub repos_root exists: {hub_config.repos_root}",
3202
+ manifest_has_worktrees = (
3203
+ hub_config.manifest_path.exists()
3204
+ and _manifest_has_worktrees(hub_config.manifest_path)
2591
3205
  )
3206
+ worktrees_enabled = hub_config.worktrees_root.exists() or manifest_has_worktrees
3207
+ if worktrees_enabled:
3208
+ if ensure_executable("git"):
3209
+ _append_check(
3210
+ checks,
3211
+ "hub.git",
3212
+ "ok",
3213
+ "git is available for hub worktrees.",
3214
+ )
3215
+ else:
3216
+ _append_check(
3217
+ checks,
3218
+ "hub.git",
3219
+ "error",
3220
+ "git is not available but hub worktrees are enabled.",
3221
+ "Install git or disable worktrees.",
3222
+ )
2592
3223
 
2593
- manifest_has_worktrees = (
2594
- hub_config.manifest_path.exists()
2595
- and _manifest_has_worktrees(hub_config.manifest_path)
2596
- )
2597
- worktrees_enabled = hub_config.worktrees_root.exists() or manifest_has_worktrees
2598
- if worktrees_enabled:
2599
- if ensure_executable("git"):
3224
+ env_candidates = [
3225
+ hub_config.root / ".env",
3226
+ hub_config.root / ".codex-autorunner" / ".env",
3227
+ ]
3228
+ env_found = [str(path) for path in env_candidates if path.exists()]
3229
+ if env_found:
2600
3230
  _append_check(
2601
3231
  checks,
2602
- "hub.git",
3232
+ "dotenv.locations",
2603
3233
  "ok",
2604
- "git is available for hub worktrees.",
3234
+ f"Found .env files: {', '.join(env_found)}",
2605
3235
  )
2606
3236
  else:
2607
3237
  _append_check(
2608
3238
  checks,
2609
- "hub.git",
2610
- "error",
2611
- "git is not available but hub worktrees are enabled.",
2612
- "Install git or disable worktrees.",
3239
+ "dotenv.locations",
3240
+ "warning",
3241
+ "No .env files found in repo root or .codex-autorunner/.env.",
3242
+ "Create one of these files if you rely on env vars.",
2613
3243
  )
2614
3244
 
2615
- telegram_cfg = None
2616
- if isinstance(config.raw, dict):
2617
- telegram_cfg = config.raw.get("telegram_bot")
2618
- if isinstance(telegram_cfg, dict) and telegram_cfg.get("enabled") is True:
2619
- missing_telegram = missing_optional_dependencies((("httpx", "httpx"),))
2620
- if missing_telegram:
2621
- deps_list = ", ".join(missing_telegram)
2622
- _append_check(
2623
- checks,
2624
- "telegram.dependencies",
2625
- "error",
2626
- f"Telegram is enabled but missing optional deps: {deps_list}",
2627
- "Install with `pip install codex-autorunner[telegram]`.",
2628
- )
2629
- else:
2630
- _append_check(
2631
- checks,
2632
- "telegram.dependencies",
2633
- "ok",
2634
- "Telegram dependencies are installed.",
2635
- )
3245
+ host = str(hub_config.server_host or "")
3246
+ if not _is_loopback_host(host):
3247
+ if not hub_config.server_auth_token_env:
3248
+ _append_check(
3249
+ checks,
3250
+ "server.auth",
3251
+ "error",
3252
+ f"Non-loopback host {host} requires server.auth_token_env.",
3253
+ "Set server.auth_token_env or bind to 127.0.0.1.",
3254
+ )
3255
+ else:
3256
+ token_val = os.environ.get(hub_config.server_auth_token_env)
3257
+ if not token_val:
3258
+ _append_check(
3259
+ checks,
3260
+ "server.auth",
3261
+ "warning",
3262
+ f"Auth token env var {hub_config.server_auth_token_env} is not set.",
3263
+ "Export the env var or add it to .env.",
3264
+ )
3265
+ else:
3266
+ _append_check(
3267
+ checks,
3268
+ "server.auth",
3269
+ "ok",
3270
+ "Server auth token env var is set for non-loopback host.",
3271
+ )
3272
+
3273
+ for repo_id, repo_path in _iter_hub_repos(hub_config):
3274
+ prefix = f"repo[{repo_id}]"
3275
+ if not repo_path.exists():
3276
+ _append_repo_check(
3277
+ checks,
3278
+ prefix,
3279
+ "state.roots",
3280
+ "error",
3281
+ f"Repo path not found: {repo_path}",
3282
+ "Clone or initialize the repo, or update the hub manifest.",
3283
+ )
3284
+ continue
3285
+ try:
3286
+ repo_cfg = derive_repo_config(hub_config, repo_path)
3287
+ except ConfigError as exc:
3288
+ _append_repo_check(
3289
+ checks,
3290
+ prefix,
3291
+ "config",
3292
+ "error",
3293
+ f"Failed to derive repo config: {exc}",
3294
+ )
3295
+ continue
3296
+ checks.extend(_repo_checks(repo_cfg, global_state_root, prefix=prefix))
3297
+
3298
+ else:
3299
+ assert repo_config is not None
3300
+ checks.extend(_repo_checks(repo_config, global_state_root))
2636
3301
 
2637
3302
  return DoctorReport(checks=checks)