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