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.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. 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