syncmill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/agents.py
ADDED
|
@@ -0,0 +1,2064 @@
|
|
|
1
|
+
"""Agent abstraction — wraps CLI tools as pure functions (prompt in, result out)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import contextvars
|
|
8
|
+
import codecs
|
|
9
|
+
import io
|
|
10
|
+
import locale
|
|
11
|
+
import selectors
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from time import monotonic, time
|
|
21
|
+
from typing import NamedTuple, Protocol, runtime_checkable
|
|
22
|
+
from uuid import UUID, uuid4
|
|
23
|
+
|
|
24
|
+
from syncmill.platform_support import require_supported_platform
|
|
25
|
+
from syncmill.config import AgentConfig, SyncmillConfig
|
|
26
|
+
from syncmill.git_env import is_git_routing_var
|
|
27
|
+
from syncmill.models import (
|
|
28
|
+
TaskResult,
|
|
29
|
+
TaskStatus,
|
|
30
|
+
TokenUsage,
|
|
31
|
+
ToolCallRecord,
|
|
32
|
+
ToolOutcome,
|
|
33
|
+
ToolTelemetry,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ANSI escape sequence stripper (pattern from autocli)
|
|
37
|
+
_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
38
|
+
_CWD_ROUTING_ENV = {"OLDPWD", "CDPATH"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _strip_ansi(text: str) -> str:
|
|
42
|
+
return _ANSI_RE.sub("", text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _clean_output(raw: str | bytes | None) -> str:
|
|
46
|
+
"""ANSI-strip and trim output that may be str, bytes, or None.
|
|
47
|
+
|
|
48
|
+
subprocess.run yields *bytes* in TimeoutExpired.stdout even with text=True,
|
|
49
|
+
so the timeout path must decode before ANSI-stripping.
|
|
50
|
+
"""
|
|
51
|
+
if raw is None:
|
|
52
|
+
return ""
|
|
53
|
+
if isinstance(raw, bytes):
|
|
54
|
+
raw = raw.decode("utf-8", errors="replace")
|
|
55
|
+
return _strip_ansi(raw).strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#: syncmill's own run-identity IPC, stripped from every agent subprocess.
|
|
59
|
+
#: Duplicated as literals rather than imported so ``agents`` keeps importing
|
|
60
|
+
#: nothing from ``monitor`` (the monitor imports this module; the reverse would
|
|
61
|
+
#: cycle) — ``session.py`` strips the same pair for the same reason. An agent
|
|
62
|
+
#: that inherits them and then invokes syncmill itself would satisfy the inner
|
|
63
|
+
#: monitor's self-detection and adopt the parent's run id, overwriting the
|
|
64
|
+
#: parent's envelope and result.json with its own nested run.
|
|
65
|
+
_RUN_IPC_ENV = {"SYNCMILL_MONITOR_DIR", "SYNCMILL_RUN_ID"}
|
|
66
|
+
_WORKFLOW_JOB_ENV = {"SYNCMILL_TUI_JOB_ID"}
|
|
67
|
+
|
|
68
|
+
# Workflow workers deliberately inherit a much smaller environment than normal
|
|
69
|
+
# top-level runs. HOME remains because provider CLIs keep authenticated session
|
|
70
|
+
# state there; provider API keys are the only secret-shaped defaults. Extra
|
|
71
|
+
# cloud/proxy credentials require an exact per-agent allowlist in configuration.
|
|
72
|
+
_WORKFLOW_COMMON_ENV = {
|
|
73
|
+
"COLORTERM",
|
|
74
|
+
"HOME",
|
|
75
|
+
"LANG",
|
|
76
|
+
"LC_ALL",
|
|
77
|
+
"LOGNAME",
|
|
78
|
+
"PATH",
|
|
79
|
+
"SHELL",
|
|
80
|
+
"TEMP",
|
|
81
|
+
"TERM",
|
|
82
|
+
"TMP",
|
|
83
|
+
"TMPDIR",
|
|
84
|
+
"USER",
|
|
85
|
+
"XDG_CACHE_HOME",
|
|
86
|
+
"XDG_CONFIG_HOME",
|
|
87
|
+
"XDG_DATA_HOME",
|
|
88
|
+
}
|
|
89
|
+
_WORKFLOW_PROVIDER_ENV = {
|
|
90
|
+
"claude": {"ANTHROPIC_API_KEY"},
|
|
91
|
+
"codex": {"OPENAI_API_KEY"},
|
|
92
|
+
"kimi-code": {"KIMI_API_KEY", "MOONSHOT_API_KEY"},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#: Launch each agent CLI as its own session/process-group leader (POSIX only —
|
|
96
|
+
#: the flag is unsupported elsewhere). Two effects, both load-bearing: a timeout
|
|
97
|
+
#: kill can reach the child's *grandchildren* (real agent CLIs spawn MCP/language
|
|
98
|
+
#: servers that inherit the stdout/stderr pipes — killing only the direct child
|
|
99
|
+
#: leaves them holding the pipes, wedging the drain until the full timeout, and
|
|
100
|
+
#: alive after syncmill exits); and the child is detached from the controlling
|
|
101
|
+
#: terminal, so a CLI that prompts interactively (auth expiry, "continue? [y/N]")
|
|
102
|
+
#: can never read the user's tty — under a concurrent fan-out N agents would all
|
|
103
|
+
#: race for it. ``rooms.py``/``tui_jobs.py`` already launch their children this
|
|
104
|
+
#: way; this aligns the agent paths.
|
|
105
|
+
_START_NEW_SESSION = os.name == "posix"
|
|
106
|
+
|
|
107
|
+
#: C0 controls other than tab/newline/CR, plus DEL and the C1 range: the
|
|
108
|
+
#: characters a terminal can interpret as control sequences (ESC/CSI/OSC), and
|
|
109
|
+
#: — for NUL specifically — the ones no argv element may contain at all.
|
|
110
|
+
PROMPT_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scrub_prompt_text(text: str) -> str:
|
|
114
|
+
"""Drop characters that cannot ride an argv element (or a terminal) safely.
|
|
115
|
+
|
|
116
|
+
A NUL survives strict UTF-8 but makes ``Popen``/``create_subprocess_exec``
|
|
117
|
+
raise ``ValueError("embedded null byte")`` on the argv element — an
|
|
118
|
+
exception class the launch handlers do not catch, so it escapes the strategy
|
|
119
|
+
and freezes the whole run as ``recovery_required`` before any agent starts.
|
|
120
|
+
Terminal controls (ESC/CSI/OSC, C1) are dropped for the same reason
|
|
121
|
+
``rooms._validate_prompt_text`` rejects them: recalled text is untrusted and
|
|
122
|
+
is echoed by the CLI/TUI display paths.
|
|
123
|
+
|
|
124
|
+
``rooms`` VALIDATES (a human typed the room prompt, so a rejection is
|
|
125
|
+
actionable); this SCRUBS, because its input is best-effort recalled memory
|
|
126
|
+
that no user can correct.
|
|
127
|
+
"""
|
|
128
|
+
return PROMPT_CONTROL_CHARS.sub("", text)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _drop_transcript_caches() -> None:
|
|
132
|
+
"""Release the transcript memo once a result is built.
|
|
133
|
+
|
|
134
|
+
The single-entry caches exist only to collapse the five accessor calls of
|
|
135
|
+
ONE result-construction span into one parse; after the result is attached
|
|
136
|
+
they would otherwise pin the last (potentially tens-of-MB) transcript plus
|
|
137
|
+
its parsed event graph for the lifetime of a long-lived process (`board run
|
|
138
|
+
--loop`, the TUI) — codex round-1 major.
|
|
139
|
+
"""
|
|
140
|
+
_claude_events.cache_clear()
|
|
141
|
+
_json_lines.cache_clear()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class OutputLimitExceeded(RuntimeError):
|
|
145
|
+
"""The provider's combined raw output exceeded its configured budget."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _bounded_sync_run(
|
|
149
|
+
cmd: list[str], *, timeout: int, output_max_bytes: int | None,
|
|
150
|
+
tail_chars: int | None, **kwargs,
|
|
151
|
+
) -> subprocess.CompletedProcess:
|
|
152
|
+
"""Drain both pipes in fixed chunks, retaining only the requested budget."""
|
|
153
|
+
text_mode = kwargs.pop("text", False)
|
|
154
|
+
text_mode = kwargs.pop("universal_newlines", False) or text_mode
|
|
155
|
+
encoding = kwargs.pop("encoding", None) or locale.getencoding()
|
|
156
|
+
errors = kwargs.pop("errors", None) or "strict"
|
|
157
|
+
kwargs.pop("capture_output", None)
|
|
158
|
+
kwargs.setdefault("start_new_session", _START_NEW_SESSION)
|
|
159
|
+
buffers = [bytearray(), bytearray()]
|
|
160
|
+
tails = ["", ""]
|
|
161
|
+
decoders = [
|
|
162
|
+
io.IncrementalNewlineDecoder(codecs.getincrementaldecoder(encoding)(errors), True)
|
|
163
|
+
for _ in range(2)
|
|
164
|
+
]
|
|
165
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
|
|
166
|
+
total = 0
|
|
167
|
+
deadline = monotonic() + timeout
|
|
168
|
+
|
|
169
|
+
def output(index: int):
|
|
170
|
+
if tail_chars is not None:
|
|
171
|
+
return tails[index]
|
|
172
|
+
raw = bytes(buffers[index])
|
|
173
|
+
if text_mode:
|
|
174
|
+
return raw.decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
|
|
175
|
+
return raw
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
with selectors.DefaultSelector() as selector:
|
|
179
|
+
for index, stream in enumerate((proc.stdout, proc.stderr)):
|
|
180
|
+
selector.register(stream, selectors.EVENT_READ, index)
|
|
181
|
+
while selector.get_map():
|
|
182
|
+
remaining = deadline - monotonic()
|
|
183
|
+
if remaining <= 0:
|
|
184
|
+
raise subprocess.TimeoutExpired(cmd, timeout, output(0), output(1))
|
|
185
|
+
for key, _ in selector.select(remaining):
|
|
186
|
+
chunk = os.read(key.fd, 4096)
|
|
187
|
+
index = key.data
|
|
188
|
+
total += len(chunk)
|
|
189
|
+
if output_max_bytes is not None and total > output_max_bytes:
|
|
190
|
+
raise OutputLimitExceeded(f"output limit exceeded ({output_max_bytes} bytes)")
|
|
191
|
+
if tail_chars is not None:
|
|
192
|
+
decoded = decoders[index].decode(chunk, final=not chunk)
|
|
193
|
+
tails[index] = (tails[index] + decoded)[-tail_chars:]
|
|
194
|
+
else:
|
|
195
|
+
buffers[index].extend(chunk)
|
|
196
|
+
if not chunk:
|
|
197
|
+
selector.unregister(key.fileobj)
|
|
198
|
+
# Both pipes are at EOF but the leader may still be running (a
|
|
199
|
+
# grandchild closed the inherited fds first). Popen.wait builds its
|
|
200
|
+
# own TimeoutExpired with output=None, which would drop the bytes we
|
|
201
|
+
# already drained — re-raise with them so this path keeps the same
|
|
202
|
+
# "sync timeouts retain their bounded partial stdout" contract the
|
|
203
|
+
# deadline branch above honors.
|
|
204
|
+
try:
|
|
205
|
+
proc.wait(timeout=max(0.001, deadline - monotonic()))
|
|
206
|
+
except subprocess.TimeoutExpired:
|
|
207
|
+
raise subprocess.TimeoutExpired(
|
|
208
|
+
cmd, timeout, output(0), output(1)
|
|
209
|
+
) from None
|
|
210
|
+
except BaseException:
|
|
211
|
+
with contextlib.suppress(Exception):
|
|
212
|
+
_kill_process_group(proc, owned=True)
|
|
213
|
+
proc.kill()
|
|
214
|
+
with contextlib.suppress(Exception):
|
|
215
|
+
proc.wait(timeout=_REAP_TIMEOUT_SECONDS)
|
|
216
|
+
raise
|
|
217
|
+
finally:
|
|
218
|
+
_close_process_streams(proc)
|
|
219
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, output(0), output(1))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _sync_run(
|
|
223
|
+
cmd: list[str], *, timeout: int, output_max_bytes: int | None = None,
|
|
224
|
+
tail_chars: int | None = None, **kwargs,
|
|
225
|
+
) -> subprocess.CompletedProcess:
|
|
226
|
+
"""``subprocess.run(capture_output=True)`` that group-kills on timeout.
|
|
227
|
+
|
|
228
|
+
``subprocess.run`` kills only the DIRECT child on timeout and never exposes
|
|
229
|
+
the ``Popen``, so the pid needed for a group kill is unreachable — MCP/
|
|
230
|
+
language-server grandchildren survived a sync-path timeout indefinitely
|
|
231
|
+
(codex round-1 major). This drop-in retains the ``Popen``: on timeout it
|
|
232
|
+
SIGKILLs the child's whole process group first, then the child, collects
|
|
233
|
+
whatever output was produced (bounded — a surviving pipe holder must not
|
|
234
|
+
wedge the reap), and re-raises ``TimeoutExpired`` with that output so the
|
|
235
|
+
caller's handling is unchanged. Raises ``FileNotFoundError``/``OSError``
|
|
236
|
+
from the launch exactly like ``subprocess.run``.
|
|
237
|
+
|
|
238
|
+
The NEUTRAL owned-group runner: gates, isolated apply verification, and
|
|
239
|
+
toolgraph preflight route their timeout-controlled subprocesses through it
|
|
240
|
+
too (codex round-7 — their bare ``subprocess.run(timeout=...)`` killed only
|
|
241
|
+
the direct child, orphaning spawned workers). ``capture_output`` is
|
|
242
|
+
accepted for run()-compatibility and ignored — this runner always pipes —
|
|
243
|
+
and ``start_new_session`` defaults on (POSIX) so the group kill has a group.
|
|
244
|
+
"""
|
|
245
|
+
require_supported_platform()
|
|
246
|
+
if output_max_bytes is not None and output_max_bytes < 1:
|
|
247
|
+
raise ValueError("output_max_bytes must be positive")
|
|
248
|
+
if tail_chars is not None and tail_chars < 1:
|
|
249
|
+
raise ValueError("tail_chars must be positive")
|
|
250
|
+
if output_max_bytes is not None or tail_chars is not None:
|
|
251
|
+
return _bounded_sync_run(
|
|
252
|
+
cmd, timeout=timeout, output_max_bytes=output_max_bytes,
|
|
253
|
+
tail_chars=tail_chars, **kwargs,
|
|
254
|
+
)
|
|
255
|
+
# Deliberately NOT a `with Popen(...)` block: Popen.__exit__ ends with an
|
|
256
|
+
# UNBOUNDED wait(), so an unreapable child (a grandchild pinning the pipes
|
|
257
|
+
# past the bounded reap) would hang this frame on the way out — defeating
|
|
258
|
+
# the very timeout contract this wrapper exists to provide (codex round-5).
|
|
259
|
+
# Streams are closed explicitly instead; a reap that times out leaves at
|
|
260
|
+
# worst a zombie, reaped at process exit — the bounded give-up, not a hang.
|
|
261
|
+
kwargs.pop("capture_output", None) # always captured below
|
|
262
|
+
kwargs.setdefault("start_new_session", _START_NEW_SESSION)
|
|
263
|
+
proc = subprocess.Popen(
|
|
264
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs
|
|
265
|
+
)
|
|
266
|
+
try:
|
|
267
|
+
try:
|
|
268
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
269
|
+
except subprocess.TimeoutExpired as first:
|
|
270
|
+
_kill_process_group(proc, owned=True) # we launched this group
|
|
271
|
+
proc.kill()
|
|
272
|
+
try:
|
|
273
|
+
stdout, stderr = proc.communicate(timeout=_REAP_TIMEOUT_SECONDS)
|
|
274
|
+
except (subprocess.TimeoutExpired, UnicodeDecodeError):
|
|
275
|
+
# Grandchild still holds the pipes, or what it wrote does not
|
|
276
|
+
# decode — keep whatever partial buffers the FIRST timeout
|
|
277
|
+
# captured rather than dropping them.
|
|
278
|
+
#
|
|
279
|
+
# UnicodeDecodeError is caught here because this collection is a
|
|
280
|
+
# courtesy: the call ALREADY timed out, and the caller's handler
|
|
281
|
+
# is written for that. Letting a decode failure escape replaces
|
|
282
|
+
# `TimeoutExpired` with a `ValueError` nothing on that path
|
|
283
|
+
# catches, so a caller that classifies a timeout one way and an
|
|
284
|
+
# internal error another gets the wrong one — which for the
|
|
285
|
+
# toolgraph preflight means blocking a run whose policy said to
|
|
286
|
+
# warn. The first timeout is still the truth; this only decides
|
|
287
|
+
# how much of the output survives it.
|
|
288
|
+
stdout, stderr = first.stdout, first.stderr
|
|
289
|
+
raise subprocess.TimeoutExpired(
|
|
290
|
+
cmd, timeout, output=stdout, stderr=stderr
|
|
291
|
+
) from None
|
|
292
|
+
except BaseException:
|
|
293
|
+
# A Ctrl-C (or any interrupt) unwinds this frame, but
|
|
294
|
+
# start_new_session detached the child from the terminal's
|
|
295
|
+
# foreground group — the SIGINT that reached US never reached IT,
|
|
296
|
+
# so without an explicit kill the agent (and its grandchildren)
|
|
297
|
+
# keeps running, editing a workspace the user believes cancelled
|
|
298
|
+
# (codex round-2 blocker, reproduced). Kill the tree, reap
|
|
299
|
+
# bounded, then propagate.
|
|
300
|
+
with contextlib.suppress(Exception):
|
|
301
|
+
_kill_process_group(proc, owned=True)
|
|
302
|
+
proc.kill()
|
|
303
|
+
with contextlib.suppress(Exception):
|
|
304
|
+
proc.wait(timeout=_REAP_TIMEOUT_SECONDS)
|
|
305
|
+
raise
|
|
306
|
+
finally:
|
|
307
|
+
_close_process_streams(proc)
|
|
308
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _close_process_streams(proc: object) -> None:
|
|
312
|
+
"""Close a Popen's owned pipes without waiting on the process (never raises)."""
|
|
313
|
+
for name in ("stdout", "stderr", "stdin"):
|
|
314
|
+
stream = getattr(proc, name, None)
|
|
315
|
+
if stream is not None:
|
|
316
|
+
with contextlib.suppress(Exception):
|
|
317
|
+
stream.close()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _kill_process_group(proc: object, *, owned: bool = False) -> bool:
|
|
321
|
+
"""Best-effort SIGKILL of the child's own process group (grandchildren too).
|
|
322
|
+
|
|
323
|
+
Meaningful only when the child was launched with ``start_new_session`` — it
|
|
324
|
+
then leads a group whose pgid equals its pid. Returns True when a group
|
|
325
|
+
signal was actually sent; never raises.
|
|
326
|
+
|
|
327
|
+
``owned=True`` (every in-module caller — they launched the child with
|
|
328
|
+
``start_new_session`` themselves) kills the group even after the direct
|
|
329
|
+
child was REAPED (``returncode`` set): a fast-exiting leader whose
|
|
330
|
+
grandchild still holds the pipes leaves that grandchild as the group's
|
|
331
|
+
surviving member, and skipping here is a guaranteed leak (codex round-6,
|
|
332
|
+
reproduced). This is safe because POSIX reserves a pid from reuse while a
|
|
333
|
+
process group with that id still has members — so as long as our
|
|
334
|
+
grandchildren live, the pgid provably still names OUR group, and once they
|
|
335
|
+
are gone the killpg is an ESRCH no-op. The residue is the narrow window
|
|
336
|
+
where the group empties AND the pid is recycled as a new group's id between
|
|
337
|
+
the reap and this call — traded for closing the reproduced leak.
|
|
338
|
+
|
|
339
|
+
``owned=False`` (an arbitrary process we did not launch) keeps the strict
|
|
340
|
+
guard: a reaped child's pid may already belong to someone else, and a group
|
|
341
|
+
kill to a recycled pid is the one failure mode worse than an orphan.
|
|
342
|
+
"""
|
|
343
|
+
if not _START_NEW_SESSION:
|
|
344
|
+
return False
|
|
345
|
+
pid = getattr(proc, "pid", None)
|
|
346
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
347
|
+
return False
|
|
348
|
+
if getattr(proc, "returncode", None) is not None and not owned:
|
|
349
|
+
return False
|
|
350
|
+
try:
|
|
351
|
+
os.killpg(pid, signal.SIGKILL)
|
|
352
|
+
except OSError:
|
|
353
|
+
return False
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _isolated_process_env(workdir: Path, agent_id: str = "") -> dict[str, str]:
|
|
358
|
+
"""Build an environment that cannot route work outside ``workdir``."""
|
|
359
|
+
excluded = _CWD_ROUTING_ENV | _RUN_IPC_ENV | _WORKFLOW_JOB_ENV | {"PWD"}
|
|
360
|
+
workflow_allowlist = _WORKFLOW_ENV_ALLOWLIST.get()
|
|
361
|
+
allowed = None
|
|
362
|
+
if workflow_allowlist is not None:
|
|
363
|
+
allowed = (
|
|
364
|
+
_WORKFLOW_COMMON_ENV
|
|
365
|
+
| _WORKFLOW_PROVIDER_ENV.get(agent_id, set())
|
|
366
|
+
| workflow_allowlist
|
|
367
|
+
)
|
|
368
|
+
env = {
|
|
369
|
+
key: value
|
|
370
|
+
for key, value in os.environ.items()
|
|
371
|
+
if key not in excluded
|
|
372
|
+
and not is_git_routing_var(key)
|
|
373
|
+
and (allowed is None or key in allowed)
|
|
374
|
+
}
|
|
375
|
+
env["PWD"] = str(workdir.resolve())
|
|
376
|
+
return env
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ── Live-output observability seam (#16) ──────────────────────────────────
|
|
380
|
+
#
|
|
381
|
+
# An AMBIENT seam, NOT a change to the agent contract: ``aexecute`` consults a
|
|
382
|
+
# ``ContextVar`` (default ``None`` → bounded capture without a live sink)
|
|
383
|
+
# and, when a sink is present, tees each output chunk to it as the agent streams
|
|
384
|
+
# — so a monitor can show live output in tmux while the agent stays a pure
|
|
385
|
+
# ``(prompt, workdir, timeout)`` function that knows nothing about the Supervisor
|
|
386
|
+
# (the way it already "knows" ``os.environ`` and stdout). The ``OutputSink``
|
|
387
|
+
# Protocol lives HERE, not in ``monitor.py``, precisely so ``agents.py`` imports
|
|
388
|
+
# nothing from ``monitor`` (the monitor imports this) and the graph stays acyclic.
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@runtime_checkable
|
|
392
|
+
class OutputSink(Protocol):
|
|
393
|
+
"""A best-effort observer of an agent's incremental output (#16).
|
|
394
|
+
|
|
395
|
+
``feed`` receives raw output bytes (the same bytes ``aexecute`` accumulates
|
|
396
|
+
for the result) as they stream, keyed by agent id so a single sink can route
|
|
397
|
+
several concurrent agents. Contract: it **must not block and must never
|
|
398
|
+
raise** — it runs inside the agent's drain loop on the shared event loop, so a
|
|
399
|
+
blocking or throwing sink would stall or break the run (``aexecute`` guards
|
|
400
|
+
the call defensively regardless).
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
def feed(self, agent_id: str, chunk: bytes) -> None: ...
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
_OUTPUT_SINK: contextvars.ContextVar[OutputSink | None] = contextvars.ContextVar(
|
|
407
|
+
"syncmill_output_sink", default=None
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
#: The sink key for THIS execution, when it needs to differ from the agent id.
|
|
411
|
+
#: A fan-out normally runs each agent once, so the agent id identifies the stream;
|
|
412
|
+
#: DecomposeStrategy's round-robin legitimately runs one agent several times AT
|
|
413
|
+
#: THE SAME TIME, and those executions would otherwise interleave byte-wise into a
|
|
414
|
+
#: single log and share one pane. Ambient like ``_OUTPUT_SINK`` (a Task copies the
|
|
415
|
+
#: context at creation, so each fan-out item carries its own), so the agent
|
|
416
|
+
#: contract stays ``(prompt, workdir, timeout)``.
|
|
417
|
+
_SINK_LABEL: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
418
|
+
"syncmill_sink_label", default=None
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
_TOOL_CAPTURE: contextvars.ContextVar[
|
|
422
|
+
tuple[bool, dict[str, dict[str, str]]]
|
|
423
|
+
] = contextvars.ContextVar("syncmill_tool_capture", default=(False, {}))
|
|
424
|
+
|
|
425
|
+
_PROVIDER_SESSIONS: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
|
|
426
|
+
"syncmill_provider_sessions", default=None
|
|
427
|
+
)
|
|
428
|
+
_ACTIVE_PROVIDER_SESSION: contextvars.ContextVar[tuple[str | None, bool, bool]] = (
|
|
429
|
+
contextvars.ContextVar("syncmill_active_provider_session", default=(None, False, False))
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Difficulty-based model routing: the run's effective provider→selector map
|
|
433
|
+
# (the decision's ``effective_selectors`` — an immutable mapping keyed by real
|
|
434
|
+
# agent id, ``None`` meaning "omit the model flag"). Default ``None`` preserves
|
|
435
|
+
# today's command builders byte-for-byte; the built-in builders each read only
|
|
436
|
+
# their own agent-id entry. Kept selector-shaped (not the whole decision) so
|
|
437
|
+
# this module never imports ``model_routing``.
|
|
438
|
+
_MODEL_SELECTORS: contextvars.ContextVar[Mapping[str, str | None] | None] = (
|
|
439
|
+
contextvars.ContextVar("syncmill_model_selectors", default=None)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Workflow planning is an ambient execution mode, like model routing. It keeps
|
|
443
|
+
# the AgentRunner contract unchanged while built-in CLIs receive read-only
|
|
444
|
+
# permissions in the disposable planning worktree.
|
|
445
|
+
_READ_ONLY_PLANNING: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
446
|
+
"syncmill_read_only_planning", default=False
|
|
447
|
+
)
|
|
448
|
+
_DISABLE_NESTED_AGENTS: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
449
|
+
"syncmill_disable_nested_agents", default=False
|
|
450
|
+
)
|
|
451
|
+
_OUTPUT_BYTE_LIMIT: contextvars.ContextVar[int | None] = contextvars.ContextVar(
|
|
452
|
+
"syncmill_output_byte_limit", default=None
|
|
453
|
+
)
|
|
454
|
+
_WORKFLOW_ENV_ALLOWLIST: contextvars.ContextVar[frozenset[str] | None] = (
|
|
455
|
+
contextvars.ContextVar("syncmill_workflow_env_allowlist", default=None)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
#: Where THIS execution reports the wall-clock instant it started timing, when a
|
|
459
|
+
#: fan-out asked for it (``aexecute_each(started_at=)``). A one-element cell per
|
|
460
|
+
#: item, set beside ``_SINK_LABEL`` so each Task carries its own. A batch's items
|
|
461
|
+
#: do not start together — each spawn runs synchronously in the loop before the
|
|
462
|
+
#: next item reaches its seam — so a span placed from the fan-out's start, or from
|
|
463
|
+
#: when results were collected, misplaces every node but the first.
|
|
464
|
+
_INVOCATION_START: contextvars.ContextVar[list[float | None] | None] = (
|
|
465
|
+
contextvars.ContextVar("syncmill_invocation_start", default=None)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def record_invocation_start() -> None:
|
|
470
|
+
"""Record now as this execution's start, if a fan-out is collecting starts.
|
|
471
|
+
|
|
472
|
+
Called at the instant ``duration_seconds`` starts counting, so a span built
|
|
473
|
+
as ``start + duration_seconds`` covers what was actually measured. A no-op
|
|
474
|
+
outside such a fan-out. Runners that bypass ``CLIAgent.aexecute`` may call it
|
|
475
|
+
themselves; those that do not are placed at the fan-out's start instead.
|
|
476
|
+
"""
|
|
477
|
+
cell = _INVOCATION_START.get()
|
|
478
|
+
if cell is not None:
|
|
479
|
+
cell[0] = time()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@contextlib.contextmanager
|
|
483
|
+
def provider_session_scope(
|
|
484
|
+
enabled: bool = True,
|
|
485
|
+
seed: dict[str, str] | None = None,
|
|
486
|
+
fork: bool = False,
|
|
487
|
+
) -> Iterator[None]:
|
|
488
|
+
"""Keep provider-native sessions affine to agent + isolated worktree."""
|
|
489
|
+
token = _PROVIDER_SESSIONS.set(
|
|
490
|
+
{"sessions": {}, "seed": dict(seed or {}), "fork": fork}
|
|
491
|
+
if enabled else None
|
|
492
|
+
)
|
|
493
|
+
try:
|
|
494
|
+
yield
|
|
495
|
+
finally:
|
|
496
|
+
_PROVIDER_SESSIONS.reset(token)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@contextlib.contextmanager
|
|
500
|
+
def capture_tool_calls(
|
|
501
|
+
enabled: bool, tool_name_map: dict[str, dict[str, str]] | None = None
|
|
502
|
+
) -> Iterator[None]:
|
|
503
|
+
"""Enable typed provider-event capture for agent tasks in this context."""
|
|
504
|
+
token = _TOOL_CAPTURE.set((enabled, tool_name_map or {}))
|
|
505
|
+
try:
|
|
506
|
+
yield
|
|
507
|
+
finally:
|
|
508
|
+
_TOOL_CAPTURE.reset(token)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@contextlib.contextmanager
|
|
512
|
+
def model_routing_scope(selectors: Mapping[str, str | None]) -> Iterator[None]:
|
|
513
|
+
"""Expose a run's effective model selectors to the built-in builders.
|
|
514
|
+
|
|
515
|
+
The Supervisor opens exactly one scope per top-level run, around the single
|
|
516
|
+
strategy execution — every phase/fallback/fan-out inside inherits it
|
|
517
|
+
(``asyncio`` task creation copies the current context), so the decision is
|
|
518
|
+
compute-once by construction. A run with routing off opens no scope at all.
|
|
519
|
+
"""
|
|
520
|
+
token = _MODEL_SELECTORS.set(selectors)
|
|
521
|
+
try:
|
|
522
|
+
yield
|
|
523
|
+
finally:
|
|
524
|
+
_MODEL_SELECTORS.reset(token)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@contextlib.contextmanager
|
|
528
|
+
def read_only_planning_scope(
|
|
529
|
+
max_output_bytes: int = 2 * 1024 * 1024,
|
|
530
|
+
env_allowlist: Sequence[str] = (),
|
|
531
|
+
) -> Iterator[None]:
|
|
532
|
+
"""Restrict a built-in provider while it emits a declarative workflow."""
|
|
533
|
+
planning_token = _READ_ONLY_PLANNING.set(True)
|
|
534
|
+
output_token = _OUTPUT_BYTE_LIMIT.set(max_output_bytes)
|
|
535
|
+
env_token = _WORKFLOW_ENV_ALLOWLIST.set(frozenset(env_allowlist))
|
|
536
|
+
try:
|
|
537
|
+
yield
|
|
538
|
+
finally:
|
|
539
|
+
_WORKFLOW_ENV_ALLOWLIST.reset(env_token)
|
|
540
|
+
_OUTPUT_BYTE_LIMIT.reset(output_token)
|
|
541
|
+
_READ_ONLY_PLANNING.reset(planning_token)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@contextlib.contextmanager
|
|
545
|
+
def workflow_worker_scope(
|
|
546
|
+
max_output_bytes: int, env_allowlist: Sequence[str] = ()
|
|
547
|
+
) -> Iterator[None]:
|
|
548
|
+
"""Discourage a workflow node from recursively spawning agents.
|
|
549
|
+
|
|
550
|
+
Only Claude's builder consumes the nesting flag (as ``--disallowedTools
|
|
551
|
+
Agent``); Codex and Kimi have no equivalent, and even under Claude an
|
|
552
|
+
unrestricted shell can still invoke a provider or syncmill itself. So this
|
|
553
|
+
narrows the common path rather than sealing it, and
|
|
554
|
+
``workflow.max_agent_calls`` bounds the calls SYNCMILL DISPATCHES, not
|
|
555
|
+
every provider call a node might cause. Treat it as a budget on
|
|
556
|
+
orchestration, not a sandbox.
|
|
557
|
+
"""
|
|
558
|
+
nested_token = _DISABLE_NESTED_AGENTS.set(True)
|
|
559
|
+
output_token = _OUTPUT_BYTE_LIMIT.set(max_output_bytes)
|
|
560
|
+
env_token = _WORKFLOW_ENV_ALLOWLIST.set(frozenset(env_allowlist))
|
|
561
|
+
try:
|
|
562
|
+
yield
|
|
563
|
+
finally:
|
|
564
|
+
_WORKFLOW_ENV_ALLOWLIST.reset(env_token)
|
|
565
|
+
_OUTPUT_BYTE_LIMIT.reset(output_token)
|
|
566
|
+
_DISABLE_NESTED_AGENTS.reset(nested_token)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _effective_output_limit(configured: int | None) -> int | None:
|
|
570
|
+
"""The tighter of the agent's configured budget and any active scope's.
|
|
571
|
+
|
|
572
|
+
Two limits arrived independently and they are not competing for the same
|
|
573
|
+
job. ``AgentConfig.output_max_bytes`` is the per-invocation safety bound that
|
|
574
|
+
always applies, on the sync path as well as this one. The workflow scopes set
|
|
575
|
+
a much smaller per-node budget (``workflow.max_output_bytes`` defaults to
|
|
576
|
+
1 MiB against the agent default's 32 MiB), so they are there to TIGHTEN the
|
|
577
|
+
bound for the duration of a node, never to lift it.
|
|
578
|
+
|
|
579
|
+
Taking the minimum keeps both properties: a node cannot raise the ceiling its
|
|
580
|
+
own configuration sets, and a run outside any scope is bounded exactly as it
|
|
581
|
+
was. ``None`` means unbounded and loses to any real number, since a scope
|
|
582
|
+
that names a budget is more specific than a configuration that declines to.
|
|
583
|
+
"""
|
|
584
|
+
scoped = _OUTPUT_BYTE_LIMIT.get()
|
|
585
|
+
candidates = [x for x in (configured, scoped) if x is not None]
|
|
586
|
+
return min(candidates) if candidates else None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _model_selector(agent_id: str) -> str | None:
|
|
590
|
+
"""The active scope's selector for one agent, or ``None`` (omit the flag)."""
|
|
591
|
+
selectors = _MODEL_SELECTORS.get()
|
|
592
|
+
if selectors is None:
|
|
593
|
+
return None
|
|
594
|
+
return selectors.get(agent_id)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def normalize_tool_key(raw_name: str, mapping: dict[str, str] | None = None) -> str | None:
|
|
598
|
+
"""Resolve a provider MCP name to strict canonical ``server::tool`` form."""
|
|
599
|
+
mapped = (mapping or {}).get(raw_name)
|
|
600
|
+
candidate = mapped or raw_name
|
|
601
|
+
if candidate.startswith("mcp__") and mapped is None:
|
|
602
|
+
parts = candidate[5:].split("__")
|
|
603
|
+
if len(parts) != 2:
|
|
604
|
+
return None
|
|
605
|
+
candidate = f"{parts[0]}::{parts[1]}"
|
|
606
|
+
if (
|
|
607
|
+
candidate.count("::") != 1
|
|
608
|
+
or any(not part for part in candidate.split("::"))
|
|
609
|
+
or any(ch.isspace() for ch in candidate)
|
|
610
|
+
):
|
|
611
|
+
return None
|
|
612
|
+
return candidate
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@lru_cache(maxsize=1)
|
|
616
|
+
def _json_lines(stdout: str) -> tuple[list[dict], int]:
|
|
617
|
+
# Memoized for the same reason as _claude_events: the codex/kimi accessors
|
|
618
|
+
# each re-derive the JSONL events from the same transcript string within one
|
|
619
|
+
# synchronous span. Callers must not mutate the returned list.
|
|
620
|
+
events: list[dict] = []
|
|
621
|
+
malformed = 0
|
|
622
|
+
for raw in _strip_ansi(stdout).splitlines():
|
|
623
|
+
if not raw.strip():
|
|
624
|
+
continue
|
|
625
|
+
try:
|
|
626
|
+
event = json.loads(raw)
|
|
627
|
+
except (json.JSONDecodeError, RecursionError):
|
|
628
|
+
# RecursionError: a pathologically-nested line must count as
|
|
629
|
+
# malformed, not abort the run (see _claude_events).
|
|
630
|
+
malformed += 1
|
|
631
|
+
continue
|
|
632
|
+
if isinstance(event, dict):
|
|
633
|
+
events.append(event)
|
|
634
|
+
else:
|
|
635
|
+
malformed += 1
|
|
636
|
+
return events, malformed
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class _ClaudeTranscript(NamedTuple):
|
|
640
|
+
"""Events recovered from a Claude transcript, plus how it decoded.
|
|
641
|
+
|
|
642
|
+
``structured`` records that the payload *was* a JSON transcript, which is
|
|
643
|
+
strictly more than ``events`` being non-empty: ``[]``, ``["scalar"]``, or a
|
|
644
|
+
truncated stream can decode as JSON yet yield no usable event. Callers need
|
|
645
|
+
that distinction because the raw transcript may only ever be surfaced as
|
|
646
|
+
output when the payload was never JSON at all — otherwise event bodies
|
|
647
|
+
(system, session, tool results) leak into ``TaskResult.output``.
|
|
648
|
+
"""
|
|
649
|
+
|
|
650
|
+
events: list[dict]
|
|
651
|
+
malformed: int
|
|
652
|
+
structured: bool
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@lru_cache(maxsize=1)
|
|
656
|
+
def _claude_events(stdout: str) -> _ClaudeTranscript:
|
|
657
|
+
"""Normalize any Claude ``-p`` transcript shape into an ordered event list.
|
|
658
|
+
|
|
659
|
+
Memoized (one entry): ``_build_result`` + ``_attach_provider_session`` call
|
|
660
|
+
five accessors that each re-derive the transcript from the SAME string, in
|
|
661
|
+
one synchronous no-await span — so one entry collapses five full
|
|
662
|
+
ANSI-strip + JSON parses of a potentially tens-of-MB transcript into one,
|
|
663
|
+
and a span parsing transcript A can never interleave with one parsing B.
|
|
664
|
+
This also stops a verbose agent from blocking the shared event loop 5× as
|
|
665
|
+
long as necessary (siblings stop draining their pipes while it parses).
|
|
666
|
+
Pure function of its input, so the cache can never serve stale data.
|
|
667
|
+
|
|
668
|
+
Claude emits three wire shapes depending on flags and CLI version: a single
|
|
669
|
+
JSON object (``--output-format json``), JSONL (``--output-format
|
|
670
|
+
stream-json``), and — since 2.1.x — a top-level JSON *array* of those same
|
|
671
|
+
events. Parsing each shape separately is what let an array-shaped exit-0
|
|
672
|
+
rate-limit response read as a success: the dict-only checks never saw the
|
|
673
|
+
``is_error``/``session_id``/``result`` fields buried in the array.
|
|
674
|
+
|
|
675
|
+
``malformed`` counts dropped events — an unparseable JSONL line, a non-dict
|
|
676
|
+
array member, or a payload that decoded to a bare scalar (counted as 1) — so
|
|
677
|
+
telemetry built from the survivors cannot claim to be complete.
|
|
678
|
+
"""
|
|
679
|
+
cleaned = _strip_ansi(stdout).strip()
|
|
680
|
+
if not cleaned:
|
|
681
|
+
return _ClaudeTranscript([], 0, True)
|
|
682
|
+
parsed = None
|
|
683
|
+
try:
|
|
684
|
+
parsed = json.loads(cleaned)
|
|
685
|
+
except (json.JSONDecodeError, ValueError, RecursionError):
|
|
686
|
+
# RecursionError (pathologically-nested stdout such as "[" * 100000) is
|
|
687
|
+
# a RuntimeError, not a ValueError — catch it explicitly so a hostile
|
|
688
|
+
# transcript degrades to the JSONL/plain-text path instead of aborting
|
|
689
|
+
# the run (the parse_board_update lesson).
|
|
690
|
+
parsed = None
|
|
691
|
+
if isinstance(parsed, dict):
|
|
692
|
+
return _ClaudeTranscript([parsed], 0, True)
|
|
693
|
+
if isinstance(parsed, list):
|
|
694
|
+
events = [item for item in parsed if isinstance(item, dict)]
|
|
695
|
+
return _ClaudeTranscript(events, len(parsed) - len(events), True)
|
|
696
|
+
if parsed is not None:
|
|
697
|
+
# Valid JSON, but a bare scalar — structured yet carrying no events.
|
|
698
|
+
return _ClaudeTranscript([], 1, True)
|
|
699
|
+
events, malformed = _json_lines(cleaned)
|
|
700
|
+
# JSONL is only "structured" if at least one line decoded; otherwise this is
|
|
701
|
+
# plain text (a crash message, a usage banner) and belongs in output as-is.
|
|
702
|
+
return _ClaudeTranscript(events, malformed, bool(events))
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _malformed_transcript_error(stdout: str) -> str | None:
|
|
706
|
+
"""Flag zero-exit stdout that LOOKS like JSON yet yields no usable event.
|
|
707
|
+
|
|
708
|
+
A payload opening with ``{``/``[`` announced a structured transcript; if
|
|
709
|
+
nothing decodes into a usable provider event, that is a protocol failure —
|
|
710
|
+
route/compete must not crown it a winner as raw "output". Genuine plain
|
|
711
|
+
text (a crash message, a usage banner) keeps the compatible fallthrough:
|
|
712
|
+
it never claimed to be JSON.
|
|
713
|
+
"""
|
|
714
|
+
cleaned = _strip_ansi(stdout).strip()
|
|
715
|
+
if cleaned.startswith(("{", "[")):
|
|
716
|
+
return "malformed provider transcript: no usable events"
|
|
717
|
+
return None
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _claude_error_message(event: dict) -> str | None:
|
|
721
|
+
"""Extract a human-readable error from an event, or ``None`` if it is not one.
|
|
722
|
+
|
|
723
|
+
Two error encodings exist: the in-band ``is_error`` flag on a result event
|
|
724
|
+
(Claude can exit 0 while setting it), and a top-level ``type:"error"`` event
|
|
725
|
+
carrying the provider's message — the shape a 429 takes in the array
|
|
726
|
+
transcript. The message is preserved verbatim rather than classified; rate
|
|
727
|
+
limiting is deliberately not given retry semantics here, so a rate-limited
|
|
728
|
+
agent fails like any other and the existing breaker/fallback paths apply.
|
|
729
|
+
"""
|
|
730
|
+
if event.get("is_error") is True and event.get("type") in (None, "result"):
|
|
731
|
+
result = event.get("result")
|
|
732
|
+
return result if isinstance(result, str) and result.strip() else "Unknown Claude error"
|
|
733
|
+
if event.get("type") == "error":
|
|
734
|
+
error = event.get("error")
|
|
735
|
+
message = error.get("message") if isinstance(error, dict) else event.get("message")
|
|
736
|
+
return message if isinstance(message, str) and message.strip() else "Unknown Claude error"
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@contextlib.contextmanager
|
|
741
|
+
def stream_to(sink: OutputSink | None) -> Iterator[None]:
|
|
742
|
+
"""Route agents' incremental output to ``sink`` for the duration of the block.
|
|
743
|
+
|
|
744
|
+
A monitor wraps a concurrent strategy's fan-out in this; every agent task the
|
|
745
|
+
fan-out creates inherits the ``ContextVar`` (asyncio copies the current
|
|
746
|
+
context when it creates a Task, and ``run_async`` drives the loop from this
|
|
747
|
+
same synchronous frame), so each ``aexecute`` tees to ``sink``. ``None`` (the
|
|
748
|
+
default) restores the unchanged fast path — used by no-op callers/tests.
|
|
749
|
+
"""
|
|
750
|
+
token = _OUTPUT_SINK.set(sink)
|
|
751
|
+
try:
|
|
752
|
+
yield
|
|
753
|
+
finally:
|
|
754
|
+
_OUTPUT_SINK.reset(token)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# ── Protocol ─────────────────────────────────────────────────────────────
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@runtime_checkable
|
|
761
|
+
class AgentRunner(Protocol):
|
|
762
|
+
"""Any CLI agent that can execute a coding task.
|
|
763
|
+
|
|
764
|
+
Agents are pure functions: prompt in, TaskResult out.
|
|
765
|
+
They know nothing about memory backends, other agents, or the Supervisor.
|
|
766
|
+
|
|
767
|
+
Two execution paths, same contract and same result:
|
|
768
|
+
|
|
769
|
+
* ``execute`` — synchronous (``subprocess.run``). Used by the sequential
|
|
770
|
+
strategies (route, pipeline) and the sync Supervisor.
|
|
771
|
+
* ``aexecute`` — asynchronous (``asyncio.create_subprocess_exec``). The path
|
|
772
|
+
the concurrent strategies (compete, council) await to run agents at once;
|
|
773
|
+
bridge it from sync code with ``memory.run_async`` and fan out via
|
|
774
|
+
``aexecute_isolated`` (one worktree per agent) or ``aexecute_all`` (one
|
|
775
|
+
shared workdir).
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
@property
|
|
779
|
+
def agent_id(self) -> str: ...
|
|
780
|
+
|
|
781
|
+
def execute(self, prompt: str, workdir: Path, timeout: int) -> TaskResult: ...
|
|
782
|
+
|
|
783
|
+
async def aexecute(
|
|
784
|
+
self, prompt: str, workdir: Path, timeout: int
|
|
785
|
+
) -> TaskResult: ...
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# ── Base implementation ──────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
class CLIAgent:
|
|
792
|
+
"""Base class for CLI-based agents using subprocess.run."""
|
|
793
|
+
|
|
794
|
+
def __init__(self, agent_id: str, config: AgentConfig):
|
|
795
|
+
self._agent_id = agent_id
|
|
796
|
+
self._config = config
|
|
797
|
+
|
|
798
|
+
@property
|
|
799
|
+
def agent_id(self) -> str:
|
|
800
|
+
return self._agent_id
|
|
801
|
+
|
|
802
|
+
def _build_command(self, prompt: str) -> list[str]:
|
|
803
|
+
raise NotImplementedError
|
|
804
|
+
|
|
805
|
+
def _parse_output(self, stdout: str) -> str:
|
|
806
|
+
"""Extract meaningful output from CLI stdout. Override per agent."""
|
|
807
|
+
return _strip_ansi(stdout).strip()
|
|
808
|
+
|
|
809
|
+
def _is_error_output(self, stdout: str) -> str | None:
|
|
810
|
+
"""Check if CLI output indicates an error. Override per agent.
|
|
811
|
+
Returns error message if error, None if OK."""
|
|
812
|
+
return None
|
|
813
|
+
|
|
814
|
+
def _parse_tool_telemetry(self, stdout: str) -> ToolTelemetry:
|
|
815
|
+
return ToolTelemetry(source=self._agent_id, complete=True)
|
|
816
|
+
|
|
817
|
+
def _provider_session_from_output(self, stdout: str) -> str | None:
|
|
818
|
+
return None
|
|
819
|
+
|
|
820
|
+
def _usage_from_output(self, stdout: str) -> TokenUsage | None:
|
|
821
|
+
"""Best-effort token/cache telemetry from the CLI transcript (None when
|
|
822
|
+
the provider emits none). Overridden per provider; callers guard it."""
|
|
823
|
+
return None
|
|
824
|
+
|
|
825
|
+
def _session_for(self, workdir: Path) -> tuple[str | None, bool, str]:
|
|
826
|
+
state = _PROVIDER_SESSIONS.get()
|
|
827
|
+
key = f"{self._agent_id}\0{workdir.resolve()}"
|
|
828
|
+
if state is None:
|
|
829
|
+
return None, False, key
|
|
830
|
+
sessions = state["sessions"]
|
|
831
|
+
if key in sessions:
|
|
832
|
+
return sessions[key], True, key
|
|
833
|
+
# The seed map is keyed by agent id for the Supervisor paths, but a
|
|
834
|
+
# fan-out that labels its items (rooms seed by MEMBER id) looks its own
|
|
835
|
+
# label up first — so two members sharing one provider can never collide
|
|
836
|
+
# on a single seeded session. Unlabeled callers (and decompose's subtask
|
|
837
|
+
# labels, which never appear in a seed) fall through to the agent id.
|
|
838
|
+
# The seed map is keyed by agent id for the Supervisor paths, but a
|
|
839
|
+
# fan-out that labels its items (rooms seed by MEMBER id) is looked up
|
|
840
|
+
# by its label ALONE — never falling back to the agent id, or one
|
|
841
|
+
# member of a duplicate-provider room would inherit another member's
|
|
842
|
+
# provider-keyed seed. Unlabeled callers keep the agent-id key.
|
|
843
|
+
label = _SINK_LABEL.get()
|
|
844
|
+
seeded = state["seed"].get(label) if label else state["seed"].get(
|
|
845
|
+
self._agent_id
|
|
846
|
+
)
|
|
847
|
+
if seeded:
|
|
848
|
+
sessions[key] = seeded
|
|
849
|
+
return seeded, True, key
|
|
850
|
+
if self._agent_id == "claude":
|
|
851
|
+
session_id = str(uuid4())
|
|
852
|
+
sessions[key] = session_id
|
|
853
|
+
return session_id, False, key
|
|
854
|
+
return None, False, key
|
|
855
|
+
|
|
856
|
+
def _attach_provider_session(
|
|
857
|
+
self, result: TaskResult, stdout: str, key: str, initial: str | None
|
|
858
|
+
) -> None:
|
|
859
|
+
session_id = self._provider_session_from_output(stdout) or initial
|
|
860
|
+
state = _PROVIDER_SESSIONS.get()
|
|
861
|
+
if session_id and state is not None:
|
|
862
|
+
state["sessions"][key] = session_id
|
|
863
|
+
if session_id:
|
|
864
|
+
result.provider_session_id = session_id
|
|
865
|
+
result.conversation_id = session_id
|
|
866
|
+
|
|
867
|
+
# ── Result building (shared by the sync + async paths) ─────────────────
|
|
868
|
+
|
|
869
|
+
def _build_result(
|
|
870
|
+
self,
|
|
871
|
+
task_id: UUID,
|
|
872
|
+
returncode: int,
|
|
873
|
+
stdout: str,
|
|
874
|
+
stderr: str,
|
|
875
|
+
duration: float,
|
|
876
|
+
) -> TaskResult:
|
|
877
|
+
"""Classify a *completed* process into a TaskResult.
|
|
878
|
+
|
|
879
|
+
The single source of truth for success/failure detection, so the sync
|
|
880
|
+
(``execute``) and async (``aexecute``) paths can never drift: a CLI that
|
|
881
|
+
exits 0 while signalling an in-band error (e.g. Claude's
|
|
882
|
+
``is_error``) fails the same way through either path.
|
|
883
|
+
"""
|
|
884
|
+
output = self._parse_output(stdout)
|
|
885
|
+
output_error = self._is_error_output(stdout)
|
|
886
|
+
is_failed = returncode != 0 or output_error is not None
|
|
887
|
+
error_msg = output_error or (_strip_ansi(stderr).strip() or None) if is_failed else None
|
|
888
|
+
|
|
889
|
+
capture_enabled, _ = _TOOL_CAPTURE.get()
|
|
890
|
+
telemetry = self._parse_tool_telemetry(stdout) if capture_enabled else None
|
|
891
|
+
|
|
892
|
+
# Usage is best-effort telemetry, never load-bearing: a malformed or
|
|
893
|
+
# absent usage payload yields None, and a parser bug must never turn a
|
|
894
|
+
# completed run into a crash — hence the broad guard.
|
|
895
|
+
try:
|
|
896
|
+
usage = self._usage_from_output(stdout)
|
|
897
|
+
except Exception:
|
|
898
|
+
usage = None
|
|
899
|
+
|
|
900
|
+
return TaskResult(
|
|
901
|
+
task_id=task_id,
|
|
902
|
+
agent_id=self._agent_id,
|
|
903
|
+
status=TaskStatus.FAILED if is_failed else TaskStatus.COMPLETED,
|
|
904
|
+
output=output,
|
|
905
|
+
exit_code=returncode,
|
|
906
|
+
duration_seconds=round(duration, 2),
|
|
907
|
+
error=error_msg,
|
|
908
|
+
tool_telemetry=telemetry,
|
|
909
|
+
usage=usage,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
def _failure(
|
|
913
|
+
self,
|
|
914
|
+
task_id: UUID,
|
|
915
|
+
*,
|
|
916
|
+
output: str,
|
|
917
|
+
error: str,
|
|
918
|
+
duration: float,
|
|
919
|
+
exit_code: int = -1,
|
|
920
|
+
) -> TaskResult:
|
|
921
|
+
"""Build a FAILED result for a launch/timeout error (no clean exit)."""
|
|
922
|
+
return TaskResult(
|
|
923
|
+
task_id=task_id,
|
|
924
|
+
agent_id=self._agent_id,
|
|
925
|
+
status=TaskStatus.FAILED,
|
|
926
|
+
output=output,
|
|
927
|
+
exit_code=exit_code,
|
|
928
|
+
duration_seconds=round(duration, 2),
|
|
929
|
+
error=error,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
# ── Execution ──────────────────────────────────────────────────────────
|
|
933
|
+
|
|
934
|
+
def execute(self, prompt: str, workdir: Path, timeout: int) -> TaskResult:
|
|
935
|
+
if "\x00" in prompt:
|
|
936
|
+
# subprocess rejects a NUL in an argv element with ValueError, which
|
|
937
|
+
# no launch handler catches — it would escape into the Supervisor's
|
|
938
|
+
# crash path and freeze the run as recovery_required without any
|
|
939
|
+
# agent having run. Callers scrub recalled text
|
|
940
|
+
# (``strategies.base._enrich_prompt``); this is the deterministic
|
|
941
|
+
# backstop. Checked rather than caught, so a genuinely unexpected
|
|
942
|
+
# ValueError still surfaces as the programming error it is.
|
|
943
|
+
return self._failure(
|
|
944
|
+
uuid4(),
|
|
945
|
+
output="",
|
|
946
|
+
error="prompt contains a NUL byte, which cannot be passed to a "
|
|
947
|
+
"provider process",
|
|
948
|
+
duration=0.0,
|
|
949
|
+
)
|
|
950
|
+
task_id = uuid4()
|
|
951
|
+
session_id, resume, session_key = self._session_for(workdir)
|
|
952
|
+
state = _PROVIDER_SESSIONS.get()
|
|
953
|
+
token = _ACTIVE_PROVIDER_SESSION.set(
|
|
954
|
+
(session_id, resume, bool(state and state.get("fork")))
|
|
955
|
+
)
|
|
956
|
+
try:
|
|
957
|
+
cmd = self._build_command(prompt)
|
|
958
|
+
finally:
|
|
959
|
+
_ACTIVE_PROVIDER_SESSION.reset(token)
|
|
960
|
+
start = monotonic()
|
|
961
|
+
|
|
962
|
+
try:
|
|
963
|
+
proc = _sync_run(
|
|
964
|
+
cmd,
|
|
965
|
+
text=True,
|
|
966
|
+
# No tty, no inherited stdin: an agent CLI that prompts must fail
|
|
967
|
+
# fast on EOF rather than have N concurrent agents race for the
|
|
968
|
+
# user's terminal. Own process group so grandchildren (MCP
|
|
969
|
+
# servers) are signalable and can't read the tty either.
|
|
970
|
+
stdin=subprocess.DEVNULL,
|
|
971
|
+
start_new_session=_START_NEW_SESSION,
|
|
972
|
+
# An agent CLI can emit bytes that are not valid UTF-8 (a latin-1
|
|
973
|
+
# path in an error message, a binary blob). Under the default
|
|
974
|
+
# strict decoding that raises UnicodeDecodeError — a ValueError,
|
|
975
|
+
# which none of the handlers below catch — so it would escape
|
|
976
|
+
# execute entirely and fail the whole run. The async twin already
|
|
977
|
+
# decodes with errors="replace"; this keeps the two paths'
|
|
978
|
+
# classification identical, as the aexecute docstring promises.
|
|
979
|
+
errors="replace",
|
|
980
|
+
cwd=str(workdir),
|
|
981
|
+
timeout=timeout,
|
|
982
|
+
env=_isolated_process_env(workdir, self._agent_id),
|
|
983
|
+
# Same composition as the async path. No scope wraps a sync call
|
|
984
|
+
# today — both openers wrap `run_async` — but reading the budget
|
|
985
|
+
# straight off the config here would make a future sync call site
|
|
986
|
+
# silently ignore a scope that is open, which is the kind of gap
|
|
987
|
+
# that shows up as a node quietly getting the 32 MiB ceiling.
|
|
988
|
+
output_max_bytes=_effective_output_limit(self._config.output_max_bytes),
|
|
989
|
+
)
|
|
990
|
+
result = self._build_result(
|
|
991
|
+
task_id, proc.returncode, proc.stdout, proc.stderr, monotonic() - start
|
|
992
|
+
)
|
|
993
|
+
self._attach_provider_session(result, proc.stdout, session_key, session_id)
|
|
994
|
+
_drop_transcript_caches()
|
|
995
|
+
return result
|
|
996
|
+
except OutputLimitExceeded as exc:
|
|
997
|
+
return self._failure(
|
|
998
|
+
task_id, output="", error=str(exc), duration=monotonic() - start,
|
|
999
|
+
)
|
|
1000
|
+
except subprocess.TimeoutExpired as exc:
|
|
1001
|
+
# _sync_run signals the whole process GROUP and then waits, bounded,
|
|
1002
|
+
# for the direct child. Grandchildren are signalled but not awaited,
|
|
1003
|
+
# so "nothing left to terminate here" is about this frame's
|
|
1004
|
+
# responsibility, not a guarantee that every descendant is reaped.
|
|
1005
|
+
return self._failure(
|
|
1006
|
+
task_id,
|
|
1007
|
+
output=_clean_output(exc.stdout),
|
|
1008
|
+
error=f"Timed out after {timeout}s",
|
|
1009
|
+
duration=monotonic() - start,
|
|
1010
|
+
)
|
|
1011
|
+
except FileNotFoundError:
|
|
1012
|
+
return self._failure(
|
|
1013
|
+
task_id,
|
|
1014
|
+
output="",
|
|
1015
|
+
error=f"CLI tool '{cmd[0]}' not found in PATH",
|
|
1016
|
+
duration=0.0,
|
|
1017
|
+
)
|
|
1018
|
+
except OSError as exc:
|
|
1019
|
+
# PermissionError, bad cwd, ENOEXEC, etc. — don't crash the task.
|
|
1020
|
+
return self._failure(
|
|
1021
|
+
task_id,
|
|
1022
|
+
output="",
|
|
1023
|
+
error=f"Failed to launch '{cmd[0]}': {exc}",
|
|
1024
|
+
duration=monotonic() - start,
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
async def aexecute(self, prompt: str, workdir: Path, timeout: int) -> TaskResult:
|
|
1028
|
+
"""Async twin of ``execute`` via ``asyncio.create_subprocess_exec``.
|
|
1029
|
+
|
|
1030
|
+
Same prompt-in / TaskResult-out contract and identical success/failure
|
|
1031
|
+
classification (both go through ``_build_result``/``_failure``). This is
|
|
1032
|
+
the path the concurrent strategies await.
|
|
1033
|
+
|
|
1034
|
+
Both paths enforce the combined raw-output budget while reading. A
|
|
1035
|
+
monitor sink receives those same chunks and a failure marker on timeout
|
|
1036
|
+
or overflow. Async timeouts return empty output; sync timeouts retain
|
|
1037
|
+
their bounded partial stdout.
|
|
1038
|
+
"""
|
|
1039
|
+
require_supported_platform()
|
|
1040
|
+
if "\x00" in prompt:
|
|
1041
|
+
# subprocess rejects a NUL in an argv element with ValueError, which
|
|
1042
|
+
# no launch handler catches — it would escape into the Supervisor's
|
|
1043
|
+
# crash path and freeze the run as recovery_required without any
|
|
1044
|
+
# agent having run. Callers scrub recalled text
|
|
1045
|
+
# (``strategies.base._enrich_prompt``); this is the deterministic
|
|
1046
|
+
# backstop. Checked rather than caught, so a genuinely unexpected
|
|
1047
|
+
# ValueError still surfaces as the programming error it is.
|
|
1048
|
+
return self._failure(
|
|
1049
|
+
uuid4(),
|
|
1050
|
+
output="",
|
|
1051
|
+
error="prompt contains a NUL byte, which cannot be passed to a "
|
|
1052
|
+
"provider process",
|
|
1053
|
+
duration=0.0,
|
|
1054
|
+
)
|
|
1055
|
+
task_id = uuid4()
|
|
1056
|
+
session_id, resume, session_key = self._session_for(workdir)
|
|
1057
|
+
state = _PROVIDER_SESSIONS.get()
|
|
1058
|
+
token = _ACTIVE_PROVIDER_SESSION.set(
|
|
1059
|
+
(session_id, resume, bool(state and state.get("fork")))
|
|
1060
|
+
)
|
|
1061
|
+
try:
|
|
1062
|
+
cmd = self._build_command(prompt)
|
|
1063
|
+
finally:
|
|
1064
|
+
_ACTIVE_PROVIDER_SESSION.reset(token)
|
|
1065
|
+
start = monotonic()
|
|
1066
|
+
record_invocation_start()
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1070
|
+
*cmd,
|
|
1071
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
1072
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1073
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1074
|
+
cwd=str(workdir),
|
|
1075
|
+
env=_isolated_process_env(workdir, self._agent_id),
|
|
1076
|
+
start_new_session=_START_NEW_SESSION,
|
|
1077
|
+
)
|
|
1078
|
+
except FileNotFoundError:
|
|
1079
|
+
return self._failure(
|
|
1080
|
+
task_id,
|
|
1081
|
+
output="",
|
|
1082
|
+
error=f"CLI tool '{cmd[0]}' not found in PATH",
|
|
1083
|
+
duration=0.0,
|
|
1084
|
+
)
|
|
1085
|
+
except OSError as exc:
|
|
1086
|
+
return self._failure(
|
|
1087
|
+
task_id,
|
|
1088
|
+
output="",
|
|
1089
|
+
error=f"Failed to launch '{cmd[0]}': {exc}",
|
|
1090
|
+
duration=monotonic() - start,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
sink = _OUTPUT_SINK.get()
|
|
1094
|
+
sink_key = _SINK_LABEL.get() or self._agent_id
|
|
1095
|
+
comm = _communicate_teed(
|
|
1096
|
+
proc, sink, sink_key, _effective_output_limit(self._config.output_max_bytes)
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
try:
|
|
1100
|
+
stdout_b, stderr_b = await asyncio.wait_for(comm, timeout=timeout)
|
|
1101
|
+
except OutputLimitExceeded as exc:
|
|
1102
|
+
await _terminate(proc)
|
|
1103
|
+
if sink is not None:
|
|
1104
|
+
with contextlib.suppress(Exception):
|
|
1105
|
+
sink.feed(sink_key, f"\n[syncmill] {exc}\n".encode())
|
|
1106
|
+
return self._failure(
|
|
1107
|
+
task_id, output="", error=str(exc), duration=monotonic() - start,
|
|
1108
|
+
)
|
|
1109
|
+
except asyncio.TimeoutError:
|
|
1110
|
+
# wait_for cancelled communicate()/the tee; SIGKILL the child and reap
|
|
1111
|
+
# it so it can't linger as a zombie.
|
|
1112
|
+
await _terminate(proc)
|
|
1113
|
+
if sink is not None:
|
|
1114
|
+
# The pane already shows the pre-timeout bytes; annotate so it
|
|
1115
|
+
# self-explains (the result's output stays empty — see docstring).
|
|
1116
|
+
with contextlib.suppress(Exception):
|
|
1117
|
+
sink.feed(
|
|
1118
|
+
sink_key,
|
|
1119
|
+
f"\n[syncmill] timed out after {timeout}s\n".encode(),
|
|
1120
|
+
)
|
|
1121
|
+
return self._failure(
|
|
1122
|
+
task_id,
|
|
1123
|
+
output="",
|
|
1124
|
+
error=f"Timed out after {timeout}s",
|
|
1125
|
+
duration=monotonic() - start,
|
|
1126
|
+
)
|
|
1127
|
+
except asyncio.CancelledError:
|
|
1128
|
+
# Cancelled mid-flight — e.g. a sibling in the concurrent fan-out
|
|
1129
|
+
# (aexecute_isolated) raised and is draining us. SIGKILL+reap our child
|
|
1130
|
+
# first so no agent subprocess outlives the cancelled task, then let
|
|
1131
|
+
# the cancellation propagate (never swallow it). `proc.kill()` inside
|
|
1132
|
+
# `_terminate` fires before any await, so the signal lands even if the
|
|
1133
|
+
# reap is itself cut short.
|
|
1134
|
+
await _terminate(proc)
|
|
1135
|
+
raise
|
|
1136
|
+
except BaseException:
|
|
1137
|
+
await _terminate(proc)
|
|
1138
|
+
raise
|
|
1139
|
+
|
|
1140
|
+
# A completed wait supplies an exit status, so this is defensive rather
|
|
1141
|
+
# than a path the stdlib takes: a custom subprocess adapter could return
|
|
1142
|
+
# without one. Coerce to -1 — the codebase's "no clean exit" sentinel —
|
|
1143
|
+
# so exit_code stays an int and the run classifies FAILED. No confirmed
|
|
1144
|
+
# exit must never read as success.
|
|
1145
|
+
returncode = proc.returncode if proc.returncode is not None else -1
|
|
1146
|
+
# Decode ONCE: a second decode of a tens-of-MB transcript doubles peak
|
|
1147
|
+
# memory and CPU for nothing — both consumers want the same string.
|
|
1148
|
+
stdout_s = stdout_b.decode("utf-8", errors="replace")
|
|
1149
|
+
result = self._build_result(
|
|
1150
|
+
task_id,
|
|
1151
|
+
returncode,
|
|
1152
|
+
stdout_s,
|
|
1153
|
+
stderr_b.decode("utf-8", errors="replace"),
|
|
1154
|
+
monotonic() - start,
|
|
1155
|
+
)
|
|
1156
|
+
self._attach_provider_session(result, stdout_s, session_key, session_id)
|
|
1157
|
+
_drop_transcript_caches()
|
|
1158
|
+
return result
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _usage_int(value: object) -> int | None:
|
|
1162
|
+
"""A non-negative int from an untrusted usage field, else None (bools are
|
|
1163
|
+
ints in Python — excluded explicitly)."""
|
|
1164
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
1165
|
+
return None
|
|
1166
|
+
return value
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
# ── Claude Code ──────────────────────────────────────────────────────────
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
class ClaudeAgent(CLIAgent):
|
|
1173
|
+
def __init__(self, config: AgentConfig | None = None):
|
|
1174
|
+
super().__init__("claude", config or AgentConfig())
|
|
1175
|
+
|
|
1176
|
+
def _build_command(self, prompt: str) -> list[str]:
|
|
1177
|
+
capture_enabled, _ = _TOOL_CAPTURE.get()
|
|
1178
|
+
session_id, resume, fork = _ACTIVE_PROVIDER_SESSION.get()
|
|
1179
|
+
selector = _model_selector(self._agent_id)
|
|
1180
|
+
return [
|
|
1181
|
+
"claude",
|
|
1182
|
+
"-p", prompt,
|
|
1183
|
+
"--output-format", "stream-json" if capture_enabled else "json",
|
|
1184
|
+
*(["--verbose"] if capture_enabled else []),
|
|
1185
|
+
"--max-turns", str(self._config.max_turns),
|
|
1186
|
+
*(
|
|
1187
|
+
["--resume", session_id]
|
|
1188
|
+
if session_id and resume
|
|
1189
|
+
else ["--session-id", session_id]
|
|
1190
|
+
if session_id
|
|
1191
|
+
else []
|
|
1192
|
+
),
|
|
1193
|
+
*(["--fork-session"] if fork and resume else []),
|
|
1194
|
+
*(["--model", selector] if selector else []),
|
|
1195
|
+
*(
|
|
1196
|
+
[
|
|
1197
|
+
"--safe-mode",
|
|
1198
|
+
"--permission-mode", "plan",
|
|
1199
|
+
"--tools", "Read,Glob,Grep",
|
|
1200
|
+
]
|
|
1201
|
+
if _READ_ONLY_PLANNING.get()
|
|
1202
|
+
else []
|
|
1203
|
+
),
|
|
1204
|
+
*(
|
|
1205
|
+
["--disallowedTools", "Agent"]
|
|
1206
|
+
if _READ_ONLY_PLANNING.get() or _DISABLE_NESTED_AGENTS.get()
|
|
1207
|
+
else []
|
|
1208
|
+
),
|
|
1209
|
+
]
|
|
1210
|
+
|
|
1211
|
+
def _provider_session_from_output(self, stdout: str) -> str | None:
|
|
1212
|
+
for event in reversed(_claude_events(stdout).events):
|
|
1213
|
+
value = event.get("session_id")
|
|
1214
|
+
if isinstance(value, str) and value:
|
|
1215
|
+
return value
|
|
1216
|
+
return None
|
|
1217
|
+
|
|
1218
|
+
def _usage_from_output(self, stdout: str) -> TokenUsage | None:
|
|
1219
|
+
# The terminal `result` event carries `usage` (input_tokens EXCLUDES the
|
|
1220
|
+
# cache tokens, which are reported beside it) plus a top-level
|
|
1221
|
+
# total_cost_usd. Normalize total_input to the FULL prompt size.
|
|
1222
|
+
for event in reversed(_claude_events(stdout).events):
|
|
1223
|
+
if event.get("type") != "result":
|
|
1224
|
+
continue
|
|
1225
|
+
usage = event.get("usage")
|
|
1226
|
+
if not isinstance(usage, dict):
|
|
1227
|
+
return None
|
|
1228
|
+
input_tokens = _usage_int(usage.get("input_tokens"))
|
|
1229
|
+
cache_read = _usage_int(usage.get("cache_read_input_tokens"))
|
|
1230
|
+
cache_creation = _usage_int(usage.get("cache_creation_input_tokens"))
|
|
1231
|
+
output_tokens = _usage_int(usage.get("output_tokens"))
|
|
1232
|
+
if input_tokens is None and output_tokens is None:
|
|
1233
|
+
return None
|
|
1234
|
+
total_input = (
|
|
1235
|
+
(input_tokens or 0) + (cache_read or 0) + (cache_creation or 0)
|
|
1236
|
+
if input_tokens is not None
|
|
1237
|
+
else None
|
|
1238
|
+
)
|
|
1239
|
+
cost = event.get("total_cost_usd")
|
|
1240
|
+
return TokenUsage(
|
|
1241
|
+
total_input_tokens=total_input,
|
|
1242
|
+
output_tokens=output_tokens,
|
|
1243
|
+
cache_read_tokens=cache_read,
|
|
1244
|
+
cache_creation_tokens=cache_creation,
|
|
1245
|
+
total_cost_usd=(
|
|
1246
|
+
float(cost)
|
|
1247
|
+
if isinstance(cost, (int, float)) and not isinstance(cost, bool)
|
|
1248
|
+
else None
|
|
1249
|
+
),
|
|
1250
|
+
)
|
|
1251
|
+
return None
|
|
1252
|
+
|
|
1253
|
+
def _parse_output(self, stdout: str) -> str:
|
|
1254
|
+
"""Last result text across object / array / JSONL transcripts alike."""
|
|
1255
|
+
cleaned = _strip_ansi(stdout).strip()
|
|
1256
|
+
events, _, structured = _claude_events(stdout)
|
|
1257
|
+
for event in reversed(events):
|
|
1258
|
+
# The SAME terminal-success boundary as _is_error_output: once a
|
|
1259
|
+
# definitive success exists, its own result text (possibly empty)
|
|
1260
|
+
# IS the output — an earlier recovered error's prose must never be
|
|
1261
|
+
# published as the successful run's output.
|
|
1262
|
+
if event.get("type") == "result" and event.get("is_error") is False:
|
|
1263
|
+
result = event.get("result")
|
|
1264
|
+
return result.strip() if isinstance(result, str) else ""
|
|
1265
|
+
results = [
|
|
1266
|
+
event["result"].strip()
|
|
1267
|
+
for event in events
|
|
1268
|
+
if event.get("type") == "result"
|
|
1269
|
+
and isinstance(event.get("result"), str)
|
|
1270
|
+
and event["result"].strip()
|
|
1271
|
+
]
|
|
1272
|
+
if results:
|
|
1273
|
+
return results[-1]
|
|
1274
|
+
texts = [
|
|
1275
|
+
event["result"].strip()
|
|
1276
|
+
for event in events
|
|
1277
|
+
if isinstance(event.get("result"), str) and event["result"].strip()
|
|
1278
|
+
]
|
|
1279
|
+
if texts:
|
|
1280
|
+
return texts[-1]
|
|
1281
|
+
# The payload decoded but carried no result text: Claude answered with
|
|
1282
|
+
# nothing, errored, or was truncated. Returning the raw transcript here
|
|
1283
|
+
# would put system/session/tool-body events into TaskResult.output, so
|
|
1284
|
+
# only genuinely unstructured output falls back to the raw text.
|
|
1285
|
+
return "" if structured else cleaned
|
|
1286
|
+
|
|
1287
|
+
def _is_error_output(self, stdout: str) -> str | None:
|
|
1288
|
+
events = _claude_events(stdout).events
|
|
1289
|
+
for event in reversed(events):
|
|
1290
|
+
# A definitive successful terminal result ends the scan: any error
|
|
1291
|
+
# events *before* it (e.g. a transient 429 the CLI retried past)
|
|
1292
|
+
# were recovered from, and failing the run on them would discard
|
|
1293
|
+
# finished work. Strict `is False` mirrors the strict `is True` in
|
|
1294
|
+
# _claude_error_message — a missing/None is_error decides nothing.
|
|
1295
|
+
if event.get("type") == "result" and event.get("is_error") is False:
|
|
1296
|
+
return None
|
|
1297
|
+
message = _claude_error_message(event)
|
|
1298
|
+
if message is not None:
|
|
1299
|
+
return message
|
|
1300
|
+
if not events:
|
|
1301
|
+
return _malformed_transcript_error(stdout)
|
|
1302
|
+
return None
|
|
1303
|
+
|
|
1304
|
+
def _parse_tool_telemetry(self, stdout: str) -> ToolTelemetry:
|
|
1305
|
+
events, malformed, _ = _claude_events(stdout)
|
|
1306
|
+
mapping = _TOOL_CAPTURE.get()[1].get(self._agent_id, {})
|
|
1307
|
+
pending: dict[str, tuple[int, str]] = {}
|
|
1308
|
+
calls: list[ToolCallRecord] = []
|
|
1309
|
+
unqualified = 0
|
|
1310
|
+
sequence = 0
|
|
1311
|
+
for event in events:
|
|
1312
|
+
message = event.get("message")
|
|
1313
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
1314
|
+
if not isinstance(content, list):
|
|
1315
|
+
continue
|
|
1316
|
+
for item in content:
|
|
1317
|
+
if not isinstance(item, dict):
|
|
1318
|
+
continue
|
|
1319
|
+
if item.get("type") == "tool_use":
|
|
1320
|
+
raw_name, call_id = item.get("name"), item.get("id")
|
|
1321
|
+
if not isinstance(raw_name, str) or not raw_name.startswith("mcp__"):
|
|
1322
|
+
continue
|
|
1323
|
+
tool_key = normalize_tool_key(raw_name, mapping)
|
|
1324
|
+
if tool_key is None or not isinstance(call_id, str):
|
|
1325
|
+
unqualified += 1
|
|
1326
|
+
continue
|
|
1327
|
+
sequence += 1
|
|
1328
|
+
pending[call_id] = (sequence, tool_key)
|
|
1329
|
+
elif item.get("type") == "tool_result":
|
|
1330
|
+
call_id = item.get("tool_use_id")
|
|
1331
|
+
entry = pending.pop(call_id, None) if isinstance(call_id, str) else None
|
|
1332
|
+
if entry is None:
|
|
1333
|
+
continue
|
|
1334
|
+
outcome = (
|
|
1335
|
+
ToolOutcome.FAILED
|
|
1336
|
+
if item.get("is_error") is True
|
|
1337
|
+
else ToolOutcome.SUCCEEDED
|
|
1338
|
+
)
|
|
1339
|
+
calls.append(ToolCallRecord(entry[0], entry[1], outcome))
|
|
1340
|
+
for sequence_no, tool_key in pending.values():
|
|
1341
|
+
calls.append(ToolCallRecord(sequence_no, tool_key, ToolOutcome.UNKNOWN))
|
|
1342
|
+
calls.sort(key=lambda call: call.sequence)
|
|
1343
|
+
return ToolTelemetry(
|
|
1344
|
+
source=self._agent_id,
|
|
1345
|
+
complete=malformed == 0 and unqualified == 0 and not pending,
|
|
1346
|
+
calls=tuple(calls),
|
|
1347
|
+
malformed_count=malformed,
|
|
1348
|
+
unqualified_count=unqualified,
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
# ── Kimi Code CLI (kimi) ────────────────────────────────────────────────────
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
class KimiCodeAgent(CLIAgent):
|
|
1356
|
+
"""Kimi Code. The product is ``kimi-code`` (so is the agent id) but the
|
|
1357
|
+
binary is ``kimi``. In ``--prompt`` mode it auto-approves tool use (the
|
|
1358
|
+
``--yolo``/``--auto`` flags are *rejected* alongside ``--prompt``), and
|
|
1359
|
+
``--output-format stream-json`` emits JSONL, one event per line:
|
|
1360
|
+
|
|
1361
|
+
{"role":"assistant","tool_calls":[...]} # tool call — no text
|
|
1362
|
+
{"role":"tool","tool_call_id":...,"content":...} # tool result
|
|
1363
|
+
{"role":"assistant","content":"..."} # assistant text ← keep
|
|
1364
|
+
{"role":"meta","type":"session.resume_hint",...,"content":...} # ignore
|
|
1365
|
+
|
|
1366
|
+
We keep only ``role == "assistant"`` lines that carry a text ``content`` —
|
|
1367
|
+
the ``meta`` resume-hint line also has a ``content`` field, so filtering on
|
|
1368
|
+
role (not merely on the presence of ``content``) is what excludes it.
|
|
1369
|
+
Errors surface as a non-zero exit (like Codex); the one zero-exit failure
|
|
1370
|
+
``_is_error_output`` flags is a JSON-shaped payload with no usable events
|
|
1371
|
+
(a malformed transcript).
|
|
1372
|
+
"""
|
|
1373
|
+
|
|
1374
|
+
def __init__(self, config: AgentConfig | None = None):
|
|
1375
|
+
super().__init__("kimi-code", config or AgentConfig())
|
|
1376
|
+
|
|
1377
|
+
def _build_command(self, prompt: str) -> list[str]:
|
|
1378
|
+
session_id, resume, _ = _ACTIVE_PROVIDER_SESSION.get()
|
|
1379
|
+
selector = _model_selector(self._agent_id)
|
|
1380
|
+
# The model flag leads (`kimi -m SELECTOR -p PROMPT ...`) — the argv
|
|
1381
|
+
# shape pinned by the design doc's §6 provider table.
|
|
1382
|
+
return [
|
|
1383
|
+
"kimi",
|
|
1384
|
+
*(["-m", selector] if selector else []),
|
|
1385
|
+
"-p", prompt,
|
|
1386
|
+
"--output-format", "stream-json",
|
|
1387
|
+
*(["--session", session_id] if session_id and resume else []),
|
|
1388
|
+
]
|
|
1389
|
+
|
|
1390
|
+
def _provider_session_from_output(self, stdout: str) -> str | None:
|
|
1391
|
+
for event in reversed(_json_lines(stdout)[0]):
|
|
1392
|
+
if event.get("role") != "meta" or event.get("type") != "session.resume_hint":
|
|
1393
|
+
continue
|
|
1394
|
+
for key in ("session_id", "session", "id"):
|
|
1395
|
+
value = event.get(key)
|
|
1396
|
+
if isinstance(value, str) and value:
|
|
1397
|
+
return value
|
|
1398
|
+
content = event.get("content")
|
|
1399
|
+
if isinstance(content, str):
|
|
1400
|
+
match = re.search(
|
|
1401
|
+
r"(?:--session|-S|-r)(?:=|\s+)([^\s]+)", content
|
|
1402
|
+
)
|
|
1403
|
+
if match:
|
|
1404
|
+
return match.group(1).strip("'\"")
|
|
1405
|
+
return None
|
|
1406
|
+
|
|
1407
|
+
def _usage_from_output(self, stdout: str) -> TokenUsage | None:
|
|
1408
|
+
# Kimi documents no usage event today; scan meta events defensively for
|
|
1409
|
+
# a usage dict of int fields so telemetry lights up if the CLI grows one.
|
|
1410
|
+
for event in reversed(_json_lines(stdout)[0]):
|
|
1411
|
+
if event.get("role") != "meta":
|
|
1412
|
+
continue
|
|
1413
|
+
usage = event.get("usage")
|
|
1414
|
+
if not isinstance(usage, dict):
|
|
1415
|
+
continue
|
|
1416
|
+
input_tokens = _usage_int(usage.get("input_tokens"))
|
|
1417
|
+
output_tokens = _usage_int(usage.get("output_tokens"))
|
|
1418
|
+
if input_tokens is None and output_tokens is None:
|
|
1419
|
+
continue
|
|
1420
|
+
cache_read = _usage_int(
|
|
1421
|
+
usage.get("cached_input_tokens") or usage.get("cache_read_input_tokens")
|
|
1422
|
+
)
|
|
1423
|
+
return TokenUsage(
|
|
1424
|
+
total_input_tokens=input_tokens,
|
|
1425
|
+
output_tokens=output_tokens,
|
|
1426
|
+
cache_read_tokens=cache_read,
|
|
1427
|
+
)
|
|
1428
|
+
return None
|
|
1429
|
+
|
|
1430
|
+
def _parse_output(self, stdout: str) -> str:
|
|
1431
|
+
"""kimi --output-format stream-json returns JSONL; collect the assistant
|
|
1432
|
+
text messages in order."""
|
|
1433
|
+
cleaned = _strip_ansi(stdout).strip()
|
|
1434
|
+
messages: list[str] = []
|
|
1435
|
+
for line in cleaned.splitlines():
|
|
1436
|
+
line = line.strip()
|
|
1437
|
+
if not line:
|
|
1438
|
+
continue
|
|
1439
|
+
try:
|
|
1440
|
+
event = json.loads(line)
|
|
1441
|
+
except (json.JSONDecodeError, RecursionError):
|
|
1442
|
+
# RecursionError: a pathologically-nested line is malformed,
|
|
1443
|
+
# never a run-aborting crash (see _claude_events).
|
|
1444
|
+
continue
|
|
1445
|
+
if not isinstance(event, dict) or event.get("role") != "assistant":
|
|
1446
|
+
continue
|
|
1447
|
+
content = event.get("content")
|
|
1448
|
+
if isinstance(content, str) and content.strip():
|
|
1449
|
+
messages.append(content.strip())
|
|
1450
|
+
return "\n".join(messages) if messages else cleaned
|
|
1451
|
+
|
|
1452
|
+
def _is_error_output(self, stdout: str) -> str | None:
|
|
1453
|
+
# Kimi signals failure via a non-zero exit; the one zero-exit failure
|
|
1454
|
+
# shape is a payload that claimed to be JSONL yet decoded to nothing.
|
|
1455
|
+
events, _ = _json_lines(stdout)
|
|
1456
|
+
if events:
|
|
1457
|
+
return None
|
|
1458
|
+
return _malformed_transcript_error(stdout)
|
|
1459
|
+
|
|
1460
|
+
def _parse_tool_telemetry(self, stdout: str) -> ToolTelemetry:
|
|
1461
|
+
events, malformed = _json_lines(stdout)
|
|
1462
|
+
mapping = _TOOL_CAPTURE.get()[1].get(self._agent_id, {})
|
|
1463
|
+
pending: dict[str, tuple[int, str]] = {}
|
|
1464
|
+
calls: list[ToolCallRecord] = []
|
|
1465
|
+
unqualified = 0
|
|
1466
|
+
sequence = 0
|
|
1467
|
+
for event in events:
|
|
1468
|
+
if event.get("role") == "assistant" and isinstance(event.get("tool_calls"), list):
|
|
1469
|
+
for item in event["tool_calls"]:
|
|
1470
|
+
if not isinstance(item, dict):
|
|
1471
|
+
continue
|
|
1472
|
+
function = item.get("function")
|
|
1473
|
+
raw_name = (
|
|
1474
|
+
function.get("name")
|
|
1475
|
+
if isinstance(function, dict)
|
|
1476
|
+
else item.get("name")
|
|
1477
|
+
)
|
|
1478
|
+
call_id = item.get("id") or item.get("tool_call_id")
|
|
1479
|
+
if not isinstance(raw_name, str) or not raw_name.startswith("mcp__"):
|
|
1480
|
+
continue
|
|
1481
|
+
tool_key = normalize_tool_key(raw_name, mapping)
|
|
1482
|
+
if tool_key is None or not isinstance(call_id, str):
|
|
1483
|
+
unqualified += 1
|
|
1484
|
+
continue
|
|
1485
|
+
sequence += 1
|
|
1486
|
+
pending[call_id] = (sequence, tool_key)
|
|
1487
|
+
elif event.get("role") == "tool":
|
|
1488
|
+
call_id = event.get("tool_call_id")
|
|
1489
|
+
entry = pending.pop(call_id, None) if isinstance(call_id, str) else None
|
|
1490
|
+
if entry is None:
|
|
1491
|
+
continue
|
|
1492
|
+
status = event.get("status")
|
|
1493
|
+
if event.get("is_error") is True or status in {"failed", "error"}:
|
|
1494
|
+
outcome = ToolOutcome.FAILED
|
|
1495
|
+
elif event.get("is_error") is False or status in {
|
|
1496
|
+
"completed", "success", "succeeded"
|
|
1497
|
+
}:
|
|
1498
|
+
outcome = ToolOutcome.SUCCEEDED
|
|
1499
|
+
else:
|
|
1500
|
+
outcome = ToolOutcome.UNKNOWN
|
|
1501
|
+
calls.append(ToolCallRecord(entry[0], entry[1], outcome))
|
|
1502
|
+
for sequence_no, tool_key in pending.values():
|
|
1503
|
+
calls.append(ToolCallRecord(sequence_no, tool_key, ToolOutcome.UNKNOWN))
|
|
1504
|
+
calls.sort(key=lambda call: call.sequence)
|
|
1505
|
+
return ToolTelemetry(
|
|
1506
|
+
source=self._agent_id,
|
|
1507
|
+
complete=(
|
|
1508
|
+
malformed == 0
|
|
1509
|
+
and unqualified == 0
|
|
1510
|
+
and not pending
|
|
1511
|
+
and all(call.outcome != ToolOutcome.UNKNOWN for call in calls)
|
|
1512
|
+
),
|
|
1513
|
+
calls=tuple(calls),
|
|
1514
|
+
malformed_count=malformed,
|
|
1515
|
+
unqualified_count=unqualified,
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
# ── Codex CLI ────────────────────────────────────────────────────────────
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
class CodexAgent(CLIAgent):
|
|
1523
|
+
def __init__(self, config: AgentConfig | None = None):
|
|
1524
|
+
super().__init__("codex", config or AgentConfig())
|
|
1525
|
+
|
|
1526
|
+
def _build_command(self, prompt: str) -> list[str]:
|
|
1527
|
+
session_id, resume, _ = _ACTIVE_PROVIDER_SESSION.get()
|
|
1528
|
+
selector = _model_selector(self._agent_id)
|
|
1529
|
+
# `-m` precedes the positionals in both shapes (§6 provider table):
|
|
1530
|
+
# `codex exec -m SEL PROMPT ...` / `codex exec resume -m SEL SESSION ...`.
|
|
1531
|
+
model = ["-m", selector] if selector else []
|
|
1532
|
+
# Sandbox/approval ride `-c` config overrides on BOTH shapes. Verified
|
|
1533
|
+
# against codex-cli 0.147.0: `codex exec` REJECTS `--full-auto`
|
|
1534
|
+
# outright ("unexpected argument"), `exec resume` accepts neither it
|
|
1535
|
+
# nor `-s`, and `on-failure` is no longer an approval policy (the
|
|
1536
|
+
# values are untrusted/on-request/never — `never` is the
|
|
1537
|
+
# non-interactive one). `-c key=value` is the long-standing portable
|
|
1538
|
+
# spelling, so fresh and resumed members run under identical, valid
|
|
1539
|
+
# permissions on old and new CLIs alike.
|
|
1540
|
+
sandbox_mode = "read-only" if _READ_ONLY_PLANNING.get() else "workspace-write"
|
|
1541
|
+
sandbox = [
|
|
1542
|
+
"-c", f'sandbox_mode="{sandbox_mode}"',
|
|
1543
|
+
"-c", 'approval_policy="never"',
|
|
1544
|
+
]
|
|
1545
|
+
if session_id and resume:
|
|
1546
|
+
return [
|
|
1547
|
+
"codex", "exec", "resume", *model, *sandbox,
|
|
1548
|
+
session_id, prompt,
|
|
1549
|
+
"--json",
|
|
1550
|
+
]
|
|
1551
|
+
return [
|
|
1552
|
+
"codex", "exec",
|
|
1553
|
+
*model,
|
|
1554
|
+
*sandbox,
|
|
1555
|
+
prompt,
|
|
1556
|
+
"--json",
|
|
1557
|
+
]
|
|
1558
|
+
|
|
1559
|
+
def _provider_session_from_output(self, stdout: str) -> str | None:
|
|
1560
|
+
for event in _json_lines(stdout)[0]:
|
|
1561
|
+
if event.get("type") == "thread.started":
|
|
1562
|
+
value = event.get("thread_id") or event.get("threadId")
|
|
1563
|
+
if isinstance(value, str) and value:
|
|
1564
|
+
return value
|
|
1565
|
+
return None
|
|
1566
|
+
|
|
1567
|
+
def _usage_from_output(self, stdout: str) -> TokenUsage | None:
|
|
1568
|
+
# Codex emits one `turn.completed` event per turn with a `usage` dict
|
|
1569
|
+
# whose input_tokens INCLUDES cached_input_tokens — sum across turns.
|
|
1570
|
+
total_input = output_tokens = cache_read = 0
|
|
1571
|
+
seen = False
|
|
1572
|
+
for event in _json_lines(stdout)[0]:
|
|
1573
|
+
if event.get("type") != "turn.completed":
|
|
1574
|
+
continue
|
|
1575
|
+
usage = event.get("usage")
|
|
1576
|
+
if not isinstance(usage, dict):
|
|
1577
|
+
continue
|
|
1578
|
+
input_tokens = _usage_int(usage.get("input_tokens"))
|
|
1579
|
+
if input_tokens is None:
|
|
1580
|
+
continue
|
|
1581
|
+
seen = True
|
|
1582
|
+
total_input += input_tokens
|
|
1583
|
+
output_tokens += _usage_int(usage.get("output_tokens")) or 0
|
|
1584
|
+
cache_read += _usage_int(usage.get("cached_input_tokens")) or 0
|
|
1585
|
+
if not seen:
|
|
1586
|
+
return None
|
|
1587
|
+
return TokenUsage(
|
|
1588
|
+
total_input_tokens=total_input,
|
|
1589
|
+
output_tokens=output_tokens,
|
|
1590
|
+
cache_read_tokens=cache_read,
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
def _parse_output(self, stdout: str) -> str:
|
|
1594
|
+
"""Codex --json returns JSONL (one JSON object per line)."""
|
|
1595
|
+
cleaned = _strip_ansi(stdout).strip()
|
|
1596
|
+
messages: list[str] = []
|
|
1597
|
+
for line in cleaned.splitlines():
|
|
1598
|
+
line = line.strip()
|
|
1599
|
+
if not line:
|
|
1600
|
+
continue
|
|
1601
|
+
try:
|
|
1602
|
+
event = json.loads(line)
|
|
1603
|
+
except (json.JSONDecodeError, RecursionError):
|
|
1604
|
+
# RecursionError: a pathologically-nested line is malformed,
|
|
1605
|
+
# never a run-aborting crash (see _claude_events).
|
|
1606
|
+
continue
|
|
1607
|
+
if not isinstance(event, dict):
|
|
1608
|
+
continue # a valid-JSON scalar/list line carries no event
|
|
1609
|
+
# Codex JSONL: look for agent_message items
|
|
1610
|
+
if event.get("type") == "item.completed":
|
|
1611
|
+
item = event.get("item", {})
|
|
1612
|
+
if isinstance(item, dict) and item.get("type") == "agent_message":
|
|
1613
|
+
text = item.get("text", "")
|
|
1614
|
+
if isinstance(text, str) and text.strip():
|
|
1615
|
+
messages.append(text.strip())
|
|
1616
|
+
return "\n".join(messages) if messages else cleaned
|
|
1617
|
+
|
|
1618
|
+
def _is_error_output(self, stdout: str) -> str | None:
|
|
1619
|
+
# Codex signals failure via a non-zero exit; the one zero-exit failure
|
|
1620
|
+
# shape is a payload that claimed to be JSONL yet decoded to nothing.
|
|
1621
|
+
events, _ = _json_lines(stdout)
|
|
1622
|
+
if events:
|
|
1623
|
+
return None
|
|
1624
|
+
return _malformed_transcript_error(stdout)
|
|
1625
|
+
|
|
1626
|
+
def _parse_tool_telemetry(self, stdout: str) -> ToolTelemetry:
|
|
1627
|
+
events, malformed = _json_lines(stdout)
|
|
1628
|
+
calls: list[ToolCallRecord] = []
|
|
1629
|
+
seen: set[str] = set()
|
|
1630
|
+
unqualified = 0
|
|
1631
|
+
sequence = 0
|
|
1632
|
+
for event in events:
|
|
1633
|
+
if event.get("type") != "item.completed":
|
|
1634
|
+
continue
|
|
1635
|
+
item = event.get("item")
|
|
1636
|
+
if not isinstance(item, dict) or item.get("type") != "mcp_tool_call":
|
|
1637
|
+
continue
|
|
1638
|
+
item_id = item.get("id")
|
|
1639
|
+
if isinstance(item_id, str) and item_id in seen:
|
|
1640
|
+
continue
|
|
1641
|
+
if isinstance(item_id, str):
|
|
1642
|
+
seen.add(item_id)
|
|
1643
|
+
server, tool = item.get("server"), item.get("tool")
|
|
1644
|
+
tool_key = (
|
|
1645
|
+
normalize_tool_key(f"{server}::{tool}")
|
|
1646
|
+
if isinstance(server, str) and isinstance(tool, str)
|
|
1647
|
+
else None
|
|
1648
|
+
)
|
|
1649
|
+
if tool_key is None:
|
|
1650
|
+
unqualified += 1
|
|
1651
|
+
continue
|
|
1652
|
+
status = item.get("status")
|
|
1653
|
+
outcome = (
|
|
1654
|
+
ToolOutcome.SUCCEEDED
|
|
1655
|
+
if status == "completed"
|
|
1656
|
+
else ToolOutcome.FAILED
|
|
1657
|
+
if status == "failed"
|
|
1658
|
+
else ToolOutcome.UNKNOWN
|
|
1659
|
+
)
|
|
1660
|
+
sequence += 1
|
|
1661
|
+
calls.append(ToolCallRecord(sequence, tool_key, outcome))
|
|
1662
|
+
return ToolTelemetry(
|
|
1663
|
+
source=self._agent_id,
|
|
1664
|
+
complete=(
|
|
1665
|
+
malformed == 0
|
|
1666
|
+
and unqualified == 0
|
|
1667
|
+
and all(call.outcome != ToolOutcome.UNKNOWN for call in calls)
|
|
1668
|
+
),
|
|
1669
|
+
calls=tuple(calls),
|
|
1670
|
+
malformed_count=malformed,
|
|
1671
|
+
unqualified_count=unqualified,
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
# ── Concurrent execution ───────────────────────────────────────────────────
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
# How long to wait for a SIGKILLed child to be reaped before giving up. The
|
|
1679
|
+
# child itself dies within milliseconds; this bound only guards the pathological
|
|
1680
|
+
# case where it spawned a grandchild that inherited (and still holds) its
|
|
1681
|
+
# stdout/stderr pipes, which can otherwise wedge proc.wait() indefinitely.
|
|
1682
|
+
_REAP_TIMEOUT_SECONDS = 5.0
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
async def _terminate(proc: asyncio.subprocess.Process) -> None:
|
|
1686
|
+
"""SIGKILL a timed-out child and reap it — best-effort, time-bounded, never
|
|
1687
|
+
raises, never hangs.
|
|
1688
|
+
|
|
1689
|
+
After ``wait_for`` cancels ``communicate()`` on timeout, nothing drains the
|
|
1690
|
+
child's pipes; if the child spawned a grandchild that inherited them (the
|
|
1691
|
+
real agent CLIs do — MCP servers, language servers, shells), ``proc.wait()``
|
|
1692
|
+
can block forever waiting for those pipes to close. The group kill below
|
|
1693
|
+
reaches those grandchildren (the child leads its own group under
|
|
1694
|
+
``_START_NEW_SESSION``), and bounding the reap turns any residual hang into
|
|
1695
|
+
a clean give-up — a transport may then be finalized at GC (a harmless
|
|
1696
|
+
"Event loop is closed" note on stderr at shutdown). On platforms without
|
|
1697
|
+
process groups the orphaned grandchild is reparented to init, the same
|
|
1698
|
+
limitation as the sync path.
|
|
1699
|
+
"""
|
|
1700
|
+
# Group first (reaches grandchildren — including after the leader was
|
|
1701
|
+
# already reaped: owned=True, see _kill_process_group), then the direct
|
|
1702
|
+
# kill — which also preserves the exact pre-group-kill semantics for a
|
|
1703
|
+
# child that exited on its own between the timeout and this call.
|
|
1704
|
+
_kill_process_group(proc, owned=True)
|
|
1705
|
+
with contextlib.suppress(ProcessLookupError):
|
|
1706
|
+
proc.kill()
|
|
1707
|
+
|
|
1708
|
+
async def discard(stream):
|
|
1709
|
+
if stream is not None:
|
|
1710
|
+
while await stream.read(4096):
|
|
1711
|
+
pass
|
|
1712
|
+
|
|
1713
|
+
# Discard in fixed chunks: paused asyncio pipe buffers otherwise prevent
|
|
1714
|
+
# wait() from finishing even after the entire process group has died.
|
|
1715
|
+
drains = [
|
|
1716
|
+
asyncio.ensure_future(discard(getattr(proc, "stdout", None))),
|
|
1717
|
+
asyncio.ensure_future(discard(getattr(proc, "stderr", None))),
|
|
1718
|
+
asyncio.ensure_future(proc.wait()),
|
|
1719
|
+
]
|
|
1720
|
+
try:
|
|
1721
|
+
await asyncio.wait_for(asyncio.gather(*drains), timeout=_REAP_TIMEOUT_SECONDS)
|
|
1722
|
+
except Exception:
|
|
1723
|
+
pass
|
|
1724
|
+
finally:
|
|
1725
|
+
# gather may already be done after one reader failed. Cancel its
|
|
1726
|
+
# children explicitly so a broken pipe cannot leave sibling drains alive.
|
|
1727
|
+
for task in drains:
|
|
1728
|
+
task.cancel()
|
|
1729
|
+
with contextlib.suppress(BaseException):
|
|
1730
|
+
await asyncio.gather(*drains, return_exceptions=True)
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
async def _communicate_teed(
|
|
1734
|
+
proc: asyncio.subprocess.Process, sink: OutputSink | None, agent_id: str,
|
|
1735
|
+
output_max_bytes: int | None = None,
|
|
1736
|
+
) -> tuple[bytes, bytes]:
|
|
1737
|
+
"""Drain both streams with one byte budget and an optional live sink.
|
|
1738
|
+
|
|
1739
|
+
The live-monitor (#16) counterpart to ``communicate()``: it drains stdout and
|
|
1740
|
+
stderr *concurrently* (the same reason ``communicate()`` does — reading one to
|
|
1741
|
+
EOF before the other can deadlock on a full pipe buffer). The budget is
|
|
1742
|
+
COMBINED across the two streams, matching what the config field documents;
|
|
1743
|
+
crossing it raises ``OutputLimitExceeded``, and the caller kills the child and
|
|
1744
|
+
returns a bounded failure. ``sink.feed`` is guarded — a misbehaving sink must
|
|
1745
|
+
never break the run (its contract is "must not block and must never raise",
|
|
1746
|
+
but we defend anyway).
|
|
1747
|
+
|
|
1748
|
+
The caller owns the overall timeout and kills/reaps on any failure. The
|
|
1749
|
+
sibling reader is cancelled and awaited before errors leave this function.
|
|
1750
|
+
|
|
1751
|
+
The wait after both pipes EOF has **no deadline of its own** — the caller's
|
|
1752
|
+
``wait_for`` bounds the whole invocation, this exit included. So a child that
|
|
1753
|
+
closes both pipes and keeps running (a grandchild holding nothing, a provider
|
|
1754
|
+
flushing and lingering) is not reaped after a few seconds; it is killed when
|
|
1755
|
+
the invocation times out, and that path returns empty output by contract.
|
|
1756
|
+
The feature branch bounded this wait separately and returned the captured
|
|
1757
|
+
bytes after five seconds. Taking main's shape keeps one deadline per
|
|
1758
|
+
invocation rather than two that can disagree, at the cost of that faster
|
|
1759
|
+
partial return.
|
|
1760
|
+
"""
|
|
1761
|
+
total = 0
|
|
1762
|
+
|
|
1763
|
+
async def _drain(stream: asyncio.StreamReader | None) -> bytes:
|
|
1764
|
+
nonlocal total
|
|
1765
|
+
if stream is None: # pragma: no cover - PIPE always gives a reader
|
|
1766
|
+
return b""
|
|
1767
|
+
chunks: list[bytes] = []
|
|
1768
|
+
while True:
|
|
1769
|
+
chunk = await stream.read(4096)
|
|
1770
|
+
if not chunk:
|
|
1771
|
+
break
|
|
1772
|
+
total += len(chunk)
|
|
1773
|
+
if output_max_bytes is not None and total > output_max_bytes:
|
|
1774
|
+
raise OutputLimitExceeded(f"output limit exceeded ({output_max_bytes} bytes)")
|
|
1775
|
+
chunks.append(chunk)
|
|
1776
|
+
# `except Exception` (not BaseException) deliberately: a CancelledError
|
|
1777
|
+
# from a timeout/sibling-drain must propagate, never be swallowed here.
|
|
1778
|
+
try:
|
|
1779
|
+
if sink is not None:
|
|
1780
|
+
sink.feed(agent_id, chunk)
|
|
1781
|
+
except Exception:
|
|
1782
|
+
pass
|
|
1783
|
+
return b"".join(chunks)
|
|
1784
|
+
|
|
1785
|
+
# Cancel the sibling drain if one raises: a bare gather propagates the first
|
|
1786
|
+
# exception while the OTHER drain keeps running as an orphan holding a pipe
|
|
1787
|
+
# fd (plus an "exception was never retrieved" warning at loop close).
|
|
1788
|
+
drains = (
|
|
1789
|
+
asyncio.ensure_future(_drain(proc.stdout)),
|
|
1790
|
+
asyncio.ensure_future(_drain(proc.stderr)),
|
|
1791
|
+
)
|
|
1792
|
+
try:
|
|
1793
|
+
stdout_b, stderr_b = await asyncio.gather(*drains)
|
|
1794
|
+
except BaseException:
|
|
1795
|
+
for t in drains:
|
|
1796
|
+
t.cancel()
|
|
1797
|
+
with contextlib.suppress(BaseException):
|
|
1798
|
+
await asyncio.gather(*drains, return_exceptions=True)
|
|
1799
|
+
raise
|
|
1800
|
+
# The caller bounds the entire invocation, including exit after pipe EOF.
|
|
1801
|
+
await proc.wait()
|
|
1802
|
+
return stdout_b, stderr_b
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
async def aexecute_all(
|
|
1806
|
+
agents: list[AgentRunner],
|
|
1807
|
+
prompt: str,
|
|
1808
|
+
workdir: Path,
|
|
1809
|
+
timeout: int,
|
|
1810
|
+
) -> list[TaskResult]:
|
|
1811
|
+
"""Run several agents on the same prompt concurrently; results in agent order.
|
|
1812
|
+
|
|
1813
|
+
Part of the async foundation the concurrent strategies (compete/council)
|
|
1814
|
+
build on. Bridge it from sync orchestration code with ``memory.run_async``.
|
|
1815
|
+
|
|
1816
|
+
Agent isolation is untouched — each agent still receives only
|
|
1817
|
+
``(prompt, workdir, timeout)``. This variant runs every agent in the *same*
|
|
1818
|
+
``workdir``, so it fits only agents meant to share one tree — concurrent
|
|
1819
|
+
agents that edit would clobber each other. For the isolated case (the
|
|
1820
|
+
compete/council shape), use ``aexecute_isolated``, which gives each agent its
|
|
1821
|
+
own worktree path; a single ``workdir`` can't express N worktrees (see the
|
|
1822
|
+
per-agent isolation test in test_worktree.py).
|
|
1823
|
+
|
|
1824
|
+
Shares ``_gather_or_drain`` with its siblings: a genuinely-raising
|
|
1825
|
+
``aexecute`` surfaces only after the sibling tasks are cancelled and
|
|
1826
|
+
drained, so none outlive the failed batch on the reused module loop.
|
|
1827
|
+
"""
|
|
1828
|
+
tasks = [
|
|
1829
|
+
asyncio.ensure_future(agent.aexecute(prompt, workdir, timeout))
|
|
1830
|
+
for agent in agents
|
|
1831
|
+
]
|
|
1832
|
+
return await _gather_or_drain(tasks)
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
async def aexecute_isolated(
|
|
1836
|
+
agents: list[AgentRunner],
|
|
1837
|
+
prompt: str,
|
|
1838
|
+
worktrees: list[Path],
|
|
1839
|
+
timeout_for: Callable[[str], int],
|
|
1840
|
+
) -> list[TaskResult]:
|
|
1841
|
+
"""Run several agents on the same prompt CONCURRENTLY, each in its OWN workdir.
|
|
1842
|
+
|
|
1843
|
+
The isolated counterpart to ``aexecute_all`` (which runs every agent in one
|
|
1844
|
+
shared ``workdir`` — fine only when they don't edit, since concurrent editors
|
|
1845
|
+
would clobber). This is the shape the concurrent *compete* strategy needs:
|
|
1846
|
+
every agent edits a separate worktree, so their diffs never collide.
|
|
1847
|
+
|
|
1848
|
+
The caller — a concurrent strategy that owns its worktree topology — acquires
|
|
1849
|
+
one fresh worktree per agent UP FRONT and SERIALLY (acquire is sync git that
|
|
1850
|
+
contends on the index lock; it must not run inside this fan-out) and passes
|
|
1851
|
+
the parallel ``worktrees`` paths here. ``timeout_for`` maps an agent id to its
|
|
1852
|
+
configured timeout, so neither ``WorktreePool`` nor ``SyncmillConfig`` leaks
|
|
1853
|
+
into the agent layer — agent isolation is untouched, each agent still receives
|
|
1854
|
+
only ``(prompt, workdir, timeout)``.
|
|
1855
|
+
|
|
1856
|
+
``worktrees`` is positionally aligned with ``agents`` (same length); ``gather``
|
|
1857
|
+
preserves submission order, so ``results[i]`` lines up with ``agents[i]`` and
|
|
1858
|
+
``worktrees[i]`` — the caller zips them to diff the winner's own tree.
|
|
1859
|
+
|
|
1860
|
+
Result classification trusts ``aexecute``'s no-raise contract — every
|
|
1861
|
+
*expected* failure (missing binary, bad cwd, timeout) is already converted to
|
|
1862
|
+
a FAILED ``TaskResult``, so a competitor that merely fails simply loses. An
|
|
1863
|
+
``aexecute`` that genuinely *raises* signals a programming error; like the
|
|
1864
|
+
sync path it surfaces rather than being masked into a FAILED result (don't
|
|
1865
|
+
swallow real bugs) — but only after the siblings are cancelled and drained, so
|
|
1866
|
+
none outlive the failed batch on the reused loop (see ``_gather_or_drain``).
|
|
1867
|
+
"""
|
|
1868
|
+
if len(worktrees) != len(agents):
|
|
1869
|
+
raise ValueError(
|
|
1870
|
+
f"aexecute_isolated needs one worktree per agent: "
|
|
1871
|
+
f"{len(agents)} agents, {len(worktrees)} worktrees."
|
|
1872
|
+
)
|
|
1873
|
+
# Eager scheduling with a caller-supplied timeout_for: if it raises on item
|
|
1874
|
+
# k, items 0..k-1 are already live — cancel + drain them before surfacing
|
|
1875
|
+
# (the _gather_or_drain contract, extended to the construction phase).
|
|
1876
|
+
tasks: list[asyncio.Future[TaskResult]] = []
|
|
1877
|
+
try:
|
|
1878
|
+
for agent, wt in zip(agents, worktrees):
|
|
1879
|
+
tasks.append(
|
|
1880
|
+
asyncio.ensure_future(
|
|
1881
|
+
agent.aexecute(prompt, wt, timeout_for(agent.agent_id))
|
|
1882
|
+
)
|
|
1883
|
+
)
|
|
1884
|
+
except BaseException:
|
|
1885
|
+
await _cancel_and_drain(tasks)
|
|
1886
|
+
raise
|
|
1887
|
+
return await _gather_or_drain(tasks)
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
async def aexecute_each(
|
|
1891
|
+
agents: list[AgentRunner],
|
|
1892
|
+
prompts: list[str],
|
|
1893
|
+
worktrees: list[Path],
|
|
1894
|
+
timeout_for: Callable[[str], int],
|
|
1895
|
+
labels: list[str] | None = None,
|
|
1896
|
+
timeouts: list[int] | None = None,
|
|
1897
|
+
env_allowlists: list[Sequence[str]] | None = None,
|
|
1898
|
+
started_at: list[float | None] | None = None,
|
|
1899
|
+
) -> list[TaskResult]:
|
|
1900
|
+
"""Run several agents CONCURRENTLY with a DISTINCT prompt each, one per worktree.
|
|
1901
|
+
|
|
1902
|
+
The per-item-prompt counterpart to ``aexecute_isolated`` (which broadcasts ONE
|
|
1903
|
+
prompt to every agent). This is the shape ``DecomposeStrategy`` needs: each
|
|
1904
|
+
subtask is a *different* prompt routed to a (possibly different) agent in its own
|
|
1905
|
+
isolated worktree, so the subtasks' diffs never collide.
|
|
1906
|
+
|
|
1907
|
+
``agents``, ``prompts``, and ``worktrees`` are positionally aligned (same
|
|
1908
|
+
length); ``gather`` preserves submission order, so ``results[i]`` lines up with
|
|
1909
|
+
``agents[i]`` / ``prompts[i]`` / ``worktrees[i]``. Agent isolation is untouched —
|
|
1910
|
+
each agent still receives only ``(prompt, workdir, timeout)``, with
|
|
1911
|
+
``timeout_for`` mapping an agent id to its configured timeout, so neither the
|
|
1912
|
+
``WorktreePool`` nor ``SyncmillConfig`` leaks into the agent layer.
|
|
1913
|
+
``timeouts`` optionally supplies one already-bounded timeout per item (needed
|
|
1914
|
+
when repeated uses of one provider have different workflow-node deadlines);
|
|
1915
|
+
omitting it preserves the established ``timeout_for(agent_id)`` behavior.
|
|
1916
|
+
``started_at``, when given, is filled with each item's wall-clock start as
|
|
1917
|
+
recorded by ``record_invocation_start`` (``None`` where an item never
|
|
1918
|
+
reached it), including when the fan-out raises.
|
|
1919
|
+
|
|
1920
|
+
Shares ``aexecute_isolated``'s no-raise / cancel-drain contract (via
|
|
1921
|
+
``_gather_or_drain``): every *expected* failure is already a FAILED
|
|
1922
|
+
``TaskResult``, and an ``aexecute`` that genuinely *raises* is surfaced — but
|
|
1923
|
+
only after the siblings are cancelled and drained, so none outlive the failed
|
|
1924
|
+
batch on the reused loop.
|
|
1925
|
+
"""
|
|
1926
|
+
if not (len(agents) == len(prompts) == len(worktrees)):
|
|
1927
|
+
raise ValueError(
|
|
1928
|
+
f"aexecute_each needs aligned agents/prompts/worktrees: {len(agents)} "
|
|
1929
|
+
f"agents, {len(prompts)} prompts, {len(worktrees)} worktrees."
|
|
1930
|
+
)
|
|
1931
|
+
if labels is not None and len(labels) != len(agents):
|
|
1932
|
+
raise ValueError(
|
|
1933
|
+
f"aexecute_each needs one label per agent: {len(labels)} labels, "
|
|
1934
|
+
f"{len(agents)} agents."
|
|
1935
|
+
)
|
|
1936
|
+
if timeouts is not None and len(timeouts) != len(agents):
|
|
1937
|
+
raise ValueError(
|
|
1938
|
+
f"aexecute_each needs one timeout per agent: {len(timeouts)} timeouts, "
|
|
1939
|
+
f"{len(agents)} agents."
|
|
1940
|
+
)
|
|
1941
|
+
if env_allowlists is not None and len(env_allowlists) != len(agents):
|
|
1942
|
+
raise ValueError(
|
|
1943
|
+
f"aexecute_each needs one env allowlist per agent: "
|
|
1944
|
+
f"{len(env_allowlists)} allowlists, {len(agents)} agents."
|
|
1945
|
+
)
|
|
1946
|
+
if started_at is not None and len(started_at) != len(agents):
|
|
1947
|
+
raise ValueError(
|
|
1948
|
+
f"aexecute_each needs one start slot per agent: {len(started_at)} "
|
|
1949
|
+
f"slots, {len(agents)} agents."
|
|
1950
|
+
)
|
|
1951
|
+
cells: list[list[float | None]] = [[None] for _ in agents]
|
|
1952
|
+
# A Task copies the current context when it is created, so setting the label
|
|
1953
|
+
# immediately before each ensure_future gives every item its own — without it
|
|
1954
|
+
# the round-robin's repeated agent id would merge concurrent streams.
|
|
1955
|
+
tasks = []
|
|
1956
|
+
try:
|
|
1957
|
+
for i, (agent, prompt, wt) in enumerate(zip(agents, prompts, worktrees)):
|
|
1958
|
+
token = _SINK_LABEL.set(labels[i] if labels is not None else None)
|
|
1959
|
+
env_token = (
|
|
1960
|
+
_WORKFLOW_ENV_ALLOWLIST.set(frozenset(env_allowlists[i]))
|
|
1961
|
+
if env_allowlists is not None
|
|
1962
|
+
else None
|
|
1963
|
+
)
|
|
1964
|
+
start_token = (
|
|
1965
|
+
_INVOCATION_START.set(cells[i]) if started_at is not None else None
|
|
1966
|
+
)
|
|
1967
|
+
try:
|
|
1968
|
+
tasks.append(
|
|
1969
|
+
asyncio.ensure_future(
|
|
1970
|
+
agent.aexecute(
|
|
1971
|
+
prompt,
|
|
1972
|
+
wt,
|
|
1973
|
+
timeouts[i] if timeouts is not None else timeout_for(agent.agent_id),
|
|
1974
|
+
)
|
|
1975
|
+
)
|
|
1976
|
+
)
|
|
1977
|
+
finally:
|
|
1978
|
+
if start_token is not None:
|
|
1979
|
+
_INVOCATION_START.reset(start_token)
|
|
1980
|
+
if env_token is not None:
|
|
1981
|
+
_WORKFLOW_ENV_ALLOWLIST.reset(env_token)
|
|
1982
|
+
_SINK_LABEL.reset(token)
|
|
1983
|
+
except BaseException:
|
|
1984
|
+
# A raising timeout_for on item k must not strand items 0..k-1 as live
|
|
1985
|
+
# ownerless subprocesses (the aexecute_isolated construction contract).
|
|
1986
|
+
await _cancel_and_drain(tasks)
|
|
1987
|
+
if started_at is not None:
|
|
1988
|
+
started_at[:] = [cell[0] for cell in cells]
|
|
1989
|
+
raise
|
|
1990
|
+
try:
|
|
1991
|
+
return await _gather_or_drain(tasks)
|
|
1992
|
+
finally:
|
|
1993
|
+
if started_at is not None:
|
|
1994
|
+
started_at[:] = [cell[0] for cell in cells]
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
async def _cancel_and_drain(tasks: list[asyncio.Future[TaskResult]]) -> None:
|
|
1998
|
+
"""Cancel already-scheduled fan-out tasks and await them out (never raises)."""
|
|
1999
|
+
for task in tasks:
|
|
2000
|
+
task.cancel()
|
|
2001
|
+
if tasks:
|
|
2002
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
async def _gather_or_drain(
|
|
2006
|
+
tasks: list[asyncio.Future[TaskResult]],
|
|
2007
|
+
) -> list[TaskResult]:
|
|
2008
|
+
"""Gather concurrent agent tasks; on ANY exception cancel + drain the siblings
|
|
2009
|
+
before re-raising the original unchanged.
|
|
2010
|
+
|
|
2011
|
+
The shared tail of ``aexecute_isolated``/``aexecute_each``. A plain ``gather``
|
|
2012
|
+
would propagate the first exception while the siblings keep running, leaving
|
|
2013
|
+
live tasks/subprocesses on the *reused* event loop that could resume mid-cleanup
|
|
2014
|
+
(after the caller tore their worktrees down). Cancelling and draining first —
|
|
2015
|
+
``aexecute`` reaps its child on cancellation — guarantees none outlive the failed
|
|
2016
|
+
batch. This subtle, safety-critical logic lives in one place so the two fan-out
|
|
2017
|
+
helpers can't drift.
|
|
2018
|
+
"""
|
|
2019
|
+
try:
|
|
2020
|
+
return await asyncio.gather(*tasks)
|
|
2021
|
+
except BaseException:
|
|
2022
|
+
for task in tasks:
|
|
2023
|
+
task.cancel()
|
|
2024
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
2025
|
+
raise
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
# ── Registry ─────────────────────────────────────────────────────────────
|
|
2029
|
+
|
|
2030
|
+
_AGENT_CONSTRUCTORS: dict[str, type[CLIAgent]] = {
|
|
2031
|
+
"claude": ClaudeAgent,
|
|
2032
|
+
"codex": CodexAgent,
|
|
2033
|
+
"kimi-code": KimiCodeAgent,
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
class AgentRegistry:
|
|
2038
|
+
"""Manages agent instances ordered by config priority."""
|
|
2039
|
+
|
|
2040
|
+
def __init__(self, config: SyncmillConfig):
|
|
2041
|
+
unknown = [a for a in config.agent_priority if a not in _AGENT_CONSTRUCTORS]
|
|
2042
|
+
if unknown:
|
|
2043
|
+
valid = ", ".join(sorted(_AGENT_CONSTRUCTORS))
|
|
2044
|
+
raise ValueError(
|
|
2045
|
+
f"Unknown agent id(s) in agent_priority: {', '.join(unknown)}. "
|
|
2046
|
+
f"Valid agents: {valid}."
|
|
2047
|
+
)
|
|
2048
|
+
self._agents: list[AgentRunner] = []
|
|
2049
|
+
for agent_id in config.agent_priority:
|
|
2050
|
+
agent_cfg = config.agent_config(agent_id)
|
|
2051
|
+
if not agent_cfg.enabled:
|
|
2052
|
+
continue
|
|
2053
|
+
self._agents.append(_AGENT_CONSTRUCTORS[agent_id](agent_cfg))
|
|
2054
|
+
|
|
2055
|
+
def available(self) -> list[AgentRunner]:
|
|
2056
|
+
"""Return enabled agents in priority order."""
|
|
2057
|
+
return list(self._agents)
|
|
2058
|
+
|
|
2059
|
+
def get(self, agent_id: str) -> AgentRunner | None:
|
|
2060
|
+
"""Get a specific agent by id."""
|
|
2061
|
+
for agent in self._agents:
|
|
2062
|
+
if agent.agent_id == agent_id:
|
|
2063
|
+
return agent
|
|
2064
|
+
return None
|