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.
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/opencode/client.py +68 -35
- codex_autorunner/agents/opencode/logging.py +21 -5
- codex_autorunner/agents/opencode/run_prompt.py +1 -0
- codex_autorunner/agents/opencode/runtime.py +118 -30
- codex_autorunner/agents/opencode/supervisor.py +36 -48
- codex_autorunner/agents/registry.py +136 -8
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +16 -35
- codex_autorunner/cli.py +157 -139
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_logging.py +7 -3
- codex_autorunner/core/app_server_prompts.py +27 -260
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +390 -100
- codex_autorunner/core/docs.py +10 -2
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +278 -262
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +15 -9
- codex_autorunner/core/locks.py +4 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/review_context.py +5 -8
- codex_autorunner/core/run_index.py +6 -0
- codex_autorunner/core/runner_process.py +5 -2
- codex_autorunner/core/state.py +0 -88
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/utils.py +29 -2
- codex_autorunner/discovery.py +2 -4
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +576 -92
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +141 -167
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +175 -0
- codex_autorunner/integrations/telegram/constants.py +16 -1
- codex_autorunner/integrations/telegram/dispatch.py +17 -0
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
- codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
- codex_autorunner/integrations/telegram/helpers.py +88 -16
- codex_autorunner/integrations/telegram/outbox.py +208 -37
- codex_autorunner/integrations/telegram/progress_stream.py +3 -10
- codex_autorunner/integrations/telegram/service.py +214 -40
- codex_autorunner/integrations/telegram/state.py +100 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +36 -3
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +23 -14
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +81 -109
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/system.py +6 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +1 -0
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +25 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +162 -196
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +41 -118
- codex_autorunner/static/index.html +787 -858
- codex_autorunner/static/liveUpdates.js +1 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -211
- codex_autorunner/static/styles.css +7567 -3865
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +34 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +1 -0
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/web/app.py +269 -91
- codex_autorunner/web/middleware.py +3 -4
- codex_autorunner/web/schemas.py +89 -109
- codex_autorunner/web/static_assets.py +1 -44
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/agents/execution/policy.py +0 -292
- codex_autorunner/agents/factory.py +0 -52
- codex_autorunner/agents/orchestrator.py +0 -358
- codex_autorunner/core/doc_chat.py +0 -1446
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -250
- codex_autorunner/spec_ingest.py +0 -812
- codex_autorunner/static/docChatActions.js +0 -287
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -285
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -504
- codex_autorunner/static/logs.js +0 -678
- codex_autorunner/static/review.js +0 -157
- codex_autorunner/static/runs.js +0 -418
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -94
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.2.dist-info/RECORD +0 -222
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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 =
|
|
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) <
|
|
550
|
-
remaining =
|
|
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 >=
|
|
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=
|
|
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) <
|
|
568
|
-
remaining =
|
|
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) >
|
|
592
|
-
oversize_preview = bytearray(buffer[:
|
|
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) >
|
|
813
|
+
if len(buffer) > self._max_message_bytes:
|
|
605
814
|
await self._emit_oversize_warning(
|
|
606
815
|
bytes_dropped=len(buffer),
|
|
607
|
-
preview=buffer[:
|
|
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":
|
|
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
|
|
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=
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1341
|
+
_record_raw_event(state, message)
|
|
1093
1342
|
status = None
|
|
1094
1343
|
if isinstance(params, dict):
|
|
1095
1344
|
status = params.get("status")
|
|
1096
|
-
|
|
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=
|
|
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
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
await asyncio.sleep(
|
|
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
|
-
|
|
1187
|
-
|
|
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
|
]
|