codex-autorunner 0.1.2__py3-none-any.whl → 1.0.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 (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,14 +1,27 @@
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
 
13
26
  from ...core.circuit_breaker import CircuitBreaker
14
27
  from ...core.exceptions import (
@@ -38,6 +51,16 @@ _RESTART_BACKOFF_INITIAL_SECONDS = 0.5
38
51
  _RESTART_BACKOFF_MAX_SECONDS = 30.0
39
52
  _RESTART_BACKOFF_JITTER_RATIO = 0.1
40
53
 
54
+ # Per-turn stall detection defaults.
55
+ _TURN_STALL_TIMEOUT_SECONDS = 60.0
56
+ _TURN_STALL_POLL_INTERVAL_SECONDS = 2.0
57
+ _TURN_STALL_RECOVERY_MIN_INTERVAL_SECONDS = 10.0
58
+ _MAX_TURN_RAW_EVENTS = 200
59
+ _INVALID_JSON_PREVIEW_BYTES = 200
60
+
61
+ # Track live clients so tests/cleanup can cancel any background restart tasks.
62
+ _CLIENT_INSTANCES: weakref.WeakSet = weakref.WeakSet()
63
+
41
64
 
42
65
  class CodexAppServerError(CodexError):
43
66
  """Base error for app-server client failures."""
@@ -104,10 +127,15 @@ class _TurnState:
104
127
  turn_id: str
105
128
  thread_id: Optional[str]
106
129
  future: asyncio.Future["TurnResult"]
107
- agent_messages: list[str]
108
- errors: list[str]
109
- raw_events: list[Dict[str, Any]]
130
+ agent_messages: list[str] = field(default_factory=list)
131
+ errors: list[str] = field(default_factory=list)
132
+ raw_events: list[Dict[str, Any]] = field(default_factory=list)
110
133
  status: Optional[str] = None
134
+ last_event_at: float = field(default_factory=time.monotonic)
135
+ last_method: Optional[str] = None
136
+ recovery_attempts: int = 0
137
+ last_recovery_at: float = 0.0
138
+ agent_message_deltas: Dict[str, str] = field(default_factory=dict)
111
139
 
112
140
 
113
141
  class CodexAppServerClient:
@@ -119,8 +147,17 @@ class CodexAppServerClient:
119
147
  env: Optional[Dict[str, str]] = None,
120
148
  approval_handler: Optional[ApprovalHandler] = None,
121
149
  default_approval_decision: str = "cancel",
122
- auto_restart: bool = True,
150
+ auto_restart: Optional[bool] = None,
123
151
  request_timeout: Optional[float] = None,
152
+ turn_stall_timeout_seconds: Optional[float] = _TURN_STALL_TIMEOUT_SECONDS,
153
+ turn_stall_poll_interval_seconds: Optional[float] = None,
154
+ turn_stall_recovery_min_interval_seconds: Optional[float] = None,
155
+ max_message_bytes: Optional[int] = None,
156
+ oversize_preview_bytes: Optional[int] = None,
157
+ max_oversize_drain_bytes: Optional[int] = None,
158
+ restart_backoff_initial_seconds: Optional[float] = None,
159
+ restart_backoff_max_seconds: Optional[float] = None,
160
+ restart_backoff_jitter_ratio: Optional[float] = None,
124
161
  notification_handler: Optional[NotificationHandler] = None,
125
162
  logger: Optional[logging.Logger] = None,
126
163
  ) -> None:
@@ -129,11 +166,52 @@ class CodexAppServerClient:
129
166
  self._env = env
130
167
  self._approval_handler = approval_handler
131
168
  self._default_approval_decision = default_approval_decision
132
- self._auto_restart = auto_restart
169
+ disable_restart_env = os.environ.get(
170
+ "CODEX_DISABLE_APP_SERVER_AUTORESTART_FOR_TESTS"
171
+ )
172
+ if disable_restart_env:
173
+ self._auto_restart = False
174
+ elif auto_restart is None:
175
+ self._auto_restart = True
176
+ else:
177
+ self._auto_restart = auto_restart
133
178
  self._request_timeout = request_timeout
134
179
  self._notification_handler = notification_handler
135
180
  self._logger = logger or logging.getLogger(__name__)
136
181
  self._circuit_breaker = CircuitBreaker("App-Server", logger=self._logger)
182
+ self._max_message_bytes = (
183
+ max_message_bytes
184
+ if max_message_bytes is not None and max_message_bytes > 0
185
+ else _MAX_MESSAGE_BYTES
186
+ )
187
+ self._oversize_preview_bytes = (
188
+ oversize_preview_bytes
189
+ if oversize_preview_bytes is not None and oversize_preview_bytes > 0
190
+ else _OVERSIZE_PREVIEW_BYTES
191
+ )
192
+ self._max_oversize_drain_bytes = (
193
+ max_oversize_drain_bytes
194
+ if max_oversize_drain_bytes is not None and max_oversize_drain_bytes > 0
195
+ else _MAX_OVERSIZE_DRAIN_BYTES
196
+ )
197
+ self._restart_backoff_initial_seconds = (
198
+ restart_backoff_initial_seconds
199
+ if restart_backoff_initial_seconds is not None
200
+ and restart_backoff_initial_seconds > 0
201
+ else _RESTART_BACKOFF_INITIAL_SECONDS
202
+ )
203
+ self._restart_backoff_max_seconds = (
204
+ restart_backoff_max_seconds
205
+ if restart_backoff_max_seconds is not None
206
+ and restart_backoff_max_seconds > 0
207
+ else _RESTART_BACKOFF_MAX_SECONDS
208
+ )
209
+ self._restart_backoff_jitter_ratio = (
210
+ restart_backoff_jitter_ratio
211
+ if restart_backoff_jitter_ratio is not None
212
+ and restart_backoff_jitter_ratio >= 0
213
+ else _RESTART_BACKOFF_JITTER_RATIO
214
+ )
137
215
 
138
216
  self._process: Optional[asyncio.subprocess.Process] = None
139
217
  self._reader_task: Optional[asyncio.Task] = None
@@ -154,8 +232,37 @@ class CodexAppServerClient:
154
232
  self._client_version = _client_version()
155
233
  self._include_client_version = True
156
234
  self._restart_task: Optional[asyncio.Task] = None
157
- self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
235
+ self._restart_backoff_seconds = self._restart_backoff_initial_seconds
158
236
  self._stderr_tail: deque[str] = deque(maxlen=5)
237
+ self._turn_stall_timeout_seconds: Optional[float] = turn_stall_timeout_seconds
238
+ if (
239
+ self._turn_stall_timeout_seconds is not None
240
+ and self._turn_stall_timeout_seconds <= 0
241
+ ):
242
+ self._turn_stall_timeout_seconds = None
243
+ self._turn_stall_poll_interval_seconds: float = (
244
+ turn_stall_poll_interval_seconds
245
+ if turn_stall_poll_interval_seconds is not None
246
+ else _TURN_STALL_POLL_INTERVAL_SECONDS
247
+ )
248
+ if (
249
+ self._turn_stall_poll_interval_seconds is not None
250
+ and self._turn_stall_poll_interval_seconds <= 0
251
+ ):
252
+ self._turn_stall_poll_interval_seconds = _TURN_STALL_POLL_INTERVAL_SECONDS
253
+ self._turn_stall_recovery_min_interval_seconds: float = (
254
+ turn_stall_recovery_min_interval_seconds
255
+ if turn_stall_recovery_min_interval_seconds is not None
256
+ else _TURN_STALL_RECOVERY_MIN_INTERVAL_SECONDS
257
+ )
258
+ if (
259
+ self._turn_stall_recovery_min_interval_seconds is not None
260
+ and self._turn_stall_recovery_min_interval_seconds < 0
261
+ ):
262
+ self._turn_stall_recovery_min_interval_seconds = (
263
+ _TURN_STALL_RECOVERY_MIN_INTERVAL_SECONDS
264
+ )
265
+ _CLIENT_INSTANCES.add(self)
159
266
 
160
267
  async def start(self) -> None:
161
268
  await self._ensure_process()
@@ -171,6 +278,7 @@ class CodexAppServerClient:
171
278
  self._restart_task = None
172
279
  await self._terminate_process()
173
280
  self._fail_pending(CodexAppServerDisconnected("Client closed"))
281
+ _CLIENT_INSTANCES.discard(self)
174
282
 
175
283
  async def wait_for_disconnect(self, *, timeout: Optional[float] = None) -> None:
176
284
  disconnected = self._ensure_disconnect_event()
@@ -346,15 +454,114 @@ class CodexAppServerClient:
346
454
  self._turns.pop(key, None)
347
455
  return result
348
456
  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
457
+ deadline = time.monotonic() + timeout if timeout is not None else None
458
+ while True:
459
+ slice_timeout = self._turn_stall_poll_interval_seconds
460
+ if deadline is not None:
461
+ remaining = deadline - time.monotonic()
462
+ if remaining <= 0:
463
+ raise asyncio.TimeoutError()
464
+ if slice_timeout is None or slice_timeout > remaining:
465
+ slice_timeout = remaining
466
+ try:
467
+ if slice_timeout is None:
468
+ result = await asyncio.shield(state.future)
469
+ else:
470
+ result = await asyncio.wait_for(
471
+ asyncio.shield(state.future), timeout=slice_timeout
472
+ )
473
+ if key is not None:
474
+ self._turns.pop(key, None)
475
+ return result
476
+ except asyncio.TimeoutError:
477
+ pass
478
+
479
+ stall_timeout = self._turn_stall_timeout_seconds
480
+ idle_seconds = time.monotonic() - state.last_event_at
481
+ if (
482
+ stall_timeout is not None
483
+ and idle_seconds >= stall_timeout
484
+ and not state.future.done()
485
+ ):
486
+ await self._recover_stalled_turn(
487
+ state,
488
+ turn_id,
489
+ thread_id=thread_id or state.thread_id,
490
+ idle_seconds=idle_seconds,
491
+ )
492
+
493
+ async def _recover_stalled_turn(
494
+ self,
495
+ state: _TurnState,
496
+ turn_id: str,
497
+ *,
498
+ thread_id: Optional[str],
499
+ idle_seconds: float,
500
+ ) -> None:
501
+ now = time.monotonic()
502
+ if thread_id is None:
503
+ state.last_event_at = now
504
+ return
505
+ min_interval = self._turn_stall_recovery_min_interval_seconds
506
+ if (
507
+ min_interval is not None
508
+ and state.last_recovery_at
509
+ and now - state.last_recovery_at < min_interval
510
+ ):
511
+ return
512
+ state.last_recovery_at = now
513
+ state.recovery_attempts += 1
514
+ log_event(
515
+ self._logger,
516
+ logging.WARNING,
517
+ "app_server.turn_stalled",
518
+ turn_id=turn_id,
519
+ thread_id=thread_id,
520
+ idle_seconds=round(idle_seconds, 2),
521
+ last_method=state.last_method,
522
+ recovery_attempts=state.recovery_attempts,
523
+ )
524
+ try:
525
+ resume_result = await self.thread_resume(thread_id)
526
+ except Exception as exc:
527
+ log_event(
528
+ self._logger,
529
+ logging.WARNING,
530
+ "app_server.turn_recovery.failed",
531
+ turn_id=turn_id,
532
+ thread_id=thread_id,
533
+ idle_seconds=round(idle_seconds, 2),
534
+ exc=exc,
535
+ )
536
+ state.last_event_at = now
537
+ return
538
+
539
+ snapshot = _extract_turn_snapshot_from_resume(resume_result, turn_id)
540
+ if snapshot is None:
541
+ state.last_event_at = now
542
+ return
543
+
544
+ status, agent_messages, errors = snapshot
545
+ if agent_messages:
546
+ state.agent_messages = agent_messages
547
+ if errors:
548
+ state.errors.extend(errors)
549
+ if status:
550
+ state.status = status
551
+
552
+ if status and _status_is_terminal(status) and not state.future.done():
553
+ state.future.set_result(
554
+ TurnResult(
555
+ turn_id=state.turn_id,
556
+ agent_messages=_agent_messages_for_result(state),
557
+ errors=list(state.errors),
558
+ raw_events=list(state.raw_events),
559
+ status=state.status,
560
+ )
561
+ )
562
+ return
563
+
564
+ state.last_event_at = now
358
565
 
359
566
  async def _ensure_process(self) -> None:
360
567
  async with self._circuit_breaker.call():
@@ -431,7 +638,7 @@ class CodexAppServerClient:
431
638
  self._initializing = False
432
639
  await self._send_message(self._build_message("initialized", params=None))
433
640
  self._initialized = True
434
- self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
641
+ self._restart_backoff_seconds = self._restart_backoff_initial_seconds
435
642
  log_event(self._logger, logging.INFO, "app_server.initialized")
436
643
 
437
644
  async def _request_raw(
@@ -546,26 +753,28 @@ class CodexAppServerClient:
546
753
  newline_index = chunk.find(b"\n")
547
754
  if newline_index == -1:
548
755
  if not drain_limit_reached:
549
- if len(oversize_preview) < _OVERSIZE_PREVIEW_BYTES:
550
- remaining = _OVERSIZE_PREVIEW_BYTES - len(
756
+ if len(oversize_preview) < self._oversize_preview_bytes:
757
+ remaining = self._oversize_preview_bytes - len(
551
758
  oversize_preview
552
759
  )
553
760
  oversize_preview.extend(chunk[:remaining])
554
761
  oversize_bytes_dropped += len(chunk)
555
- if oversize_bytes_dropped >= _MAX_OVERSIZE_DRAIN_BYTES:
762
+ if oversize_bytes_dropped >= self._max_oversize_drain_bytes:
556
763
  await self._emit_oversize_warning(
557
764
  bytes_dropped=oversize_bytes_dropped,
558
765
  preview=oversize_preview,
559
766
  aborted=True,
560
- drain_limit=_MAX_OVERSIZE_DRAIN_BYTES,
767
+ drain_limit=self._max_oversize_drain_bytes,
561
768
  )
562
769
  drain_limit_reached = True
563
770
  continue
564
771
  before = chunk[: newline_index + 1]
565
772
  after = chunk[newline_index + 1 :]
566
773
  if not drain_limit_reached:
567
- if len(oversize_preview) < _OVERSIZE_PREVIEW_BYTES:
568
- remaining = _OVERSIZE_PREVIEW_BYTES - len(oversize_preview)
774
+ if len(oversize_preview) < self._oversize_preview_bytes:
775
+ remaining = self._oversize_preview_bytes - len(
776
+ oversize_preview
777
+ )
569
778
  oversize_preview.extend(before[:remaining])
570
779
  oversize_bytes_dropped += len(before)
571
780
  await self._emit_oversize_warning(
@@ -588,8 +797,8 @@ class CodexAppServerClient:
588
797
  line = buffer[:newline_index]
589
798
  del buffer[: newline_index + 1]
590
799
  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])
800
+ if not dropping_oversize and len(buffer) > self._max_message_bytes:
801
+ oversize_preview = bytearray(buffer[: self._oversize_preview_bytes])
593
802
  oversize_bytes_dropped = len(buffer)
594
803
  buffer.clear()
595
804
  dropping_oversize = True
@@ -601,10 +810,10 @@ class CodexAppServerClient:
601
810
  truncated=True,
602
811
  )
603
812
  elif buffer:
604
- if len(buffer) > _MAX_MESSAGE_BYTES:
813
+ if len(buffer) > self._max_message_bytes:
605
814
  await self._emit_oversize_warning(
606
815
  bytes_dropped=len(buffer),
607
- preview=buffer[:_OVERSIZE_PREVIEW_BYTES],
816
+ preview=buffer[: self._oversize_preview_bytes],
608
817
  truncated=True,
609
818
  )
610
819
  else:
@@ -622,7 +831,15 @@ class CodexAppServerClient:
622
831
  return
623
832
  try:
624
833
  message = json.loads(payload)
625
- except json.JSONDecodeError:
834
+ except json.JSONDecodeError as exc:
835
+ log_event(
836
+ self._logger,
837
+ logging.WARNING,
838
+ "app_server.read.invalid_json",
839
+ preview=payload[:_INVALID_JSON_PREVIEW_BYTES],
840
+ length=len(payload),
841
+ exc=exc,
842
+ )
626
843
  return
627
844
  if not isinstance(message, dict):
628
845
  return
@@ -655,7 +872,7 @@ class CodexAppServerClient:
655
872
  if self._notification_handler is None:
656
873
  return
657
874
  params: Dict[str, Any] = {
658
- "byteLimit": _MAX_MESSAGE_BYTES,
875
+ "byteLimit": self._max_message_bytes,
659
876
  "bytesDropped": bytes_dropped,
660
877
  }
661
878
  inferred_method = metadata.get("method")
@@ -691,6 +908,7 @@ class CodexAppServerClient:
691
908
  handled=False,
692
909
  exc=exc,
693
910
  )
911
+ self._logger.debug("Notification handler failed: %s", exc)
694
912
 
695
913
  async def _drain_stderr(self) -> None:
696
914
  if not self._process or not self._process.stderr:
@@ -714,7 +932,8 @@ class CodexAppServerClient:
714
932
  line_len=len(text),
715
933
  tail_size=len(self._stderr_tail),
716
934
  )
717
- except Exception:
935
+ except Exception as exc:
936
+ self._logger.debug("Failed to read stderr: %s", exc)
718
937
  return
719
938
 
720
939
  async def _handle_message(self, message: Dict[str, Any]) -> None:
@@ -856,7 +1075,39 @@ class CodexAppServerClient:
856
1075
  method = message.get("method")
857
1076
  params = message.get("params") or {}
858
1077
  handled = False
859
- if method == "item/completed":
1078
+ if isinstance(method, str):
1079
+ turn_id_hint = _extract_turn_id(params) or _extract_turn_id(
1080
+ params.get("turn") if isinstance(params, dict) else None
1081
+ )
1082
+ if turn_id_hint:
1083
+ thread_id_hint = _extract_thread_id_for_turn(params)
1084
+ _key, state = await self._find_turn_state(
1085
+ turn_id_hint, thread_id=thread_id_hint
1086
+ )
1087
+ if state is not None:
1088
+ state.last_event_at = time.monotonic()
1089
+ state.last_method = method
1090
+ if method == "item/agentMessage/delta":
1091
+ turn_id = _extract_turn_id(params)
1092
+ if turn_id:
1093
+ thread_id = _extract_thread_id_for_turn(params)
1094
+ _key, state = await self._find_turn_state(turn_id, thread_id=thread_id)
1095
+ if state is None:
1096
+ if thread_id:
1097
+ state = self._ensure_turn_state(turn_id, thread_id)
1098
+ else:
1099
+ state = self._ensure_pending_turn_state(turn_id)
1100
+ item_id = params.get("itemId")
1101
+ delta = params.get("delta") or params.get("text")
1102
+ if isinstance(item_id, str) and isinstance(delta, str):
1103
+ state.agent_message_deltas[item_id] = (
1104
+ state.agent_message_deltas.get(item_id, "") + delta
1105
+ )
1106
+ state.last_event_at = time.monotonic()
1107
+ state.last_method = method
1108
+ _record_raw_event(state, message)
1109
+ handled = True
1110
+ elif method == "item/completed":
860
1111
  turn_id = _extract_turn_id(params) or _extract_turn_id(
861
1112
  params.get("item") if isinstance(params, dict) else None
862
1113
  )
@@ -870,6 +1121,8 @@ class CodexAppServerClient:
870
1121
  state = self._ensure_turn_state(turn_id, thread_id)
871
1122
  else:
872
1123
  state = self._ensure_pending_turn_state(turn_id)
1124
+ state.last_event_at = time.monotonic()
1125
+ state.last_method = method
873
1126
  self._apply_item_completed(state, message, params)
874
1127
  handled = True
875
1128
  elif method == "turn/completed":
@@ -884,6 +1137,8 @@ class CodexAppServerClient:
884
1137
  state = self._ensure_turn_state(turn_id, thread_id)
885
1138
  else:
886
1139
  state = self._ensure_pending_turn_state(turn_id)
1140
+ state.last_event_at = time.monotonic()
1141
+ state.last_method = method
887
1142
  self._apply_turn_completed(state, message, params)
888
1143
  handled = True
889
1144
  elif method == "error":
@@ -898,6 +1153,8 @@ class CodexAppServerClient:
898
1153
  state = self._ensure_turn_state(turn_id, thread_id)
899
1154
  else:
900
1155
  state = self._ensure_pending_turn_state(turn_id)
1156
+ state.last_event_at = time.monotonic()
1157
+ state.last_method = method
901
1158
  self._apply_error(state, message, params)
902
1159
  handled = True
903
1160
  if self._notification_handler is not None:
@@ -966,9 +1223,6 @@ class CodexAppServerClient:
966
1223
  turn_id=turn_id,
967
1224
  thread_id=thread_id,
968
1225
  future=future,
969
- agent_messages=[],
970
- errors=[],
971
- raw_events=[],
972
1226
  )
973
1227
  self._turns[key] = state
974
1228
  return state
@@ -983,9 +1237,6 @@ class CodexAppServerClient:
983
1237
  turn_id=turn_id,
984
1238
  thread_id=None,
985
1239
  future=future,
986
- agent_messages=[],
987
- errors=[],
988
- raw_events=[],
989
1240
  )
990
1241
  self._pending_turns[turn_id] = state
991
1242
  return state
@@ -995,10 +1246,13 @@ class CodexAppServerClient:
995
1246
  target.agent_messages = list(source.agent_messages)
996
1247
  else:
997
1248
  target.agent_messages.extend(source.agent_messages)
1249
+ if source.agent_message_deltas:
1250
+ target.agent_message_deltas.update(source.agent_message_deltas)
998
1251
  if not target.raw_events:
999
1252
  target.raw_events = list(source.raw_events)
1000
1253
  else:
1001
1254
  target.raw_events.extend(source.raw_events)
1255
+ _trim_raw_events(target)
1002
1256
  if not target.errors:
1003
1257
  target.errors = list(source.errors)
1004
1258
  else:
@@ -1010,7 +1264,7 @@ class CodexAppServerClient:
1010
1264
  TurnResult(
1011
1265
  turn_id=target.turn_id,
1012
1266
  status=target.status,
1013
- agent_messages=list(target.agent_messages),
1267
+ agent_messages=_agent_messages_for_result(target),
1014
1268
  errors=list(target.errors),
1015
1269
  raw_events=list(target.raw_events),
1016
1270
  )
@@ -1037,22 +1291,17 @@ class CodexAppServerClient:
1037
1291
  self, state: _TurnState, message: Dict[str, Any], params: Any
1038
1292
  ) -> None:
1039
1293
  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)
1294
+ text: Optional[str] = None
1048
1295
 
1049
1296
  if isinstance(item, dict) and item.get("type") == "agentMessage":
1050
- text = item.get("text")
1051
- if isinstance(text, str):
1052
- append_message(text)
1297
+ item_id = params.get("itemId") if isinstance(params, dict) else None
1298
+ text = _extract_agent_message_text(item)
1299
+ if not text and isinstance(item_id, str):
1300
+ text = state.agent_message_deltas.pop(item_id, None)
1301
+ _append_agent_message(state.agent_messages, text)
1053
1302
  review_text = _extract_review_text(item)
1054
1303
  if review_text and review_text != text:
1055
- append_message(review_text)
1304
+ _append_agent_message(state.agent_messages, review_text)
1056
1305
  item_type = item.get("type") if isinstance(item, dict) else None
1057
1306
  log_event(
1058
1307
  self._logger,
@@ -1061,7 +1310,7 @@ class CodexAppServerClient:
1061
1310
  turn_id=state.turn_id,
1062
1311
  item_type=item_type,
1063
1312
  )
1064
- state.raw_events.append(message)
1313
+ _record_raw_event(state, message)
1065
1314
 
1066
1315
  def _apply_error(
1067
1316
  self, state: _TurnState, message: Dict[str, Any], params: Any
@@ -1084,29 +1333,35 @@ class CodexAppServerClient:
1084
1333
  code=error_code,
1085
1334
  will_retry=will_retry,
1086
1335
  )
1087
- state.raw_events.append(message)
1336
+ _record_raw_event(state, message)
1088
1337
 
1089
1338
  def _apply_turn_completed(
1090
1339
  self, state: _TurnState, message: Dict[str, Any], params: Any
1091
1340
  ) -> None:
1092
- state.raw_events.append(message)
1341
+ _record_raw_event(state, message)
1093
1342
  status = None
1094
1343
  if isinstance(params, dict):
1095
1344
  status = params.get("status")
1096
- state.status = status
1345
+ if status is None and isinstance(params.get("turn"), dict):
1346
+ turn_status = params["turn"].get("status")
1347
+ if isinstance(turn_status, dict):
1348
+ status = turn_status.get("type") or turn_status.get("status")
1349
+ elif isinstance(turn_status, str):
1350
+ status = turn_status
1351
+ state.status = status if status is not None else state.status
1097
1352
  log_event(
1098
1353
  self._logger,
1099
1354
  logging.INFO,
1100
1355
  "app_server.turn.completed",
1101
1356
  turn_id=state.turn_id,
1102
- status=status,
1357
+ status=state.status,
1103
1358
  )
1104
1359
  if not state.future.done():
1105
1360
  state.future.set_result(
1106
1361
  TurnResult(
1107
1362
  turn_id=state.turn_id,
1108
1363
  status=state.status,
1109
- agent_messages=list(state.agent_messages),
1364
+ agent_messages=_agent_messages_for_result(state),
1110
1365
  errors=list(state.errors),
1111
1366
  raw_events=list(state.raw_events),
1112
1367
  )
@@ -1162,44 +1417,55 @@ class CodexAppServerClient:
1162
1417
 
1163
1418
  @retry_transient(max_attempts=10, base_wait=0.5, max_wait=30.0)
1164
1419
  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
1420
  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),
1421
+ delay = max(
1422
+ self._restart_backoff_seconds, self._restart_backoff_initial_seconds
1180
1423
  )
1181
- except CodexAppServerDisconnected:
1182
- raise
1183
- except CircuitOpenError:
1184
- await asyncio.sleep(60.0)
1424
+ jitter = delay * self._restart_backoff_jitter_ratio
1425
+ if jitter:
1426
+ delay += random.uniform(0, jitter)
1427
+ await asyncio.sleep(delay)
1428
+ if self._closed:
1429
+ raise CodexAppServerDisconnected("Client closed")
1430
+ try:
1431
+ await self._ensure_process()
1432
+ self._restart_backoff_seconds = self._restart_backoff_initial_seconds
1433
+ log_event(
1434
+ self._logger,
1435
+ logging.INFO,
1436
+ "app_server.restarted",
1437
+ delay_seconds=round(delay, 2),
1438
+ )
1439
+ except CodexAppServerDisconnected:
1440
+ raise
1441
+ except CircuitOpenError:
1442
+ await asyncio.sleep(60.0)
1443
+ raise
1444
+ except Exception as exc:
1445
+ next_delay = min(
1446
+ max(
1447
+ self._restart_backoff_seconds * 2,
1448
+ self._restart_backoff_initial_seconds,
1449
+ ),
1450
+ self._restart_backoff_max_seconds,
1451
+ )
1452
+ log_event(
1453
+ self._logger,
1454
+ logging.WARNING,
1455
+ "app_server.restart.failed",
1456
+ delay_seconds=round(delay, 2),
1457
+ next_delay_seconds=round(next_delay, 2),
1458
+ exc=exc,
1459
+ )
1460
+ self._restart_backoff_seconds = next_delay
1461
+ raise CodexAppServerDisconnected(f"Restart failed: {exc}") from exc
1462
+ except asyncio.CancelledError:
1463
+ # Ensure any partially-started process is cleaned up to avoid
1464
+ # \"Task was destroyed\" noise when event loops shut down.
1465
+ await self._terminate_process()
1185
1466
  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
1467
+ finally:
1468
+ self._restart_task = None
1203
1469
 
1204
1470
  async def _terminate_process(self) -> None:
1205
1471
  if self._reader_task is not None:
@@ -1458,6 +1724,223 @@ def _normalize_sandbox_policy_type(raw: str) -> str:
1458
1724
  return canonical or raw.strip()
1459
1725
 
1460
1726
 
1727
+ def _append_agent_message(messages: list[str], candidate: Optional[str]) -> None:
1728
+ if not candidate:
1729
+ return
1730
+ if messages and messages[-1] == candidate:
1731
+ return
1732
+ messages.append(candidate)
1733
+
1734
+
1735
+ def _record_raw_event(state: _TurnState, message: Dict[str, Any]) -> None:
1736
+ state.raw_events.append(message)
1737
+ _trim_raw_events(state)
1738
+
1739
+
1740
+ def _trim_raw_events(state: _TurnState) -> None:
1741
+ if len(state.raw_events) > _MAX_TURN_RAW_EVENTS:
1742
+ state.raw_events = state.raw_events[-_MAX_TURN_RAW_EVENTS:]
1743
+
1744
+
1745
+ def _agent_message_deltas_as_list(agent_message_deltas: Dict[str, str]) -> list[str]:
1746
+ return [
1747
+ text for text in agent_message_deltas.values() if isinstance(text, str) and text
1748
+ ]
1749
+
1750
+
1751
+ def _agent_messages_for_result(state: _TurnState) -> list[str]:
1752
+ if state.agent_messages:
1753
+ return list(state.agent_messages)
1754
+ return _agent_message_deltas_as_list(state.agent_message_deltas)
1755
+
1756
+
1757
+ def _extract_status_value(value: Any) -> Optional[str]:
1758
+ if isinstance(value, str):
1759
+ return value
1760
+ if isinstance(value, dict):
1761
+ for key in ("type", "status", "state"):
1762
+ candidate = value.get(key)
1763
+ if isinstance(candidate, str):
1764
+ return candidate
1765
+ return None
1766
+
1767
+
1768
+ def _status_is_terminal(status: Any) -> bool:
1769
+ normalized = _extract_status_value(status)
1770
+ if not isinstance(normalized, str):
1771
+ return False
1772
+ normalized = normalized.lower()
1773
+ return normalized in {
1774
+ "completed",
1775
+ "complete",
1776
+ "done",
1777
+ "failed",
1778
+ "error",
1779
+ "errored",
1780
+ "cancelled",
1781
+ "canceled",
1782
+ "interrupted",
1783
+ "stopped",
1784
+ "success",
1785
+ "succeeded",
1786
+ }
1787
+
1788
+
1789
+ def _extract_agent_message_text(item: Any) -> Optional[str]:
1790
+ if not isinstance(item, dict):
1791
+ return None
1792
+ text = item.get("text")
1793
+ if isinstance(text, str) and text.strip():
1794
+ return text
1795
+ content = item.get("content")
1796
+ if isinstance(content, list):
1797
+ parts: list[str] = []
1798
+ for entry in content:
1799
+ if not isinstance(entry, dict):
1800
+ continue
1801
+ entry_type = entry.get("type")
1802
+ if entry_type not in (None, "output_text", "text", "message"):
1803
+ continue
1804
+ candidate = entry.get("text")
1805
+ if isinstance(candidate, str) and candidate.strip():
1806
+ parts.append(candidate)
1807
+ if parts:
1808
+ return "".join(parts)
1809
+ return None
1810
+
1811
+
1812
+ def _extract_errors_from_container(container: Any) -> list[str]:
1813
+ if not isinstance(container, dict):
1814
+ return []
1815
+ errors: list[str] = []
1816
+ error_message = _extract_error_message(container)
1817
+ if error_message:
1818
+ errors.append(error_message)
1819
+ raw_errors = container.get("errors")
1820
+ if isinstance(raw_errors, list):
1821
+ for entry in raw_errors:
1822
+ if isinstance(entry, str) and entry.strip():
1823
+ errors.append(entry.strip())
1824
+ elif isinstance(entry, dict):
1825
+ extracted = _extract_error_message(entry)
1826
+ if extracted:
1827
+ errors.append(extracted)
1828
+ return errors
1829
+
1830
+
1831
+ def _extract_agent_messages_from_container(
1832
+ container: Any, target_turn_id: Optional[str]
1833
+ ) -> list[str]:
1834
+ if not isinstance(container, dict):
1835
+ return []
1836
+ agent_messages: list[str] = []
1837
+ for key in ("items", "messages"):
1838
+ entries = container.get(key)
1839
+ if not isinstance(entries, list):
1840
+ continue
1841
+ for entry in entries:
1842
+ if not isinstance(entry, dict):
1843
+ continue
1844
+ entry_turn_id = _extract_turn_id(entry)
1845
+ if entry_turn_id and target_turn_id and entry_turn_id != target_turn_id:
1846
+ continue
1847
+ text = _extract_agent_message_text(entry)
1848
+ if text:
1849
+ agent_messages.append(text)
1850
+ elif entry.get("role") == "assistant":
1851
+ fallback = entry.get("text")
1852
+ if isinstance(fallback, str) and fallback.strip():
1853
+ agent_messages.append(fallback)
1854
+ return agent_messages
1855
+
1856
+
1857
+ def _extract_turn_snapshot_from_resume(
1858
+ payload: Any, target_turn_id: str
1859
+ ) -> Optional[tuple[Optional[str], list[str], list[str]]]:
1860
+ if not isinstance(payload, dict):
1861
+ return None
1862
+ status: Optional[str] = None
1863
+ agent_messages: list[str] = []
1864
+ errors: list[str] = []
1865
+
1866
+ def _collect_from_turn(turn: Any) -> bool:
1867
+ nonlocal status
1868
+ if not isinstance(turn, dict):
1869
+ return False
1870
+ if _extract_turn_id(turn) != target_turn_id:
1871
+ return False
1872
+ if status is None:
1873
+ status = _extract_status_value(turn.get("status"))
1874
+ agent_messages.extend(
1875
+ _extract_agent_messages_from_container(turn, target_turn_id)
1876
+ )
1877
+ errors.extend(_extract_errors_from_container(turn))
1878
+ return True
1879
+
1880
+ found = _collect_from_turn(payload)
1881
+
1882
+ for key in ("turns", "data", "results"):
1883
+ turns = payload.get(key)
1884
+ if not isinstance(turns, list):
1885
+ continue
1886
+ for turn in turns:
1887
+ if _collect_from_turn(turn):
1888
+ found = True
1889
+
1890
+ thread = payload.get("thread")
1891
+ if isinstance(thread, dict):
1892
+ thread_items = thread.get("items")
1893
+ if isinstance(thread_items, list):
1894
+ for item in thread_items:
1895
+ if _extract_turn_id(item) != target_turn_id:
1896
+ continue
1897
+ text = _extract_agent_message_text(item)
1898
+ if text:
1899
+ agent_messages.append(text)
1900
+ thread_turns = thread.get("turns")
1901
+ if isinstance(thread_turns, list):
1902
+ for turn in thread_turns:
1903
+ if _collect_from_turn(turn):
1904
+ found = True
1905
+
1906
+ single_turn = payload.get("turn")
1907
+ if isinstance(single_turn, dict) and _collect_from_turn(single_turn):
1908
+ found = True
1909
+
1910
+ items = payload.get("items")
1911
+ if isinstance(items, list):
1912
+ for item in items:
1913
+ if _extract_turn_id(item) != target_turn_id:
1914
+ continue
1915
+ text = _extract_agent_message_text(item)
1916
+ if text:
1917
+ agent_messages.append(text)
1918
+
1919
+ if status is None:
1920
+ status = _extract_status_value(payload.get("status"))
1921
+
1922
+ if not found and not agent_messages and not errors and status is None:
1923
+ return None
1924
+ return status, agent_messages, errors
1925
+
1926
+
1927
+ @no_type_check
1928
+ async def _close_all_clients() -> None:
1929
+ """
1930
+ Close any CodexAppServerClient instances that may still be alive.
1931
+
1932
+ This is primarily used in tests to avoid pending restart tasks keeping
1933
+ subprocess transports alive when the event loop shuts down.
1934
+ """
1935
+ logger = logging.getLogger(__name__)
1936
+ for client in list(_CLIENT_INSTANCES):
1937
+ try:
1938
+ await client.close()
1939
+ except Exception as exc:
1940
+ logger.debug("Failed to close client: %s", exc)
1941
+ continue
1942
+
1943
+
1461
1944
  __all__ = [
1462
1945
  "APPROVAL_METHODS",
1463
1946
  "ApprovalDecision",
@@ -1471,4 +1954,5 @@ __all__ = [
1471
1954
  "TurnHandle",
1472
1955
  "TurnResult",
1473
1956
  "_normalize_sandbox_policy",
1957
+ "_close_all_clients",
1474
1958
  ]