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,19 +1,37 @@
1
1
  import asyncio
2
2
  import json
3
3
  import logging
4
+ import os
4
5
  import random
5
6
  import re
7
+ import time
6
8
  import uuid
9
+ import weakref
7
10
  from collections import deque
8
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
9
12
  from importlib import metadata as importlib_metadata
10
13
  from pathlib import Path
11
- from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, Union, cast
14
+ from typing import (
15
+ Any,
16
+ Awaitable,
17
+ Callable,
18
+ Dict,
19
+ Optional,
20
+ Sequence,
21
+ Union,
22
+ cast,
23
+ no_type_check,
24
+ )
12
25
 
26
+ from ...core.app_server_utils import (
27
+ _extract_thread_id,
28
+ _extract_thread_id_for_turn,
29
+ _extract_turn_id,
30
+ )
13
31
  from ...core.circuit_breaker import CircuitBreaker
14
32
  from ...core.exceptions import (
33
+ AppServerError,
15
34
  CircuitOpenError,
16
- CodexError,
17
35
  PermanentError,
18
36
  TransientError,
19
37
  )
@@ -38,8 +56,18 @@ _RESTART_BACKOFF_INITIAL_SECONDS = 0.5
38
56
  _RESTART_BACKOFF_MAX_SECONDS = 30.0
39
57
  _RESTART_BACKOFF_JITTER_RATIO = 0.1
40
58
 
59
+ # Per-turn stall detection defaults.
60
+ _TURN_STALL_TIMEOUT_SECONDS = 60.0
61
+ _TURN_STALL_POLL_INTERVAL_SECONDS = 2.0
62
+ _TURN_STALL_RECOVERY_MIN_INTERVAL_SECONDS = 10.0
63
+ _MAX_TURN_RAW_EVENTS = 200
64
+ _INVALID_JSON_PREVIEW_BYTES = 200
65
+
66
+ # Track live clients so tests/cleanup can cancel any background restart tasks.
67
+ _CLIENT_INSTANCES: weakref.WeakSet = weakref.WeakSet()
41
68
 
42
- class CodexAppServerError(CodexError):
69
+
70
+ class CodexAppServerError(AppServerError):
43
71
  """Base error for app-server client failures."""
44
72
 
45
73
 
@@ -104,10 +132,15 @@ class _TurnState:
104
132
  turn_id: str
105
133
  thread_id: Optional[str]
106
134
  future: asyncio.Future["TurnResult"]
107
- agent_messages: list[str]
108
- errors: list[str]
109
- raw_events: list[Dict[str, Any]]
135
+ agent_messages: list[str] = field(default_factory=list)
136
+ errors: list[str] = field(default_factory=list)
137
+ raw_events: list[Dict[str, Any]] = field(default_factory=list)
110
138
  status: Optional[str] = None
139
+ last_event_at: float = field(default_factory=time.monotonic)
140
+ last_method: Optional[str] = None
141
+ recovery_attempts: int = 0
142
+ last_recovery_at: float = 0.0
143
+ agent_message_deltas: Dict[str, str] = field(default_factory=dict)
111
144
 
112
145
 
113
146
  class CodexAppServerClient:
@@ -119,8 +152,17 @@ class CodexAppServerClient:
119
152
  env: Optional[Dict[str, str]] = None,
120
153
  approval_handler: Optional[ApprovalHandler] = None,
121
154
  default_approval_decision: str = "cancel",
122
- auto_restart: bool = True,
155
+ auto_restart: Optional[bool] = None,
123
156
  request_timeout: Optional[float] = None,
157
+ turn_stall_timeout_seconds: Optional[float] = _TURN_STALL_TIMEOUT_SECONDS,
158
+ turn_stall_poll_interval_seconds: Optional[float] = None,
159
+ turn_stall_recovery_min_interval_seconds: Optional[float] = None,
160
+ max_message_bytes: Optional[int] = None,
161
+ oversize_preview_bytes: Optional[int] = None,
162
+ max_oversize_drain_bytes: Optional[int] = None,
163
+ restart_backoff_initial_seconds: Optional[float] = None,
164
+ restart_backoff_max_seconds: Optional[float] = None,
165
+ restart_backoff_jitter_ratio: Optional[float] = None,
124
166
  notification_handler: Optional[NotificationHandler] = None,
125
167
  logger: Optional[logging.Logger] = None,
126
168
  ) -> None:
@@ -129,11 +171,52 @@ class CodexAppServerClient:
129
171
  self._env = env
130
172
  self._approval_handler = approval_handler
131
173
  self._default_approval_decision = default_approval_decision
132
- self._auto_restart = auto_restart
174
+ disable_restart_env = os.environ.get(
175
+ "CODEX_DISABLE_APP_SERVER_AUTORESTART_FOR_TESTS"
176
+ )
177
+ if disable_restart_env:
178
+ self._auto_restart = False
179
+ elif auto_restart is None:
180
+ self._auto_restart = True
181
+ else:
182
+ self._auto_restart = auto_restart
133
183
  self._request_timeout = request_timeout
134
184
  self._notification_handler = notification_handler
135
185
  self._logger = logger or logging.getLogger(__name__)
136
186
  self._circuit_breaker = CircuitBreaker("App-Server", logger=self._logger)
187
+ self._max_message_bytes = (
188
+ max_message_bytes
189
+ if max_message_bytes is not None and max_message_bytes > 0
190
+ else _MAX_MESSAGE_BYTES
191
+ )
192
+ self._oversize_preview_bytes = (
193
+ oversize_preview_bytes
194
+ if oversize_preview_bytes is not None and oversize_preview_bytes > 0
195
+ else _OVERSIZE_PREVIEW_BYTES
196
+ )
197
+ self._max_oversize_drain_bytes = (
198
+ max_oversize_drain_bytes
199
+ if max_oversize_drain_bytes is not None and max_oversize_drain_bytes > 0
200
+ else _MAX_OVERSIZE_DRAIN_BYTES
201
+ )
202
+ self._restart_backoff_initial_seconds = (
203
+ restart_backoff_initial_seconds
204
+ if restart_backoff_initial_seconds is not None
205
+ and restart_backoff_initial_seconds > 0
206
+ else _RESTART_BACKOFF_INITIAL_SECONDS
207
+ )
208
+ self._restart_backoff_max_seconds = (
209
+ restart_backoff_max_seconds
210
+ if restart_backoff_max_seconds is not None
211
+ and restart_backoff_max_seconds > 0
212
+ else _RESTART_BACKOFF_MAX_SECONDS
213
+ )
214
+ self._restart_backoff_jitter_ratio = (
215
+ restart_backoff_jitter_ratio
216
+ if restart_backoff_jitter_ratio is not None
217
+ and restart_backoff_jitter_ratio >= 0
218
+ else _RESTART_BACKOFF_JITTER_RATIO
219
+ )
137
220
 
138
221
  self._process: Optional[asyncio.subprocess.Process] = None
139
222
  self._reader_task: Optional[asyncio.Task] = None
@@ -154,8 +237,37 @@ class CodexAppServerClient:
154
237
  self._client_version = _client_version()
155
238
  self._include_client_version = True
156
239
  self._restart_task: Optional[asyncio.Task] = None
157
- self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
240
+ self._restart_backoff_seconds = self._restart_backoff_initial_seconds
158
241
  self._stderr_tail: deque[str] = deque(maxlen=5)
242
+ self._turn_stall_timeout_seconds: Optional[float] = turn_stall_timeout_seconds
243
+ if (
244
+ self._turn_stall_timeout_seconds is not None
245
+ and self._turn_stall_timeout_seconds <= 0
246
+ ):
247
+ self._turn_stall_timeout_seconds = None
248
+ self._turn_stall_poll_interval_seconds: float = (
249
+ turn_stall_poll_interval_seconds
250
+ if turn_stall_poll_interval_seconds is not None
251
+ else _TURN_STALL_POLL_INTERVAL_SECONDS
252
+ )
253
+ if (
254
+ self._turn_stall_poll_interval_seconds is not None
255
+ and self._turn_stall_poll_interval_seconds <= 0
256
+ ):
257
+ self._turn_stall_poll_interval_seconds = _TURN_STALL_POLL_INTERVAL_SECONDS
258
+ self._turn_stall_recovery_min_interval_seconds: float = (
259
+ turn_stall_recovery_min_interval_seconds
260
+ if turn_stall_recovery_min_interval_seconds is not None
261
+ else _TURN_STALL_RECOVERY_MIN_INTERVAL_SECONDS
262
+ )
263
+ if (
264
+ self._turn_stall_recovery_min_interval_seconds is not None
265
+ and self._turn_stall_recovery_min_interval_seconds < 0
266
+ ):
267
+ self._turn_stall_recovery_min_interval_seconds = (
268
+ _TURN_STALL_RECOVERY_MIN_INTERVAL_SECONDS
269
+ )
270
+ _CLIENT_INSTANCES.add(self)
159
271
 
160
272
  async def start(self) -> None:
161
273
  await self._ensure_process()
@@ -171,6 +283,7 @@ class CodexAppServerClient:
171
283
  self._restart_task = None
172
284
  await self._terminate_process()
173
285
  self._fail_pending(CodexAppServerDisconnected("Client closed"))
286
+ _CLIENT_INSTANCES.discard(self)
174
287
 
175
288
  async def wait_for_disconnect(self, *, timeout: Optional[float] = None) -> None:
176
289
  disconnected = self._ensure_disconnect_event()
@@ -346,15 +459,114 @@ class CodexAppServerClient:
346
459
  self._turns.pop(key, None)
347
460
  return result
348
461
  timeout = timeout if timeout is not None else self._request_timeout
349
- if timeout is None:
350
- result = await state.future
351
- if key is not None:
352
- self._turns.pop(key, None)
353
- return result
354
- result = await asyncio.wait_for(state.future, timeout)
355
- if key is not None:
356
- self._turns.pop(key, None)
357
- return result
462
+ deadline = time.monotonic() + timeout if timeout is not None else None
463
+ while True:
464
+ slice_timeout = self._turn_stall_poll_interval_seconds
465
+ if deadline is not None:
466
+ remaining = deadline - time.monotonic()
467
+ if remaining <= 0:
468
+ raise asyncio.TimeoutError()
469
+ if slice_timeout is None or slice_timeout > remaining:
470
+ slice_timeout = remaining
471
+ try:
472
+ if slice_timeout is None:
473
+ result = await asyncio.shield(state.future)
474
+ else:
475
+ result = await asyncio.wait_for(
476
+ asyncio.shield(state.future), timeout=slice_timeout
477
+ )
478
+ if key is not None:
479
+ self._turns.pop(key, None)
480
+ return result
481
+ except asyncio.TimeoutError:
482
+ pass
483
+
484
+ stall_timeout = self._turn_stall_timeout_seconds
485
+ idle_seconds = time.monotonic() - state.last_event_at
486
+ if (
487
+ stall_timeout is not None
488
+ and idle_seconds >= stall_timeout
489
+ and not state.future.done()
490
+ ):
491
+ await self._recover_stalled_turn(
492
+ state,
493
+ turn_id,
494
+ thread_id=thread_id or state.thread_id,
495
+ idle_seconds=idle_seconds,
496
+ )
497
+
498
+ async def _recover_stalled_turn(
499
+ self,
500
+ state: _TurnState,
501
+ turn_id: str,
502
+ *,
503
+ thread_id: Optional[str],
504
+ idle_seconds: float,
505
+ ) -> None:
506
+ now = time.monotonic()
507
+ if thread_id is None:
508
+ state.last_event_at = now
509
+ return
510
+ min_interval = self._turn_stall_recovery_min_interval_seconds
511
+ if (
512
+ min_interval is not None
513
+ and state.last_recovery_at
514
+ and now - state.last_recovery_at < min_interval
515
+ ):
516
+ return
517
+ state.last_recovery_at = now
518
+ state.recovery_attempts += 1
519
+ log_event(
520
+ self._logger,
521
+ logging.WARNING,
522
+ "app_server.turn_stalled",
523
+ turn_id=turn_id,
524
+ thread_id=thread_id,
525
+ idle_seconds=round(idle_seconds, 2),
526
+ last_method=state.last_method,
527
+ recovery_attempts=state.recovery_attempts,
528
+ )
529
+ try:
530
+ resume_result = await self.thread_resume(thread_id)
531
+ except Exception as exc:
532
+ log_event(
533
+ self._logger,
534
+ logging.WARNING,
535
+ "app_server.turn_recovery.failed",
536
+ turn_id=turn_id,
537
+ thread_id=thread_id,
538
+ idle_seconds=round(idle_seconds, 2),
539
+ exc=exc,
540
+ )
541
+ state.last_event_at = now
542
+ return
543
+
544
+ snapshot = _extract_turn_snapshot_from_resume(resume_result, turn_id)
545
+ if snapshot is None:
546
+ state.last_event_at = now
547
+ return
548
+
549
+ status, agent_messages, errors = snapshot
550
+ if agent_messages:
551
+ state.agent_messages = agent_messages
552
+ if errors:
553
+ state.errors.extend(errors)
554
+ if status:
555
+ state.status = status
556
+
557
+ if status and _status_is_terminal(status) and not state.future.done():
558
+ state.future.set_result(
559
+ TurnResult(
560
+ turn_id=state.turn_id,
561
+ agent_messages=_agent_messages_for_result(state),
562
+ errors=list(state.errors),
563
+ raw_events=list(state.raw_events),
564
+ status=state.status,
565
+ )
566
+ )
567
+ return
568
+
569
+ state.last_event_at = now
358
570
 
359
571
  async def _ensure_process(self) -> None:
360
572
  async with self._circuit_breaker.call():
@@ -431,7 +643,7 @@ class CodexAppServerClient:
431
643
  self._initializing = False
432
644
  await self._send_message(self._build_message("initialized", params=None))
433
645
  self._initialized = True
434
- self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
646
+ self._restart_backoff_seconds = self._restart_backoff_initial_seconds
435
647
  log_event(self._logger, logging.INFO, "app_server.initialized")
436
648
 
437
649
  async def _request_raw(
@@ -546,26 +758,28 @@ class CodexAppServerClient:
546
758
  newline_index = chunk.find(b"\n")
547
759
  if newline_index == -1:
548
760
  if not drain_limit_reached:
549
- if len(oversize_preview) < _OVERSIZE_PREVIEW_BYTES:
550
- remaining = _OVERSIZE_PREVIEW_BYTES - len(
761
+ if len(oversize_preview) < self._oversize_preview_bytes:
762
+ remaining = self._oversize_preview_bytes - len(
551
763
  oversize_preview
552
764
  )
553
765
  oversize_preview.extend(chunk[:remaining])
554
766
  oversize_bytes_dropped += len(chunk)
555
- if oversize_bytes_dropped >= _MAX_OVERSIZE_DRAIN_BYTES:
767
+ if oversize_bytes_dropped >= self._max_oversize_drain_bytes:
556
768
  await self._emit_oversize_warning(
557
769
  bytes_dropped=oversize_bytes_dropped,
558
770
  preview=oversize_preview,
559
771
  aborted=True,
560
- drain_limit=_MAX_OVERSIZE_DRAIN_BYTES,
772
+ drain_limit=self._max_oversize_drain_bytes,
561
773
  )
562
774
  drain_limit_reached = True
563
775
  continue
564
776
  before = chunk[: newline_index + 1]
565
777
  after = chunk[newline_index + 1 :]
566
778
  if not drain_limit_reached:
567
- if len(oversize_preview) < _OVERSIZE_PREVIEW_BYTES:
568
- remaining = _OVERSIZE_PREVIEW_BYTES - len(oversize_preview)
779
+ if len(oversize_preview) < self._oversize_preview_bytes:
780
+ remaining = self._oversize_preview_bytes - len(
781
+ oversize_preview
782
+ )
569
783
  oversize_preview.extend(before[:remaining])
570
784
  oversize_bytes_dropped += len(before)
571
785
  await self._emit_oversize_warning(
@@ -588,8 +802,8 @@ class CodexAppServerClient:
588
802
  line = buffer[:newline_index]
589
803
  del buffer[: newline_index + 1]
590
804
  await self._handle_payload_line(line)
591
- if not dropping_oversize and len(buffer) > _MAX_MESSAGE_BYTES:
592
- oversize_preview = bytearray(buffer[:_OVERSIZE_PREVIEW_BYTES])
805
+ if not dropping_oversize and len(buffer) > self._max_message_bytes:
806
+ oversize_preview = bytearray(buffer[: self._oversize_preview_bytes])
593
807
  oversize_bytes_dropped = len(buffer)
594
808
  buffer.clear()
595
809
  dropping_oversize = True
@@ -601,10 +815,10 @@ class CodexAppServerClient:
601
815
  truncated=True,
602
816
  )
603
817
  elif buffer:
604
- if len(buffer) > _MAX_MESSAGE_BYTES:
818
+ if len(buffer) > self._max_message_bytes:
605
819
  await self._emit_oversize_warning(
606
820
  bytes_dropped=len(buffer),
607
- preview=buffer[:_OVERSIZE_PREVIEW_BYTES],
821
+ preview=buffer[: self._oversize_preview_bytes],
608
822
  truncated=True,
609
823
  )
610
824
  else:
@@ -622,7 +836,15 @@ class CodexAppServerClient:
622
836
  return
623
837
  try:
624
838
  message = json.loads(payload)
625
- except json.JSONDecodeError:
839
+ except json.JSONDecodeError as exc:
840
+ log_event(
841
+ self._logger,
842
+ logging.WARNING,
843
+ "app_server.read.invalid_json",
844
+ preview=payload[:_INVALID_JSON_PREVIEW_BYTES],
845
+ length=len(payload),
846
+ exc=exc,
847
+ )
626
848
  return
627
849
  if not isinstance(message, dict):
628
850
  return
@@ -655,7 +877,7 @@ class CodexAppServerClient:
655
877
  if self._notification_handler is None:
656
878
  return
657
879
  params: Dict[str, Any] = {
658
- "byteLimit": _MAX_MESSAGE_BYTES,
880
+ "byteLimit": self._max_message_bytes,
659
881
  "bytesDropped": bytes_dropped,
660
882
  }
661
883
  inferred_method = metadata.get("method")
@@ -691,6 +913,7 @@ class CodexAppServerClient:
691
913
  handled=False,
692
914
  exc=exc,
693
915
  )
916
+ self._logger.debug("Notification handler failed: %s", exc)
694
917
 
695
918
  async def _drain_stderr(self) -> None:
696
919
  if not self._process or not self._process.stderr:
@@ -714,7 +937,8 @@ class CodexAppServerClient:
714
937
  line_len=len(text),
715
938
  tail_size=len(self._stderr_tail),
716
939
  )
717
- except Exception:
940
+ except Exception as exc:
941
+ self._logger.debug("Failed to read stderr: %s", exc)
718
942
  return
719
943
 
720
944
  async def _handle_message(self, message: Dict[str, Any]) -> None:
@@ -856,7 +1080,39 @@ class CodexAppServerClient:
856
1080
  method = message.get("method")
857
1081
  params = message.get("params") or {}
858
1082
  handled = False
859
- if method == "item/completed":
1083
+ if isinstance(method, str):
1084
+ turn_id_hint = _extract_turn_id(params) or _extract_turn_id(
1085
+ params.get("turn") if isinstance(params, dict) else None
1086
+ )
1087
+ if turn_id_hint:
1088
+ thread_id_hint = _extract_thread_id_for_turn(params)
1089
+ _key, state = await self._find_turn_state(
1090
+ turn_id_hint, thread_id=thread_id_hint
1091
+ )
1092
+ if state is not None:
1093
+ state.last_event_at = time.monotonic()
1094
+ state.last_method = method
1095
+ if method == "item/agentMessage/delta":
1096
+ turn_id = _extract_turn_id(params)
1097
+ if turn_id:
1098
+ thread_id = _extract_thread_id_for_turn(params)
1099
+ _key, state = await self._find_turn_state(turn_id, thread_id=thread_id)
1100
+ if state is None:
1101
+ if thread_id:
1102
+ state = self._ensure_turn_state(turn_id, thread_id)
1103
+ else:
1104
+ state = self._ensure_pending_turn_state(turn_id)
1105
+ item_id = params.get("itemId")
1106
+ delta = params.get("delta") or params.get("text")
1107
+ if isinstance(item_id, str) and isinstance(delta, str):
1108
+ state.agent_message_deltas[item_id] = (
1109
+ state.agent_message_deltas.get(item_id, "") + delta
1110
+ )
1111
+ state.last_event_at = time.monotonic()
1112
+ state.last_method = method
1113
+ _record_raw_event(state, message)
1114
+ handled = True
1115
+ elif method == "item/completed":
860
1116
  turn_id = _extract_turn_id(params) or _extract_turn_id(
861
1117
  params.get("item") if isinstance(params, dict) else None
862
1118
  )
@@ -870,6 +1126,8 @@ class CodexAppServerClient:
870
1126
  state = self._ensure_turn_state(turn_id, thread_id)
871
1127
  else:
872
1128
  state = self._ensure_pending_turn_state(turn_id)
1129
+ state.last_event_at = time.monotonic()
1130
+ state.last_method = method
873
1131
  self._apply_item_completed(state, message, params)
874
1132
  handled = True
875
1133
  elif method == "turn/completed":
@@ -884,6 +1142,8 @@ class CodexAppServerClient:
884
1142
  state = self._ensure_turn_state(turn_id, thread_id)
885
1143
  else:
886
1144
  state = self._ensure_pending_turn_state(turn_id)
1145
+ state.last_event_at = time.monotonic()
1146
+ state.last_method = method
887
1147
  self._apply_turn_completed(state, message, params)
888
1148
  handled = True
889
1149
  elif method == "error":
@@ -898,6 +1158,8 @@ class CodexAppServerClient:
898
1158
  state = self._ensure_turn_state(turn_id, thread_id)
899
1159
  else:
900
1160
  state = self._ensure_pending_turn_state(turn_id)
1161
+ state.last_event_at = time.monotonic()
1162
+ state.last_method = method
901
1163
  self._apply_error(state, message, params)
902
1164
  handled = True
903
1165
  if self._notification_handler is not None:
@@ -966,9 +1228,6 @@ class CodexAppServerClient:
966
1228
  turn_id=turn_id,
967
1229
  thread_id=thread_id,
968
1230
  future=future,
969
- agent_messages=[],
970
- errors=[],
971
- raw_events=[],
972
1231
  )
973
1232
  self._turns[key] = state
974
1233
  return state
@@ -983,9 +1242,6 @@ class CodexAppServerClient:
983
1242
  turn_id=turn_id,
984
1243
  thread_id=None,
985
1244
  future=future,
986
- agent_messages=[],
987
- errors=[],
988
- raw_events=[],
989
1245
  )
990
1246
  self._pending_turns[turn_id] = state
991
1247
  return state
@@ -995,10 +1251,13 @@ class CodexAppServerClient:
995
1251
  target.agent_messages = list(source.agent_messages)
996
1252
  else:
997
1253
  target.agent_messages.extend(source.agent_messages)
1254
+ if source.agent_message_deltas:
1255
+ target.agent_message_deltas.update(source.agent_message_deltas)
998
1256
  if not target.raw_events:
999
1257
  target.raw_events = list(source.raw_events)
1000
1258
  else:
1001
1259
  target.raw_events.extend(source.raw_events)
1260
+ _trim_raw_events(target)
1002
1261
  if not target.errors:
1003
1262
  target.errors = list(source.errors)
1004
1263
  else:
@@ -1010,7 +1269,7 @@ class CodexAppServerClient:
1010
1269
  TurnResult(
1011
1270
  turn_id=target.turn_id,
1012
1271
  status=target.status,
1013
- agent_messages=list(target.agent_messages),
1272
+ agent_messages=_agent_messages_for_result(target),
1014
1273
  errors=list(target.errors),
1015
1274
  raw_events=list(target.raw_events),
1016
1275
  )
@@ -1037,22 +1296,17 @@ class CodexAppServerClient:
1037
1296
  self, state: _TurnState, message: Dict[str, Any], params: Any
1038
1297
  ) -> None:
1039
1298
  item = params.get("item") if isinstance(params, dict) else None
1040
- text = None
1041
-
1042
- def append_message(candidate: Optional[str]) -> None:
1043
- if not candidate:
1044
- return
1045
- if state.agent_messages and state.agent_messages[-1] == candidate:
1046
- return
1047
- state.agent_messages.append(candidate)
1299
+ text: Optional[str] = None
1048
1300
 
1049
1301
  if isinstance(item, dict) and item.get("type") == "agentMessage":
1050
- text = item.get("text")
1051
- if isinstance(text, str):
1052
- append_message(text)
1302
+ item_id = params.get("itemId") if isinstance(params, dict) else None
1303
+ text = _extract_agent_message_text(item)
1304
+ if not text and isinstance(item_id, str):
1305
+ text = state.agent_message_deltas.pop(item_id, None)
1306
+ _append_agent_message(state.agent_messages, text)
1053
1307
  review_text = _extract_review_text(item)
1054
1308
  if review_text and review_text != text:
1055
- append_message(review_text)
1309
+ _append_agent_message(state.agent_messages, review_text)
1056
1310
  item_type = item.get("type") if isinstance(item, dict) else None
1057
1311
  log_event(
1058
1312
  self._logger,
@@ -1061,7 +1315,7 @@ class CodexAppServerClient:
1061
1315
  turn_id=state.turn_id,
1062
1316
  item_type=item_type,
1063
1317
  )
1064
- state.raw_events.append(message)
1318
+ _record_raw_event(state, message)
1065
1319
 
1066
1320
  def _apply_error(
1067
1321
  self, state: _TurnState, message: Dict[str, Any], params: Any
@@ -1084,29 +1338,35 @@ class CodexAppServerClient:
1084
1338
  code=error_code,
1085
1339
  will_retry=will_retry,
1086
1340
  )
1087
- state.raw_events.append(message)
1341
+ _record_raw_event(state, message)
1088
1342
 
1089
1343
  def _apply_turn_completed(
1090
1344
  self, state: _TurnState, message: Dict[str, Any], params: Any
1091
1345
  ) -> None:
1092
- state.raw_events.append(message)
1346
+ _record_raw_event(state, message)
1093
1347
  status = None
1094
1348
  if isinstance(params, dict):
1095
1349
  status = params.get("status")
1096
- state.status = status
1350
+ if status is None and isinstance(params.get("turn"), dict):
1351
+ turn_status = params["turn"].get("status")
1352
+ if isinstance(turn_status, dict):
1353
+ status = turn_status.get("type") or turn_status.get("status")
1354
+ elif isinstance(turn_status, str):
1355
+ status = turn_status
1356
+ state.status = status if status is not None else state.status
1097
1357
  log_event(
1098
1358
  self._logger,
1099
1359
  logging.INFO,
1100
1360
  "app_server.turn.completed",
1101
1361
  turn_id=state.turn_id,
1102
- status=status,
1362
+ status=state.status,
1103
1363
  )
1104
1364
  if not state.future.done():
1105
1365
  state.future.set_result(
1106
1366
  TurnResult(
1107
1367
  turn_id=state.turn_id,
1108
1368
  status=state.status,
1109
- agent_messages=list(state.agent_messages),
1369
+ agent_messages=_agent_messages_for_result(state),
1110
1370
  errors=list(state.errors),
1111
1371
  raw_events=list(state.raw_events),
1112
1372
  )
@@ -1162,44 +1422,55 @@ class CodexAppServerClient:
1162
1422
 
1163
1423
  @retry_transient(max_attempts=10, base_wait=0.5, max_wait=30.0)
1164
1424
  async def _restart_after_disconnect(self) -> None:
1165
- delay = max(self._restart_backoff_seconds, _RESTART_BACKOFF_INITIAL_SECONDS)
1166
- jitter = delay * _RESTART_BACKOFF_JITTER_RATIO
1167
- if jitter:
1168
- delay += random.uniform(0, jitter)
1169
- await asyncio.sleep(delay)
1170
- if self._closed:
1171
- raise CodexAppServerDisconnected("Client closed")
1172
1425
  try:
1173
- await self._ensure_process()
1174
- self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
1175
- log_event(
1176
- self._logger,
1177
- logging.INFO,
1178
- "app_server.restarted",
1179
- delay_seconds=round(delay, 2),
1426
+ delay = max(
1427
+ self._restart_backoff_seconds, self._restart_backoff_initial_seconds
1180
1428
  )
1181
- except CodexAppServerDisconnected:
1182
- raise
1183
- except CircuitOpenError:
1184
- await asyncio.sleep(60.0)
1429
+ jitter = delay * self._restart_backoff_jitter_ratio
1430
+ if jitter:
1431
+ delay += random.uniform(0, jitter)
1432
+ await asyncio.sleep(delay)
1433
+ if self._closed:
1434
+ raise CodexAppServerDisconnected("Client closed")
1435
+ try:
1436
+ await self._ensure_process()
1437
+ self._restart_backoff_seconds = self._restart_backoff_initial_seconds
1438
+ log_event(
1439
+ self._logger,
1440
+ logging.INFO,
1441
+ "app_server.restarted",
1442
+ delay_seconds=round(delay, 2),
1443
+ )
1444
+ except CodexAppServerDisconnected:
1445
+ raise
1446
+ except CircuitOpenError:
1447
+ await asyncio.sleep(60.0)
1448
+ raise
1449
+ except Exception as exc:
1450
+ next_delay = min(
1451
+ max(
1452
+ self._restart_backoff_seconds * 2,
1453
+ self._restart_backoff_initial_seconds,
1454
+ ),
1455
+ self._restart_backoff_max_seconds,
1456
+ )
1457
+ log_event(
1458
+ self._logger,
1459
+ logging.WARNING,
1460
+ "app_server.restart.failed",
1461
+ delay_seconds=round(delay, 2),
1462
+ next_delay_seconds=round(next_delay, 2),
1463
+ exc=exc,
1464
+ )
1465
+ self._restart_backoff_seconds = next_delay
1466
+ raise CodexAppServerDisconnected(f"Restart failed: {exc}") from exc
1467
+ except asyncio.CancelledError:
1468
+ # Ensure any partially-started process is cleaned up to avoid
1469
+ # \"Task was destroyed\" noise when event loops shut down.
1470
+ await self._terminate_process()
1185
1471
  raise
1186
- except Exception as exc:
1187
- next_delay = min(
1188
- max(
1189
- self._restart_backoff_seconds * 2, _RESTART_BACKOFF_INITIAL_SECONDS
1190
- ),
1191
- _RESTART_BACKOFF_MAX_SECONDS,
1192
- )
1193
- log_event(
1194
- self._logger,
1195
- logging.WARNING,
1196
- "app_server.restart.failed",
1197
- delay_seconds=round(delay, 2),
1198
- next_delay_seconds=round(next_delay, 2),
1199
- exc=exc,
1200
- )
1201
- self._restart_backoff_seconds = next_delay
1202
- raise CodexAppServerDisconnected(f"Restart failed: {exc}") from exc
1472
+ finally:
1473
+ self._restart_task = None
1203
1474
 
1204
1475
  async def _terminate_process(self) -> None:
1205
1476
  if self._reader_task is not None:
@@ -1313,54 +1584,12 @@ def _preview_excerpt(text: str, limit: int = 256) -> str:
1313
1584
  return f"{normalized[:limit].rstrip()}..."
1314
1585
 
1315
1586
 
1316
- def _extract_turn_id(payload: Any) -> Optional[str]:
1317
- if not isinstance(payload, dict):
1318
- return None
1319
- for key in ("turnId", "turn_id", "id"):
1320
- value = payload.get(key)
1321
- if isinstance(value, str):
1322
- return value
1323
- turn = payload.get("turn")
1324
- if isinstance(turn, dict):
1325
- for key in ("id", "turnId", "turn_id"):
1326
- value = turn.get(key)
1327
- if isinstance(value, str):
1328
- return value
1329
- return None
1330
-
1331
-
1332
1587
  def _turn_key(thread_id: Optional[str], turn_id: Optional[str]) -> Optional[TurnKey]:
1333
1588
  if not thread_id or not turn_id:
1334
1589
  return None
1335
1590
  return (thread_id, turn_id)
1336
1591
 
1337
1592
 
1338
- def _extract_thread_id_for_turn(payload: Any) -> Optional[str]:
1339
- if not isinstance(payload, dict):
1340
- return None
1341
- for candidate in (payload, payload.get("turn"), payload.get("item")):
1342
- thread_id = _extract_thread_id_from_container(candidate)
1343
- if thread_id:
1344
- return thread_id
1345
- return None
1346
-
1347
-
1348
- def _extract_thread_id_from_container(payload: Any) -> Optional[str]:
1349
- if not isinstance(payload, dict):
1350
- return None
1351
- for key in ("threadId", "thread_id"):
1352
- value = payload.get(key)
1353
- if isinstance(value, str):
1354
- return value
1355
- thread = payload.get("thread")
1356
- if isinstance(thread, dict):
1357
- for key in ("id", "threadId", "thread_id"):
1358
- value = thread.get(key)
1359
- if isinstance(value, str):
1360
- return value
1361
- return None
1362
-
1363
-
1364
1593
  def _extract_review_text(item: Any) -> Optional[str]:
1365
1594
  if not isinstance(item, dict):
1366
1595
  return None
@@ -1405,22 +1634,6 @@ def _extract_error_message(payload: Any) -> Optional[str]:
1405
1634
  return message
1406
1635
 
1407
1636
 
1408
- def _extract_thread_id(payload: Any) -> Optional[str]:
1409
- if not isinstance(payload, dict):
1410
- return None
1411
- for key in ("threadId", "thread_id", "id"):
1412
- value = payload.get(key)
1413
- if isinstance(value, str):
1414
- return value
1415
- thread = payload.get("thread")
1416
- if isinstance(thread, dict):
1417
- for key in ("id", "threadId", "thread_id"):
1418
- value = thread.get(key)
1419
- if isinstance(value, str):
1420
- return value
1421
- return None
1422
-
1423
-
1424
1637
  _SANDBOX_POLICY_CANONICAL = {
1425
1638
  "dangerfullaccess": "dangerFullAccess",
1426
1639
  "readonly": "readOnly",
@@ -1458,6 +1671,223 @@ def _normalize_sandbox_policy_type(raw: str) -> str:
1458
1671
  return canonical or raw.strip()
1459
1672
 
1460
1673
 
1674
+ def _append_agent_message(messages: list[str], candidate: Optional[str]) -> None:
1675
+ if not candidate:
1676
+ return
1677
+ if messages and messages[-1] == candidate:
1678
+ return
1679
+ messages.append(candidate)
1680
+
1681
+
1682
+ def _record_raw_event(state: _TurnState, message: Dict[str, Any]) -> None:
1683
+ state.raw_events.append(message)
1684
+ _trim_raw_events(state)
1685
+
1686
+
1687
+ def _trim_raw_events(state: _TurnState) -> None:
1688
+ if len(state.raw_events) > _MAX_TURN_RAW_EVENTS:
1689
+ state.raw_events = state.raw_events[-_MAX_TURN_RAW_EVENTS:]
1690
+
1691
+
1692
+ def _agent_message_deltas_as_list(agent_message_deltas: Dict[str, str]) -> list[str]:
1693
+ return [
1694
+ text for text in agent_message_deltas.values() if isinstance(text, str) and text
1695
+ ]
1696
+
1697
+
1698
+ def _agent_messages_for_result(state: _TurnState) -> list[str]:
1699
+ if state.agent_messages:
1700
+ return list(state.agent_messages)
1701
+ return _agent_message_deltas_as_list(state.agent_message_deltas)
1702
+
1703
+
1704
+ def _extract_status_value(value: Any) -> Optional[str]:
1705
+ if isinstance(value, str):
1706
+ return value
1707
+ if isinstance(value, dict):
1708
+ for key in ("type", "status", "state"):
1709
+ candidate = value.get(key)
1710
+ if isinstance(candidate, str):
1711
+ return candidate
1712
+ return None
1713
+
1714
+
1715
+ def _status_is_terminal(status: Any) -> bool:
1716
+ normalized = _extract_status_value(status)
1717
+ if not isinstance(normalized, str):
1718
+ return False
1719
+ normalized = normalized.lower()
1720
+ return normalized in {
1721
+ "completed",
1722
+ "complete",
1723
+ "done",
1724
+ "failed",
1725
+ "error",
1726
+ "errored",
1727
+ "cancelled",
1728
+ "canceled",
1729
+ "interrupted",
1730
+ "stopped",
1731
+ "success",
1732
+ "succeeded",
1733
+ }
1734
+
1735
+
1736
+ def _extract_agent_message_text(item: Any) -> Optional[str]:
1737
+ if not isinstance(item, dict):
1738
+ return None
1739
+ text = item.get("text")
1740
+ if isinstance(text, str) and text.strip():
1741
+ return text
1742
+ content = item.get("content")
1743
+ if isinstance(content, list):
1744
+ parts: list[str] = []
1745
+ for entry in content:
1746
+ if not isinstance(entry, dict):
1747
+ continue
1748
+ entry_type = entry.get("type")
1749
+ if entry_type not in (None, "output_text", "text", "message"):
1750
+ continue
1751
+ candidate = entry.get("text")
1752
+ if isinstance(candidate, str) and candidate.strip():
1753
+ parts.append(candidate)
1754
+ if parts:
1755
+ return "".join(parts)
1756
+ return None
1757
+
1758
+
1759
+ def _extract_errors_from_container(container: Any) -> list[str]:
1760
+ if not isinstance(container, dict):
1761
+ return []
1762
+ errors: list[str] = []
1763
+ error_message = _extract_error_message(container)
1764
+ if error_message:
1765
+ errors.append(error_message)
1766
+ raw_errors = container.get("errors")
1767
+ if isinstance(raw_errors, list):
1768
+ for entry in raw_errors:
1769
+ if isinstance(entry, str) and entry.strip():
1770
+ errors.append(entry.strip())
1771
+ elif isinstance(entry, dict):
1772
+ extracted = _extract_error_message(entry)
1773
+ if extracted:
1774
+ errors.append(extracted)
1775
+ return errors
1776
+
1777
+
1778
+ def _extract_agent_messages_from_container(
1779
+ container: Any, target_turn_id: Optional[str]
1780
+ ) -> list[str]:
1781
+ if not isinstance(container, dict):
1782
+ return []
1783
+ agent_messages: list[str] = []
1784
+ for key in ("items", "messages"):
1785
+ entries = container.get(key)
1786
+ if not isinstance(entries, list):
1787
+ continue
1788
+ for entry in entries:
1789
+ if not isinstance(entry, dict):
1790
+ continue
1791
+ entry_turn_id = _extract_turn_id(entry)
1792
+ if entry_turn_id and target_turn_id and entry_turn_id != target_turn_id:
1793
+ continue
1794
+ text = _extract_agent_message_text(entry)
1795
+ if text:
1796
+ agent_messages.append(text)
1797
+ elif entry.get("role") == "assistant":
1798
+ fallback = entry.get("text")
1799
+ if isinstance(fallback, str) and fallback.strip():
1800
+ agent_messages.append(fallback)
1801
+ return agent_messages
1802
+
1803
+
1804
+ def _extract_turn_snapshot_from_resume(
1805
+ payload: Any, target_turn_id: str
1806
+ ) -> Optional[tuple[Optional[str], list[str], list[str]]]:
1807
+ if not isinstance(payload, dict):
1808
+ return None
1809
+ status: Optional[str] = None
1810
+ agent_messages: list[str] = []
1811
+ errors: list[str] = []
1812
+
1813
+ def _collect_from_turn(turn: Any) -> bool:
1814
+ nonlocal status
1815
+ if not isinstance(turn, dict):
1816
+ return False
1817
+ if _extract_turn_id(turn) != target_turn_id:
1818
+ return False
1819
+ if status is None:
1820
+ status = _extract_status_value(turn.get("status"))
1821
+ agent_messages.extend(
1822
+ _extract_agent_messages_from_container(turn, target_turn_id)
1823
+ )
1824
+ errors.extend(_extract_errors_from_container(turn))
1825
+ return True
1826
+
1827
+ found = _collect_from_turn(payload)
1828
+
1829
+ for key in ("turns", "data", "results"):
1830
+ turns = payload.get(key)
1831
+ if not isinstance(turns, list):
1832
+ continue
1833
+ for turn in turns:
1834
+ if _collect_from_turn(turn):
1835
+ found = True
1836
+
1837
+ thread = payload.get("thread")
1838
+ if isinstance(thread, dict):
1839
+ thread_items = thread.get("items")
1840
+ if isinstance(thread_items, list):
1841
+ for item in thread_items:
1842
+ if _extract_turn_id(item) != target_turn_id:
1843
+ continue
1844
+ text = _extract_agent_message_text(item)
1845
+ if text:
1846
+ agent_messages.append(text)
1847
+ thread_turns = thread.get("turns")
1848
+ if isinstance(thread_turns, list):
1849
+ for turn in thread_turns:
1850
+ if _collect_from_turn(turn):
1851
+ found = True
1852
+
1853
+ single_turn = payload.get("turn")
1854
+ if isinstance(single_turn, dict) and _collect_from_turn(single_turn):
1855
+ found = True
1856
+
1857
+ items = payload.get("items")
1858
+ if isinstance(items, list):
1859
+ for item in items:
1860
+ if _extract_turn_id(item) != target_turn_id:
1861
+ continue
1862
+ text = _extract_agent_message_text(item)
1863
+ if text:
1864
+ agent_messages.append(text)
1865
+
1866
+ if status is None:
1867
+ status = _extract_status_value(payload.get("status"))
1868
+
1869
+ if not found and not agent_messages and not errors and status is None:
1870
+ return None
1871
+ return status, agent_messages, errors
1872
+
1873
+
1874
+ @no_type_check
1875
+ async def _close_all_clients() -> None:
1876
+ """
1877
+ Close any CodexAppServerClient instances that may still be alive.
1878
+
1879
+ This is primarily used in tests to avoid pending restart tasks keeping
1880
+ subprocess transports alive when the event loop shuts down.
1881
+ """
1882
+ logger = logging.getLogger(__name__)
1883
+ for client in list(_CLIENT_INSTANCES):
1884
+ try:
1885
+ await client.close()
1886
+ except Exception as exc:
1887
+ logger.debug("Failed to close client: %s", exc)
1888
+ continue
1889
+
1890
+
1461
1891
  __all__ = [
1462
1892
  "APPROVAL_METHODS",
1463
1893
  "ApprovalDecision",
@@ -1471,4 +1901,5 @@ __all__ = [
1471
1901
  "TurnHandle",
1472
1902
  "TurnResult",
1473
1903
  "_normalize_sandbox_policy",
1904
+ "_close_all_clients",
1474
1905
  ]