codex-autorunner 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Awaitable, Callable, Optional
8
+
9
+ from ...core.config import RepoConfig
10
+ from ...core.ports.agent_backend import AgentBackend
11
+ from ...core.state import RunnerState
12
+ from ...core.utils import build_opencode_supervisor
13
+ from ...workspace import canonical_workspace_root, workspace_id_for_path
14
+ from ..app_server.env import build_app_server_env
15
+ from ..app_server.supervisor import WorkspaceAppServerSupervisor
16
+ from .codex_backend import CodexAppServerBackend
17
+ from .opencode_backend import OpenCodeBackend
18
+
19
+ NotificationHandler = Callable[[dict[str, Any]], Awaitable[None]]
20
+ BackendFactory = Callable[
21
+ [str, RunnerState, Optional[NotificationHandler]], AgentBackend
22
+ ]
23
+ SupervisorFactory = Callable[[str, Optional[NotificationHandler]], Any]
24
+
25
+
26
+ def _build_workspace_env(
27
+ repo_root: Path,
28
+ config: RepoConfig,
29
+ *,
30
+ event_prefix: str,
31
+ logger: logging.Logger,
32
+ ) -> dict[str, str]:
33
+ workspace_root = canonical_workspace_root(repo_root)
34
+ workspace_id = workspace_id_for_path(workspace_root)
35
+ state_dir = config.app_server.state_root / workspace_id
36
+ state_dir.mkdir(parents=True, exist_ok=True)
37
+ return build_app_server_env(
38
+ config.app_server.command,
39
+ workspace_root,
40
+ state_dir,
41
+ logger=logger,
42
+ event_prefix=event_prefix,
43
+ )
44
+
45
+
46
+ class AgentBackendFactory:
47
+ def __init__(self, repo_root: Path, config: RepoConfig) -> None:
48
+ self._repo_root = repo_root
49
+ self._config = config
50
+ self._logger = logging.getLogger("codex_autorunner.app_server")
51
+ self._backend_cache: dict[str, AgentBackend] = {}
52
+ self._opencode_supervisor: Optional[Any] = None
53
+
54
+ def __call__(
55
+ self,
56
+ agent_id: str,
57
+ state: RunnerState,
58
+ notification_handler: Optional[NotificationHandler],
59
+ ) -> AgentBackend:
60
+ if agent_id == "codex":
61
+ if not self._config.app_server.command:
62
+ raise ValueError("app_server.command is required for codex backend")
63
+
64
+ approval_policy = state.autorunner_approval_policy or "never"
65
+ sandbox_mode = state.autorunner_sandbox_mode or "dangerFullAccess"
66
+ if sandbox_mode == "workspaceWrite":
67
+ sandbox_policy: Any = {
68
+ "type": "workspaceWrite",
69
+ "writableRoots": [str(self._repo_root)],
70
+ "networkAccess": bool(state.autorunner_workspace_write_network),
71
+ }
72
+ else:
73
+ sandbox_policy = sandbox_mode
74
+
75
+ model = state.autorunner_model_override or self._config.codex_model
76
+ reasoning_effort = (
77
+ state.autorunner_effort_override or self._config.codex_reasoning
78
+ )
79
+
80
+ env = _build_workspace_env(
81
+ self._repo_root,
82
+ self._config,
83
+ event_prefix="autorunner",
84
+ logger=self._logger,
85
+ )
86
+
87
+ cached = self._backend_cache.get(agent_id)
88
+ if cached is None:
89
+ cached = CodexAppServerBackend(
90
+ command=self._config.app_server.command,
91
+ cwd=self._repo_root,
92
+ env=env,
93
+ approval_policy=approval_policy,
94
+ sandbox_policy=sandbox_policy,
95
+ model=model,
96
+ reasoning_effort=reasoning_effort,
97
+ turn_timeout_seconds=None,
98
+ auto_restart=self._config.app_server.auto_restart,
99
+ request_timeout=self._config.app_server.request_timeout,
100
+ turn_stall_timeout_seconds=self._config.app_server.turn_stall_timeout_seconds,
101
+ turn_stall_poll_interval_seconds=self._config.app_server.turn_stall_poll_interval_seconds,
102
+ turn_stall_recovery_min_interval_seconds=self._config.app_server.turn_stall_recovery_min_interval_seconds,
103
+ max_message_bytes=self._config.app_server.client.max_message_bytes,
104
+ oversize_preview_bytes=self._config.app_server.client.oversize_preview_bytes,
105
+ max_oversize_drain_bytes=self._config.app_server.client.max_oversize_drain_bytes,
106
+ restart_backoff_initial_seconds=self._config.app_server.client.restart_backoff_initial_seconds,
107
+ restart_backoff_max_seconds=self._config.app_server.client.restart_backoff_max_seconds,
108
+ restart_backoff_jitter_ratio=self._config.app_server.client.restart_backoff_jitter_ratio,
109
+ notification_handler=notification_handler,
110
+ logger=self._logger,
111
+ )
112
+ self._backend_cache[agent_id] = cached
113
+ else:
114
+ if isinstance(cached, CodexAppServerBackend):
115
+ cached.configure(
116
+ approval_policy=approval_policy,
117
+ sandbox_policy=sandbox_policy,
118
+ model=model,
119
+ reasoning_effort=reasoning_effort,
120
+ turn_timeout_seconds=None,
121
+ notification_handler=notification_handler,
122
+ )
123
+ return cached
124
+
125
+ if agent_id == "opencode":
126
+ agent_cfg = self._config.agents.get("opencode")
127
+ base_url = agent_cfg.base_url if agent_cfg else None
128
+ username = os.environ.get("OPENCODE_SERVER_USERNAME")
129
+ password = os.environ.get("OPENCODE_SERVER_PASSWORD")
130
+ if password and not username:
131
+ username = "opencode"
132
+ auth = (username, password) if username and password else None
133
+
134
+ cached = self._backend_cache.get(agent_id)
135
+ if cached is None:
136
+ if not base_url:
137
+ supervisor = self._ensure_opencode_supervisor()
138
+ if supervisor is None:
139
+ raise ValueError("opencode backend is not configured")
140
+ cached = OpenCodeBackend(
141
+ supervisor=supervisor,
142
+ workspace_root=self._repo_root,
143
+ auth=auth,
144
+ timeout=self._config.app_server.request_timeout,
145
+ model=state.autorunner_model_override,
146
+ reasoning=state.autorunner_effort_override,
147
+ approval_policy=state.autorunner_approval_policy,
148
+ session_stall_timeout_seconds=self._config.opencode.session_stall_timeout_seconds,
149
+ logger=self._logger,
150
+ )
151
+ else:
152
+ cached = OpenCodeBackend(
153
+ base_url=base_url,
154
+ workspace_root=self._repo_root,
155
+ auth=auth,
156
+ timeout=self._config.app_server.request_timeout,
157
+ model=state.autorunner_model_override,
158
+ reasoning=state.autorunner_effort_override,
159
+ approval_policy=state.autorunner_approval_policy,
160
+ session_stall_timeout_seconds=self._config.opencode.session_stall_timeout_seconds,
161
+ logger=self._logger,
162
+ )
163
+ self._backend_cache[agent_id] = cached
164
+ else:
165
+ if isinstance(cached, OpenCodeBackend):
166
+ cached.configure(
167
+ model=state.autorunner_model_override,
168
+ reasoning=state.autorunner_effort_override,
169
+ approval_policy=state.autorunner_approval_policy,
170
+ )
171
+ return cached
172
+
173
+ raise ValueError(f"Unsupported agent backend: {agent_id}")
174
+
175
+ def _ensure_opencode_supervisor(self) -> Optional[Any]:
176
+ if self._opencode_supervisor is not None:
177
+ return self._opencode_supervisor
178
+ opencode_command = self._config.agent_serve_command("opencode")
179
+ opencode_binary = None
180
+ try:
181
+ opencode_binary = self._config.agent_binary("opencode")
182
+ except Exception:
183
+ opencode_binary = None
184
+ agent_config = self._config.agents.get("opencode")
185
+ subagent_models = agent_config.subagent_models if agent_config else None
186
+ supervisor = build_opencode_supervisor(
187
+ opencode_command=opencode_command,
188
+ opencode_binary=opencode_binary,
189
+ workspace_root=self._repo_root,
190
+ logger=self._logger,
191
+ request_timeout=self._config.app_server.request_timeout,
192
+ max_handles=self._config.app_server.max_handles,
193
+ idle_ttl_seconds=self._config.app_server.idle_ttl_seconds,
194
+ session_stall_timeout_seconds=self._config.opencode.session_stall_timeout_seconds,
195
+ max_text_chars=self._config.opencode.max_text_chars,
196
+ base_env=None,
197
+ subagent_models=subagent_models,
198
+ )
199
+ self._opencode_supervisor = supervisor
200
+ return supervisor
201
+
202
+ async def close_all(self) -> None:
203
+ backends = list(self._backend_cache.values())
204
+ self._backend_cache = {}
205
+ for backend in backends:
206
+ close = getattr(backend, "close", None)
207
+ if close is None:
208
+ continue
209
+ result = close()
210
+ if inspect.isawaitable(result):
211
+ await result
212
+ if self._opencode_supervisor is not None:
213
+ try:
214
+ await self._opencode_supervisor.close_all()
215
+ except Exception:
216
+ self._logger.warning(
217
+ "Failed closing opencode supervisor", exc_info=True
218
+ )
219
+ self._opencode_supervisor = None
220
+
221
+
222
+ def build_agent_backend_factory(repo_root: Path, config: RepoConfig) -> BackendFactory:
223
+ return AgentBackendFactory(repo_root, config)
224
+
225
+
226
+ def build_app_server_supervisor_factory(
227
+ config: RepoConfig,
228
+ *,
229
+ logger: Optional[logging.Logger] = None,
230
+ ) -> SupervisorFactory:
231
+ app_logger = logger or logging.getLogger("codex_autorunner.app_server")
232
+
233
+ def factory(
234
+ event_prefix: str, notification_handler: Optional[NotificationHandler]
235
+ ) -> WorkspaceAppServerSupervisor:
236
+ if not config.app_server.command:
237
+ raise ValueError("app_server.command is required for supervisor")
238
+
239
+ def _env_builder(
240
+ workspace_root: Path, _workspace_id: str, state_dir: Path
241
+ ) -> dict[str, str]:
242
+ state_dir.mkdir(parents=True, exist_ok=True)
243
+ return build_app_server_env(
244
+ config.app_server.command,
245
+ workspace_root,
246
+ state_dir,
247
+ logger=app_logger,
248
+ event_prefix=event_prefix,
249
+ )
250
+
251
+ return WorkspaceAppServerSupervisor(
252
+ config.app_server.command,
253
+ state_root=config.app_server.state_root,
254
+ env_builder=_env_builder,
255
+ logger=app_logger,
256
+ notification_handler=notification_handler,
257
+ auto_restart=config.app_server.auto_restart,
258
+ max_handles=config.app_server.max_handles,
259
+ idle_ttl_seconds=config.app_server.idle_ttl_seconds,
260
+ request_timeout=config.app_server.request_timeout,
261
+ turn_stall_timeout_seconds=config.app_server.turn_stall_timeout_seconds,
262
+ turn_stall_poll_interval_seconds=config.app_server.turn_stall_poll_interval_seconds,
263
+ turn_stall_recovery_min_interval_seconds=config.app_server.turn_stall_recovery_min_interval_seconds,
264
+ max_message_bytes=config.app_server.client.max_message_bytes,
265
+ oversize_preview_bytes=config.app_server.client.oversize_preview_bytes,
266
+ max_oversize_drain_bytes=config.app_server.client.max_oversize_drain_bytes,
267
+ restart_backoff_initial_seconds=config.app_server.client.restart_backoff_initial_seconds,
268
+ restart_backoff_max_seconds=config.app_server.client.restart_backoff_max_seconds,
269
+ restart_backoff_jitter_ratio=config.app_server.client.restart_backoff_jitter_ratio,
270
+ )
271
+
272
+ return factory
273
+
274
+
275
+ __all__ = [
276
+ "AgentBackendFactory",
277
+ "build_agent_backend_factory",
278
+ "build_app_server_supervisor_factory",
279
+ ]
@@ -23,10 +23,15 @@ from typing import (
23
23
  no_type_check,
24
24
  )
25
25
 
26
+ from ...core.app_server_utils import (
27
+ _extract_thread_id,
28
+ _extract_thread_id_for_turn,
29
+ _extract_turn_id,
30
+ )
26
31
  from ...core.circuit_breaker import CircuitBreaker
27
32
  from ...core.exceptions import (
33
+ AppServerError,
28
34
  CircuitOpenError,
29
- CodexError,
30
35
  PermanentError,
31
36
  TransientError,
32
37
  )
@@ -62,7 +67,7 @@ _INVALID_JSON_PREVIEW_BYTES = 200
62
67
  _CLIENT_INSTANCES: weakref.WeakSet = weakref.WeakSet()
63
68
 
64
69
 
65
- class CodexAppServerError(CodexError):
70
+ class CodexAppServerError(AppServerError):
66
71
  """Base error for app-server client failures."""
67
72
 
68
73
 
@@ -1579,54 +1584,12 @@ def _preview_excerpt(text: str, limit: int = 256) -> str:
1579
1584
  return f"{normalized[:limit].rstrip()}..."
1580
1585
 
1581
1586
 
1582
- def _extract_turn_id(payload: Any) -> Optional[str]:
1583
- if not isinstance(payload, dict):
1584
- return None
1585
- for key in ("turnId", "turn_id", "id"):
1586
- value = payload.get(key)
1587
- if isinstance(value, str):
1588
- return value
1589
- turn = payload.get("turn")
1590
- if isinstance(turn, dict):
1591
- for key in ("id", "turnId", "turn_id"):
1592
- value = turn.get(key)
1593
- if isinstance(value, str):
1594
- return value
1595
- return None
1596
-
1597
-
1598
1587
  def _turn_key(thread_id: Optional[str], turn_id: Optional[str]) -> Optional[TurnKey]:
1599
1588
  if not thread_id or not turn_id:
1600
1589
  return None
1601
1590
  return (thread_id, turn_id)
1602
1591
 
1603
1592
 
1604
- def _extract_thread_id_for_turn(payload: Any) -> Optional[str]:
1605
- if not isinstance(payload, dict):
1606
- return None
1607
- for candidate in (payload, payload.get("turn"), payload.get("item")):
1608
- thread_id = _extract_thread_id_from_container(candidate)
1609
- if thread_id:
1610
- return thread_id
1611
- return None
1612
-
1613
-
1614
- def _extract_thread_id_from_container(payload: Any) -> Optional[str]:
1615
- if not isinstance(payload, dict):
1616
- return None
1617
- for key in ("threadId", "thread_id"):
1618
- value = payload.get(key)
1619
- if isinstance(value, str):
1620
- return value
1621
- thread = payload.get("thread")
1622
- if isinstance(thread, dict):
1623
- for key in ("id", "threadId", "thread_id"):
1624
- value = thread.get(key)
1625
- if isinstance(value, str):
1626
- return value
1627
- return None
1628
-
1629
-
1630
1593
  def _extract_review_text(item: Any) -> Optional[str]:
1631
1594
  if not isinstance(item, dict):
1632
1595
  return None
@@ -1671,22 +1634,6 @@ def _extract_error_message(payload: Any) -> Optional[str]:
1671
1634
  return message
1672
1635
 
1673
1636
 
1674
- def _extract_thread_id(payload: Any) -> Optional[str]:
1675
- if not isinstance(payload, dict):
1676
- return None
1677
- for key in ("threadId", "thread_id", "id"):
1678
- value = payload.get(key)
1679
- if isinstance(value, str):
1680
- return value
1681
- thread = payload.get("thread")
1682
- if isinstance(thread, dict):
1683
- for key in ("id", "threadId", "thread_id"):
1684
- value = thread.get(key)
1685
- if isinstance(value, str):
1686
- return value
1687
- return None
1688
-
1689
-
1690
1637
  _SANDBOX_POLICY_CANONICAL = {
1691
1638
  "dangerfullaccess": "dangerFullAccess",
1692
1639
  "readonly": "readOnly",
@@ -1,110 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
4
- import os
5
- from pathlib import Path
6
- from typing import Mapping, Optional, Sequence
3
+ from ...core.app_server_utils import build_app_server_env
7
4
 
8
- from ...core.logging_utils import log_event
9
- from ...core.utils import resolve_executable, subprocess_env
10
-
11
-
12
- def app_server_env(
13
- command: Sequence[str],
14
- cwd: Path,
15
- *,
16
- base_env: Optional[Mapping[str, str]] = None,
17
- ) -> dict[str, str]:
18
- extra_paths: list[str] = []
19
- if command:
20
- binary = command[0]
21
- resolved = resolve_executable(binary, env=base_env)
22
- candidate: Optional[Path] = Path(resolved) if resolved else None
23
- if candidate is None:
24
- candidate = Path(binary).expanduser()
25
- if not candidate.is_absolute():
26
- candidate = (cwd / candidate).resolve()
27
- if candidate.exists():
28
- extra_paths.append(str(candidate.parent))
29
- return subprocess_env(extra_paths=extra_paths, base_env=base_env)
30
-
31
-
32
- def seed_codex_home(
33
- codex_home: Path,
34
- *,
35
- logger: Optional[logging.Logger] = None,
36
- event_prefix: str = "app_server",
37
- ) -> None:
38
- logger = logger or logging.getLogger(__name__)
39
- auth_path = codex_home / "auth.json"
40
- source_root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
41
- if source_root.resolve() == codex_home.resolve():
42
- return
43
- source_auth = source_root / "auth.json"
44
- if auth_path.exists():
45
- if auth_path.is_symlink() and auth_path.resolve() == source_auth.resolve():
46
- return
47
- log_event(
48
- logger,
49
- logging.INFO,
50
- f"{event_prefix}.codex_home.seed.skipped",
51
- reason="auth_exists",
52
- source=str(source_root),
53
- target=str(codex_home),
54
- )
55
- return
56
- if not source_root.exists():
57
- log_event(
58
- logger,
59
- logging.WARNING,
60
- f"{event_prefix}.codex_home.seed.skipped",
61
- reason="source_missing",
62
- source=str(source_root),
63
- target=str(codex_home),
64
- )
65
- return
66
- if not source_auth.exists():
67
- log_event(
68
- logger,
69
- logging.WARNING,
70
- f"{event_prefix}.codex_home.seed.skipped",
71
- reason="auth_missing",
72
- source=str(source_root),
73
- target=str(codex_home),
74
- )
75
- return
76
- try:
77
- auth_path.symlink_to(source_auth)
78
- log_event(
79
- logger,
80
- logging.INFO,
81
- f"{event_prefix}.codex_home.seeded",
82
- source=str(source_root),
83
- target=str(codex_home),
84
- )
85
- except OSError as exc:
86
- log_event(
87
- logger,
88
- logging.WARNING,
89
- f"{event_prefix}.codex_home.seed.failed",
90
- exc=exc,
91
- source=str(source_root),
92
- target=str(codex_home),
93
- )
94
-
95
-
96
- def build_app_server_env(
97
- command: Sequence[str],
98
- workspace_root: Path,
99
- state_dir: Path,
100
- *,
101
- logger: Optional[logging.Logger] = None,
102
- event_prefix: str = "app_server",
103
- base_env: Optional[Mapping[str, str]] = None,
104
- ) -> dict[str, str]:
105
- env = app_server_env(command, workspace_root, base_env=base_env)
106
- codex_home = state_dir / "codex_home"
107
- codex_home.mkdir(parents=True, exist_ok=True)
108
- seed_codex_home(codex_home, logger=logger, event_prefix=event_prefix)
109
- env["CODEX_HOME"] = str(codex_home)
110
- return env
5
+ __all__ = ["build_app_server_env"]
@@ -1,17 +1,19 @@
1
1
  import asyncio
2
2
  import json
3
+ import logging
3
4
  import threading
4
5
  import time
5
6
  from dataclasses import dataclass, field
6
7
  from typing import Any, AsyncIterator, Dict, Optional
7
8
 
8
- from ..integrations.app_server.client import (
9
- _extract_thread_id,
10
- _extract_thread_id_for_turn,
11
- _extract_turn_id,
9
+ from ...core.app_server_ids import (
10
+ extract_thread_id,
11
+ extract_thread_id_for_turn,
12
+ extract_turn_id,
12
13
  )
13
14
 
14
15
  TurnKey = tuple[str, str]
16
+ LOGGER = logging.getLogger("codex_autorunner.app_server")
15
17
 
16
18
 
17
19
  def format_sse(event: str, data: object) -> str:
@@ -143,11 +145,11 @@ class AppServerEventBuffer:
143
145
  ) -> tuple[Optional[str], Optional[str]]:
144
146
  params_raw = message.get("params")
145
147
  params: Dict[str, Any] = params_raw if isinstance(params_raw, dict) else {}
146
- turn_id = _extract_turn_id(params) or _extract_turn_id(message)
148
+ turn_id = extract_turn_id(params) or extract_turn_id(message)
147
149
  thread_id = (
148
- _extract_thread_id_for_turn(params)
149
- or _extract_thread_id(params)
150
- or _extract_thread_id(message)
150
+ extract_thread_id_for_turn(params)
151
+ or extract_thread_id(params)
152
+ or extract_thread_id(message)
151
153
  )
152
154
  if not thread_id and turn_id:
153
155
  thread_id = self._turn_index.get(turn_id)
@@ -184,9 +186,14 @@ class AppServerEventBuffer:
184
186
  try:
185
187
  lines = formatter.format_event(message)
186
188
  except Exception:
189
+ LOGGER.warning("Failed to format app server event log line.", exc_info=True)
187
190
  return
188
191
  for line in lines:
189
192
  try:
190
193
  emit(line)
191
194
  except Exception:
195
+ LOGGER.warning(
196
+ "Failed to emit app server event log line.",
197
+ exc_info=True,
198
+ )
192
199
  continue
@@ -259,6 +259,17 @@ class PageCallback:
259
259
  page: int
260
260
 
261
261
 
262
+ @dataclass(frozen=True)
263
+ class FlowCallback:
264
+ action: str
265
+ run_id: Optional[str] = None
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class FlowRunCallback:
270
+ run_id: str
271
+
272
+
262
273
  def parse_command(
263
274
  text: Optional[str],
264
275
  *,
@@ -741,6 +752,27 @@ def encode_compact_callback(action: str) -> str:
741
752
  return data
742
753
 
743
754
 
755
+ def encode_flow_callback(action: str, run_id: Optional[str] = None) -> str:
756
+ action = str(action or "").strip()
757
+ if not action:
758
+ raise ValueError("flow action required")
759
+ if run_id:
760
+ data = f"flow:{action}:{run_id}"
761
+ else:
762
+ data = f"flow:{action}"
763
+ _validate_callback_data(data)
764
+ return data
765
+
766
+
767
+ def encode_flow_run_callback(run_id: str) -> str:
768
+ run_id = str(run_id or "").strip()
769
+ if not run_id:
770
+ raise ValueError("flow run id required")
771
+ data = f"flow_run:{run_id}"
772
+ _validate_callback_data(data)
773
+ return data
774
+
775
+
744
776
  def parse_callback_data(
745
777
  data: Optional[str],
746
778
  ) -> Optional[
@@ -760,6 +792,8 @@ def parse_callback_data(
760
792
  ReviewCommitCallback,
761
793
  CancelCallback,
762
794
  CompactCallback,
795
+ FlowCallback,
796
+ FlowRunCallback,
763
797
  PageCallback,
764
798
  ]
765
799
  ]:
@@ -857,6 +891,19 @@ def parse_callback_data(
857
891
  if not page.isdigit():
858
892
  return None
859
893
  return PageCallback(kind=kind, page=int(page))
894
+ if data.startswith("flow:"):
895
+ _, _, rest = data.partition(":")
896
+ action, sep, run_id = rest.partition(":")
897
+ if not action:
898
+ return None
899
+ if sep and not run_id:
900
+ return None
901
+ return FlowCallback(action=action, run_id=run_id or None)
902
+ if data.startswith("flow_run:"):
903
+ _, _, run_id = data.partition(":")
904
+ if not run_id:
905
+ return None
906
+ return FlowRunCallback(run_id=run_id)
860
907
  return None
861
908
 
862
909
 
@@ -1062,6 +1109,24 @@ def build_bind_keyboard(
1062
1109
  return build_inline_keyboard(rows)
1063
1110
 
1064
1111
 
1112
+ def build_flow_runs_keyboard(
1113
+ options: Sequence[tuple[str, str]],
1114
+ *,
1115
+ page_button: Optional[tuple[str, str]] = None,
1116
+ include_cancel: bool = False,
1117
+ ) -> dict[str, Any]:
1118
+ rows = [
1119
+ [InlineButton(label, encode_flow_run_callback(run_id))]
1120
+ for run_id, label in options
1121
+ ]
1122
+ if page_button:
1123
+ label, callback_data = page_button
1124
+ rows.append([InlineButton(label, callback_data)])
1125
+ if include_cancel:
1126
+ rows.append([InlineButton("Cancel", encode_cancel_callback("flow-runs"))])
1127
+ return build_inline_keyboard(rows)
1128
+
1129
+
1065
1130
  def _validate_callback_data(data: str) -> None:
1066
1131
  if len(data.encode("utf-8")) > TELEGRAM_CALLBACK_DATA_LIMIT:
1067
1132
  raise ValueError("callback_data exceeds Telegram limit")