bazaar-compute-node 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bazaar_compute_node/__init__.py +3 -0
- bazaar_compute_node/app/__init__.py +1 -0
- bazaar_compute_node/app/application.py +398 -0
- bazaar_compute_node/app/attachments.py +154 -0
- bazaar_compute_node/app/command.py +342 -0
- bazaar_compute_node/app/config.py +121 -0
- bazaar_compute_node/app/registry.py +120 -0
- bazaar_compute_node/app/transport.py +264 -0
- bazaar_compute_node/app/windows_pipe.py +463 -0
- bazaar_compute_node/app/wrapper.py +63 -0
- bazaar_compute_node/bcc.py +524 -0
- bazaar_compute_node/cli.py +382 -0
- bazaar_compute_node/contrib/__init__.py +1 -0
- bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
- bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
- bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
- bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
- bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
- bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
- bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
- bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
- bazaar_compute_node/contrib/logging/__init__.py +5 -0
- bazaar_compute_node/contrib/logging/audit.py +61 -0
- bazaar_compute_node/contrib/logging/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
- bazaar_compute_node/contrib/sqlite/codec.py +768 -0
- bazaar_compute_node/contrib/sqlite/database.py +282 -0
- bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
- bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
- bazaar_compute_node/contrib/wecom/__init__.py +1 -0
- bazaar_compute_node/contrib/wecom/channel.py +960 -0
- bazaar_compute_node/contrib/wecom/markdown.py +146 -0
- bazaar_compute_node/contrib/wecom/plugin.py +29 -0
- bazaar_compute_node/core/__init__.py +5 -0
- bazaar_compute_node/core/approval.py +51 -0
- bazaar_compute_node/core/audit.py +101 -0
- bazaar_compute_node/core/channel.py +121 -0
- bazaar_compute_node/core/client.py +30 -0
- bazaar_compute_node/core/command.py +85 -0
- bazaar_compute_node/core/concurrency.py +29 -0
- bazaar_compute_node/core/correlation.py +48 -0
- bazaar_compute_node/core/instruction.py +224 -0
- bazaar_compute_node/core/lifecycle.py +48 -0
- bazaar_compute_node/core/models/__init__.py +63 -0
- bazaar_compute_node/core/models/entities.py +514 -0
- bazaar_compute_node/core/models/states.py +369 -0
- bazaar_compute_node/core/observability.py +47 -0
- bazaar_compute_node/core/orchestration/__init__.py +5 -0
- bazaar_compute_node/core/orchestration/command.py +614 -0
- bazaar_compute_node/core/orchestration/services.py +135 -0
- bazaar_compute_node/core/orchestration/session.py +891 -0
- bazaar_compute_node/core/orchestration/turn.py +451 -0
- bazaar_compute_node/core/outcomes.py +51 -0
- bazaar_compute_node/core/paths.py +19 -0
- bazaar_compute_node/core/runtime.py +118 -0
- bazaar_compute_node/core/storage.py +167 -0
- bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
- bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
- bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
- bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
- bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from .protocol import (
|
|
15
|
+
JsonlMessage,
|
|
16
|
+
JsonlProcessExited,
|
|
17
|
+
JsonlProcessNotRunning,
|
|
18
|
+
JsonlProtocolError,
|
|
19
|
+
JsonlRemoteError,
|
|
20
|
+
JsonlRequestId,
|
|
21
|
+
JsonlRequestTimeout,
|
|
22
|
+
JsonlTransportError,
|
|
23
|
+
is_request_id,
|
|
24
|
+
validate_message,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
StderrHandler = Callable[[str], Awaitable[None] | None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JsonlProcessState(StrEnum):
|
|
31
|
+
STOPPED = "stopped"
|
|
32
|
+
STARTING = "starting"
|
|
33
|
+
RUNNING = "running"
|
|
34
|
+
STOPPING = "stopping"
|
|
35
|
+
EXITED = "exited"
|
|
36
|
+
FAILED = "failed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class JsonlProcessSpec:
|
|
41
|
+
executable: str
|
|
42
|
+
arguments: tuple[str, ...] = ()
|
|
43
|
+
cwd: Path | None = None
|
|
44
|
+
environment: Mapping[str, str] | None = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if not self.executable:
|
|
48
|
+
raise ValueError("executable must be a non-empty string")
|
|
49
|
+
if any(not isinstance(argument, str) for argument in self.arguments):
|
|
50
|
+
raise TypeError("arguments must contain only strings")
|
|
51
|
+
if self.cwd is not None and not isinstance(self.cwd, Path):
|
|
52
|
+
raise TypeError("cwd must be a Path or None")
|
|
53
|
+
if self.environment is not None and any(
|
|
54
|
+
not isinstance(key, str) or not isinstance(value, str)
|
|
55
|
+
for key, value in self.environment.items()
|
|
56
|
+
):
|
|
57
|
+
raise TypeError("environment must contain only string keys and values")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def command(self) -> tuple[str, ...]:
|
|
61
|
+
return (self.executable, *self.arguments)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_QUEUE_CLOSED = object()
|
|
65
|
+
_JSONL_READ_CHUNK_BYTES = 64 * 1024
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class JsonlProcessSupervisor:
|
|
69
|
+
"""Own one subprocess speaking newline-delimited JSON over stdio."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
spec: JsonlProcessSpec,
|
|
74
|
+
*,
|
|
75
|
+
stderr_tail_limit: int = 64,
|
|
76
|
+
stderr_handler: StderrHandler | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
if stderr_tail_limit <= 0:
|
|
79
|
+
raise ValueError("stderr_tail_limit must be positive")
|
|
80
|
+
self.spec = spec
|
|
81
|
+
self._stderr_tail: deque[str] = deque(maxlen=stderr_tail_limit)
|
|
82
|
+
self._stderr_handler = stderr_handler
|
|
83
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
84
|
+
self._state = JsonlProcessState.STOPPED
|
|
85
|
+
self._returncode: int | None = None
|
|
86
|
+
self._fatal_error: JsonlTransportError | None = None
|
|
87
|
+
self._stdout_task: asyncio.Task[None] | None = None
|
|
88
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
89
|
+
self._watch_task: asyncio.Task[None] | None = None
|
|
90
|
+
self._write_lock = asyncio.Lock()
|
|
91
|
+
self._lifecycle_lock = asyncio.Lock()
|
|
92
|
+
self._exit_event = asyncio.Event()
|
|
93
|
+
self._incoming: asyncio.Queue[JsonlMessage | object] = asyncio.Queue()
|
|
94
|
+
self._pending: dict[JsonlRequestId, asyncio.Future[JsonlMessage]] = {}
|
|
95
|
+
self._next_request_id = 0
|
|
96
|
+
self._closed_message_sent = False
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def state(self) -> JsonlProcessState:
|
|
100
|
+
return self._state
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def pid(self) -> int | None:
|
|
104
|
+
process = self._process
|
|
105
|
+
return process.pid if process is not None else None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def returncode(self) -> int | None:
|
|
109
|
+
return self._returncode
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_running(self) -> bool:
|
|
113
|
+
process = self._process
|
|
114
|
+
return process is not None and process.returncode is None
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def fatal_error(self) -> JsonlTransportError | None:
|
|
118
|
+
return self._fatal_error
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def stderr_tail(self) -> tuple[str, ...]:
|
|
122
|
+
return tuple(self._stderr_tail)
|
|
123
|
+
|
|
124
|
+
async def start(self, *, timeout: float) -> None:
|
|
125
|
+
_validate_timeout(timeout)
|
|
126
|
+
async with self._lifecycle_lock:
|
|
127
|
+
if self.is_running:
|
|
128
|
+
return
|
|
129
|
+
await self._join_tasks()
|
|
130
|
+
self._reset_runtime_state()
|
|
131
|
+
self._state = JsonlProcessState.STARTING
|
|
132
|
+
try:
|
|
133
|
+
async with asyncio.timeout(timeout):
|
|
134
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
135
|
+
*self.spec.command,
|
|
136
|
+
stdin=asyncio.subprocess.PIPE,
|
|
137
|
+
stdout=asyncio.subprocess.PIPE,
|
|
138
|
+
stderr=asyncio.subprocess.PIPE,
|
|
139
|
+
cwd=(str(self.spec.cwd) if self.spec.cwd is not None else None),
|
|
140
|
+
env=(
|
|
141
|
+
dict(self.spec.environment)
|
|
142
|
+
if self.spec.environment is not None
|
|
143
|
+
else None
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
except BaseException:
|
|
147
|
+
self._process = None
|
|
148
|
+
self._state = JsonlProcessState.FAILED
|
|
149
|
+
raise
|
|
150
|
+
self._state = JsonlProcessState.RUNNING
|
|
151
|
+
process = self._process
|
|
152
|
+
self._stdout_task = asyncio.create_task(
|
|
153
|
+
self._read_stdout(process),
|
|
154
|
+
name="codex-app-server-stdout",
|
|
155
|
+
)
|
|
156
|
+
self._stderr_task = asyncio.create_task(
|
|
157
|
+
self._read_stderr(process),
|
|
158
|
+
name="codex-app-server-stderr",
|
|
159
|
+
)
|
|
160
|
+
self._watch_task = asyncio.create_task(
|
|
161
|
+
self._watch_process(process),
|
|
162
|
+
name="codex-app-server-process",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def stop(self, *, timeout: float) -> None:
|
|
166
|
+
_validate_timeout(timeout)
|
|
167
|
+
async with self._lifecycle_lock:
|
|
168
|
+
process = self._process
|
|
169
|
+
if process is None:
|
|
170
|
+
await self._join_tasks()
|
|
171
|
+
self._state = JsonlProcessState.STOPPED
|
|
172
|
+
self._send_closed_message()
|
|
173
|
+
return
|
|
174
|
+
self._state = JsonlProcessState.STOPPING
|
|
175
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
176
|
+
if process.stdin is not None:
|
|
177
|
+
process.stdin.close()
|
|
178
|
+
try:
|
|
179
|
+
await self._wait_for_process(process, deadline)
|
|
180
|
+
except TimeoutError:
|
|
181
|
+
_terminate_process(process)
|
|
182
|
+
try:
|
|
183
|
+
await self._wait_for_process(process, deadline)
|
|
184
|
+
except TimeoutError:
|
|
185
|
+
_kill_process(process)
|
|
186
|
+
await process.wait()
|
|
187
|
+
await self._join_tasks()
|
|
188
|
+
self._state = JsonlProcessState.STOPPED
|
|
189
|
+
self._send_closed_message()
|
|
190
|
+
|
|
191
|
+
async def wait(self, *, timeout: float | None = None) -> int | None:
|
|
192
|
+
if self._process is None:
|
|
193
|
+
return self._returncode
|
|
194
|
+
if timeout is None:
|
|
195
|
+
await self._exit_event.wait()
|
|
196
|
+
else:
|
|
197
|
+
_validate_timeout(timeout)
|
|
198
|
+
async with asyncio.timeout(timeout):
|
|
199
|
+
await self._exit_event.wait()
|
|
200
|
+
return self._returncode
|
|
201
|
+
|
|
202
|
+
async def request(
|
|
203
|
+
self,
|
|
204
|
+
method: str,
|
|
205
|
+
params: Mapping[str, object] | None = None,
|
|
206
|
+
*,
|
|
207
|
+
timeout: float,
|
|
208
|
+
) -> JsonlMessage:
|
|
209
|
+
_validate_method(method)
|
|
210
|
+
_validate_timeout(timeout)
|
|
211
|
+
self._ensure_running()
|
|
212
|
+
request_id = self._next_id()
|
|
213
|
+
loop = asyncio.get_running_loop()
|
|
214
|
+
future = loop.create_future()
|
|
215
|
+
self._pending[request_id] = future
|
|
216
|
+
payload: JsonlMessage = {"id": request_id, "method": method}
|
|
217
|
+
if params is not None:
|
|
218
|
+
payload["params"] = dict(params)
|
|
219
|
+
try:
|
|
220
|
+
async with asyncio.timeout(timeout):
|
|
221
|
+
await self._write_message(payload)
|
|
222
|
+
return await asyncio.shield(future)
|
|
223
|
+
except TimeoutError:
|
|
224
|
+
self._remove_pending(request_id, future)
|
|
225
|
+
raise JsonlRequestTimeout(request_id=request_id, method=method) from None
|
|
226
|
+
except asyncio.CancelledError:
|
|
227
|
+
self._remove_pending(request_id, future)
|
|
228
|
+
raise
|
|
229
|
+
except BaseException:
|
|
230
|
+
self._remove_pending(request_id, future)
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
async def notify(
|
|
234
|
+
self,
|
|
235
|
+
method: str,
|
|
236
|
+
params: Mapping[str, object] | None = None,
|
|
237
|
+
*,
|
|
238
|
+
timeout: float,
|
|
239
|
+
) -> None:
|
|
240
|
+
_validate_method(method)
|
|
241
|
+
_validate_timeout(timeout)
|
|
242
|
+
self._ensure_running()
|
|
243
|
+
payload: JsonlMessage = {"method": method}
|
|
244
|
+
if params is not None:
|
|
245
|
+
payload["params"] = dict(params)
|
|
246
|
+
async with asyncio.timeout(timeout):
|
|
247
|
+
await self._write_message(payload)
|
|
248
|
+
|
|
249
|
+
async def respond(
|
|
250
|
+
self,
|
|
251
|
+
request_id: JsonlRequestId,
|
|
252
|
+
*,
|
|
253
|
+
result: Mapping[str, object] | None = None,
|
|
254
|
+
error: Mapping[str, object] | None = None,
|
|
255
|
+
timeout: float,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Write one response to a provider-initiated JSON-RPC request."""
|
|
258
|
+
|
|
259
|
+
if not is_request_id(request_id):
|
|
260
|
+
raise TypeError("request_id must be an integer or string")
|
|
261
|
+
if result is not None and error is not None:
|
|
262
|
+
raise ValueError("a JSONL response cannot contain both result and error")
|
|
263
|
+
if result is None and error is None:
|
|
264
|
+
result = {}
|
|
265
|
+
if result is not None and not isinstance(result, Mapping):
|
|
266
|
+
raise TypeError("result must be a mapping or None")
|
|
267
|
+
if error is not None and not isinstance(error, Mapping):
|
|
268
|
+
raise TypeError("error must be a mapping or None")
|
|
269
|
+
payload: JsonlMessage = {"id": request_id}
|
|
270
|
+
if result is not None:
|
|
271
|
+
payload["result"] = dict(result)
|
|
272
|
+
else:
|
|
273
|
+
payload["error"] = dict(error or {})
|
|
274
|
+
_validate_timeout(timeout)
|
|
275
|
+
async with asyncio.timeout(timeout):
|
|
276
|
+
await self._write_message(payload)
|
|
277
|
+
|
|
278
|
+
async def receive(self, *, timeout: float | None = None) -> JsonlMessage:
|
|
279
|
+
if timeout is None:
|
|
280
|
+
item = await self._incoming.get()
|
|
281
|
+
else:
|
|
282
|
+
_validate_timeout(timeout)
|
|
283
|
+
async with asyncio.timeout(timeout):
|
|
284
|
+
item = await self._incoming.get()
|
|
285
|
+
if item is _QUEUE_CLOSED:
|
|
286
|
+
error = self._fatal_error
|
|
287
|
+
if error is not None:
|
|
288
|
+
raise error
|
|
289
|
+
raise JsonlProcessExited(
|
|
290
|
+
returncode=self._returncode,
|
|
291
|
+
stderr_tail=self.stderr_tail,
|
|
292
|
+
)
|
|
293
|
+
return cast(JsonlMessage, item)
|
|
294
|
+
|
|
295
|
+
async def incoming(self) -> AsyncIterator[JsonlMessage]:
|
|
296
|
+
while True:
|
|
297
|
+
yield await self.receive()
|
|
298
|
+
|
|
299
|
+
async def _write_message(self, payload: Mapping[str, object]) -> None:
|
|
300
|
+
message = validate_message(payload)
|
|
301
|
+
try:
|
|
302
|
+
encoded = (
|
|
303
|
+
json.dumps(
|
|
304
|
+
message,
|
|
305
|
+
ensure_ascii=False,
|
|
306
|
+
separators=(",", ":"),
|
|
307
|
+
).encode("utf-8")
|
|
308
|
+
+ b"\n"
|
|
309
|
+
)
|
|
310
|
+
except (TypeError, ValueError) as error:
|
|
311
|
+
raise JsonlProtocolError(
|
|
312
|
+
"outgoing JSONL message is not serializable"
|
|
313
|
+
) from error
|
|
314
|
+
async with self._write_lock:
|
|
315
|
+
self._ensure_running()
|
|
316
|
+
process = self._process
|
|
317
|
+
if process is None or process.stdin is None:
|
|
318
|
+
raise JsonlProcessNotRunning()
|
|
319
|
+
try:
|
|
320
|
+
process.stdin.write(encoded)
|
|
321
|
+
await process.stdin.drain()
|
|
322
|
+
except (BrokenPipeError, ConnectionError, OSError) as error:
|
|
323
|
+
raise JsonlProcessExited(
|
|
324
|
+
returncode=process.returncode,
|
|
325
|
+
stderr_tail=self.stderr_tail,
|
|
326
|
+
) from error
|
|
327
|
+
|
|
328
|
+
async def _read_stdout(self, process: asyncio.subprocess.Process) -> None:
|
|
329
|
+
stdout = process.stdout
|
|
330
|
+
if stdout is None:
|
|
331
|
+
await self._protocol_failure("stdout pipe is unavailable")
|
|
332
|
+
return
|
|
333
|
+
line_number = 0
|
|
334
|
+
buffer = bytearray()
|
|
335
|
+
try:
|
|
336
|
+
while True:
|
|
337
|
+
chunk = await stdout.read(_JSONL_READ_CHUNK_BYTES)
|
|
338
|
+
if chunk:
|
|
339
|
+
buffer.extend(chunk)
|
|
340
|
+
elif buffer:
|
|
341
|
+
buffer.append(ord("\n"))
|
|
342
|
+
else:
|
|
343
|
+
break
|
|
344
|
+
while True:
|
|
345
|
+
boundary = buffer.find(b"\n")
|
|
346
|
+
if boundary < 0:
|
|
347
|
+
break
|
|
348
|
+
line = bytes(buffer[:boundary])
|
|
349
|
+
del buffer[: boundary + 1]
|
|
350
|
+
line_number += 1
|
|
351
|
+
try:
|
|
352
|
+
decoded = line.decode("utf-8")
|
|
353
|
+
payload = json.loads(decoded)
|
|
354
|
+
except UnicodeDecodeError, json.JSONDecodeError:
|
|
355
|
+
await self._protocol_failure(
|
|
356
|
+
"stdout contains invalid JSONL",
|
|
357
|
+
line_number=line_number,
|
|
358
|
+
)
|
|
359
|
+
return
|
|
360
|
+
if not isinstance(payload, dict):
|
|
361
|
+
await self._protocol_failure(
|
|
362
|
+
"stdout JSONL item must be an object",
|
|
363
|
+
line_number=line_number,
|
|
364
|
+
)
|
|
365
|
+
return
|
|
366
|
+
self._route_message(cast(JsonlMessage, payload))
|
|
367
|
+
if not chunk:
|
|
368
|
+
break
|
|
369
|
+
except asyncio.CancelledError:
|
|
370
|
+
raise
|
|
371
|
+
except (ConnectionError, OSError) as error:
|
|
372
|
+
await self._protocol_failure(f"stdout read failed: {type(error).__name__}")
|
|
373
|
+
|
|
374
|
+
async def _read_stderr(self, process: asyncio.subprocess.Process) -> None:
|
|
375
|
+
stderr = process.stderr
|
|
376
|
+
if stderr is None:
|
|
377
|
+
return
|
|
378
|
+
try:
|
|
379
|
+
while line := await stderr.readline():
|
|
380
|
+
text = line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
381
|
+
self._stderr_tail.append(text)
|
|
382
|
+
if self._stderr_handler is not None:
|
|
383
|
+
result = self._stderr_handler(text)
|
|
384
|
+
if result is not None:
|
|
385
|
+
await result
|
|
386
|
+
except asyncio.CancelledError:
|
|
387
|
+
raise
|
|
388
|
+
except ConnectionError, OSError:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
async def _watch_process(self, process: asyncio.subprocess.Process) -> None:
|
|
392
|
+
returncode = await process.wait()
|
|
393
|
+
self._returncode = returncode
|
|
394
|
+
stdout_task = self._stdout_task
|
|
395
|
+
if stdout_task is not None and stdout_task is not asyncio.current_task():
|
|
396
|
+
await asyncio.gather(stdout_task, return_exceptions=True)
|
|
397
|
+
stderr_task = self._stderr_task
|
|
398
|
+
if stderr_task is not None and stderr_task is not asyncio.current_task():
|
|
399
|
+
await asyncio.gather(stderr_task, return_exceptions=True)
|
|
400
|
+
if self._fatal_error is None and self._pending:
|
|
401
|
+
await self._fail_pending(
|
|
402
|
+
JsonlProcessExited(
|
|
403
|
+
returncode=returncode,
|
|
404
|
+
stderr_tail=self.stderr_tail,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
if self._fatal_error is None and returncode != 0:
|
|
408
|
+
self._fatal_error = JsonlProcessExited(
|
|
409
|
+
returncode=returncode,
|
|
410
|
+
stderr_tail=self.stderr_tail,
|
|
411
|
+
)
|
|
412
|
+
self._state = (
|
|
413
|
+
JsonlProcessState.FAILED
|
|
414
|
+
if self._fatal_error is not None or returncode != 0
|
|
415
|
+
else JsonlProcessState.EXITED
|
|
416
|
+
)
|
|
417
|
+
self._exit_event.set()
|
|
418
|
+
self._send_closed_message()
|
|
419
|
+
|
|
420
|
+
def _route_message(self, payload: JsonlMessage) -> None:
|
|
421
|
+
raw_id = payload.get("id")
|
|
422
|
+
if raw_id is not None and not is_request_id(raw_id):
|
|
423
|
+
self._schedule_protocol_failure("JSONL message id is invalid")
|
|
424
|
+
return
|
|
425
|
+
if raw_id is not None and "method" not in payload:
|
|
426
|
+
future = self._pending.pop(cast(JsonlRequestId, raw_id), None)
|
|
427
|
+
if future is None:
|
|
428
|
+
self._incoming.put_nowait(payload)
|
|
429
|
+
return
|
|
430
|
+
if future.done():
|
|
431
|
+
return
|
|
432
|
+
if "error" in payload:
|
|
433
|
+
error = payload["error"]
|
|
434
|
+
code: int | str | None = None
|
|
435
|
+
message = "remote JSONL request failed"
|
|
436
|
+
if isinstance(error, Mapping):
|
|
437
|
+
raw_code = error.get("code")
|
|
438
|
+
if isinstance(raw_code, (int, str)) and not isinstance(
|
|
439
|
+
raw_code, bool
|
|
440
|
+
):
|
|
441
|
+
code = raw_code
|
|
442
|
+
raw_message = error.get("message")
|
|
443
|
+
if isinstance(raw_message, str) and raw_message:
|
|
444
|
+
message = raw_message
|
|
445
|
+
future.set_exception(
|
|
446
|
+
JsonlRemoteError(
|
|
447
|
+
request_id=cast(JsonlRequestId, raw_id),
|
|
448
|
+
code=code,
|
|
449
|
+
message=message,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
return
|
|
453
|
+
future.set_result(payload)
|
|
454
|
+
return
|
|
455
|
+
self._incoming.put_nowait(payload)
|
|
456
|
+
|
|
457
|
+
async def _protocol_failure(
|
|
458
|
+
self,
|
|
459
|
+
message: str,
|
|
460
|
+
*,
|
|
461
|
+
line_number: int | None = None,
|
|
462
|
+
) -> None:
|
|
463
|
+
error = JsonlProtocolError(message, line_number=line_number)
|
|
464
|
+
if self._fatal_error is None:
|
|
465
|
+
self._fatal_error = error
|
|
466
|
+
await self._fail_pending(error)
|
|
467
|
+
process = self._process
|
|
468
|
+
if process is not None and process.returncode is None:
|
|
469
|
+
_terminate_process(process)
|
|
470
|
+
|
|
471
|
+
def _schedule_protocol_failure(self, message: str) -> None:
|
|
472
|
+
asyncio.create_task(
|
|
473
|
+
self._protocol_failure(message),
|
|
474
|
+
name="codex-app-server-protocol-failure",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
async def _fail_pending(self, error: JsonlTransportError) -> None:
|
|
478
|
+
pending = tuple(self._pending.values())
|
|
479
|
+
self._pending.clear()
|
|
480
|
+
for future in pending:
|
|
481
|
+
if not future.done():
|
|
482
|
+
future.set_exception(error)
|
|
483
|
+
|
|
484
|
+
async def _wait_for_process(
|
|
485
|
+
self,
|
|
486
|
+
process: asyncio.subprocess.Process,
|
|
487
|
+
deadline: float,
|
|
488
|
+
) -> None:
|
|
489
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
490
|
+
if remaining <= 0:
|
|
491
|
+
raise TimeoutError
|
|
492
|
+
async with asyncio.timeout(remaining):
|
|
493
|
+
await asyncio.shield(process.wait())
|
|
494
|
+
|
|
495
|
+
async def _join_tasks(self) -> None:
|
|
496
|
+
tasks = tuple(
|
|
497
|
+
task
|
|
498
|
+
for task in (self._stdout_task, self._stderr_task, self._watch_task)
|
|
499
|
+
if task is not None and task is not asyncio.current_task()
|
|
500
|
+
)
|
|
501
|
+
if tasks:
|
|
502
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
503
|
+
self._stdout_task = None
|
|
504
|
+
self._stderr_task = None
|
|
505
|
+
self._watch_task = None
|
|
506
|
+
|
|
507
|
+
def _reset_runtime_state(self) -> None:
|
|
508
|
+
while True:
|
|
509
|
+
try:
|
|
510
|
+
self._incoming.get_nowait()
|
|
511
|
+
except asyncio.QueueEmpty:
|
|
512
|
+
break
|
|
513
|
+
self._stderr_tail.clear()
|
|
514
|
+
self._returncode = None
|
|
515
|
+
self._fatal_error = None
|
|
516
|
+
self._next_request_id = 0
|
|
517
|
+
self._closed_message_sent = False
|
|
518
|
+
self._exit_event.clear()
|
|
519
|
+
self._pending.clear()
|
|
520
|
+
|
|
521
|
+
def _send_closed_message(self) -> None:
|
|
522
|
+
if self._closed_message_sent:
|
|
523
|
+
return
|
|
524
|
+
self._closed_message_sent = True
|
|
525
|
+
self._incoming.put_nowait(_QUEUE_CLOSED)
|
|
526
|
+
|
|
527
|
+
def _remove_pending(
|
|
528
|
+
self,
|
|
529
|
+
request_id: JsonlRequestId,
|
|
530
|
+
future: asyncio.Future[JsonlMessage],
|
|
531
|
+
) -> None:
|
|
532
|
+
if self._pending.get(request_id) is future:
|
|
533
|
+
self._pending.pop(request_id, None)
|
|
534
|
+
if not future.done():
|
|
535
|
+
future.cancel()
|
|
536
|
+
|
|
537
|
+
def _next_id(self) -> int:
|
|
538
|
+
self._next_request_id += 1
|
|
539
|
+
return self._next_request_id
|
|
540
|
+
|
|
541
|
+
def _ensure_running(self) -> None:
|
|
542
|
+
if not self.is_running:
|
|
543
|
+
raise JsonlProcessNotRunning()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _validate_timeout(timeout: float) -> None:
|
|
547
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
|
548
|
+
raise TypeError("timeout must be a positive number")
|
|
549
|
+
if timeout <= 0:
|
|
550
|
+
raise ValueError("timeout must be positive")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _validate_method(method: str) -> None:
|
|
554
|
+
if not isinstance(method, str) or not method:
|
|
555
|
+
raise ValueError("method must be a non-empty string")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _terminate_process(process: asyncio.subprocess.Process) -> None:
|
|
559
|
+
if process.returncode is not None:
|
|
560
|
+
return
|
|
561
|
+
try:
|
|
562
|
+
if os.name == "nt":
|
|
563
|
+
process.terminate()
|
|
564
|
+
else:
|
|
565
|
+
process.send_signal(signal.SIGTERM)
|
|
566
|
+
except ProcessLookupError, OSError:
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _kill_process(process: asyncio.subprocess.Process) -> None:
|
|
571
|
+
if process.returncode is not None:
|
|
572
|
+
return
|
|
573
|
+
try:
|
|
574
|
+
process.kill()
|
|
575
|
+
except ProcessLookupError, OSError:
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
__all__ = [
|
|
580
|
+
"JsonlProcessSpec",
|
|
581
|
+
"JsonlProcessState",
|
|
582
|
+
"JsonlProcessSupervisor",
|
|
583
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
type JsonlMessage = dict[str, object]
|
|
6
|
+
type JsonlRequestId = int | str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JsonlTransportError(RuntimeError):
|
|
10
|
+
"""Base error for the adapter-local JSONL process boundary."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, *, kind: str) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.kind = kind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JsonlProcessNotRunning(JsonlTransportError):
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
super().__init__(
|
|
20
|
+
"JSONL process is not running",
|
|
21
|
+
kind="process_not_running",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JsonlProcessExited(JsonlTransportError):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
returncode: int | None,
|
|
30
|
+
stderr_tail: tuple[str, ...] = (),
|
|
31
|
+
) -> None:
|
|
32
|
+
self.returncode = returncode
|
|
33
|
+
self.stderr_tail = stderr_tail
|
|
34
|
+
returncode_text = "unknown" if returncode is None else str(returncode)
|
|
35
|
+
super().__init__(
|
|
36
|
+
f"JSONL process exited with return code {returncode_text}",
|
|
37
|
+
kind="process_exited",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class JsonlProtocolError(JsonlTransportError):
|
|
42
|
+
def __init__(self, message: str, *, line_number: int | None = None) -> None:
|
|
43
|
+
self.line_number = line_number
|
|
44
|
+
super().__init__(message, kind="protocol_error")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class JsonlRequestTimeout(TimeoutError, JsonlTransportError):
|
|
48
|
+
def __init__(self, *, request_id: JsonlRequestId, method: str) -> None:
|
|
49
|
+
self.request_id = request_id
|
|
50
|
+
self.method = method
|
|
51
|
+
TimeoutError.__init__(
|
|
52
|
+
self,
|
|
53
|
+
f"JSONL request {method!r} timed out for id {request_id!r}",
|
|
54
|
+
)
|
|
55
|
+
self.kind = "request_timeout"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class JsonlRemoteError(JsonlTransportError):
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
request_id: JsonlRequestId,
|
|
63
|
+
code: int | str | None,
|
|
64
|
+
message: str,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.request_id = request_id
|
|
67
|
+
self.code = code
|
|
68
|
+
self.remote_message = message
|
|
69
|
+
super().__init__(
|
|
70
|
+
f"JSONL request {request_id!r} failed: {message}",
|
|
71
|
+
kind="remote_error",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CodexAppServerProtocolError(ValueError):
|
|
76
|
+
"""The Codex App Server response does not match its provider contract."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_message(payload: Mapping[str, object]) -> JsonlMessage:
|
|
80
|
+
"""Copy a mapping into the transport's provider-local message shape."""
|
|
81
|
+
|
|
82
|
+
if not isinstance(payload, Mapping):
|
|
83
|
+
raise TypeError("JSONL message must be a mapping")
|
|
84
|
+
return dict(payload)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_request_id(value: object) -> bool:
|
|
88
|
+
return isinstance(value, (int, str)) and not isinstance(value, bool)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"CodexAppServerProtocolError",
|
|
93
|
+
"JsonlMessage",
|
|
94
|
+
"JsonlProcessExited",
|
|
95
|
+
"JsonlProcessNotRunning",
|
|
96
|
+
"JsonlProtocolError",
|
|
97
|
+
"JsonlRemoteError",
|
|
98
|
+
"JsonlRequestId",
|
|
99
|
+
"JsonlRequestTimeout",
|
|
100
|
+
"JsonlTransportError",
|
|
101
|
+
"is_request_id",
|
|
102
|
+
"validate_message",
|
|
103
|
+
]
|