@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,3030 @@
|
|
|
1
|
+
"""Persistent, multiplexed JSON-RPC client for ``codex app-server``.
|
|
2
|
+
|
|
3
|
+
The app-server protocol is bidirectional JSONL over stdio. One client owns
|
|
4
|
+
one lazily started process and can carry multiple Codex threads concurrently.
|
|
5
|
+
Transport failures are deliberately surfaced to callers; this module never
|
|
6
|
+
replays a request whose delivery may have succeeded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
import time
|
|
17
|
+
from collections import deque
|
|
18
|
+
from contextlib import suppress
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Awaitable, Callable, Sequence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CodexAppServerError(RuntimeError):
|
|
24
|
+
"""Base error raised by the app-server transport."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
request_sent: bool = False,
|
|
31
|
+
safe_to_retry: bool = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.request_sent = request_sent
|
|
35
|
+
self.safe_to_retry = safe_to_retry
|
|
36
|
+
# ``start_turn`` attaches its provisional routed handle here when the
|
|
37
|
+
# request outcome is ambiguous. Callers can keep observing the thread
|
|
38
|
+
# instead of replaying the user message.
|
|
39
|
+
self.pending_turn: CodexAppServerTurn | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CodexAppServerDisconnected(CodexAppServerError):
|
|
43
|
+
"""The shared app-server transport closed unexpectedly."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
message: str,
|
|
48
|
+
*,
|
|
49
|
+
request_sent: bool = False,
|
|
50
|
+
safe_to_retry: bool = False,
|
|
51
|
+
planned: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(
|
|
54
|
+
message,
|
|
55
|
+
request_sent=request_sent,
|
|
56
|
+
safe_to_retry=safe_to_retry,
|
|
57
|
+
)
|
|
58
|
+
self.planned = planned
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CodexAppServerTimeout(CodexAppServerError):
|
|
62
|
+
"""A request exceeded its deadline before or after transport delivery."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
method: str,
|
|
67
|
+
timeout: float,
|
|
68
|
+
*,
|
|
69
|
+
request_sent: bool = True,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.method = method
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
super().__init__(
|
|
74
|
+
f"{method} timed out after {timeout:g}s",
|
|
75
|
+
request_sent=request_sent,
|
|
76
|
+
safe_to_retry=not request_sent,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CodexAppServerRequestError(CodexAppServerError):
|
|
81
|
+
"""A JSON-RPC request was explicitly rejected by app-server."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, method: str, error: Any) -> None:
|
|
84
|
+
self.method = method
|
|
85
|
+
self.error = error
|
|
86
|
+
self.code = error.get("code") if isinstance(error, dict) else None
|
|
87
|
+
self.data = error.get("data") if isinstance(error, dict) else None
|
|
88
|
+
if isinstance(error, dict):
|
|
89
|
+
message = str(error.get("message") or error)
|
|
90
|
+
else:
|
|
91
|
+
message = str(error)
|
|
92
|
+
super().__init__(
|
|
93
|
+
f"{method} failed: {message}",
|
|
94
|
+
request_sent=True,
|
|
95
|
+
safe_to_retry=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CodexAppServerProtocolError(CodexAppServerError):
|
|
100
|
+
"""App-server returned a response that violated the expected contract."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CodexAppServerSubscriptionClosed(CodexAppServerError):
|
|
104
|
+
"""A local notification subscription is no longer active."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
ProcessFactory = Callable[..., Awaitable[asyncio.subprocess.Process]]
|
|
108
|
+
NotificationHandler = Callable[[dict[str, Any]], Awaitable[None] | None]
|
|
109
|
+
ServerRequestHandler = Callable[
|
|
110
|
+
[Any, str, dict[str, Any]],
|
|
111
|
+
Awaitable[dict[str, Any]] | dict[str, Any],
|
|
112
|
+
]
|
|
113
|
+
# ``(pid, process_group_id)``; the group id is None when it is not provably
|
|
114
|
+
# owned by the client (injected factories, non-POSIX hosts).
|
|
115
|
+
ProcessLifecycleHook = Callable[[int, int | None], None]
|
|
116
|
+
|
|
117
|
+
_THREAD_GOAL_STATUSES = frozenset(
|
|
118
|
+
{
|
|
119
|
+
"active",
|
|
120
|
+
"paused",
|
|
121
|
+
"blocked",
|
|
122
|
+
"usageLimited",
|
|
123
|
+
"budgetLimited",
|
|
124
|
+
"complete",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
_TURN_STATUSES = frozenset({"completed", "interrupted", "failed", "inProgress"})
|
|
128
|
+
_REVIEW_DELIVERIES = frozenset({"inline", "detached"})
|
|
129
|
+
_REVIEW_TARGET_TYPES = frozenset(
|
|
130
|
+
{"uncommittedChanges", "baseBranch", "commit", "custom"}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _OmittedType:
|
|
135
|
+
"""Sentinel that distinguishes an omitted optional field from JSON null."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_OMITTED = _OmittedType()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _require_nonempty_string(value: Any, field: str) -> str:
|
|
142
|
+
if not isinstance(value, str) or not value.strip():
|
|
143
|
+
raise ValueError(f"{field} must be a non-empty string")
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _protocol_object(method: str, result: Any) -> dict[str, Any]:
|
|
148
|
+
if not isinstance(result, dict):
|
|
149
|
+
raise CodexAppServerProtocolError(
|
|
150
|
+
f"{method} did not return an object",
|
|
151
|
+
request_sent=True,
|
|
152
|
+
safe_to_retry=False,
|
|
153
|
+
)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _protocol_empty_object(method: str, result: Any) -> None:
|
|
158
|
+
_protocol_object(method, result)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _protocol_cursor(method: str, result: dict[str, Any]) -> str | None:
|
|
162
|
+
cursor = result.get("nextCursor")
|
|
163
|
+
if cursor is not None and not isinstance(cursor, str):
|
|
164
|
+
raise CodexAppServerProtocolError(
|
|
165
|
+
f"{method} returned an invalid next cursor",
|
|
166
|
+
request_sent=True,
|
|
167
|
+
safe_to_retry=False,
|
|
168
|
+
)
|
|
169
|
+
return cursor
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _protocol_thread_goal(
|
|
173
|
+
method: str,
|
|
174
|
+
goal: Any,
|
|
175
|
+
*,
|
|
176
|
+
thread_id: str,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
if not isinstance(goal, dict):
|
|
179
|
+
raise CodexAppServerProtocolError(
|
|
180
|
+
f"{method} did not return a goal object",
|
|
181
|
+
request_sent=True,
|
|
182
|
+
safe_to_retry=False,
|
|
183
|
+
)
|
|
184
|
+
if goal.get("threadId") != thread_id:
|
|
185
|
+
raise CodexAppServerProtocolError(
|
|
186
|
+
f"{method} returned a goal for a different thread",
|
|
187
|
+
request_sent=True,
|
|
188
|
+
safe_to_retry=False,
|
|
189
|
+
)
|
|
190
|
+
if not isinstance(goal.get("objective"), str) or not goal["objective"]:
|
|
191
|
+
raise CodexAppServerProtocolError(
|
|
192
|
+
f"{method} returned an invalid goal objective",
|
|
193
|
+
request_sent=True,
|
|
194
|
+
safe_to_retry=False,
|
|
195
|
+
)
|
|
196
|
+
if goal.get("status") not in _THREAD_GOAL_STATUSES:
|
|
197
|
+
raise CodexAppServerProtocolError(
|
|
198
|
+
f"{method} returned an invalid goal status",
|
|
199
|
+
request_sent=True,
|
|
200
|
+
safe_to_retry=False,
|
|
201
|
+
)
|
|
202
|
+
for field in (
|
|
203
|
+
"createdAt",
|
|
204
|
+
"updatedAt",
|
|
205
|
+
"tokensUsed",
|
|
206
|
+
"timeUsedSeconds",
|
|
207
|
+
):
|
|
208
|
+
value = goal.get(field)
|
|
209
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
210
|
+
raise CodexAppServerProtocolError(
|
|
211
|
+
f"{method} returned an invalid goal {field}",
|
|
212
|
+
request_sent=True,
|
|
213
|
+
safe_to_retry=False,
|
|
214
|
+
)
|
|
215
|
+
token_budget = goal.get("tokenBudget")
|
|
216
|
+
if (
|
|
217
|
+
token_budget is not None
|
|
218
|
+
and (isinstance(token_budget, bool) or not isinstance(token_budget, int))
|
|
219
|
+
):
|
|
220
|
+
raise CodexAppServerProtocolError(
|
|
221
|
+
f"{method} returned an invalid goal tokenBudget",
|
|
222
|
+
request_sent=True,
|
|
223
|
+
safe_to_retry=False,
|
|
224
|
+
)
|
|
225
|
+
return dict(goal)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _protocol_turn(method: str, turn: Any) -> dict[str, Any]:
|
|
229
|
+
if not isinstance(turn, dict) or not isinstance(turn.get("id"), str) or not turn["id"]:
|
|
230
|
+
raise CodexAppServerProtocolError(
|
|
231
|
+
f"{method} did not return a valid turn",
|
|
232
|
+
request_sent=True,
|
|
233
|
+
safe_to_retry=False,
|
|
234
|
+
)
|
|
235
|
+
if turn.get("status") not in _TURN_STATUSES or not isinstance(
|
|
236
|
+
turn.get("items"), list
|
|
237
|
+
):
|
|
238
|
+
raise CodexAppServerProtocolError(
|
|
239
|
+
f"{method} returned an invalid turn payload",
|
|
240
|
+
request_sent=True,
|
|
241
|
+
safe_to_retry=False,
|
|
242
|
+
)
|
|
243
|
+
return dict(turn)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _notification_scope(notification: dict[str, Any]) -> tuple[str, str]:
|
|
247
|
+
params = notification.get("params")
|
|
248
|
+
if not isinstance(params, dict):
|
|
249
|
+
return "", ""
|
|
250
|
+
|
|
251
|
+
thread_id = str(params.get("threadId") or "")
|
|
252
|
+
turn_id = str(params.get("turnId") or "")
|
|
253
|
+
|
|
254
|
+
thread = params.get("thread")
|
|
255
|
+
if not thread_id and isinstance(thread, dict):
|
|
256
|
+
thread_id = str(thread.get("id") or "")
|
|
257
|
+
|
|
258
|
+
turn = params.get("turn")
|
|
259
|
+
if not turn_id and isinstance(turn, dict):
|
|
260
|
+
turn_id = str(turn.get("id") or "")
|
|
261
|
+
|
|
262
|
+
return thread_id, turn_id
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(eq=False, slots=True)
|
|
266
|
+
class CodexAppServerSubscription:
|
|
267
|
+
"""A local filtered view of app-server notifications."""
|
|
268
|
+
|
|
269
|
+
client: "CodexAppServerClient"
|
|
270
|
+
thread_id: str | None
|
|
271
|
+
turn_id: str | None
|
|
272
|
+
_queue: asyncio.Queue[Any]
|
|
273
|
+
_closed: bool = False
|
|
274
|
+
_last_enqueued_sequence: int = 0
|
|
275
|
+
|
|
276
|
+
def _matches(self, notification: dict[str, Any]) -> bool:
|
|
277
|
+
notification_thread_id, notification_turn_id = _notification_scope(notification)
|
|
278
|
+
if self.thread_id is not None and notification_thread_id != self.thread_id:
|
|
279
|
+
return False
|
|
280
|
+
if self.turn_id is not None and notification_turn_id != self.turn_id:
|
|
281
|
+
return False
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
def _put(self, value: Any) -> None:
|
|
285
|
+
if self._closed:
|
|
286
|
+
return
|
|
287
|
+
try:
|
|
288
|
+
self._last_enqueued_sequence += 1
|
|
289
|
+
sequence = self._last_enqueued_sequence
|
|
290
|
+
self._queue.put_nowait((sequence, value))
|
|
291
|
+
except asyncio.QueueFull:
|
|
292
|
+
self._finish(
|
|
293
|
+
CodexAppServerDisconnected(
|
|
294
|
+
"app-server notification backlog exceeded its safety limit",
|
|
295
|
+
request_sent=True,
|
|
296
|
+
safe_to_retry=False,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def _finish(self, error: BaseException | None = None) -> None:
|
|
301
|
+
if self._closed:
|
|
302
|
+
return
|
|
303
|
+
if error is not None:
|
|
304
|
+
while not self._queue.empty():
|
|
305
|
+
with suppress(asyncio.QueueEmpty):
|
|
306
|
+
self._queue.get_nowait()
|
|
307
|
+
self._queue.put_nowait(error)
|
|
308
|
+
self._closed = True
|
|
309
|
+
self.client._release_subscription(self)
|
|
310
|
+
|
|
311
|
+
async def next_notification_with_sequence(
|
|
312
|
+
self,
|
|
313
|
+
timeout: float | None = None,
|
|
314
|
+
) -> tuple[int, dict[str, Any]]:
|
|
315
|
+
if self._closed and self._queue.empty():
|
|
316
|
+
raise CodexAppServerSubscriptionClosed("app-server notification subscription closed")
|
|
317
|
+
waiter = self._queue.get()
|
|
318
|
+
value = await (asyncio.wait_for(waiter, timeout) if timeout is not None else waiter)
|
|
319
|
+
if isinstance(value, BaseException):
|
|
320
|
+
raise value
|
|
321
|
+
if (
|
|
322
|
+
not isinstance(value, tuple)
|
|
323
|
+
or len(value) != 2
|
|
324
|
+
or not isinstance(value[0], int)
|
|
325
|
+
or not isinstance(value[1], dict)
|
|
326
|
+
):
|
|
327
|
+
raise CodexAppServerProtocolError("app-server notification was not an object")
|
|
328
|
+
return value
|
|
329
|
+
|
|
330
|
+
async def next_notification(self, timeout: float | None = None) -> dict[str, Any]:
|
|
331
|
+
_sequence, notification = await self.next_notification_with_sequence(timeout)
|
|
332
|
+
return notification
|
|
333
|
+
|
|
334
|
+
def close(self) -> None:
|
|
335
|
+
self._finish()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@dataclass(eq=False, slots=True)
|
|
339
|
+
class CodexAppServerTurn:
|
|
340
|
+
"""An accepted app-server turn and its routed notification stream."""
|
|
341
|
+
|
|
342
|
+
client: "CodexAppServerClient"
|
|
343
|
+
thread_id: str
|
|
344
|
+
turn_id: str
|
|
345
|
+
_subscription: CodexAppServerSubscription
|
|
346
|
+
# Set at the exact stdin write boundary. A caller cancelled after delivery
|
|
347
|
+
# must retire this generation, not the one observed before lazy start/init.
|
|
348
|
+
transport_generation: int = 0
|
|
349
|
+
_closed: bool = False
|
|
350
|
+
_completed: bool = False
|
|
351
|
+
# Goal owners can keep the routed stream after this initial turn ends.
|
|
352
|
+
# The caller still closes the handle when it releases the thread owner.
|
|
353
|
+
_retain_thread_stream: bool = False
|
|
354
|
+
# Native turn identities are retained only for this owner's lifetime.
|
|
355
|
+
# Delayed duplicate starts must not resurrect one of its finished turns.
|
|
356
|
+
_completed_turn_ids: set[str] = field(default_factory=set)
|
|
357
|
+
|
|
358
|
+
async def next_notification(self, timeout: float | None = None) -> dict[str, Any]:
|
|
359
|
+
return await self._subscription.next_notification(timeout)
|
|
360
|
+
|
|
361
|
+
async def next_notification_with_sequence(
|
|
362
|
+
self,
|
|
363
|
+
timeout: float | None = None,
|
|
364
|
+
) -> tuple[int, dict[str, Any]]:
|
|
365
|
+
return await self._subscription.next_notification_with_sequence(timeout)
|
|
366
|
+
|
|
367
|
+
async def steer(
|
|
368
|
+
self,
|
|
369
|
+
input_items: list[dict[str, Any]],
|
|
370
|
+
*,
|
|
371
|
+
client_user_message_id: str | None = None,
|
|
372
|
+
) -> str:
|
|
373
|
+
return await self.client.steer_turn(
|
|
374
|
+
self.thread_id,
|
|
375
|
+
self.turn_id,
|
|
376
|
+
input_items,
|
|
377
|
+
client_user_message_id=client_user_message_id,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
async def steer_with_notification_watermark(
|
|
381
|
+
self,
|
|
382
|
+
input_items: list[dict[str, Any]],
|
|
383
|
+
*,
|
|
384
|
+
client_user_message_id: str | None = None,
|
|
385
|
+
) -> tuple[str, int]:
|
|
386
|
+
return await self.client.steer_turn_with_notification_watermark(
|
|
387
|
+
self.thread_id,
|
|
388
|
+
self.turn_id,
|
|
389
|
+
input_items,
|
|
390
|
+
client_user_message_id=client_user_message_id,
|
|
391
|
+
notification_subscription=self._subscription,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
async def interrupt(self) -> None:
|
|
395
|
+
await self.client.interrupt_turn(self.thread_id, self.turn_id)
|
|
396
|
+
|
|
397
|
+
async def continue_after_subagents(
|
|
398
|
+
self,
|
|
399
|
+
*,
|
|
400
|
+
before_send: Callable[[], bool],
|
|
401
|
+
client_user_message_id: str | None = None,
|
|
402
|
+
responsesapi_client_metadata: dict[str, str] | None = None,
|
|
403
|
+
) -> str | None:
|
|
404
|
+
"""Collect native child notifications without inserting a user message.
|
|
405
|
+
|
|
406
|
+
The runner establishes child ownership and attempts this once per
|
|
407
|
+
completed native turn. A spontaneous continuation or Stop wins at the
|
|
408
|
+
stdin boundary and returns None without sending anything. An ambiguous
|
|
409
|
+
delivery raises without closing this stream or replaying the request.
|
|
410
|
+
"""
|
|
411
|
+
completed_id = self.turn_id
|
|
412
|
+
generation = self.transport_generation
|
|
413
|
+
guard_lost = False
|
|
414
|
+
delivery_attempted = False
|
|
415
|
+
|
|
416
|
+
def owns_completed_turn() -> bool:
|
|
417
|
+
nonlocal guard_lost
|
|
418
|
+
allowed = (
|
|
419
|
+
self._retain_thread_stream
|
|
420
|
+
and not self._closed
|
|
421
|
+
and self._completed
|
|
422
|
+
and bool(completed_id)
|
|
423
|
+
and self.turn_id == completed_id
|
|
424
|
+
and completed_id in self._completed_turn_ids
|
|
425
|
+
and generation == self.client.generation
|
|
426
|
+
and self.client._turns_by_thread.get(self.thread_id) is self
|
|
427
|
+
and before_send() is True
|
|
428
|
+
)
|
|
429
|
+
guard_lost = not allowed
|
|
430
|
+
return allowed
|
|
431
|
+
|
|
432
|
+
if not owns_completed_turn():
|
|
433
|
+
return None
|
|
434
|
+
params: dict[str, Any] = {"threadId": self.thread_id, "input": []}
|
|
435
|
+
if client_user_message_id is not None:
|
|
436
|
+
params["clientUserMessageId"] = _require_nonempty_string(
|
|
437
|
+
client_user_message_id, "client_user_message_id"
|
|
438
|
+
)
|
|
439
|
+
if responsesapi_client_metadata is not None:
|
|
440
|
+
params["responsesapiClientMetadata"] = dict(responsesapi_client_metadata)
|
|
441
|
+
|
|
442
|
+
def write_guard() -> bool:
|
|
443
|
+
nonlocal delivery_attempted
|
|
444
|
+
# _send performs stdin.write immediately after this guard, with no
|
|
445
|
+
# intervening await. Cancellation before the lock sends no bytes;
|
|
446
|
+
# cancellation after this point must reconcile native acceptance.
|
|
447
|
+
delivery_attempted = owns_completed_turn()
|
|
448
|
+
return delivery_attempted
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
result = await self.client._request_connected(
|
|
452
|
+
"turn/start", params, before_send=write_guard,
|
|
453
|
+
transport_turn=self,
|
|
454
|
+
)
|
|
455
|
+
except asyncio.CancelledError as exc:
|
|
456
|
+
exc.request_sent = delivery_attempted
|
|
457
|
+
exc.pending_turn = self if delivery_attempted else None
|
|
458
|
+
raise
|
|
459
|
+
except CodexAppServerProtocolError as exc:
|
|
460
|
+
if guard_lost and not exc.request_sent:
|
|
461
|
+
return None
|
|
462
|
+
raise
|
|
463
|
+
turn = result.get("turn") if isinstance(result, dict) else None
|
|
464
|
+
accepted_id = turn.get("id") if isinstance(turn, dict) else None
|
|
465
|
+
if not isinstance(accepted_id, str) or not accepted_id or accepted_id == completed_id:
|
|
466
|
+
raise CodexAppServerProtocolError(
|
|
467
|
+
"child continuation did not return a new turn id",
|
|
468
|
+
request_sent=True, safe_to_retry=False,
|
|
469
|
+
)
|
|
470
|
+
if (
|
|
471
|
+
self._closed
|
|
472
|
+
or generation != self.client.generation
|
|
473
|
+
or self.client._turns_by_thread.get(self.thread_id) is not self
|
|
474
|
+
):
|
|
475
|
+
raise CodexAppServerProtocolError(
|
|
476
|
+
"child continuation owner changed after delivery",
|
|
477
|
+
request_sent=True, safe_to_retry=False,
|
|
478
|
+
)
|
|
479
|
+
if self.turn_id == completed_id:
|
|
480
|
+
self.turn_id = accepted_id
|
|
481
|
+
self._completed = False
|
|
482
|
+
elif self.turn_id != accepted_id and accepted_id not in self._completed_turn_ids:
|
|
483
|
+
raise CodexAppServerProtocolError(
|
|
484
|
+
"child continuation response did not match its early notifications",
|
|
485
|
+
request_sent=True, safe_to_retry=False,
|
|
486
|
+
)
|
|
487
|
+
# If B completed (and possibly C started) before acknowledgement B,
|
|
488
|
+
# retain the receive-order state rather than reviving B here.
|
|
489
|
+
return accepted_id
|
|
490
|
+
|
|
491
|
+
def adopt_turn_id(self, turn_id: str) -> None:
|
|
492
|
+
"""Bind a provisional turn after an ambiguous ``turn/start`` response."""
|
|
493
|
+
resolved = str(turn_id or "")
|
|
494
|
+
if not resolved:
|
|
495
|
+
raise CodexAppServerProtocolError("cannot bind an empty app-server turn id")
|
|
496
|
+
if self.turn_id and self.turn_id != resolved:
|
|
497
|
+
raise CodexAppServerProtocolError(
|
|
498
|
+
f"provisional turn already bound to {self.turn_id}, not {resolved}"
|
|
499
|
+
)
|
|
500
|
+
self.turn_id = resolved
|
|
501
|
+
if not self._retain_thread_stream:
|
|
502
|
+
self._subscription.turn_id = resolved
|
|
503
|
+
|
|
504
|
+
async def close(self) -> None:
|
|
505
|
+
if self._closed:
|
|
506
|
+
return
|
|
507
|
+
self._closed = True
|
|
508
|
+
self.client.release_turn(self)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
async def decline_server_request(
|
|
512
|
+
_request_id: Any,
|
|
513
|
+
method: str,
|
|
514
|
+
params: dict[str, Any],
|
|
515
|
+
) -> dict[str, Any]:
|
|
516
|
+
"""Return the least-privileged valid response for interactive requests.
|
|
517
|
+
|
|
518
|
+
AgentsServer launches Codex with non-interactive approval settings. If a
|
|
519
|
+
request nevertheless reaches this connection, silently approving it would
|
|
520
|
+
be unsafe and waiting forever would wedge the turn.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
if method in {
|
|
524
|
+
"item/commandExecution/requestApproval",
|
|
525
|
+
"item/fileChange/requestApproval",
|
|
526
|
+
}:
|
|
527
|
+
return {"decision": "decline"}
|
|
528
|
+
if method in {"applyPatchApproval", "execCommandApproval"}:
|
|
529
|
+
return {"decision": "denied"}
|
|
530
|
+
if method == "item/tool/requestUserInput":
|
|
531
|
+
return {"answers": {}}
|
|
532
|
+
if method == "mcpServer/elicitation/request":
|
|
533
|
+
return {"action": "decline"}
|
|
534
|
+
if method == "item/permissions/requestApproval":
|
|
535
|
+
return {"permissions": {}, "scope": "turn", "strictAutoReview": False}
|
|
536
|
+
raise CodexAppServerRequestError(
|
|
537
|
+
method,
|
|
538
|
+
{
|
|
539
|
+
"code": -32601,
|
|
540
|
+
"message": f"AgentsServer does not handle server request {method}",
|
|
541
|
+
},
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class CodexAppServerClient:
|
|
546
|
+
"""Supervise one local app-server process and multiplex active threads."""
|
|
547
|
+
|
|
548
|
+
def __init__(
|
|
549
|
+
self,
|
|
550
|
+
codex_bin: str,
|
|
551
|
+
*,
|
|
552
|
+
cwd: str,
|
|
553
|
+
env_factory: Callable[[], dict[str, str]],
|
|
554
|
+
app_server_args: Sequence[str] = (),
|
|
555
|
+
request_timeout: float = 30.0,
|
|
556
|
+
lifecycle_timeout: float = 300.0,
|
|
557
|
+
process_exit_timeout: float = 1.0,
|
|
558
|
+
process_stream_limit: int = 16 * 1024 * 1024,
|
|
559
|
+
notification_queue_limit: int = 8192,
|
|
560
|
+
json_parse_thread_threshold: int = 1024 * 1024,
|
|
561
|
+
fork_cleanup_grace: float = 30.0,
|
|
562
|
+
process_factory: ProcessFactory | None = None,
|
|
563
|
+
before_start: Callable[[], Awaitable[Any]] | None = None,
|
|
564
|
+
server_request_handler: ServerRequestHandler | None = None,
|
|
565
|
+
initialize_params: dict[str, Any] | None = None,
|
|
566
|
+
on_process_started: ProcessLifecycleHook | None = None,
|
|
567
|
+
on_process_exited: ProcessLifecycleHook | None = None,
|
|
568
|
+
sensitive_values: Sequence[str] = (),
|
|
569
|
+
protected_env_keys: Sequence[str] = (),
|
|
570
|
+
) -> None:
|
|
571
|
+
self.codex_bin = codex_bin
|
|
572
|
+
self.cwd = cwd
|
|
573
|
+
self.env_factory = env_factory
|
|
574
|
+
self._before_start = before_start
|
|
575
|
+
self._sensitive_values = tuple(value for value in sensitive_values if isinstance(value, str) and value)
|
|
576
|
+
self._protected_env_keys = tuple(protected_env_keys)
|
|
577
|
+
# Lifecycle hooks receive ``(pid, process_group_id)``. The group id is
|
|
578
|
+
# None whenever start() could not prove it owns the child's session,
|
|
579
|
+
# so a hook must never derive a signal target from the pid alone.
|
|
580
|
+
self._on_process_started = on_process_started
|
|
581
|
+
self._on_process_exited = on_process_exited
|
|
582
|
+
self.app_server_args = tuple(str(value) for value in app_server_args)
|
|
583
|
+
self.request_timeout = request_timeout
|
|
584
|
+
self.lifecycle_timeout = lifecycle_timeout
|
|
585
|
+
self.process_exit_timeout = max(0.0, float(process_exit_timeout))
|
|
586
|
+
self.process_stream_limit = process_stream_limit
|
|
587
|
+
self.notification_queue_limit = max(1, int(notification_queue_limit))
|
|
588
|
+
self.json_parse_thread_threshold = max(
|
|
589
|
+
0,
|
|
590
|
+
int(json_parse_thread_threshold),
|
|
591
|
+
)
|
|
592
|
+
# Fork creation itself may consume the full lifecycle timeout. Allow a
|
|
593
|
+
# substantial post-timeout window for the adjacent thread/started
|
|
594
|
+
# notification instead of relying on sub-second scheduling luck.
|
|
595
|
+
self.fork_cleanup_grace = max(0.0, float(fork_cleanup_grace))
|
|
596
|
+
self._process_factory = process_factory or asyncio.create_subprocess_exec
|
|
597
|
+
# Only the default launcher proves that ``start_new_session=True`` was
|
|
598
|
+
# honored by the process we own. Tests and injected factories may use
|
|
599
|
+
# arbitrary/recycled PIDs, so never derive a signal target from them.
|
|
600
|
+
self._owns_spawned_process_groups = process_factory is None and os.name == "posix"
|
|
601
|
+
self._server_request_handler = server_request_handler or decline_server_request
|
|
602
|
+
self._initialize_params = initialize_params or {
|
|
603
|
+
"clientInfo": {
|
|
604
|
+
"name": "agents_server",
|
|
605
|
+
"title": "AgentsServer",
|
|
606
|
+
"version": "1",
|
|
607
|
+
},
|
|
608
|
+
"capabilities": {"experimentalApi": True},
|
|
609
|
+
}
|
|
610
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
611
|
+
self._process_group_id: int | None = None
|
|
612
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
613
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
614
|
+
self._start_lock = asyncio.Lock()
|
|
615
|
+
self._write_lock = asyncio.Lock()
|
|
616
|
+
# A timed-out/cancelled thread/fork may still finish in app-server.
|
|
617
|
+
# Keep each source serialized through its late-notification cleanup so
|
|
618
|
+
# one watcher can never claim a later fork from the same source.
|
|
619
|
+
self._fork_source_locks: dict[str, asyncio.Lock] = {}
|
|
620
|
+
self._next_id = 0
|
|
621
|
+
self._pending: dict[
|
|
622
|
+
int,
|
|
623
|
+
tuple[
|
|
624
|
+
str,
|
|
625
|
+
asyncio.Future[Any],
|
|
626
|
+
CodexAppServerSubscription | None,
|
|
627
|
+
],
|
|
628
|
+
] = {}
|
|
629
|
+
self._turns_by_thread: dict[str, CodexAppServerTurn] = {}
|
|
630
|
+
self._loaded_threads: set[str] = set()
|
|
631
|
+
# Provider identity outlives subscription/load state for one app-server
|
|
632
|
+
# process. Fork cleanup uses this to distinguish a newly-created child
|
|
633
|
+
# from an existing child that another operation merely resumed.
|
|
634
|
+
self._known_thread_ids: set[str] = set()
|
|
635
|
+
self._thread_names: dict[str, str] = {}
|
|
636
|
+
self._subscriptions: set[CodexAppServerSubscription] = set()
|
|
637
|
+
self._notification_handlers: set[NotificationHandler] = set()
|
|
638
|
+
self._callback_tasks: set[asyncio.Task[Any]] = set()
|
|
639
|
+
self._callback_tails: dict[
|
|
640
|
+
tuple[NotificationHandler, str],
|
|
641
|
+
asyncio.Task[Any],
|
|
642
|
+
] = {}
|
|
643
|
+
self._server_request_tasks: dict[Any, asyncio.Task[None]] = {}
|
|
644
|
+
self._stderr_tail: deque[str] = deque(maxlen=40)
|
|
645
|
+
# Native authentication can emit delayed diagnostics. Once credentials
|
|
646
|
+
# have crossed this transport, never retain unstructured stderr from it.
|
|
647
|
+
self._authentication_submitted = bool(self._sensitive_values)
|
|
648
|
+
# Diagnostic-only record of notifications _route_notification could
|
|
649
|
+
# not match to any live subscription (see its docstring) - these are
|
|
650
|
+
# otherwise dropped with no trace, which is exactly what makes a
|
|
651
|
+
# stalled turn look like it hung for no reason.
|
|
652
|
+
self._unmatched_notifications: deque[dict[str, Any]] = deque(maxlen=40)
|
|
653
|
+
self._closing = False
|
|
654
|
+
self._initialized = False
|
|
655
|
+
self._initialize_result: dict[str, Any] | None = None
|
|
656
|
+
self._generation = 0
|
|
657
|
+
|
|
658
|
+
@property
|
|
659
|
+
def ready(self) -> bool:
|
|
660
|
+
return bool(
|
|
661
|
+
self._initialized
|
|
662
|
+
and self._proc is not None
|
|
663
|
+
and self._proc.returncode is None
|
|
664
|
+
and self._reader_task is not None
|
|
665
|
+
and not self._reader_task.done()
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
@property
|
|
669
|
+
def stderr_tail(self) -> list[str]:
|
|
670
|
+
return list(self._stderr_tail)
|
|
671
|
+
|
|
672
|
+
@property
|
|
673
|
+
def unmatched_notifications(self) -> list[dict[str, Any]]:
|
|
674
|
+
return list(self._unmatched_notifications)
|
|
675
|
+
|
|
676
|
+
@property
|
|
677
|
+
def process(self) -> asyncio.subprocess.Process | None:
|
|
678
|
+
"""The supervised process, exposed read-only for diagnostics."""
|
|
679
|
+
|
|
680
|
+
return self._proc
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def generation(self) -> int:
|
|
684
|
+
"""Incremented after every successful initialize handshake."""
|
|
685
|
+
|
|
686
|
+
return self._generation
|
|
687
|
+
|
|
688
|
+
@property
|
|
689
|
+
def initialize_result(self) -> dict[str, Any] | None:
|
|
690
|
+
return dict(self._initialize_result) if self._initialize_result is not None else None
|
|
691
|
+
|
|
692
|
+
def is_thread_loaded(self, thread_id: str) -> bool:
|
|
693
|
+
return thread_id in self._loaded_threads
|
|
694
|
+
|
|
695
|
+
def cached_thread_name(self, thread_id: str) -> str | None:
|
|
696
|
+
"""Metadata already received from Codex; never starts a request."""
|
|
697
|
+
return self._thread_names.get(thread_id)
|
|
698
|
+
|
|
699
|
+
def _cache_thread_name(self, thread: Any) -> None:
|
|
700
|
+
if not isinstance(thread, dict):
|
|
701
|
+
return
|
|
702
|
+
thread_id, name = thread.get("id"), thread.get("name")
|
|
703
|
+
if not isinstance(thread_id, str) or not thread_id or len(thread_id) > 512:
|
|
704
|
+
return
|
|
705
|
+
if not isinstance(name, str) or not name.strip() or len(name) > 4096:
|
|
706
|
+
return
|
|
707
|
+
self._thread_names.pop(thread_id, None)
|
|
708
|
+
self._thread_names[thread_id] = name
|
|
709
|
+
if len(self._thread_names) > 1024:
|
|
710
|
+
self._thread_names.pop(next(iter(self._thread_names)))
|
|
711
|
+
|
|
712
|
+
async def start(self) -> None:
|
|
713
|
+
"""Start and initialize the process once, or reuse the live process."""
|
|
714
|
+
|
|
715
|
+
if self.ready:
|
|
716
|
+
return
|
|
717
|
+
async with self._start_lock:
|
|
718
|
+
if self.ready:
|
|
719
|
+
return
|
|
720
|
+
# A caller may subscribe before the first lazy start. Preserve
|
|
721
|
+
# those local subscriptions when there is no old transport to
|
|
722
|
+
# discard.
|
|
723
|
+
if (
|
|
724
|
+
self._proc is not None
|
|
725
|
+
or self._reader_task is not None
|
|
726
|
+
or self._stderr_task is not None
|
|
727
|
+
or self._pending
|
|
728
|
+
):
|
|
729
|
+
await self._discard_process()
|
|
730
|
+
self._closing = False
|
|
731
|
+
try:
|
|
732
|
+
if self._before_start is not None:
|
|
733
|
+
await self._before_start()
|
|
734
|
+
proc = await self._process_factory(
|
|
735
|
+
self.codex_bin,
|
|
736
|
+
"app-server",
|
|
737
|
+
*self.app_server_args,
|
|
738
|
+
"--listen",
|
|
739
|
+
"stdio://",
|
|
740
|
+
stdin=asyncio.subprocess.PIPE,
|
|
741
|
+
stdout=asyncio.subprocess.PIPE,
|
|
742
|
+
stderr=asyncio.subprocess.PIPE,
|
|
743
|
+
cwd=self.cwd,
|
|
744
|
+
env=self.env_factory(),
|
|
745
|
+
limit=self.process_stream_limit,
|
|
746
|
+
start_new_session=True,
|
|
747
|
+
)
|
|
748
|
+
except Exception as exc:
|
|
749
|
+
raise CodexAppServerDisconnected(
|
|
750
|
+
f"failed to start codex app-server: {exc}",
|
|
751
|
+
request_sent=False,
|
|
752
|
+
safe_to_retry=True,
|
|
753
|
+
) from exc
|
|
754
|
+
|
|
755
|
+
raw_pid = getattr(proc, "pid", None)
|
|
756
|
+
self._process_group_id = (
|
|
757
|
+
raw_pid
|
|
758
|
+
if (
|
|
759
|
+
self._owns_spawned_process_groups
|
|
760
|
+
and isinstance(raw_pid, int)
|
|
761
|
+
and not isinstance(raw_pid, bool)
|
|
762
|
+
and raw_pid > 0
|
|
763
|
+
)
|
|
764
|
+
else None
|
|
765
|
+
)
|
|
766
|
+
self._proc = proc
|
|
767
|
+
if (
|
|
768
|
+
self._on_process_started is not None
|
|
769
|
+
and isinstance(raw_pid, int)
|
|
770
|
+
and not isinstance(raw_pid, bool)
|
|
771
|
+
and raw_pid > 0
|
|
772
|
+
):
|
|
773
|
+
# Registry/bookkeeping hooks must never break a provider
|
|
774
|
+
# start; they exist so an orphaned child can be reaped later.
|
|
775
|
+
with suppress(Exception):
|
|
776
|
+
self._on_process_started(raw_pid, self._process_group_id)
|
|
777
|
+
self._reader_task = asyncio.create_task(
|
|
778
|
+
self._reader_loop(proc),
|
|
779
|
+
name="codex-app-server-reader",
|
|
780
|
+
)
|
|
781
|
+
self._stderr_task = asyncio.create_task(
|
|
782
|
+
self._stderr_loop(proc),
|
|
783
|
+
name="codex-app-server-stderr",
|
|
784
|
+
)
|
|
785
|
+
try:
|
|
786
|
+
result = await self._request_connected(
|
|
787
|
+
"initialize",
|
|
788
|
+
dict(self._initialize_params),
|
|
789
|
+
discard_on_send_timeout=False,
|
|
790
|
+
)
|
|
791
|
+
self._initialize_result = result if isinstance(result, dict) else {}
|
|
792
|
+
await self._send(
|
|
793
|
+
{"method": "initialized"},
|
|
794
|
+
discard_on_send_timeout=False,
|
|
795
|
+
)
|
|
796
|
+
self._initialized = True
|
|
797
|
+
self._generation += 1
|
|
798
|
+
except Exception:
|
|
799
|
+
await self._discard_process()
|
|
800
|
+
raise
|
|
801
|
+
|
|
802
|
+
async def close(self) -> None:
|
|
803
|
+
self._closing = True
|
|
804
|
+
await self._discard_process()
|
|
805
|
+
|
|
806
|
+
async def retire_generation(self, expected_generation: int) -> bool:
|
|
807
|
+
"""Stop only the transport generation that may own ambiguous work.
|
|
808
|
+
|
|
809
|
+
A cancelled request can have crossed the stdin write boundary before
|
|
810
|
+
its acknowledgement arrived. Callers that cannot recover the native
|
|
811
|
+
turn id must be able to retire that exact process without racing a
|
|
812
|
+
replacement generation started by another task.
|
|
813
|
+
"""
|
|
814
|
+
|
|
815
|
+
async with self._start_lock:
|
|
816
|
+
if self._generation != expected_generation:
|
|
817
|
+
return False
|
|
818
|
+
await self._discard_process()
|
|
819
|
+
return True
|
|
820
|
+
|
|
821
|
+
async def __aenter__(self) -> "CodexAppServerClient":
|
|
822
|
+
await self.start()
|
|
823
|
+
return self
|
|
824
|
+
|
|
825
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
826
|
+
await self.close()
|
|
827
|
+
|
|
828
|
+
def subscribe(
|
|
829
|
+
self,
|
|
830
|
+
*,
|
|
831
|
+
thread_id: str | None = None,
|
|
832
|
+
turn_id: str | None = None,
|
|
833
|
+
) -> CodexAppServerSubscription:
|
|
834
|
+
if turn_id is not None and thread_id is None:
|
|
835
|
+
raise ValueError("turn subscriptions require a thread_id")
|
|
836
|
+
subscription = CodexAppServerSubscription(
|
|
837
|
+
self,
|
|
838
|
+
thread_id,
|
|
839
|
+
turn_id,
|
|
840
|
+
asyncio.Queue(maxsize=self.notification_queue_limit),
|
|
841
|
+
)
|
|
842
|
+
self._subscriptions.add(subscription)
|
|
843
|
+
return subscription
|
|
844
|
+
|
|
845
|
+
def subscribe_thread(self, thread_id: str) -> CodexAppServerSubscription:
|
|
846
|
+
return self.subscribe(thread_id=thread_id)
|
|
847
|
+
|
|
848
|
+
def subscribe_turn(self, thread_id: str, turn_id: str) -> CodexAppServerSubscription:
|
|
849
|
+
return self.subscribe(thread_id=thread_id, turn_id=turn_id)
|
|
850
|
+
|
|
851
|
+
def _release_subscription(self, subscription: CodexAppServerSubscription) -> None:
|
|
852
|
+
self._subscriptions.discard(subscription)
|
|
853
|
+
|
|
854
|
+
def _finish_scoped_subscriptions(
|
|
855
|
+
self,
|
|
856
|
+
thread_id: str,
|
|
857
|
+
*,
|
|
858
|
+
turn_id: str | None = None,
|
|
859
|
+
include_thread_subscription: bool = False,
|
|
860
|
+
) -> None:
|
|
861
|
+
if include_thread_subscription:
|
|
862
|
+
retained = self._turns_by_thread.pop(thread_id, None)
|
|
863
|
+
if retained is not None:
|
|
864
|
+
retained._completed = True
|
|
865
|
+
retained._closed = True
|
|
866
|
+
for subscription in tuple(self._subscriptions):
|
|
867
|
+
if subscription.thread_id != thread_id:
|
|
868
|
+
continue
|
|
869
|
+
if include_thread_subscription or subscription.turn_id == turn_id:
|
|
870
|
+
subscription._finish()
|
|
871
|
+
|
|
872
|
+
def add_notification_handler(self, handler: NotificationHandler) -> None:
|
|
873
|
+
self._notification_handlers.add(handler)
|
|
874
|
+
|
|
875
|
+
def remove_notification_handler(self, handler: NotificationHandler) -> None:
|
|
876
|
+
self._notification_handlers.discard(handler)
|
|
877
|
+
|
|
878
|
+
async def wait_for_notification_handler(
|
|
879
|
+
self,
|
|
880
|
+
handler: NotificationHandler,
|
|
881
|
+
thread_id: str,
|
|
882
|
+
) -> None:
|
|
883
|
+
"""Wait through the latest callback received for one provider thread."""
|
|
884
|
+
tail = self._callback_tails.get((handler, thread_id))
|
|
885
|
+
if tail is None:
|
|
886
|
+
return
|
|
887
|
+
try:
|
|
888
|
+
await asyncio.shield(tail)
|
|
889
|
+
except Exception:
|
|
890
|
+
# Callback failures are deliberately isolated from transport and
|
|
891
|
+
# subscription consumers, matching normal callback dispatch.
|
|
892
|
+
pass
|
|
893
|
+
|
|
894
|
+
async def _discard_process(
|
|
895
|
+
self,
|
|
896
|
+
*,
|
|
897
|
+
preserve_task: asyncio.Task[Any] | None = None,
|
|
898
|
+
) -> None:
|
|
899
|
+
self._initialized = False
|
|
900
|
+
self._initialize_result = None
|
|
901
|
+
proc = self._proc
|
|
902
|
+
self._proc = None
|
|
903
|
+
process_group_id = self._process_group_id
|
|
904
|
+
self._process_group_id = None
|
|
905
|
+
current = asyncio.current_task()
|
|
906
|
+
reader_task = self._reader_task
|
|
907
|
+
stderr_task = self._stderr_task
|
|
908
|
+
had_transport_state = bool(
|
|
909
|
+
proc
|
|
910
|
+
or reader_task
|
|
911
|
+
or stderr_task
|
|
912
|
+
or self._pending
|
|
913
|
+
or self._turns_by_thread
|
|
914
|
+
or self._known_thread_ids
|
|
915
|
+
or self._subscriptions
|
|
916
|
+
or self._server_request_tasks
|
|
917
|
+
)
|
|
918
|
+
self._reader_task = None
|
|
919
|
+
self._stderr_task = None
|
|
920
|
+
|
|
921
|
+
if proc:
|
|
922
|
+
loop = asyncio.get_running_loop()
|
|
923
|
+
deadline = loop.time() + 2.0
|
|
924
|
+
if process_group_id is not None:
|
|
925
|
+
# start() created this exact new session. The native child can
|
|
926
|
+
# outlive the Node leader, so signal and verify the owned group
|
|
927
|
+
# even when the leader's returncode has already been observed.
|
|
928
|
+
group_alive = True
|
|
929
|
+
try:
|
|
930
|
+
os.killpg(process_group_id, signal.SIGTERM)
|
|
931
|
+
except ProcessLookupError:
|
|
932
|
+
group_alive = False
|
|
933
|
+
except PermissionError:
|
|
934
|
+
# Keep the group target: signal 0/KILL below remain scoped
|
|
935
|
+
# to the same exact group and cannot hit a sibling service.
|
|
936
|
+
pass
|
|
937
|
+
while group_alive and loop.time() < deadline:
|
|
938
|
+
try:
|
|
939
|
+
os.killpg(process_group_id, 0)
|
|
940
|
+
except ProcessLookupError:
|
|
941
|
+
group_alive = False
|
|
942
|
+
break
|
|
943
|
+
except PermissionError:
|
|
944
|
+
pass
|
|
945
|
+
await asyncio.sleep(0.05)
|
|
946
|
+
if group_alive:
|
|
947
|
+
with suppress(ProcessLookupError, PermissionError):
|
|
948
|
+
os.killpg(process_group_id, signal.SIGKILL)
|
|
949
|
+
if proc.returncode is None:
|
|
950
|
+
with suppress(Exception):
|
|
951
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
952
|
+
elif proc.returncode is None:
|
|
953
|
+
with suppress(ProcessLookupError):
|
|
954
|
+
proc.terminate()
|
|
955
|
+
with suppress(Exception):
|
|
956
|
+
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
|
957
|
+
if proc.returncode is None:
|
|
958
|
+
with suppress(ProcessLookupError):
|
|
959
|
+
proc.kill()
|
|
960
|
+
with suppress(Exception):
|
|
961
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
962
|
+
raw_pid = getattr(proc, "pid", None)
|
|
963
|
+
if (
|
|
964
|
+
self._on_process_exited is not None
|
|
965
|
+
and isinstance(raw_pid, int)
|
|
966
|
+
and not isinstance(raw_pid, bool)
|
|
967
|
+
and raw_pid > 0
|
|
968
|
+
):
|
|
969
|
+
with suppress(Exception):
|
|
970
|
+
self._on_process_exited(raw_pid, process_group_id)
|
|
971
|
+
|
|
972
|
+
for task in (reader_task, stderr_task):
|
|
973
|
+
if task and task is not current:
|
|
974
|
+
task.cancel()
|
|
975
|
+
with suppress(asyncio.CancelledError):
|
|
976
|
+
await task
|
|
977
|
+
|
|
978
|
+
background_tasks = [
|
|
979
|
+
task
|
|
980
|
+
for task in (
|
|
981
|
+
*self._server_request_tasks.values(),
|
|
982
|
+
*self._callback_tasks,
|
|
983
|
+
)
|
|
984
|
+
if task is not current and task is not preserve_task
|
|
985
|
+
]
|
|
986
|
+
for task in background_tasks:
|
|
987
|
+
task.cancel()
|
|
988
|
+
self._server_request_tasks.clear()
|
|
989
|
+
self._callback_tasks.clear()
|
|
990
|
+
self._callback_tails.clear()
|
|
991
|
+
if background_tasks:
|
|
992
|
+
await asyncio.gather(*background_tasks, return_exceptions=True)
|
|
993
|
+
|
|
994
|
+
if had_transport_state:
|
|
995
|
+
self._fail_all(
|
|
996
|
+
CodexAppServerDisconnected(
|
|
997
|
+
(
|
|
998
|
+
"codex app-server stopped with AgentsServer"
|
|
999
|
+
if self._closing
|
|
1000
|
+
else "codex app-server transport closed"
|
|
1001
|
+
),
|
|
1002
|
+
request_sent=bool(self._pending),
|
|
1003
|
+
safe_to_retry=not bool(self._pending),
|
|
1004
|
+
planned=self._closing,
|
|
1005
|
+
)
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
def _fail_all(self, error: BaseException) -> None:
|
|
1009
|
+
for _, future, _notification_boundary_subscription in list(
|
|
1010
|
+
self._pending.values()
|
|
1011
|
+
):
|
|
1012
|
+
if not future.done():
|
|
1013
|
+
future.set_exception(error)
|
|
1014
|
+
self._pending.clear()
|
|
1015
|
+
|
|
1016
|
+
self._turns_by_thread.clear()
|
|
1017
|
+
self._loaded_threads.clear()
|
|
1018
|
+
self._known_thread_ids.clear()
|
|
1019
|
+
self._thread_names.clear()
|
|
1020
|
+
for subscription in tuple(self._subscriptions):
|
|
1021
|
+
subscription._finish(error)
|
|
1022
|
+
|
|
1023
|
+
async def request(
|
|
1024
|
+
self,
|
|
1025
|
+
method: str,
|
|
1026
|
+
params: dict[str, Any],
|
|
1027
|
+
*,
|
|
1028
|
+
timeout: float | None = None,
|
|
1029
|
+
) -> Any:
|
|
1030
|
+
if method == "account/login/start":
|
|
1031
|
+
self._authentication_submitted = True
|
|
1032
|
+
self._stderr_tail.clear()
|
|
1033
|
+
await self.start()
|
|
1034
|
+
return await self._request_connected(
|
|
1035
|
+
method, params, timeout=timeout,
|
|
1036
|
+
# A status refresh must not retire an active shared provider if its
|
|
1037
|
+
# small control write stalls. Auth mutations are never replayed.
|
|
1038
|
+
discard_on_send_timeout=not method.startswith("account/"),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
async def _request_connected(
|
|
1042
|
+
self,
|
|
1043
|
+
method: str,
|
|
1044
|
+
params: dict[str, Any],
|
|
1045
|
+
*,
|
|
1046
|
+
timeout: float | None = None,
|
|
1047
|
+
notification_boundary_subscription: CodexAppServerSubscription | None = None,
|
|
1048
|
+
discard_on_send_timeout: bool = True,
|
|
1049
|
+
transport_turn: CodexAppServerTurn | None = None,
|
|
1050
|
+
before_send: Callable[[], bool] | None = None,
|
|
1051
|
+
) -> Any:
|
|
1052
|
+
if self._protected_env_keys and method in {"thread/start", "thread/resume", "thread/fork"}:
|
|
1053
|
+
effective = await self._request_connected("config/read", {
|
|
1054
|
+
"includeLayers": False, "cwd": params.get("cwd") or self.cwd,
|
|
1055
|
+
}, discard_on_send_timeout=False)
|
|
1056
|
+
config = effective.get("config") if isinstance(effective, dict) else None
|
|
1057
|
+
policy = config.get("shell_environment_policy") if isinstance(config, dict) else None
|
|
1058
|
+
if not isinstance(config, dict) or policy is not None and not isinstance(policy, dict):
|
|
1059
|
+
raise CodexAppServerProtocolError("Native shell environment policy could not be verified", request_sent=False, safe_to_retry=False)
|
|
1060
|
+
exclusions = (policy or {}).get("exclude") or []
|
|
1061
|
+
overrides = dict(params.get("config") or {})
|
|
1062
|
+
requested = overrides.get("shell_environment_policy.exclude") or []
|
|
1063
|
+
if not all(isinstance(values, list) and all(isinstance(value, str) for value in values) for values in (exclusions, requested)):
|
|
1064
|
+
raise CodexAppServerProtocolError("Native shell environment exclusions could not be verified", request_sent=False, safe_to_retry=False)
|
|
1065
|
+
overrides["shell_environment_policy.exclude"] = list(dict.fromkeys([*exclusions, *requested, *self._protected_env_keys]))
|
|
1066
|
+
params = {**params, "config": overrides}
|
|
1067
|
+
proc = self._proc
|
|
1068
|
+
if not proc or proc.returncode is not None or not proc.stdin:
|
|
1069
|
+
raise CodexAppServerDisconnected(
|
|
1070
|
+
"codex app-server is not connected",
|
|
1071
|
+
request_sent=False,
|
|
1072
|
+
safe_to_retry=True,
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
loop = asyncio.get_running_loop()
|
|
1076
|
+
self._next_id += 1
|
|
1077
|
+
request_id = self._next_id
|
|
1078
|
+
future: asyncio.Future[Any] = loop.create_future()
|
|
1079
|
+
self._pending[request_id] = (
|
|
1080
|
+
method,
|
|
1081
|
+
future,
|
|
1082
|
+
notification_boundary_subscription,
|
|
1083
|
+
)
|
|
1084
|
+
effective_timeout = self.request_timeout if timeout is None else timeout
|
|
1085
|
+
deadline = loop.time() + max(0.0, float(effective_timeout))
|
|
1086
|
+
try:
|
|
1087
|
+
await self._send(
|
|
1088
|
+
{"id": request_id, "method": method, "params": params},
|
|
1089
|
+
deadline=deadline,
|
|
1090
|
+
timeout=effective_timeout,
|
|
1091
|
+
expected_process=proc,
|
|
1092
|
+
discard_on_send_timeout=discard_on_send_timeout,
|
|
1093
|
+
transport_turn=transport_turn,
|
|
1094
|
+
before_send=before_send,
|
|
1095
|
+
)
|
|
1096
|
+
try:
|
|
1097
|
+
return await asyncio.wait_for(
|
|
1098
|
+
asyncio.shield(future),
|
|
1099
|
+
timeout=max(0.0, deadline - loop.time()),
|
|
1100
|
+
)
|
|
1101
|
+
except asyncio.TimeoutError as exc:
|
|
1102
|
+
raise CodexAppServerTimeout(method, effective_timeout) from exc
|
|
1103
|
+
finally:
|
|
1104
|
+
self._pending.pop(request_id, None)
|
|
1105
|
+
if not future.done():
|
|
1106
|
+
future.cancel()
|
|
1107
|
+
elif not future.cancelled():
|
|
1108
|
+
# Transport retirement can fail every pending future while
|
|
1109
|
+
# this request is unwinding from its write timeout. Consume
|
|
1110
|
+
# that superseded exception so asyncio does not report it as
|
|
1111
|
+
# an unretrieved future error.
|
|
1112
|
+
with suppress(BaseException):
|
|
1113
|
+
future.exception()
|
|
1114
|
+
|
|
1115
|
+
async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
1116
|
+
payload: dict[str, Any] = {"method": method}
|
|
1117
|
+
if params is not None:
|
|
1118
|
+
payload["params"] = params
|
|
1119
|
+
await self._send(payload)
|
|
1120
|
+
|
|
1121
|
+
async def _discard_process_after_write_timeout(
|
|
1122
|
+
self,
|
|
1123
|
+
proc: asyncio.subprocess.Process,
|
|
1124
|
+
) -> None:
|
|
1125
|
+
"""Retire exactly the process whose stdin missed its write deadline."""
|
|
1126
|
+
async with self._start_lock:
|
|
1127
|
+
if proc is not self._proc:
|
|
1128
|
+
return
|
|
1129
|
+
cleanup_task = asyncio.create_task(
|
|
1130
|
+
self._discard_process(preserve_task=asyncio.current_task())
|
|
1131
|
+
)
|
|
1132
|
+
cancelled = False
|
|
1133
|
+
while not cleanup_task.done():
|
|
1134
|
+
try:
|
|
1135
|
+
await asyncio.shield(cleanup_task)
|
|
1136
|
+
except asyncio.CancelledError:
|
|
1137
|
+
# _discard_process clears the owned process reference
|
|
1138
|
+
# before its first await. Every subsequent cancellation
|
|
1139
|
+
# must remain outside the cleanup task; a plain join here
|
|
1140
|
+
# lets a second cancel abandon the now-unreachable child.
|
|
1141
|
+
cancelled = True
|
|
1142
|
+
# Propagate cleanup failure before restoring caller cancellation.
|
|
1143
|
+
# result() also retrieves any exception when the task completed in
|
|
1144
|
+
# the same loop turn as a repeated cancel.
|
|
1145
|
+
cleanup_task.result()
|
|
1146
|
+
if cancelled:
|
|
1147
|
+
raise asyncio.CancelledError
|
|
1148
|
+
|
|
1149
|
+
async def _send(
|
|
1150
|
+
self,
|
|
1151
|
+
payload: dict[str, Any],
|
|
1152
|
+
*,
|
|
1153
|
+
timeout: float | None = None,
|
|
1154
|
+
deadline: float | None = None,
|
|
1155
|
+
expected_process: asyncio.subprocess.Process | None = None,
|
|
1156
|
+
discard_on_send_timeout: bool = True,
|
|
1157
|
+
transport_turn: CodexAppServerTurn | None = None,
|
|
1158
|
+
before_send: Callable[[], bool] | None = None,
|
|
1159
|
+
) -> None:
|
|
1160
|
+
loop = asyncio.get_running_loop()
|
|
1161
|
+
effective_timeout = self.request_timeout if timeout is None else timeout
|
|
1162
|
+
timeout_seconds = max(0.0, float(effective_timeout))
|
|
1163
|
+
if deadline is None:
|
|
1164
|
+
deadline = loop.time() + timeout_seconds
|
|
1165
|
+
method = str(payload.get("method") or "app-server write")
|
|
1166
|
+
encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
|
1167
|
+
proc = expected_process if expected_process is not None else self._proc
|
|
1168
|
+
if not proc or proc.returncode is not None or not proc.stdin:
|
|
1169
|
+
raise CodexAppServerDisconnected(
|
|
1170
|
+
"codex app-server stdin is unavailable",
|
|
1171
|
+
request_sent=False,
|
|
1172
|
+
safe_to_retry=True,
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
lock_acquired = False
|
|
1176
|
+
request_sent = False
|
|
1177
|
+
send_timeout: asyncio.TimeoutError | None = None
|
|
1178
|
+
try:
|
|
1179
|
+
try:
|
|
1180
|
+
await asyncio.wait_for(
|
|
1181
|
+
self._write_lock.acquire(),
|
|
1182
|
+
timeout=max(0.0, deadline - loop.time()),
|
|
1183
|
+
)
|
|
1184
|
+
except asyncio.TimeoutError as exc:
|
|
1185
|
+
send_timeout = exc
|
|
1186
|
+
else:
|
|
1187
|
+
lock_acquired = True
|
|
1188
|
+
# A process may be retired while this sender waits behind an
|
|
1189
|
+
# older write. Never put a request on that stale pipe.
|
|
1190
|
+
if (
|
|
1191
|
+
proc is not self._proc
|
|
1192
|
+
or proc.returncode is not None
|
|
1193
|
+
or not proc.stdin
|
|
1194
|
+
):
|
|
1195
|
+
raise CodexAppServerDisconnected(
|
|
1196
|
+
"codex app-server stdin is unavailable",
|
|
1197
|
+
request_sent=False,
|
|
1198
|
+
safe_to_retry=True,
|
|
1199
|
+
)
|
|
1200
|
+
if loop.time() >= deadline:
|
|
1201
|
+
send_timeout = asyncio.TimeoutError()
|
|
1202
|
+
else:
|
|
1203
|
+
# A control request can wait behind another writer after
|
|
1204
|
+
# its caller checked ownership. Recheck synchronously at
|
|
1205
|
+
# the actual write boundary; there is no await between
|
|
1206
|
+
# this decision and stdin.write. Rejection sends no bytes.
|
|
1207
|
+
if before_send is not None:
|
|
1208
|
+
try:
|
|
1209
|
+
allowed = before_send() is True
|
|
1210
|
+
except Exception as exc:
|
|
1211
|
+
raise CodexAppServerProtocolError(
|
|
1212
|
+
"app-server send guard failed before delivery",
|
|
1213
|
+
request_sent=False, safe_to_retry=True,
|
|
1214
|
+
) from exc
|
|
1215
|
+
if not allowed:
|
|
1216
|
+
raise CodexAppServerProtocolError(
|
|
1217
|
+
"app-server control owner changed before delivery",
|
|
1218
|
+
request_sent=False, safe_to_retry=True,
|
|
1219
|
+
)
|
|
1220
|
+
try:
|
|
1221
|
+
if transport_turn is not None:
|
|
1222
|
+
transport_turn.transport_generation = self._generation
|
|
1223
|
+
proc.stdin.write(encoded)
|
|
1224
|
+
request_sent = True
|
|
1225
|
+
await asyncio.wait_for(
|
|
1226
|
+
proc.stdin.drain(),
|
|
1227
|
+
timeout=max(0.0, deadline - loop.time()),
|
|
1228
|
+
)
|
|
1229
|
+
except asyncio.TimeoutError as exc:
|
|
1230
|
+
send_timeout = exc
|
|
1231
|
+
except Exception as exc:
|
|
1232
|
+
raise CodexAppServerDisconnected(
|
|
1233
|
+
f"codex app-server write failed: {exc}",
|
|
1234
|
+
request_sent=True,
|
|
1235
|
+
safe_to_retry=False,
|
|
1236
|
+
) from exc
|
|
1237
|
+
except CodexAppServerError:
|
|
1238
|
+
raise
|
|
1239
|
+
finally:
|
|
1240
|
+
if lock_acquired:
|
|
1241
|
+
self._write_lock.release()
|
|
1242
|
+
|
|
1243
|
+
if send_timeout is None:
|
|
1244
|
+
return
|
|
1245
|
+
if request_sent and discard_on_send_timeout:
|
|
1246
|
+
# Cleanup runs after releasing the shared writer lock. It is
|
|
1247
|
+
# serialized with start() so a fresh process cannot be spawned and
|
|
1248
|
+
# then accidentally cleared by retirement of the stale transport.
|
|
1249
|
+
await self._discard_process_after_write_timeout(proc)
|
|
1250
|
+
raise CodexAppServerTimeout(
|
|
1251
|
+
method,
|
|
1252
|
+
effective_timeout,
|
|
1253
|
+
request_sent=request_sent,
|
|
1254
|
+
) from send_timeout
|
|
1255
|
+
|
|
1256
|
+
def _redact_sensitive(self, value):
|
|
1257
|
+
if isinstance(value, str):
|
|
1258
|
+
for secret in self._sensitive_values:
|
|
1259
|
+
value = value.replace(secret, "[REDACTED]")
|
|
1260
|
+
return value
|
|
1261
|
+
if isinstance(value, list):
|
|
1262
|
+
return [self._redact_sensitive(item) for item in value]
|
|
1263
|
+
if isinstance(value, dict):
|
|
1264
|
+
return {self._redact_sensitive(key): self._redact_sensitive(item) for key, item in value.items()}
|
|
1265
|
+
return value
|
|
1266
|
+
|
|
1267
|
+
async def _reader_loop(self, proc: asyncio.subprocess.Process) -> None:
|
|
1268
|
+
error: BaseException | None = None
|
|
1269
|
+
try:
|
|
1270
|
+
if not proc.stdout:
|
|
1271
|
+
raise CodexAppServerDisconnected(
|
|
1272
|
+
"codex app-server stdout is unavailable",
|
|
1273
|
+
request_sent=False,
|
|
1274
|
+
safe_to_retry=True,
|
|
1275
|
+
)
|
|
1276
|
+
while True:
|
|
1277
|
+
raw = await proc.stdout.readline()
|
|
1278
|
+
if not raw:
|
|
1279
|
+
break
|
|
1280
|
+
try:
|
|
1281
|
+
if (
|
|
1282
|
+
self.json_parse_thread_threshold
|
|
1283
|
+
and len(raw) >= self.json_parse_thread_threshold
|
|
1284
|
+
):
|
|
1285
|
+
message = await asyncio.to_thread(json.loads, raw)
|
|
1286
|
+
else:
|
|
1287
|
+
message = json.loads(raw)
|
|
1288
|
+
except (TypeError, ValueError):
|
|
1289
|
+
continue
|
|
1290
|
+
if not isinstance(message, dict):
|
|
1291
|
+
continue
|
|
1292
|
+
|
|
1293
|
+
if self._sensitive_values:
|
|
1294
|
+
message = self._redact_sensitive(message)
|
|
1295
|
+
request_id = message.get("id")
|
|
1296
|
+
if request_id is not None and ("result" in message or "error" in message):
|
|
1297
|
+
pending = self._pending.get(request_id)
|
|
1298
|
+
if pending:
|
|
1299
|
+
(
|
|
1300
|
+
request_method,
|
|
1301
|
+
future,
|
|
1302
|
+
notification_boundary_subscription,
|
|
1303
|
+
) = pending
|
|
1304
|
+
if not future.done():
|
|
1305
|
+
if "error" in message:
|
|
1306
|
+
future.set_exception(
|
|
1307
|
+
CodexAppServerRequestError(
|
|
1308
|
+
request_method,
|
|
1309
|
+
{"message": "Native Codex authentication request failed"}
|
|
1310
|
+
if request_method.startswith("account/")
|
|
1311
|
+
else message.get("error"),
|
|
1312
|
+
)
|
|
1313
|
+
)
|
|
1314
|
+
else:
|
|
1315
|
+
result = message.get("result")
|
|
1316
|
+
future.set_result(
|
|
1317
|
+
(
|
|
1318
|
+
result,
|
|
1319
|
+
notification_boundary_subscription._last_enqueued_sequence,
|
|
1320
|
+
)
|
|
1321
|
+
if notification_boundary_subscription is not None
|
|
1322
|
+
else result
|
|
1323
|
+
)
|
|
1324
|
+
continue
|
|
1325
|
+
|
|
1326
|
+
if request_id is not None and message.get("method"):
|
|
1327
|
+
params = message.get("params")
|
|
1328
|
+
self._start_server_request(
|
|
1329
|
+
request_id,
|
|
1330
|
+
str(message.get("method") or ""),
|
|
1331
|
+
params if isinstance(params, dict) else {},
|
|
1332
|
+
)
|
|
1333
|
+
continue
|
|
1334
|
+
|
|
1335
|
+
method = str(message.get("method") or "")
|
|
1336
|
+
params = message.get("params")
|
|
1337
|
+
if method and isinstance(params, dict):
|
|
1338
|
+
self._route_notification({"method": method, "params": params})
|
|
1339
|
+
except asyncio.CancelledError:
|
|
1340
|
+
raise
|
|
1341
|
+
except Exception as exc:
|
|
1342
|
+
error = exc
|
|
1343
|
+
finally:
|
|
1344
|
+
if not self._closing and proc is self._proc:
|
|
1345
|
+
returncode = proc.returncode
|
|
1346
|
+
if returncode is None:
|
|
1347
|
+
try:
|
|
1348
|
+
returncode = await asyncio.wait_for(
|
|
1349
|
+
proc.wait(),
|
|
1350
|
+
timeout=self.process_exit_timeout,
|
|
1351
|
+
)
|
|
1352
|
+
except asyncio.TimeoutError:
|
|
1353
|
+
returncode = proc.returncode
|
|
1354
|
+
except Exception as exc:
|
|
1355
|
+
error = error or exc
|
|
1356
|
+
# ``close()`` may have started while the reader was waiting
|
|
1357
|
+
# for the child status. Let the planned shutdown path own
|
|
1358
|
+
# subscription cleanup instead of reporting a false crash.
|
|
1359
|
+
if not self._closing and proc is self._proc:
|
|
1360
|
+
self._initialized = False
|
|
1361
|
+
error = error or CodexAppServerDisconnected(
|
|
1362
|
+
(
|
|
1363
|
+
f"codex app-server exited with code {returncode}"
|
|
1364
|
+
if returncode is not None
|
|
1365
|
+
else (
|
|
1366
|
+
"codex app-server closed stdout before its "
|
|
1367
|
+
"process exit status became available"
|
|
1368
|
+
)
|
|
1369
|
+
),
|
|
1370
|
+
request_sent=bool(self._pending),
|
|
1371
|
+
safe_to_retry=not bool(self._pending),
|
|
1372
|
+
)
|
|
1373
|
+
self._fail_all(error)
|
|
1374
|
+
self._cancel_server_request_tasks()
|
|
1375
|
+
# An unplanned exit must release the child's registry entry
|
|
1376
|
+
# now; ``_discard_process`` only runs on the next start or
|
|
1377
|
+
# on close, and a stale {pid, pgid} could be reused.
|
|
1378
|
+
raw_pid = getattr(proc, "pid", None)
|
|
1379
|
+
if (
|
|
1380
|
+
self._on_process_exited is not None
|
|
1381
|
+
and isinstance(raw_pid, int)
|
|
1382
|
+
and not isinstance(raw_pid, bool)
|
|
1383
|
+
and raw_pid > 0
|
|
1384
|
+
):
|
|
1385
|
+
with suppress(Exception):
|
|
1386
|
+
self._on_process_exited(raw_pid, self._process_group_id)
|
|
1387
|
+
|
|
1388
|
+
async def _stderr_loop(self, proc: asyncio.subprocess.Process) -> None:
|
|
1389
|
+
if not proc.stderr:
|
|
1390
|
+
return
|
|
1391
|
+
while True:
|
|
1392
|
+
line = await proc.stderr.readline()
|
|
1393
|
+
if not line:
|
|
1394
|
+
return
|
|
1395
|
+
text = line.decode("utf-8", "replace").strip()
|
|
1396
|
+
if text and not self._authentication_submitted:
|
|
1397
|
+
self._stderr_tail.append(text)
|
|
1398
|
+
|
|
1399
|
+
def _route_notification(self, notification: dict[str, Any]) -> None:
|
|
1400
|
+
method = str(notification.get("method") or "")
|
|
1401
|
+
# Authentication notifications are not chat events. Do not expose
|
|
1402
|
+
# account metadata or upstream login errors to event subscribers.
|
|
1403
|
+
if method in {"account/login/completed", "account/updated"}:
|
|
1404
|
+
return
|
|
1405
|
+
params = notification.get("params")
|
|
1406
|
+
if not isinstance(params, dict):
|
|
1407
|
+
return
|
|
1408
|
+
|
|
1409
|
+
if method == "serverRequest/resolved":
|
|
1410
|
+
request_id = params.get("requestId")
|
|
1411
|
+
task = self._server_request_tasks.pop(request_id, None)
|
|
1412
|
+
if task:
|
|
1413
|
+
task.cancel()
|
|
1414
|
+
|
|
1415
|
+
thread_id, turn_id = _notification_scope(notification)
|
|
1416
|
+
if method == "thread/name/updated":
|
|
1417
|
+
self._thread_names.pop(thread_id, None)
|
|
1418
|
+
self._cache_thread_name({"id": thread_id, "name": params.get("threadName")})
|
|
1419
|
+
elif method == "thread/started":
|
|
1420
|
+
self._cache_thread_name(params.get("thread"))
|
|
1421
|
+
if method == "thread/started" and thread_id:
|
|
1422
|
+
self._loaded_threads.add(thread_id)
|
|
1423
|
+
elif method == "thread/closed" and thread_id:
|
|
1424
|
+
self._loaded_threads.discard(thread_id)
|
|
1425
|
+
|
|
1426
|
+
active_turn = self._turns_by_thread.get(thread_id)
|
|
1427
|
+
if (
|
|
1428
|
+
active_turn is not None
|
|
1429
|
+
and method == "turn/started"
|
|
1430
|
+
and turn_id in active_turn._completed_turn_ids
|
|
1431
|
+
):
|
|
1432
|
+
return
|
|
1433
|
+
if active_turn and turn_id:
|
|
1434
|
+
# A provisional turn may receive an item notification before the
|
|
1435
|
+
# turn/start response binds its id. Once bound, only an explicit
|
|
1436
|
+
# turn/started notification may move the handle to another turn.
|
|
1437
|
+
# Native goals and subagent results can start follow-up turns, so
|
|
1438
|
+
# keeping this id current is required for both event routing and
|
|
1439
|
+
# turn/interrupt to target the work that is actually running.
|
|
1440
|
+
if not active_turn.turn_id or method == "turn/started":
|
|
1441
|
+
active_turn.turn_id = turn_id
|
|
1442
|
+
if method == "turn/started":
|
|
1443
|
+
active_turn._completed = False
|
|
1444
|
+
if not active_turn._retain_thread_stream:
|
|
1445
|
+
active_turn._subscription.turn_id = turn_id
|
|
1446
|
+
|
|
1447
|
+
matched = False
|
|
1448
|
+
for subscription in tuple(self._subscriptions):
|
|
1449
|
+
if subscription._matches(notification):
|
|
1450
|
+
subscription._put(notification)
|
|
1451
|
+
matched = True
|
|
1452
|
+
if not matched:
|
|
1453
|
+
self._unmatched_notifications.append({
|
|
1454
|
+
"method": method,
|
|
1455
|
+
"thread_id": thread_id,
|
|
1456
|
+
"turn_id": turn_id,
|
|
1457
|
+
"at": time.time(),
|
|
1458
|
+
})
|
|
1459
|
+
|
|
1460
|
+
for handler in tuple(self._notification_handlers):
|
|
1461
|
+
owner = (handler, thread_id or "")
|
|
1462
|
+
previous = self._callback_tails.get(owner)
|
|
1463
|
+
if previous is None:
|
|
1464
|
+
try:
|
|
1465
|
+
result = handler(notification)
|
|
1466
|
+
except Exception:
|
|
1467
|
+
continue
|
|
1468
|
+
if not inspect.isawaitable(result):
|
|
1469
|
+
# Preserve the original inline contract for synchronous
|
|
1470
|
+
# caches consumed by immediately following server requests.
|
|
1471
|
+
continue
|
|
1472
|
+
task = asyncio.ensure_future(result)
|
|
1473
|
+
else:
|
|
1474
|
+
task = asyncio.create_task(
|
|
1475
|
+
self._invoke_notification_handler(
|
|
1476
|
+
handler,
|
|
1477
|
+
notification,
|
|
1478
|
+
previous,
|
|
1479
|
+
)
|
|
1480
|
+
)
|
|
1481
|
+
self._callback_tails[owner] = task
|
|
1482
|
+
self._callback_tasks.add(task)
|
|
1483
|
+
task.add_done_callback(
|
|
1484
|
+
lambda completed, owner=owner: self._finish_callback_task(
|
|
1485
|
+
owner,
|
|
1486
|
+
completed,
|
|
1487
|
+
)
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
# Synchronous handlers (including the scoped fork watcher) must see
|
|
1491
|
+
# whether this identity was known *before* this notification. Record a
|
|
1492
|
+
# genuinely new notification only after they have made that decision.
|
|
1493
|
+
if method == "thread/started" and thread_id:
|
|
1494
|
+
self._known_thread_ids.add(thread_id)
|
|
1495
|
+
|
|
1496
|
+
if method == "turn/completed" and active_turn:
|
|
1497
|
+
if not turn_id or not active_turn.turn_id or turn_id == active_turn.turn_id:
|
|
1498
|
+
active_turn._completed = True
|
|
1499
|
+
if turn_id:
|
|
1500
|
+
active_turn._completed_turn_ids.add(turn_id)
|
|
1501
|
+
# A retained handle owns the thread stream until close(), not
|
|
1502
|
+
# just its first native turn. Keep it available for the next
|
|
1503
|
+
# turn/started so Stop/steer cannot target the finished turn.
|
|
1504
|
+
if (
|
|
1505
|
+
not active_turn._retain_thread_stream
|
|
1506
|
+
and self._turns_by_thread.get(thread_id) is active_turn
|
|
1507
|
+
):
|
|
1508
|
+
self._turns_by_thread.pop(thread_id, None)
|
|
1509
|
+
if not active_turn._retain_thread_stream:
|
|
1510
|
+
active_turn._subscription._finish()
|
|
1511
|
+
if method == "turn/completed" and thread_id and turn_id:
|
|
1512
|
+
self._finish_scoped_subscriptions(thread_id, turn_id=turn_id)
|
|
1513
|
+
elif method == "thread/closed" and thread_id:
|
|
1514
|
+
self._finish_scoped_subscriptions(
|
|
1515
|
+
thread_id,
|
|
1516
|
+
include_thread_subscription=True,
|
|
1517
|
+
)
|
|
1518
|
+
|
|
1519
|
+
async def _invoke_notification_handler(
|
|
1520
|
+
self,
|
|
1521
|
+
handler: NotificationHandler,
|
|
1522
|
+
notification: dict[str, Any],
|
|
1523
|
+
previous: asyncio.Task[Any] | None,
|
|
1524
|
+
) -> None:
|
|
1525
|
+
if previous is not None:
|
|
1526
|
+
try:
|
|
1527
|
+
await previous
|
|
1528
|
+
except Exception:
|
|
1529
|
+
# One failed notification must not discard every later
|
|
1530
|
+
# notification already received for the same handler.
|
|
1531
|
+
pass
|
|
1532
|
+
try:
|
|
1533
|
+
result = handler(notification)
|
|
1534
|
+
except Exception:
|
|
1535
|
+
return
|
|
1536
|
+
if inspect.isawaitable(result):
|
|
1537
|
+
await result
|
|
1538
|
+
|
|
1539
|
+
def _finish_callback_task(
|
|
1540
|
+
self,
|
|
1541
|
+
owner: tuple[NotificationHandler, str],
|
|
1542
|
+
task: asyncio.Task[Any],
|
|
1543
|
+
) -> None:
|
|
1544
|
+
self._callback_tasks.discard(task)
|
|
1545
|
+
if self._callback_tails.get(owner) is task:
|
|
1546
|
+
self._callback_tails.pop(owner, None)
|
|
1547
|
+
if not task.cancelled():
|
|
1548
|
+
with suppress(Exception):
|
|
1549
|
+
task.result()
|
|
1550
|
+
|
|
1551
|
+
def _cancel_server_request_tasks(self) -> None:
|
|
1552
|
+
tasks = list(self._server_request_tasks.values())
|
|
1553
|
+
self._server_request_tasks.clear()
|
|
1554
|
+
for task in tasks:
|
|
1555
|
+
task.cancel()
|
|
1556
|
+
|
|
1557
|
+
def _start_server_request(
|
|
1558
|
+
self,
|
|
1559
|
+
request_id: Any,
|
|
1560
|
+
method: str,
|
|
1561
|
+
params: dict[str, Any],
|
|
1562
|
+
) -> None:
|
|
1563
|
+
previous = self._server_request_tasks.pop(request_id, None)
|
|
1564
|
+
if previous:
|
|
1565
|
+
previous.cancel()
|
|
1566
|
+
task = asyncio.create_task(
|
|
1567
|
+
self._handle_server_request(request_id, method, params),
|
|
1568
|
+
name=f"codex-app-server-request-{request_id}",
|
|
1569
|
+
)
|
|
1570
|
+
self._server_request_tasks[request_id] = task
|
|
1571
|
+
task.add_done_callback(
|
|
1572
|
+
lambda done, rid=request_id: self._finish_server_request(rid, done)
|
|
1573
|
+
)
|
|
1574
|
+
|
|
1575
|
+
def _finish_server_request(self, request_id: Any, task: asyncio.Task[None]) -> None:
|
|
1576
|
+
if self._server_request_tasks.get(request_id) is task:
|
|
1577
|
+
self._server_request_tasks.pop(request_id, None)
|
|
1578
|
+
if not task.cancelled():
|
|
1579
|
+
with suppress(Exception):
|
|
1580
|
+
task.result()
|
|
1581
|
+
|
|
1582
|
+
async def _handle_server_request(
|
|
1583
|
+
self,
|
|
1584
|
+
request_id: Any,
|
|
1585
|
+
method: str,
|
|
1586
|
+
params: dict[str, Any],
|
|
1587
|
+
) -> None:
|
|
1588
|
+
try:
|
|
1589
|
+
result = self._server_request_handler(request_id, method, params)
|
|
1590
|
+
if inspect.isawaitable(result):
|
|
1591
|
+
result = await result
|
|
1592
|
+
if not isinstance(result, dict):
|
|
1593
|
+
raise CodexAppServerProtocolError(
|
|
1594
|
+
f"server request handler returned non-object for {method}"
|
|
1595
|
+
)
|
|
1596
|
+
await self._send({"id": request_id, "result": result})
|
|
1597
|
+
except asyncio.CancelledError:
|
|
1598
|
+
# Another app-server client or a turn transition resolved it.
|
|
1599
|
+
return
|
|
1600
|
+
except CodexAppServerRequestError as exc:
|
|
1601
|
+
with suppress(CodexAppServerError):
|
|
1602
|
+
await self._send(
|
|
1603
|
+
{
|
|
1604
|
+
"id": request_id,
|
|
1605
|
+
"error": (
|
|
1606
|
+
exc.error
|
|
1607
|
+
if isinstance(exc.error, dict)
|
|
1608
|
+
else {"code": -32000, "message": str(exc)}
|
|
1609
|
+
),
|
|
1610
|
+
}
|
|
1611
|
+
)
|
|
1612
|
+
except Exception as exc:
|
|
1613
|
+
with suppress(CodexAppServerError):
|
|
1614
|
+
await self._send(
|
|
1615
|
+
{
|
|
1616
|
+
"id": request_id,
|
|
1617
|
+
"error": {"code": -32000, "message": str(exc)},
|
|
1618
|
+
}
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
async def start_thread(self, params: dict[str, Any]) -> str:
|
|
1622
|
+
result = await self.request(
|
|
1623
|
+
"thread/start",
|
|
1624
|
+
dict(params),
|
|
1625
|
+
timeout=self.lifecycle_timeout,
|
|
1626
|
+
)
|
|
1627
|
+
thread_id = self._thread_id_from_result("thread/start", result)
|
|
1628
|
+
self._cache_thread_name(result.get("thread"))
|
|
1629
|
+
self._known_thread_ids.add(thread_id)
|
|
1630
|
+
self._loaded_threads.add(thread_id)
|
|
1631
|
+
return thread_id
|
|
1632
|
+
|
|
1633
|
+
async def resume_thread(
|
|
1634
|
+
self,
|
|
1635
|
+
thread_id: str,
|
|
1636
|
+
params: dict[str, Any] | None = None,
|
|
1637
|
+
) -> str:
|
|
1638
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
1639
|
+
# Register before the first await: thread/started can be routed before
|
|
1640
|
+
# the resume response and must not look like this process's late fork.
|
|
1641
|
+
self._known_thread_ids.add(thread_id)
|
|
1642
|
+
payload = dict(params or {})
|
|
1643
|
+
payload["threadId"] = thread_id
|
|
1644
|
+
payload.setdefault("excludeTurns", True)
|
|
1645
|
+
result = await self.request(
|
|
1646
|
+
"thread/resume",
|
|
1647
|
+
payload,
|
|
1648
|
+
timeout=self.lifecycle_timeout,
|
|
1649
|
+
)
|
|
1650
|
+
resolved = self._thread_id_from_result("thread/resume", result)
|
|
1651
|
+
self._known_thread_ids.add(resolved)
|
|
1652
|
+
if resolved != thread_id:
|
|
1653
|
+
raise CodexAppServerProtocolError(
|
|
1654
|
+
"thread/resume returned a different thread id",
|
|
1655
|
+
request_sent=True,
|
|
1656
|
+
safe_to_retry=False,
|
|
1657
|
+
)
|
|
1658
|
+
self._cache_thread_name(result.get("thread"))
|
|
1659
|
+
self._loaded_threads.add(resolved)
|
|
1660
|
+
return resolved
|
|
1661
|
+
|
|
1662
|
+
async def fork_thread(
|
|
1663
|
+
self,
|
|
1664
|
+
thread_id: str,
|
|
1665
|
+
params: dict[str, Any] | None = None,
|
|
1666
|
+
*,
|
|
1667
|
+
last_turn_id: str | None = None,
|
|
1668
|
+
) -> str:
|
|
1669
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
1670
|
+
# The source itself is provider-owned even if it is currently unloaded.
|
|
1671
|
+
self._known_thread_ids.add(thread_id)
|
|
1672
|
+
source_lock = self._fork_source_locks.setdefault(
|
|
1673
|
+
thread_id,
|
|
1674
|
+
asyncio.Lock(),
|
|
1675
|
+
)
|
|
1676
|
+
async with source_lock:
|
|
1677
|
+
return await self._fork_thread_serialized(
|
|
1678
|
+
thread_id,
|
|
1679
|
+
params,
|
|
1680
|
+
last_turn_id=last_turn_id,
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
async def _fork_thread_serialized(
|
|
1684
|
+
self,
|
|
1685
|
+
thread_id: str,
|
|
1686
|
+
params: dict[str, Any] | None,
|
|
1687
|
+
*,
|
|
1688
|
+
last_turn_id: str | None,
|
|
1689
|
+
) -> str:
|
|
1690
|
+
payload = dict(params or {})
|
|
1691
|
+
payload["threadId"] = thread_id
|
|
1692
|
+
payload.setdefault("excludeTurns", True)
|
|
1693
|
+
if last_turn_id is not None:
|
|
1694
|
+
payload["lastTurnId"] = last_turn_id
|
|
1695
|
+
expected_cwd = str(payload.get("cwd") or "").strip()
|
|
1696
|
+
fork_started_at = int(time.time())
|
|
1697
|
+
late_children: asyncio.Queue[str] = asyncio.Queue()
|
|
1698
|
+
queued_late_children: set[str] = set()
|
|
1699
|
+
|
|
1700
|
+
def watch_started_fork(notification: dict[str, Any]) -> None:
|
|
1701
|
+
if notification.get("method") != "thread/started":
|
|
1702
|
+
return
|
|
1703
|
+
notification_params = notification.get("params")
|
|
1704
|
+
thread = (
|
|
1705
|
+
notification_params.get("thread")
|
|
1706
|
+
if isinstance(notification_params, dict)
|
|
1707
|
+
else None
|
|
1708
|
+
)
|
|
1709
|
+
if not isinstance(thread, dict):
|
|
1710
|
+
return
|
|
1711
|
+
child_id = str(thread.get("id") or "").strip()
|
|
1712
|
+
forked_from_id = str(thread.get("forkedFromId") or "").strip()
|
|
1713
|
+
child_cwd = str(thread.get("cwd") or "").strip()
|
|
1714
|
+
created_at = thread.get("createdAt")
|
|
1715
|
+
if (
|
|
1716
|
+
child_id
|
|
1717
|
+
and child_id != thread_id
|
|
1718
|
+
and forked_from_id == thread_id
|
|
1719
|
+
and child_id not in self._known_thread_ids
|
|
1720
|
+
and child_id not in queued_late_children
|
|
1721
|
+
# Destructive cleanup requires request-local evidence. An old
|
|
1722
|
+
# fork resumed after an app-server reconnect can share the same
|
|
1723
|
+
# ancestry but cannot share this creation window.
|
|
1724
|
+
and expected_cwd
|
|
1725
|
+
and child_cwd
|
|
1726
|
+
and os.path.realpath(child_cwd) == os.path.realpath(expected_cwd)
|
|
1727
|
+
and isinstance(created_at, int)
|
|
1728
|
+
and not isinstance(created_at, bool)
|
|
1729
|
+
and fork_started_at - 1 <= created_at <= int(time.time()) + 1
|
|
1730
|
+
):
|
|
1731
|
+
queued_late_children.add(child_id)
|
|
1732
|
+
late_children.put_nowait(child_id)
|
|
1733
|
+
|
|
1734
|
+
# This must be installed before the request is written. A fast provider
|
|
1735
|
+
# can announce the child before returning the JSON-RPC response.
|
|
1736
|
+
self.add_notification_handler(watch_started_fork)
|
|
1737
|
+
try:
|
|
1738
|
+
try:
|
|
1739
|
+
result = await self.request(
|
|
1740
|
+
"thread/fork",
|
|
1741
|
+
payload,
|
|
1742
|
+
timeout=self.lifecycle_timeout,
|
|
1743
|
+
)
|
|
1744
|
+
except BaseException as exc:
|
|
1745
|
+
ambiguous = isinstance(
|
|
1746
|
+
exc,
|
|
1747
|
+
(asyncio.CancelledError, CodexAppServerTimeout),
|
|
1748
|
+
) or (
|
|
1749
|
+
isinstance(exc, CodexAppServerDisconnected)
|
|
1750
|
+
and exc.request_sent
|
|
1751
|
+
)
|
|
1752
|
+
if ambiguous:
|
|
1753
|
+
cleanup = asyncio.create_task(
|
|
1754
|
+
self._delete_late_fork_child(late_children)
|
|
1755
|
+
)
|
|
1756
|
+
try:
|
|
1757
|
+
unretired_thread_ids = await self._join_shielded_task(
|
|
1758
|
+
cleanup
|
|
1759
|
+
)
|
|
1760
|
+
except BaseException:
|
|
1761
|
+
# The timeout/cancellation is the actionable transport
|
|
1762
|
+
# result. A cleanup failure before a child identity is
|
|
1763
|
+
# known must not hide it.
|
|
1764
|
+
pass
|
|
1765
|
+
else:
|
|
1766
|
+
if unretired_thread_ids:
|
|
1767
|
+
# Preserve the original timeout/cancellation while
|
|
1768
|
+
# handing the known provider identity to the owning
|
|
1769
|
+
# layer for durable cleanup. A tuple prevents later
|
|
1770
|
+
# mutation while the exception crosses task layers.
|
|
1771
|
+
exc.unretired_fork_thread_ids = tuple(
|
|
1772
|
+
unretired_thread_ids
|
|
1773
|
+
)
|
|
1774
|
+
raise
|
|
1775
|
+
finally:
|
|
1776
|
+
self.remove_notification_handler(watch_started_fork)
|
|
1777
|
+
resolved = self._thread_id_from_result("thread/fork", result)
|
|
1778
|
+
self._known_thread_ids.add(resolved)
|
|
1779
|
+
if resolved == thread_id:
|
|
1780
|
+
raise CodexAppServerProtocolError(
|
|
1781
|
+
"thread/fork returned the source thread id",
|
|
1782
|
+
request_sent=True,
|
|
1783
|
+
safe_to_retry=False,
|
|
1784
|
+
)
|
|
1785
|
+
thread = result.get("thread") if isinstance(result, dict) else None
|
|
1786
|
+
forked_from_id = (
|
|
1787
|
+
thread.get("forkedFromId") if isinstance(thread, dict) else None
|
|
1788
|
+
)
|
|
1789
|
+
if forked_from_id is not None and forked_from_id != thread_id:
|
|
1790
|
+
error = CodexAppServerProtocolError(
|
|
1791
|
+
"thread/fork returned a thread with a different source id",
|
|
1792
|
+
request_sent=True,
|
|
1793
|
+
safe_to_retry=False,
|
|
1794
|
+
)
|
|
1795
|
+
cleanup = asyncio.create_task(self.delete_thread(resolved))
|
|
1796
|
+
try:
|
|
1797
|
+
await self._join_shielded_task(cleanup)
|
|
1798
|
+
except BaseException:
|
|
1799
|
+
# Preserve the ancestry violation even when an older provider
|
|
1800
|
+
# cannot delete the untrusted child.
|
|
1801
|
+
pass
|
|
1802
|
+
raise error
|
|
1803
|
+
self._loaded_threads.add(resolved)
|
|
1804
|
+
return resolved
|
|
1805
|
+
|
|
1806
|
+
async def _delete_late_fork_child(
|
|
1807
|
+
self,
|
|
1808
|
+
late_children: asyncio.Queue[str],
|
|
1809
|
+
) -> tuple[str, ...]:
|
|
1810
|
+
"""Delete an announced child or return identities needing retirement."""
|
|
1811
|
+
|
|
1812
|
+
try:
|
|
1813
|
+
child_id = await asyncio.wait_for(
|
|
1814
|
+
late_children.get(),
|
|
1815
|
+
timeout=self.fork_cleanup_grace,
|
|
1816
|
+
)
|
|
1817
|
+
except asyncio.TimeoutError:
|
|
1818
|
+
return ()
|
|
1819
|
+
# Drain notifications already routed in this event-loop turn. If more
|
|
1820
|
+
# than one request-correlated identity exists, ownership is ambiguous;
|
|
1821
|
+
# leak an unexposed provider fork rather than deleting user history.
|
|
1822
|
+
await asyncio.sleep(0)
|
|
1823
|
+
candidates = {child_id}
|
|
1824
|
+
while not late_children.empty():
|
|
1825
|
+
candidates.add(late_children.get_nowait())
|
|
1826
|
+
if len(candidates) != 1:
|
|
1827
|
+
return ()
|
|
1828
|
+
try:
|
|
1829
|
+
await self.delete_thread(child_id)
|
|
1830
|
+
except BaseException:
|
|
1831
|
+
return (child_id,)
|
|
1832
|
+
return ()
|
|
1833
|
+
|
|
1834
|
+
@staticmethod
|
|
1835
|
+
async def _join_shielded_task(task: asyncio.Task[Any]) -> Any:
|
|
1836
|
+
"""Join cleanup even if the caller receives repeated cancellation."""
|
|
1837
|
+
|
|
1838
|
+
while True:
|
|
1839
|
+
try:
|
|
1840
|
+
return await asyncio.shield(task)
|
|
1841
|
+
except asyncio.CancelledError:
|
|
1842
|
+
if task.done():
|
|
1843
|
+
return task.result()
|
|
1844
|
+
|
|
1845
|
+
@staticmethod
|
|
1846
|
+
def _thread_id_from_result(method: str, result: Any) -> str:
|
|
1847
|
+
thread = result.get("thread") if isinstance(result, dict) else None
|
|
1848
|
+
resolved = str(thread.get("id") or "") if isinstance(thread, dict) else ""
|
|
1849
|
+
if not resolved.strip():
|
|
1850
|
+
raise CodexAppServerProtocolError(
|
|
1851
|
+
f"{method} did not return a thread id",
|
|
1852
|
+
request_sent=True,
|
|
1853
|
+
safe_to_retry=False,
|
|
1854
|
+
)
|
|
1855
|
+
return resolved
|
|
1856
|
+
|
|
1857
|
+
async def start_or_resume_thread(
|
|
1858
|
+
self,
|
|
1859
|
+
thread_id: str | None,
|
|
1860
|
+
start_params: dict[str, Any],
|
|
1861
|
+
resume_params: dict[str, Any] | None = None,
|
|
1862
|
+
) -> str:
|
|
1863
|
+
if thread_id:
|
|
1864
|
+
return await self.resume_thread(
|
|
1865
|
+
thread_id,
|
|
1866
|
+
resume_params if resume_params is not None else start_params,
|
|
1867
|
+
)
|
|
1868
|
+
return await self.start_thread(start_params)
|
|
1869
|
+
|
|
1870
|
+
async def inject_items(
|
|
1871
|
+
self,
|
|
1872
|
+
thread_id: str,
|
|
1873
|
+
items: list[dict[str, Any]],
|
|
1874
|
+
) -> None:
|
|
1875
|
+
await self.request(
|
|
1876
|
+
"thread/inject_items",
|
|
1877
|
+
{"threadId": thread_id, "items": items},
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
async def inject_items_guarded(
|
|
1881
|
+
self,
|
|
1882
|
+
thread_id: str,
|
|
1883
|
+
items: list[dict[str, Any]],
|
|
1884
|
+
*,
|
|
1885
|
+
expected_generation: int,
|
|
1886
|
+
before_send: Callable[[], bool],
|
|
1887
|
+
timeout: float | None = None,
|
|
1888
|
+
) -> None:
|
|
1889
|
+
"""Append one runtime notice to an already-loaded, exact-owned thread.
|
|
1890
|
+
|
|
1891
|
+
Never starts/reconnects a provider or changes its turn. The caller owns
|
|
1892
|
+
notice coalescing; a sent request with no definite acknowledgement (or
|
|
1893
|
+
cancellation during delivery) must not be blindly replayed. A notice
|
|
1894
|
+
write timeout must not retire the busy provider shared by other work.
|
|
1895
|
+
"""
|
|
1896
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
1897
|
+
if type(expected_generation) is not int or expected_generation < 1 or not callable(before_send):
|
|
1898
|
+
raise ValueError("exact generation and synchronous ownership guard are required")
|
|
1899
|
+
if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict):
|
|
1900
|
+
raise ValueError("one generated developer notice is required")
|
|
1901
|
+
item = items[0]
|
|
1902
|
+
content = item.get("content")
|
|
1903
|
+
if (item.get("type") != "message" or item.get("role") != "developer"
|
|
1904
|
+
or not isinstance(content, list) or len(content) != 1 or not isinstance(content[0], dict)
|
|
1905
|
+
or content[0].get("type") != "input_text" or not isinstance(content[0].get("text"), str)
|
|
1906
|
+
or not content[0]["text"].strip()
|
|
1907
|
+
or len(content[0]["text"].encode("utf-8", "surrogatepass")) > 2048):
|
|
1908
|
+
raise ValueError("runtime notices require bounded generated developer input_text")
|
|
1909
|
+
# Copy the only allowed fields before waiting for the shared writer.
|
|
1910
|
+
notice = {"type": "message", "role": "developer", "content": [
|
|
1911
|
+
{"type": "input_text", "text": content[0]["text"]},
|
|
1912
|
+
]}
|
|
1913
|
+
|
|
1914
|
+
def owns_loaded_thread() -> bool:
|
|
1915
|
+
return (self.ready and self._generation == expected_generation
|
|
1916
|
+
and thread_id in self._loaded_threads)
|
|
1917
|
+
|
|
1918
|
+
if not owns_loaded_thread():
|
|
1919
|
+
raise CodexAppServerProtocolError(
|
|
1920
|
+
"runtime notice owner is unavailable", request_sent=False, safe_to_retry=True,
|
|
1921
|
+
)
|
|
1922
|
+
result = await self._request_connected(
|
|
1923
|
+
"thread/inject_items", {"threadId": thread_id, "items": [notice]},
|
|
1924
|
+
timeout=timeout, discard_on_send_timeout=False,
|
|
1925
|
+
before_send=lambda: owns_loaded_thread() and before_send() is True,
|
|
1926
|
+
)
|
|
1927
|
+
if not isinstance(result, dict):
|
|
1928
|
+
raise CodexAppServerProtocolError(
|
|
1929
|
+
"runtime notice acknowledgement is invalid", request_sent=True, safe_to_retry=False,
|
|
1930
|
+
)
|
|
1931
|
+
|
|
1932
|
+
async def read_thread(
|
|
1933
|
+
self,
|
|
1934
|
+
thread_id: str,
|
|
1935
|
+
*,
|
|
1936
|
+
include_turns: bool = False,
|
|
1937
|
+
) -> dict[str, Any]:
|
|
1938
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
1939
|
+
# A read is sufficient proof that the provider identity predates any
|
|
1940
|
+
# concurrent fork watcher, regardless of current load state.
|
|
1941
|
+
self._known_thread_ids.add(thread_id)
|
|
1942
|
+
result = await self.request(
|
|
1943
|
+
"thread/read",
|
|
1944
|
+
{"threadId": thread_id, "includeTurns": include_turns},
|
|
1945
|
+
)
|
|
1946
|
+
thread = result.get("thread") if isinstance(result, dict) else None
|
|
1947
|
+
returned_thread_id = (
|
|
1948
|
+
str(thread.get("id") or "").strip()
|
|
1949
|
+
if isinstance(thread, dict)
|
|
1950
|
+
else ""
|
|
1951
|
+
)
|
|
1952
|
+
if returned_thread_id:
|
|
1953
|
+
self._known_thread_ids.add(returned_thread_id)
|
|
1954
|
+
if not isinstance(thread, dict) or returned_thread_id != thread_id:
|
|
1955
|
+
raise CodexAppServerProtocolError(
|
|
1956
|
+
"thread/read did not return the requested thread",
|
|
1957
|
+
request_sent=True,
|
|
1958
|
+
safe_to_retry=False,
|
|
1959
|
+
)
|
|
1960
|
+
self._cache_thread_name(thread)
|
|
1961
|
+
return thread
|
|
1962
|
+
|
|
1963
|
+
async def delete_thread(self, thread_id: str) -> None:
|
|
1964
|
+
"""Permanently remove a newly-created, unexposed provider thread."""
|
|
1965
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
1966
|
+
method = "thread/delete"
|
|
1967
|
+
_protocol_empty_object(
|
|
1968
|
+
method,
|
|
1969
|
+
await self.request(
|
|
1970
|
+
method,
|
|
1971
|
+
{"threadId": thread_id},
|
|
1972
|
+
timeout=self.lifecycle_timeout,
|
|
1973
|
+
),
|
|
1974
|
+
)
|
|
1975
|
+
self._loaded_threads.discard(thread_id)
|
|
1976
|
+
self._known_thread_ids.discard(thread_id)
|
|
1977
|
+
|
|
1978
|
+
async def list_turns(
|
|
1979
|
+
self,
|
|
1980
|
+
thread_id: str,
|
|
1981
|
+
*,
|
|
1982
|
+
limit: int = 4,
|
|
1983
|
+
items_view: str = "full",
|
|
1984
|
+
sort_direction: str = "desc",
|
|
1985
|
+
) -> list[dict[str, Any]]:
|
|
1986
|
+
result = await self.request(
|
|
1987
|
+
"thread/turns/list",
|
|
1988
|
+
{
|
|
1989
|
+
"threadId": thread_id,
|
|
1990
|
+
"limit": max(1, int(limit)),
|
|
1991
|
+
"itemsView": items_view,
|
|
1992
|
+
"sortDirection": sort_direction,
|
|
1993
|
+
},
|
|
1994
|
+
)
|
|
1995
|
+
data = result.get("data") if isinstance(result, dict) else None
|
|
1996
|
+
if not isinstance(data, list):
|
|
1997
|
+
raise CodexAppServerProtocolError(
|
|
1998
|
+
"thread/turns/list did not return a turn list",
|
|
1999
|
+
request_sent=True,
|
|
2000
|
+
safe_to_retry=False,
|
|
2001
|
+
)
|
|
2002
|
+
return [turn for turn in data if isinstance(turn, dict)]
|
|
2003
|
+
|
|
2004
|
+
async def list_descendant_threads(
|
|
2005
|
+
self,
|
|
2006
|
+
thread_id: str,
|
|
2007
|
+
*,
|
|
2008
|
+
page_size: int = 100,
|
|
2009
|
+
) -> list[dict[str, Any]]:
|
|
2010
|
+
"""Return every persisted subagent descendant of ``thread_id``.
|
|
2011
|
+
|
|
2012
|
+
Relationship filters are part of app-server's experimental API. The
|
|
2013
|
+
client enables that capability during initialization, so using the
|
|
2014
|
+
ancestor filter is both cheaper and more accurate than reconstructing
|
|
2015
|
+
the spawn tree from transcript items.
|
|
2016
|
+
"""
|
|
2017
|
+
|
|
2018
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2019
|
+
if isinstance(page_size, bool) or not isinstance(page_size, int) or page_size < 1:
|
|
2020
|
+
raise ValueError("page_size must be a positive integer")
|
|
2021
|
+
|
|
2022
|
+
method = "thread/list"
|
|
2023
|
+
descendants: list[dict[str, Any]] = []
|
|
2024
|
+
cursor: str | None = None
|
|
2025
|
+
seen_cursors: set[str] = set()
|
|
2026
|
+
while True:
|
|
2027
|
+
params: dict[str, Any] = {
|
|
2028
|
+
"ancestorThreadId": thread_id,
|
|
2029
|
+
"limit": page_size,
|
|
2030
|
+
"useStateDbOnly": True,
|
|
2031
|
+
# Relationship-filtered requests must opt into subagent source
|
|
2032
|
+
# kinds. The ordinary omitted/default source filter is limited
|
|
2033
|
+
# to interactive threads on older app-server releases.
|
|
2034
|
+
"sourceKinds": [
|
|
2035
|
+
"subAgent",
|
|
2036
|
+
"subAgentReview",
|
|
2037
|
+
"subAgentCompact",
|
|
2038
|
+
"subAgentThreadSpawn",
|
|
2039
|
+
"subAgentOther",
|
|
2040
|
+
],
|
|
2041
|
+
}
|
|
2042
|
+
if cursor is not None:
|
|
2043
|
+
params["cursor"] = cursor
|
|
2044
|
+
result = _protocol_object(method, await self.request(method, params))
|
|
2045
|
+
data = result.get("data")
|
|
2046
|
+
if not isinstance(data, list):
|
|
2047
|
+
raise CodexAppServerProtocolError(
|
|
2048
|
+
f"{method} did not return a thread list",
|
|
2049
|
+
request_sent=True,
|
|
2050
|
+
safe_to_retry=False,
|
|
2051
|
+
)
|
|
2052
|
+
for thread in data:
|
|
2053
|
+
if not isinstance(thread, dict) or not str(thread.get("id") or "").strip():
|
|
2054
|
+
raise CodexAppServerProtocolError(
|
|
2055
|
+
f"{method} returned an invalid thread",
|
|
2056
|
+
request_sent=True,
|
|
2057
|
+
safe_to_retry=False,
|
|
2058
|
+
)
|
|
2059
|
+
self._known_thread_ids.add(str(thread["id"]).strip())
|
|
2060
|
+
descendants.append(dict(thread))
|
|
2061
|
+
|
|
2062
|
+
cursor = _protocol_cursor(method, result)
|
|
2063
|
+
if cursor is None:
|
|
2064
|
+
return descendants
|
|
2065
|
+
if cursor in seen_cursors:
|
|
2066
|
+
raise CodexAppServerProtocolError(
|
|
2067
|
+
f"{method} repeated a pagination cursor",
|
|
2068
|
+
request_sent=True,
|
|
2069
|
+
safe_to_retry=False,
|
|
2070
|
+
)
|
|
2071
|
+
seen_cursors.add(cursor)
|
|
2072
|
+
|
|
2073
|
+
async def list_permission_profiles(
|
|
2074
|
+
self,
|
|
2075
|
+
*,
|
|
2076
|
+
cwd: str | None = None,
|
|
2077
|
+
page_size: int | None = None,
|
|
2078
|
+
) -> list[dict[str, Any]]:
|
|
2079
|
+
"""Return every selectable permission profile across cursor pages."""
|
|
2080
|
+
|
|
2081
|
+
if cwd is not None:
|
|
2082
|
+
_require_nonempty_string(cwd, "cwd")
|
|
2083
|
+
if page_size is not None and (
|
|
2084
|
+
isinstance(page_size, bool)
|
|
2085
|
+
or not isinstance(page_size, int)
|
|
2086
|
+
or page_size < 1
|
|
2087
|
+
):
|
|
2088
|
+
raise ValueError("page_size must be a positive integer")
|
|
2089
|
+
|
|
2090
|
+
profiles: list[dict[str, Any]] = []
|
|
2091
|
+
cursor: str | None = None
|
|
2092
|
+
seen_cursors: set[str] = set()
|
|
2093
|
+
while True:
|
|
2094
|
+
params: dict[str, Any] = {}
|
|
2095
|
+
if cwd is not None:
|
|
2096
|
+
params["cwd"] = cwd
|
|
2097
|
+
if page_size is not None:
|
|
2098
|
+
params["limit"] = page_size
|
|
2099
|
+
if cursor is not None:
|
|
2100
|
+
params["cursor"] = cursor
|
|
2101
|
+
|
|
2102
|
+
method = "permissionProfile/list"
|
|
2103
|
+
result = _protocol_object(method, await self.request(method, params))
|
|
2104
|
+
data = result.get("data")
|
|
2105
|
+
if not isinstance(data, list):
|
|
2106
|
+
raise CodexAppServerProtocolError(
|
|
2107
|
+
f"{method} did not return a profile list",
|
|
2108
|
+
request_sent=True,
|
|
2109
|
+
safe_to_retry=False,
|
|
2110
|
+
)
|
|
2111
|
+
for profile in data:
|
|
2112
|
+
if not isinstance(profile, dict):
|
|
2113
|
+
raise CodexAppServerProtocolError(
|
|
2114
|
+
f"{method} returned a non-object profile",
|
|
2115
|
+
request_sent=True,
|
|
2116
|
+
safe_to_retry=False,
|
|
2117
|
+
)
|
|
2118
|
+
profile_id = profile.get("id")
|
|
2119
|
+
allowed = profile.get("allowed")
|
|
2120
|
+
description = profile.get("description")
|
|
2121
|
+
if not isinstance(profile_id, str) or not profile_id:
|
|
2122
|
+
raise CodexAppServerProtocolError(
|
|
2123
|
+
f"{method} returned a profile without an id",
|
|
2124
|
+
request_sent=True,
|
|
2125
|
+
safe_to_retry=False,
|
|
2126
|
+
)
|
|
2127
|
+
if not isinstance(allowed, bool) or (
|
|
2128
|
+
description is not None and not isinstance(description, str)
|
|
2129
|
+
):
|
|
2130
|
+
raise CodexAppServerProtocolError(
|
|
2131
|
+
f"{method} returned an invalid profile",
|
|
2132
|
+
request_sent=True,
|
|
2133
|
+
safe_to_retry=False,
|
|
2134
|
+
)
|
|
2135
|
+
profiles.append(dict(profile))
|
|
2136
|
+
|
|
2137
|
+
cursor = _protocol_cursor(method, result)
|
|
2138
|
+
if cursor is None:
|
|
2139
|
+
return profiles
|
|
2140
|
+
if cursor in seen_cursors:
|
|
2141
|
+
raise CodexAppServerProtocolError(
|
|
2142
|
+
f"{method} repeated a pagination cursor",
|
|
2143
|
+
request_sent=True,
|
|
2144
|
+
safe_to_retry=False,
|
|
2145
|
+
)
|
|
2146
|
+
seen_cursors.add(cursor)
|
|
2147
|
+
|
|
2148
|
+
async def set_thread_goal(
|
|
2149
|
+
self,
|
|
2150
|
+
thread_id: str,
|
|
2151
|
+
*,
|
|
2152
|
+
objective: str | None | _OmittedType = _OMITTED,
|
|
2153
|
+
status: str | None | _OmittedType = _OMITTED,
|
|
2154
|
+
token_budget: int | None | _OmittedType = _OMITTED,
|
|
2155
|
+
) -> dict[str, Any]:
|
|
2156
|
+
"""Set or update the native persisted goal for a thread."""
|
|
2157
|
+
|
|
2158
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2159
|
+
params: dict[str, Any] = {"threadId": thread_id}
|
|
2160
|
+
if objective is not _OMITTED:
|
|
2161
|
+
if objective is not None:
|
|
2162
|
+
if not isinstance(objective, str) or not objective.strip():
|
|
2163
|
+
raise ValueError("objective must be a non-empty string")
|
|
2164
|
+
if len(objective) > 4000:
|
|
2165
|
+
raise ValueError("objective must be at most 4000 characters")
|
|
2166
|
+
params["objective"] = objective
|
|
2167
|
+
if status is not _OMITTED:
|
|
2168
|
+
if status is not None and status not in _THREAD_GOAL_STATUSES:
|
|
2169
|
+
raise ValueError(f"invalid thread goal status: {status}")
|
|
2170
|
+
params["status"] = status
|
|
2171
|
+
if token_budget is not _OMITTED:
|
|
2172
|
+
if token_budget is not None and (
|
|
2173
|
+
isinstance(token_budget, bool) or not isinstance(token_budget, int)
|
|
2174
|
+
):
|
|
2175
|
+
raise ValueError("token_budget must be an integer or null")
|
|
2176
|
+
params["tokenBudget"] = token_budget
|
|
2177
|
+
|
|
2178
|
+
method = "thread/goal/set"
|
|
2179
|
+
result = _protocol_object(method, await self.request(method, params))
|
|
2180
|
+
return _protocol_thread_goal(
|
|
2181
|
+
method,
|
|
2182
|
+
result.get("goal"),
|
|
2183
|
+
thread_id=thread_id,
|
|
2184
|
+
)
|
|
2185
|
+
|
|
2186
|
+
async def get_thread_goal(self, thread_id: str) -> dict[str, Any] | None:
|
|
2187
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2188
|
+
method = "thread/goal/get"
|
|
2189
|
+
result = _protocol_object(
|
|
2190
|
+
method,
|
|
2191
|
+
await self.request(method, {"threadId": thread_id}),
|
|
2192
|
+
)
|
|
2193
|
+
goal = result.get("goal")
|
|
2194
|
+
if goal is None:
|
|
2195
|
+
return None
|
|
2196
|
+
return _protocol_thread_goal(method, goal, thread_id=thread_id)
|
|
2197
|
+
|
|
2198
|
+
async def clear_thread_goal(self, thread_id: str) -> bool:
|
|
2199
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2200
|
+
method = "thread/goal/clear"
|
|
2201
|
+
result = _protocol_object(
|
|
2202
|
+
method,
|
|
2203
|
+
await self.request(method, {"threadId": thread_id}),
|
|
2204
|
+
)
|
|
2205
|
+
cleared = result.get("cleared")
|
|
2206
|
+
if not isinstance(cleared, bool):
|
|
2207
|
+
raise CodexAppServerProtocolError(
|
|
2208
|
+
f"{method} did not return a cleared flag",
|
|
2209
|
+
request_sent=True,
|
|
2210
|
+
safe_to_retry=False,
|
|
2211
|
+
)
|
|
2212
|
+
return cleared
|
|
2213
|
+
|
|
2214
|
+
async def compact_thread(self, thread_id: str) -> None:
|
|
2215
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2216
|
+
method = "thread/compact/start"
|
|
2217
|
+
_protocol_empty_object(
|
|
2218
|
+
method,
|
|
2219
|
+
await self.request(method, {"threadId": thread_id}),
|
|
2220
|
+
)
|
|
2221
|
+
|
|
2222
|
+
async def rollback_thread(
|
|
2223
|
+
self,
|
|
2224
|
+
thread_id: str,
|
|
2225
|
+
*,
|
|
2226
|
+
num_turns: int,
|
|
2227
|
+
) -> dict[str, Any]:
|
|
2228
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2229
|
+
if (
|
|
2230
|
+
isinstance(num_turns, bool)
|
|
2231
|
+
or not isinstance(num_turns, int)
|
|
2232
|
+
or num_turns < 1
|
|
2233
|
+
):
|
|
2234
|
+
raise ValueError("num_turns must be a positive integer")
|
|
2235
|
+
method = "thread/rollback"
|
|
2236
|
+
result = _protocol_object(
|
|
2237
|
+
method,
|
|
2238
|
+
await self.request(
|
|
2239
|
+
method,
|
|
2240
|
+
{"threadId": thread_id, "numTurns": num_turns},
|
|
2241
|
+
timeout=self.lifecycle_timeout,
|
|
2242
|
+
),
|
|
2243
|
+
)
|
|
2244
|
+
thread = result.get("thread")
|
|
2245
|
+
if not isinstance(thread, dict) or thread.get("id") != thread_id:
|
|
2246
|
+
raise CodexAppServerProtocolError(
|
|
2247
|
+
f"{method} did not return the requested thread",
|
|
2248
|
+
request_sent=True,
|
|
2249
|
+
safe_to_retry=False,
|
|
2250
|
+
)
|
|
2251
|
+
return dict(thread)
|
|
2252
|
+
|
|
2253
|
+
async def start_review(
|
|
2254
|
+
self,
|
|
2255
|
+
thread_id: str,
|
|
2256
|
+
target: dict[str, Any],
|
|
2257
|
+
*,
|
|
2258
|
+
delivery: str = "inline",
|
|
2259
|
+
) -> dict[str, Any]:
|
|
2260
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2261
|
+
if delivery not in _REVIEW_DELIVERIES:
|
|
2262
|
+
raise ValueError(f"invalid review delivery: {delivery}")
|
|
2263
|
+
if not isinstance(target, dict):
|
|
2264
|
+
raise ValueError("review target must be an object")
|
|
2265
|
+
review_target = dict(target)
|
|
2266
|
+
target_type = review_target.get("type")
|
|
2267
|
+
if target_type not in _REVIEW_TARGET_TYPES:
|
|
2268
|
+
raise ValueError(f"invalid review target type: {target_type}")
|
|
2269
|
+
if target_type == "baseBranch":
|
|
2270
|
+
_require_nonempty_string(review_target.get("branch"), "target.branch")
|
|
2271
|
+
elif target_type == "commit":
|
|
2272
|
+
_require_nonempty_string(review_target.get("sha"), "target.sha")
|
|
2273
|
+
title = review_target.get("title")
|
|
2274
|
+
if title is not None and not isinstance(title, str):
|
|
2275
|
+
raise ValueError("target.title must be a string or null")
|
|
2276
|
+
elif target_type == "custom":
|
|
2277
|
+
_require_nonempty_string(
|
|
2278
|
+
review_target.get("instructions"),
|
|
2279
|
+
"target.instructions",
|
|
2280
|
+
)
|
|
2281
|
+
|
|
2282
|
+
method = "review/start"
|
|
2283
|
+
result = _protocol_object(
|
|
2284
|
+
method,
|
|
2285
|
+
await self.request(
|
|
2286
|
+
method,
|
|
2287
|
+
{
|
|
2288
|
+
"threadId": thread_id,
|
|
2289
|
+
"target": review_target,
|
|
2290
|
+
"delivery": delivery,
|
|
2291
|
+
},
|
|
2292
|
+
timeout=self.lifecycle_timeout,
|
|
2293
|
+
),
|
|
2294
|
+
)
|
|
2295
|
+
review_thread_id = result.get("reviewThreadId")
|
|
2296
|
+
if not isinstance(review_thread_id, str) or not review_thread_id:
|
|
2297
|
+
raise CodexAppServerProtocolError(
|
|
2298
|
+
f"{method} did not return a review thread id",
|
|
2299
|
+
request_sent=True,
|
|
2300
|
+
safe_to_retry=False,
|
|
2301
|
+
)
|
|
2302
|
+
if delivery == "inline" and review_thread_id != thread_id:
|
|
2303
|
+
raise CodexAppServerProtocolError(
|
|
2304
|
+
f"{method} returned a detached thread for an inline review",
|
|
2305
|
+
request_sent=True,
|
|
2306
|
+
safe_to_retry=False,
|
|
2307
|
+
)
|
|
2308
|
+
validated = dict(result)
|
|
2309
|
+
validated["turn"] = _protocol_turn(method, result.get("turn"))
|
|
2310
|
+
return validated
|
|
2311
|
+
|
|
2312
|
+
async def run_thread_shell_command(self, thread_id: str, command: str) -> None:
|
|
2313
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2314
|
+
command = _require_nonempty_string(command, "command")
|
|
2315
|
+
method = "thread/shellCommand"
|
|
2316
|
+
_protocol_empty_object(
|
|
2317
|
+
method,
|
|
2318
|
+
await self.request(
|
|
2319
|
+
method,
|
|
2320
|
+
{"threadId": thread_id, "command": command},
|
|
2321
|
+
),
|
|
2322
|
+
)
|
|
2323
|
+
|
|
2324
|
+
async def list_background_terminals(
|
|
2325
|
+
self,
|
|
2326
|
+
thread_id: str,
|
|
2327
|
+
*,
|
|
2328
|
+
page_size: int | None = None,
|
|
2329
|
+
) -> list[dict[str, Any]]:
|
|
2330
|
+
"""Return every live background terminal across cursor pages."""
|
|
2331
|
+
|
|
2332
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2333
|
+
if page_size is not None and (
|
|
2334
|
+
isinstance(page_size, bool)
|
|
2335
|
+
or not isinstance(page_size, int)
|
|
2336
|
+
or page_size < 1
|
|
2337
|
+
):
|
|
2338
|
+
raise ValueError("page_size must be a positive integer")
|
|
2339
|
+
|
|
2340
|
+
method = "thread/backgroundTerminals/list"
|
|
2341
|
+
terminals: list[dict[str, Any]] = []
|
|
2342
|
+
cursor: str | None = None
|
|
2343
|
+
seen_cursors: set[str] = set()
|
|
2344
|
+
while True:
|
|
2345
|
+
params: dict[str, Any] = {"threadId": thread_id}
|
|
2346
|
+
if page_size is not None:
|
|
2347
|
+
params["limit"] = page_size
|
|
2348
|
+
if cursor is not None:
|
|
2349
|
+
params["cursor"] = cursor
|
|
2350
|
+
result = _protocol_object(method, await self.request(method, params))
|
|
2351
|
+
data = result.get("data")
|
|
2352
|
+
if not isinstance(data, list):
|
|
2353
|
+
raise CodexAppServerProtocolError(
|
|
2354
|
+
f"{method} did not return a terminal list",
|
|
2355
|
+
request_sent=True,
|
|
2356
|
+
safe_to_retry=False,
|
|
2357
|
+
)
|
|
2358
|
+
for terminal in data:
|
|
2359
|
+
if not isinstance(terminal, dict):
|
|
2360
|
+
raise CodexAppServerProtocolError(
|
|
2361
|
+
f"{method} returned a non-object terminal",
|
|
2362
|
+
request_sent=True,
|
|
2363
|
+
safe_to_retry=False,
|
|
2364
|
+
)
|
|
2365
|
+
for field in ("itemId", "processId", "command", "cwd"):
|
|
2366
|
+
if not isinstance(terminal.get(field), str):
|
|
2367
|
+
raise CodexAppServerProtocolError(
|
|
2368
|
+
f"{method} returned a terminal with invalid {field}",
|
|
2369
|
+
request_sent=True,
|
|
2370
|
+
safe_to_retry=False,
|
|
2371
|
+
)
|
|
2372
|
+
for field in ("osPid", "rssKb"):
|
|
2373
|
+
value = terminal.get(field)
|
|
2374
|
+
if value is not None and (
|
|
2375
|
+
isinstance(value, bool) or not isinstance(value, int)
|
|
2376
|
+
):
|
|
2377
|
+
raise CodexAppServerProtocolError(
|
|
2378
|
+
f"{method} returned a terminal with invalid {field}",
|
|
2379
|
+
request_sent=True,
|
|
2380
|
+
safe_to_retry=False,
|
|
2381
|
+
)
|
|
2382
|
+
cpu_percent = terminal.get("cpuPercent")
|
|
2383
|
+
if cpu_percent is not None and (
|
|
2384
|
+
isinstance(cpu_percent, bool)
|
|
2385
|
+
or not isinstance(cpu_percent, (int, float))
|
|
2386
|
+
):
|
|
2387
|
+
raise CodexAppServerProtocolError(
|
|
2388
|
+
f"{method} returned a terminal with invalid cpuPercent",
|
|
2389
|
+
request_sent=True,
|
|
2390
|
+
safe_to_retry=False,
|
|
2391
|
+
)
|
|
2392
|
+
terminals.append(dict(terminal))
|
|
2393
|
+
|
|
2394
|
+
cursor = _protocol_cursor(method, result)
|
|
2395
|
+
if cursor is None:
|
|
2396
|
+
return terminals
|
|
2397
|
+
if cursor in seen_cursors:
|
|
2398
|
+
raise CodexAppServerProtocolError(
|
|
2399
|
+
f"{method} repeated a pagination cursor",
|
|
2400
|
+
request_sent=True,
|
|
2401
|
+
safe_to_retry=False,
|
|
2402
|
+
)
|
|
2403
|
+
seen_cursors.add(cursor)
|
|
2404
|
+
|
|
2405
|
+
async def terminate_background_terminal(
|
|
2406
|
+
self,
|
|
2407
|
+
thread_id: str,
|
|
2408
|
+
process_id: str,
|
|
2409
|
+
) -> bool:
|
|
2410
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2411
|
+
process_id = _require_nonempty_string(process_id, "process_id")
|
|
2412
|
+
method = "thread/backgroundTerminals/terminate"
|
|
2413
|
+
result = _protocol_object(
|
|
2414
|
+
method,
|
|
2415
|
+
await self.request(
|
|
2416
|
+
method,
|
|
2417
|
+
{"threadId": thread_id, "processId": process_id},
|
|
2418
|
+
),
|
|
2419
|
+
)
|
|
2420
|
+
terminated = result.get("terminated")
|
|
2421
|
+
if not isinstance(terminated, bool):
|
|
2422
|
+
raise CodexAppServerProtocolError(
|
|
2423
|
+
f"{method} did not return a terminated flag",
|
|
2424
|
+
request_sent=True,
|
|
2425
|
+
safe_to_retry=False,
|
|
2426
|
+
)
|
|
2427
|
+
return terminated
|
|
2428
|
+
|
|
2429
|
+
async def clean_background_terminals(self, thread_id: str) -> None:
|
|
2430
|
+
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2431
|
+
method = "thread/backgroundTerminals/clean"
|
|
2432
|
+
_protocol_empty_object(
|
|
2433
|
+
method,
|
|
2434
|
+
await self.request(method, {"threadId": thread_id}),
|
|
2435
|
+
)
|
|
2436
|
+
|
|
2437
|
+
async def unsubscribe_thread(self, thread_id: str) -> str:
|
|
2438
|
+
await self.start()
|
|
2439
|
+
generation = self._generation
|
|
2440
|
+
owner = self._turns_by_thread.get(thread_id)
|
|
2441
|
+
owner_turn_id = owner.turn_id if owner is not None else None
|
|
2442
|
+
subscriptions = {
|
|
2443
|
+
subscription for subscription in self._subscriptions
|
|
2444
|
+
if subscription.thread_id == thread_id
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
def same_scope() -> bool:
|
|
2448
|
+
return (
|
|
2449
|
+
self._generation == generation
|
|
2450
|
+
and self._turns_by_thread.get(thread_id) is owner
|
|
2451
|
+
and (owner is None or owner.turn_id == owner_turn_id)
|
|
2452
|
+
and not any(
|
|
2453
|
+
subscription.thread_id == thread_id
|
|
2454
|
+
and subscription not in subscriptions
|
|
2455
|
+
for subscription in self._subscriptions
|
|
2456
|
+
)
|
|
2457
|
+
)
|
|
2458
|
+
|
|
2459
|
+
result = await self._request_connected(
|
|
2460
|
+
"thread/unsubscribe", {"threadId": thread_id},
|
|
2461
|
+
before_send=same_scope,
|
|
2462
|
+
)
|
|
2463
|
+
status = str(result.get("status") or "") if isinstance(result, dict) else ""
|
|
2464
|
+
if status not in {"notLoaded", "notSubscribed", "unsubscribed"}:
|
|
2465
|
+
raise CodexAppServerProtocolError(
|
|
2466
|
+
"thread/unsubscribe returned an invalid status",
|
|
2467
|
+
request_sent=True,
|
|
2468
|
+
safe_to_retry=False,
|
|
2469
|
+
)
|
|
2470
|
+
# An old unsubscribe response must not retire a successor installed
|
|
2471
|
+
# while the request was in flight, or one on a reconnected transport.
|
|
2472
|
+
if self._generation == generation and not (
|
|
2473
|
+
owner is not None
|
|
2474
|
+
and self._turns_by_thread.get(thread_id) is owner
|
|
2475
|
+
and owner.turn_id != owner_turn_id
|
|
2476
|
+
):
|
|
2477
|
+
if same_scope():
|
|
2478
|
+
self._loaded_threads.discard(thread_id)
|
|
2479
|
+
if owner is not None and self._turns_by_thread.get(thread_id) is owner:
|
|
2480
|
+
self._turns_by_thread.pop(thread_id, None)
|
|
2481
|
+
owner._completed = True
|
|
2482
|
+
owner._closed = True
|
|
2483
|
+
for subscription in subscriptions:
|
|
2484
|
+
subscription._finish()
|
|
2485
|
+
return status
|
|
2486
|
+
|
|
2487
|
+
async def steer_turn(
|
|
2488
|
+
self,
|
|
2489
|
+
thread_id: str,
|
|
2490
|
+
turn_id: str,
|
|
2491
|
+
input_items: list[dict[str, Any]],
|
|
2492
|
+
*,
|
|
2493
|
+
client_user_message_id: str | None = None,
|
|
2494
|
+
) -> str:
|
|
2495
|
+
params: dict[str, Any] = {
|
|
2496
|
+
"threadId": thread_id,
|
|
2497
|
+
"expectedTurnId": turn_id,
|
|
2498
|
+
"input": input_items,
|
|
2499
|
+
}
|
|
2500
|
+
if client_user_message_id:
|
|
2501
|
+
params["clientUserMessageId"] = client_user_message_id
|
|
2502
|
+
result = await self.request("turn/steer", params)
|
|
2503
|
+
resolved = str(result.get("turnId") or "") if isinstance(result, dict) else ""
|
|
2504
|
+
if resolved != turn_id:
|
|
2505
|
+
raise CodexAppServerProtocolError(
|
|
2506
|
+
f"turn/steer returned unexpected turn id {resolved or '<missing>'}",
|
|
2507
|
+
request_sent=True,
|
|
2508
|
+
safe_to_retry=False,
|
|
2509
|
+
)
|
|
2510
|
+
return resolved
|
|
2511
|
+
|
|
2512
|
+
async def steer_turn_with_notification_watermark(
|
|
2513
|
+
self,
|
|
2514
|
+
thread_id: str,
|
|
2515
|
+
turn_id: str,
|
|
2516
|
+
input_items: list[dict[str, Any]],
|
|
2517
|
+
*,
|
|
2518
|
+
client_user_message_id: str | None = None,
|
|
2519
|
+
notification_subscription: CodexAppServerSubscription | None = None,
|
|
2520
|
+
before_send: Callable[[], bool] | None = None,
|
|
2521
|
+
) -> tuple[str, int]:
|
|
2522
|
+
"""Return the exact receive-order boundary of the steer response.
|
|
2523
|
+
|
|
2524
|
+
Notifications at or below the watermark were read before app-server's
|
|
2525
|
+
steer acknowledgement and therefore belong to the preceding logical
|
|
2526
|
+
AgentsDock run. Later notifications belong to the steered run.
|
|
2527
|
+
"""
|
|
2528
|
+
params: dict[str, Any] = {
|
|
2529
|
+
"threadId": thread_id,
|
|
2530
|
+
"expectedTurnId": turn_id,
|
|
2531
|
+
"input": input_items,
|
|
2532
|
+
}
|
|
2533
|
+
if client_user_message_id:
|
|
2534
|
+
params["clientUserMessageId"] = client_user_message_id
|
|
2535
|
+
if notification_subscription is None:
|
|
2536
|
+
active_turn = self._turns_by_thread.get(thread_id)
|
|
2537
|
+
if active_turn is not None and active_turn.turn_id == turn_id:
|
|
2538
|
+
notification_subscription = active_turn._subscription
|
|
2539
|
+
if notification_subscription is None:
|
|
2540
|
+
raise CodexAppServerProtocolError(
|
|
2541
|
+
"turn/steer notification stream is unavailable",
|
|
2542
|
+
request_sent=False,
|
|
2543
|
+
safe_to_retry=True,
|
|
2544
|
+
)
|
|
2545
|
+
await self.start()
|
|
2546
|
+
response = await self._request_connected(
|
|
2547
|
+
"turn/steer",
|
|
2548
|
+
params,
|
|
2549
|
+
notification_boundary_subscription=notification_subscription,
|
|
2550
|
+
before_send=before_send,
|
|
2551
|
+
)
|
|
2552
|
+
if (
|
|
2553
|
+
not isinstance(response, tuple)
|
|
2554
|
+
or len(response) != 2
|
|
2555
|
+
or not isinstance(response[1], int)
|
|
2556
|
+
):
|
|
2557
|
+
raise CodexAppServerProtocolError(
|
|
2558
|
+
"turn/steer did not return a notification watermark",
|
|
2559
|
+
request_sent=True,
|
|
2560
|
+
safe_to_retry=False,
|
|
2561
|
+
)
|
|
2562
|
+
result, watermark = response
|
|
2563
|
+
resolved = str(result.get("turnId") or "") if isinstance(result, dict) else ""
|
|
2564
|
+
if resolved != turn_id:
|
|
2565
|
+
raise CodexAppServerProtocolError(
|
|
2566
|
+
f"turn/steer returned unexpected turn id {resolved or '<missing>'}",
|
|
2567
|
+
request_sent=True,
|
|
2568
|
+
safe_to_retry=False,
|
|
2569
|
+
)
|
|
2570
|
+
return resolved, watermark
|
|
2571
|
+
|
|
2572
|
+
async def interrupt_turn(self, thread_id: str, turn_id: str) -> None:
|
|
2573
|
+
await self.request(
|
|
2574
|
+
"turn/interrupt",
|
|
2575
|
+
{"threadId": thread_id, "turnId": turn_id},
|
|
2576
|
+
)
|
|
2577
|
+
|
|
2578
|
+
async def start_turn(
|
|
2579
|
+
self,
|
|
2580
|
+
thread_id: str,
|
|
2581
|
+
input_items: list[dict[str, Any]],
|
|
2582
|
+
*,
|
|
2583
|
+
overrides: dict[str, Any] | None = None,
|
|
2584
|
+
retain_thread_stream: bool = False,
|
|
2585
|
+
) -> CodexAppServerTurn:
|
|
2586
|
+
# Initialize before installing the provisional turn subscription.
|
|
2587
|
+
# Otherwise the first lazy start would correctly discard it as stale
|
|
2588
|
+
# transport state.
|
|
2589
|
+
await self.start()
|
|
2590
|
+
if thread_id in self._turns_by_thread:
|
|
2591
|
+
raise CodexAppServerError(
|
|
2592
|
+
f"thread {thread_id} already has an active app-server turn"
|
|
2593
|
+
)
|
|
2594
|
+
|
|
2595
|
+
subscription = self.subscribe(thread_id=thread_id)
|
|
2596
|
+
provisional = CodexAppServerTurn(
|
|
2597
|
+
self, thread_id, "", subscription,
|
|
2598
|
+
_retain_thread_stream=retain_thread_stream,
|
|
2599
|
+
)
|
|
2600
|
+
self._turns_by_thread[thread_id] = provisional
|
|
2601
|
+
|
|
2602
|
+
params = dict(overrides or {})
|
|
2603
|
+
params["threadId"] = thread_id
|
|
2604
|
+
params["input"] = input_items
|
|
2605
|
+
try:
|
|
2606
|
+
result = await self._request_connected(
|
|
2607
|
+
"turn/start",
|
|
2608
|
+
params,
|
|
2609
|
+
transport_turn=provisional,
|
|
2610
|
+
)
|
|
2611
|
+
turn = result.get("turn") if isinstance(result, dict) else None
|
|
2612
|
+
turn_id = str(turn.get("id") or "") if isinstance(turn, dict) else ""
|
|
2613
|
+
if not turn_id:
|
|
2614
|
+
raise CodexAppServerProtocolError(
|
|
2615
|
+
"turn/start did not return a turn id",
|
|
2616
|
+
request_sent=True,
|
|
2617
|
+
safe_to_retry=False,
|
|
2618
|
+
)
|
|
2619
|
+
if provisional.turn_id and provisional.turn_id != turn_id:
|
|
2620
|
+
if (
|
|
2621
|
+
retain_thread_stream
|
|
2622
|
+
and turn_id in provisional._completed_turn_ids
|
|
2623
|
+
and self._turns_by_thread.get(thread_id) is provisional
|
|
2624
|
+
and provisional.transport_generation == self._generation
|
|
2625
|
+
and not provisional._closed
|
|
2626
|
+
):
|
|
2627
|
+
# The initial accepted turn can finish and natively
|
|
2628
|
+
# continue before its delayed start response is read.
|
|
2629
|
+
# Acceptance of that exact completed ID is proven; leave
|
|
2630
|
+
# the handle bound to the newer live turn, not the reply.
|
|
2631
|
+
return provisional
|
|
2632
|
+
raise CodexAppServerProtocolError(
|
|
2633
|
+
"turn/start response did not match its early notifications",
|
|
2634
|
+
request_sent=True,
|
|
2635
|
+
safe_to_retry=False,
|
|
2636
|
+
)
|
|
2637
|
+
provisional.turn_id = turn_id
|
|
2638
|
+
if not retain_thread_stream:
|
|
2639
|
+
provisional._subscription.turn_id = turn_id
|
|
2640
|
+
return provisional
|
|
2641
|
+
except asyncio.CancelledError as exc:
|
|
2642
|
+
# turn/start may already have reached app-server. Transfer the
|
|
2643
|
+
# provisional handle to the owning runner so its cancellation
|
|
2644
|
+
# cleanup can interrupt/release it instead of leaving this thread
|
|
2645
|
+
# permanently occupied in _turns_by_thread.
|
|
2646
|
+
if self._turns_by_thread.get(thread_id) is provisional:
|
|
2647
|
+
exc.pending_turn = provisional
|
|
2648
|
+
else:
|
|
2649
|
+
provisional._subscription._finish()
|
|
2650
|
+
raise
|
|
2651
|
+
except CodexAppServerError as exc:
|
|
2652
|
+
if not exc.safe_to_retry:
|
|
2653
|
+
exc.pending_turn = provisional
|
|
2654
|
+
# A timeout can race the acceptance response. Keep the
|
|
2655
|
+
# provisional subscription alive so later notifications can
|
|
2656
|
+
# bind the real turn id and prove acceptance.
|
|
2657
|
+
if self._turns_by_thread.get(thread_id) is provisional:
|
|
2658
|
+
raise
|
|
2659
|
+
if self._turns_by_thread.get(thread_id) is provisional:
|
|
2660
|
+
self._turns_by_thread.pop(thread_id, None)
|
|
2661
|
+
provisional._subscription._finish()
|
|
2662
|
+
raise
|
|
2663
|
+
except Exception:
|
|
2664
|
+
if self._turns_by_thread.get(thread_id) is provisional:
|
|
2665
|
+
self._turns_by_thread.pop(thread_id, None)
|
|
2666
|
+
provisional._subscription._finish()
|
|
2667
|
+
raise
|
|
2668
|
+
|
|
2669
|
+
def release_turn(self, turn: CodexAppServerTurn) -> None:
|
|
2670
|
+
if self._turns_by_thread.get(turn.thread_id) is turn:
|
|
2671
|
+
self._turns_by_thread.pop(turn.thread_id, None)
|
|
2672
|
+
turn._subscription._finish()
|
|
2673
|
+
|
|
2674
|
+
def active_turn(self, thread_id: str) -> CodexAppServerTurn | None:
|
|
2675
|
+
turn = self._turns_by_thread.get(thread_id)
|
|
2676
|
+
# Retaining stream ownership does not mean the provider is executing
|
|
2677
|
+
# between turns. A new native turn/started makes this handle active.
|
|
2678
|
+
return turn if turn is not None and not turn._completed else None
|
|
2679
|
+
|
|
2680
|
+
|
|
2681
|
+
class CodexAppServerManager:
|
|
2682
|
+
"""Lifecycle façade owning exactly one lazy shared app-server client."""
|
|
2683
|
+
|
|
2684
|
+
def __init__(
|
|
2685
|
+
self,
|
|
2686
|
+
codex_bin: str,
|
|
2687
|
+
*,
|
|
2688
|
+
cwd: str,
|
|
2689
|
+
env_factory: Callable[[], dict[str, str]],
|
|
2690
|
+
app_server_args: Sequence[str] = (),
|
|
2691
|
+
request_timeout: float = 30.0,
|
|
2692
|
+
lifecycle_timeout: float = 300.0,
|
|
2693
|
+
process_stream_limit: int = 16 * 1024 * 1024,
|
|
2694
|
+
notification_queue_limit: int = 8192,
|
|
2695
|
+
json_parse_thread_threshold: int = 1024 * 1024,
|
|
2696
|
+
process_factory: ProcessFactory | None = None,
|
|
2697
|
+
before_start: Callable[[], Awaitable[Any]] | None = None,
|
|
2698
|
+
server_request_handler: ServerRequestHandler | None = None,
|
|
2699
|
+
initialize_params: dict[str, Any] | None = None,
|
|
2700
|
+
on_process_started: ProcessLifecycleHook | None = None,
|
|
2701
|
+
on_process_exited: ProcessLifecycleHook | None = None,
|
|
2702
|
+
sensitive_values: Sequence[str] = (),
|
|
2703
|
+
protected_env_keys: Sequence[str] = (),
|
|
2704
|
+
) -> None:
|
|
2705
|
+
self.client = CodexAppServerClient(
|
|
2706
|
+
codex_bin,
|
|
2707
|
+
cwd=cwd,
|
|
2708
|
+
env_factory=env_factory,
|
|
2709
|
+
app_server_args=app_server_args,
|
|
2710
|
+
request_timeout=request_timeout,
|
|
2711
|
+
lifecycle_timeout=lifecycle_timeout,
|
|
2712
|
+
process_stream_limit=process_stream_limit,
|
|
2713
|
+
notification_queue_limit=notification_queue_limit,
|
|
2714
|
+
json_parse_thread_threshold=json_parse_thread_threshold,
|
|
2715
|
+
process_factory=process_factory,
|
|
2716
|
+
before_start=before_start,
|
|
2717
|
+
server_request_handler=server_request_handler,
|
|
2718
|
+
initialize_params=initialize_params,
|
|
2719
|
+
on_process_started=on_process_started,
|
|
2720
|
+
on_process_exited=on_process_exited,
|
|
2721
|
+
sensitive_values=sensitive_values,
|
|
2722
|
+
protected_env_keys=protected_env_keys,
|
|
2723
|
+
)
|
|
2724
|
+
|
|
2725
|
+
@property
|
|
2726
|
+
def ready(self) -> bool:
|
|
2727
|
+
return self.client.ready
|
|
2728
|
+
|
|
2729
|
+
@property
|
|
2730
|
+
def process(self) -> asyncio.subprocess.Process | None:
|
|
2731
|
+
return self.client.process
|
|
2732
|
+
|
|
2733
|
+
@property
|
|
2734
|
+
def generation(self) -> int:
|
|
2735
|
+
return self.client.generation
|
|
2736
|
+
|
|
2737
|
+
def is_thread_loaded(self, thread_id: str) -> bool:
|
|
2738
|
+
return self.client.is_thread_loaded(thread_id)
|
|
2739
|
+
|
|
2740
|
+
def cached_thread_name(self, thread_id: str) -> str | None:
|
|
2741
|
+
return self.client.cached_thread_name(thread_id)
|
|
2742
|
+
|
|
2743
|
+
async def start(self) -> None:
|
|
2744
|
+
await self.client.start()
|
|
2745
|
+
|
|
2746
|
+
async def close(self) -> None:
|
|
2747
|
+
await self.client.close()
|
|
2748
|
+
|
|
2749
|
+
async def retire_generation(self, expected_generation: int) -> bool:
|
|
2750
|
+
return await self.client.retire_generation(expected_generation)
|
|
2751
|
+
|
|
2752
|
+
async def __aenter__(self) -> "CodexAppServerManager":
|
|
2753
|
+
await self.start()
|
|
2754
|
+
return self
|
|
2755
|
+
|
|
2756
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
2757
|
+
await self.close()
|
|
2758
|
+
|
|
2759
|
+
def subscribe_thread(self, thread_id: str) -> CodexAppServerSubscription:
|
|
2760
|
+
return self.client.subscribe_thread(thread_id)
|
|
2761
|
+
|
|
2762
|
+
def subscribe_turn(
|
|
2763
|
+
self,
|
|
2764
|
+
thread_id: str,
|
|
2765
|
+
turn_id: str,
|
|
2766
|
+
) -> CodexAppServerSubscription:
|
|
2767
|
+
return self.client.subscribe_turn(thread_id, turn_id)
|
|
2768
|
+
|
|
2769
|
+
def add_notification_handler(self, handler: NotificationHandler) -> None:
|
|
2770
|
+
self.client.add_notification_handler(handler)
|
|
2771
|
+
|
|
2772
|
+
def remove_notification_handler(self, handler: NotificationHandler) -> None:
|
|
2773
|
+
self.client.remove_notification_handler(handler)
|
|
2774
|
+
|
|
2775
|
+
async def wait_for_notification_handler(
|
|
2776
|
+
self,
|
|
2777
|
+
handler: NotificationHandler,
|
|
2778
|
+
thread_id: str,
|
|
2779
|
+
) -> None:
|
|
2780
|
+
await self.client.wait_for_notification_handler(handler, thread_id)
|
|
2781
|
+
|
|
2782
|
+
async def request(
|
|
2783
|
+
self,
|
|
2784
|
+
method: str,
|
|
2785
|
+
params: dict[str, Any],
|
|
2786
|
+
*,
|
|
2787
|
+
timeout: float | None = None,
|
|
2788
|
+
) -> Any:
|
|
2789
|
+
return await self.client.request(method, params, timeout=timeout)
|
|
2790
|
+
|
|
2791
|
+
async def notify(
|
|
2792
|
+
self,
|
|
2793
|
+
method: str,
|
|
2794
|
+
params: dict[str, Any] | None = None,
|
|
2795
|
+
) -> None:
|
|
2796
|
+
await self.client.notify(method, params)
|
|
2797
|
+
|
|
2798
|
+
async def start_thread(self, params: dict[str, Any]) -> str:
|
|
2799
|
+
return await self.client.start_thread(params)
|
|
2800
|
+
|
|
2801
|
+
async def resume_thread(
|
|
2802
|
+
self,
|
|
2803
|
+
thread_id: str,
|
|
2804
|
+
params: dict[str, Any] | None = None,
|
|
2805
|
+
) -> str:
|
|
2806
|
+
return await self.client.resume_thread(thread_id, params)
|
|
2807
|
+
|
|
2808
|
+
async def start_or_resume_thread(
|
|
2809
|
+
self,
|
|
2810
|
+
thread_id: str | None,
|
|
2811
|
+
start_params: dict[str, Any],
|
|
2812
|
+
resume_params: dict[str, Any] | None = None,
|
|
2813
|
+
) -> str:
|
|
2814
|
+
return await self.client.start_or_resume_thread(
|
|
2815
|
+
thread_id,
|
|
2816
|
+
start_params,
|
|
2817
|
+
resume_params,
|
|
2818
|
+
)
|
|
2819
|
+
|
|
2820
|
+
async def fork_thread(
|
|
2821
|
+
self,
|
|
2822
|
+
thread_id: str,
|
|
2823
|
+
params: dict[str, Any] | None = None,
|
|
2824
|
+
*,
|
|
2825
|
+
last_turn_id: str | None = None,
|
|
2826
|
+
) -> str:
|
|
2827
|
+
return await self.client.fork_thread(
|
|
2828
|
+
thread_id,
|
|
2829
|
+
params,
|
|
2830
|
+
last_turn_id=last_turn_id,
|
|
2831
|
+
)
|
|
2832
|
+
|
|
2833
|
+
async def inject_items(
|
|
2834
|
+
self,
|
|
2835
|
+
thread_id: str,
|
|
2836
|
+
items: list[dict[str, Any]],
|
|
2837
|
+
) -> None:
|
|
2838
|
+
await self.client.inject_items(thread_id, items)
|
|
2839
|
+
|
|
2840
|
+
async def inject_items_guarded(
|
|
2841
|
+
self,
|
|
2842
|
+
thread_id: str,
|
|
2843
|
+
items: list[dict[str, Any]],
|
|
2844
|
+
*,
|
|
2845
|
+
expected_generation: int,
|
|
2846
|
+
before_send: Callable[[], bool],
|
|
2847
|
+
timeout: float | None = None,
|
|
2848
|
+
) -> None:
|
|
2849
|
+
await self.client.inject_items_guarded(
|
|
2850
|
+
thread_id, items, expected_generation=expected_generation,
|
|
2851
|
+
before_send=before_send, timeout=timeout,
|
|
2852
|
+
)
|
|
2853
|
+
|
|
2854
|
+
async def read_thread(
|
|
2855
|
+
self,
|
|
2856
|
+
thread_id: str,
|
|
2857
|
+
*,
|
|
2858
|
+
include_turns: bool = False,
|
|
2859
|
+
) -> dict[str, Any]:
|
|
2860
|
+
return await self.client.read_thread(
|
|
2861
|
+
thread_id,
|
|
2862
|
+
include_turns=include_turns,
|
|
2863
|
+
)
|
|
2864
|
+
|
|
2865
|
+
async def delete_thread(self, thread_id: str) -> None:
|
|
2866
|
+
await self.client.delete_thread(thread_id)
|
|
2867
|
+
|
|
2868
|
+
async def list_turns(
|
|
2869
|
+
self,
|
|
2870
|
+
thread_id: str,
|
|
2871
|
+
*,
|
|
2872
|
+
limit: int = 4,
|
|
2873
|
+
items_view: str = "full",
|
|
2874
|
+
sort_direction: str = "desc",
|
|
2875
|
+
) -> list[dict[str, Any]]:
|
|
2876
|
+
return await self.client.list_turns(
|
|
2877
|
+
thread_id,
|
|
2878
|
+
limit=limit,
|
|
2879
|
+
items_view=items_view,
|
|
2880
|
+
sort_direction=sort_direction,
|
|
2881
|
+
)
|
|
2882
|
+
|
|
2883
|
+
async def list_descendant_threads(
|
|
2884
|
+
self,
|
|
2885
|
+
thread_id: str,
|
|
2886
|
+
*,
|
|
2887
|
+
page_size: int = 100,
|
|
2888
|
+
) -> list[dict[str, Any]]:
|
|
2889
|
+
return await self.client.list_descendant_threads(
|
|
2890
|
+
thread_id,
|
|
2891
|
+
page_size=page_size,
|
|
2892
|
+
)
|
|
2893
|
+
|
|
2894
|
+
async def list_permission_profiles(
|
|
2895
|
+
self,
|
|
2896
|
+
*,
|
|
2897
|
+
cwd: str | None = None,
|
|
2898
|
+
page_size: int | None = None,
|
|
2899
|
+
) -> list[dict[str, Any]]:
|
|
2900
|
+
return await self.client.list_permission_profiles(
|
|
2901
|
+
cwd=cwd,
|
|
2902
|
+
page_size=page_size,
|
|
2903
|
+
)
|
|
2904
|
+
|
|
2905
|
+
async def set_thread_goal(
|
|
2906
|
+
self,
|
|
2907
|
+
thread_id: str,
|
|
2908
|
+
*,
|
|
2909
|
+
objective: str | None | _OmittedType = _OMITTED,
|
|
2910
|
+
status: str | None | _OmittedType = _OMITTED,
|
|
2911
|
+
token_budget: int | None | _OmittedType = _OMITTED,
|
|
2912
|
+
) -> dict[str, Any]:
|
|
2913
|
+
return await self.client.set_thread_goal(
|
|
2914
|
+
thread_id,
|
|
2915
|
+
objective=objective,
|
|
2916
|
+
status=status,
|
|
2917
|
+
token_budget=token_budget,
|
|
2918
|
+
)
|
|
2919
|
+
|
|
2920
|
+
async def get_thread_goal(self, thread_id: str) -> dict[str, Any] | None:
|
|
2921
|
+
return await self.client.get_thread_goal(thread_id)
|
|
2922
|
+
|
|
2923
|
+
async def clear_thread_goal(self, thread_id: str) -> bool:
|
|
2924
|
+
return await self.client.clear_thread_goal(thread_id)
|
|
2925
|
+
|
|
2926
|
+
async def compact_thread(self, thread_id: str) -> None:
|
|
2927
|
+
await self.client.compact_thread(thread_id)
|
|
2928
|
+
|
|
2929
|
+
async def rollback_thread(
|
|
2930
|
+
self,
|
|
2931
|
+
thread_id: str,
|
|
2932
|
+
*,
|
|
2933
|
+
num_turns: int,
|
|
2934
|
+
) -> dict[str, Any]:
|
|
2935
|
+
return await self.client.rollback_thread(thread_id, num_turns=num_turns)
|
|
2936
|
+
|
|
2937
|
+
async def start_review(
|
|
2938
|
+
self,
|
|
2939
|
+
thread_id: str,
|
|
2940
|
+
target: dict[str, Any],
|
|
2941
|
+
*,
|
|
2942
|
+
delivery: str = "inline",
|
|
2943
|
+
) -> dict[str, Any]:
|
|
2944
|
+
return await self.client.start_review(
|
|
2945
|
+
thread_id,
|
|
2946
|
+
target,
|
|
2947
|
+
delivery=delivery,
|
|
2948
|
+
)
|
|
2949
|
+
|
|
2950
|
+
async def run_thread_shell_command(self, thread_id: str, command: str) -> None:
|
|
2951
|
+
await self.client.run_thread_shell_command(thread_id, command)
|
|
2952
|
+
|
|
2953
|
+
async def list_background_terminals(
|
|
2954
|
+
self,
|
|
2955
|
+
thread_id: str,
|
|
2956
|
+
*,
|
|
2957
|
+
page_size: int | None = None,
|
|
2958
|
+
) -> list[dict[str, Any]]:
|
|
2959
|
+
return await self.client.list_background_terminals(
|
|
2960
|
+
thread_id,
|
|
2961
|
+
page_size=page_size,
|
|
2962
|
+
)
|
|
2963
|
+
|
|
2964
|
+
async def terminate_background_terminal(
|
|
2965
|
+
self,
|
|
2966
|
+
thread_id: str,
|
|
2967
|
+
process_id: str,
|
|
2968
|
+
) -> bool:
|
|
2969
|
+
return await self.client.terminate_background_terminal(thread_id, process_id)
|
|
2970
|
+
|
|
2971
|
+
async def clean_background_terminals(self, thread_id: str) -> None:
|
|
2972
|
+
await self.client.clean_background_terminals(thread_id)
|
|
2973
|
+
|
|
2974
|
+
async def unsubscribe_thread(self, thread_id: str) -> str:
|
|
2975
|
+
return await self.client.unsubscribe_thread(thread_id)
|
|
2976
|
+
|
|
2977
|
+
async def start_turn(
|
|
2978
|
+
self,
|
|
2979
|
+
thread_id: str,
|
|
2980
|
+
input_items: list[dict[str, Any]],
|
|
2981
|
+
*,
|
|
2982
|
+
overrides: dict[str, Any] | None = None,
|
|
2983
|
+
retain_thread_stream: bool = False,
|
|
2984
|
+
) -> CodexAppServerTurn:
|
|
2985
|
+
return await self.client.start_turn(
|
|
2986
|
+
thread_id,
|
|
2987
|
+
input_items,
|
|
2988
|
+
overrides=overrides,
|
|
2989
|
+
retain_thread_stream=retain_thread_stream,
|
|
2990
|
+
)
|
|
2991
|
+
|
|
2992
|
+
async def steer_turn(
|
|
2993
|
+
self,
|
|
2994
|
+
thread_id: str,
|
|
2995
|
+
turn_id: str,
|
|
2996
|
+
input_items: list[dict[str, Any]],
|
|
2997
|
+
*,
|
|
2998
|
+
client_user_message_id: str | None = None,
|
|
2999
|
+
) -> str:
|
|
3000
|
+
return await self.client.steer_turn(
|
|
3001
|
+
thread_id,
|
|
3002
|
+
turn_id,
|
|
3003
|
+
input_items,
|
|
3004
|
+
client_user_message_id=client_user_message_id,
|
|
3005
|
+
)
|
|
3006
|
+
|
|
3007
|
+
async def steer_turn_with_notification_watermark(
|
|
3008
|
+
self,
|
|
3009
|
+
thread_id: str,
|
|
3010
|
+
turn_id: str,
|
|
3011
|
+
input_items: list[dict[str, Any]],
|
|
3012
|
+
*,
|
|
3013
|
+
client_user_message_id: str | None = None,
|
|
3014
|
+
notification_subscription: CodexAppServerSubscription | None = None,
|
|
3015
|
+
before_send: Callable[[], bool] | None = None,
|
|
3016
|
+
) -> tuple[str, int]:
|
|
3017
|
+
return await self.client.steer_turn_with_notification_watermark(
|
|
3018
|
+
thread_id,
|
|
3019
|
+
turn_id,
|
|
3020
|
+
input_items,
|
|
3021
|
+
client_user_message_id=client_user_message_id,
|
|
3022
|
+
notification_subscription=notification_subscription,
|
|
3023
|
+
before_send=before_send,
|
|
3024
|
+
)
|
|
3025
|
+
|
|
3026
|
+
async def interrupt_turn(self, thread_id: str, turn_id: str) -> None:
|
|
3027
|
+
await self.client.interrupt_turn(thread_id, turn_id)
|
|
3028
|
+
|
|
3029
|
+
def active_turn(self, thread_id: str) -> CodexAppServerTurn | None:
|
|
3030
|
+
return self.client.active_turn(thread_id)
|