syncade 0.6.2__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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# SIZE_OK: 400 pure LOC; codex invocation and JSONL errors share one CLI contract.
|
|
2
|
+
# Retained to avoid changing the observed CodexAdapter behavior surface.
|
|
3
|
+
# Future split: extract stream/auth parsing behind the same adapter API.
|
|
4
|
+
"""Adapter for the OpenAI ``codex`` CLI.
|
|
5
|
+
|
|
6
|
+
Built against the codex CLI's observed JSONL output — the
|
|
7
|
+
actual observed behavior of ``codex-cli 0.130.0``, not the PRD's
|
|
8
|
+
example invocation. If you're changing flag strings or the
|
|
9
|
+
JSONL-parsing path here, re-read the discovery doc first.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from syncade.adapters.base import (
|
|
17
|
+
Invocation,
|
|
18
|
+
ReviewerInvocationError,
|
|
19
|
+
)
|
|
20
|
+
from syncade.auth_preflight import assert_codex_reality_honours_declaration
|
|
21
|
+
from syncade.config import ReviewerConfig
|
|
22
|
+
from syncade.config_auth import apply_auth_to_env
|
|
23
|
+
from syncade.findings import (
|
|
24
|
+
ReviewerOutput,
|
|
25
|
+
ReviewerOutputError,
|
|
26
|
+
parse_reviewer_output,
|
|
27
|
+
)
|
|
28
|
+
from syncade.process import (
|
|
29
|
+
SubprocessNotFoundError,
|
|
30
|
+
SubprocessResult,
|
|
31
|
+
SubprocessTimeoutError,
|
|
32
|
+
run_subprocess,
|
|
33
|
+
)
|
|
34
|
+
from syncade.worktree_env import worktree_scoped_env
|
|
35
|
+
|
|
36
|
+
from .openai_parsing import (
|
|
37
|
+
_extract_failure_message,
|
|
38
|
+
_looks_like_auth_failure,
|
|
39
|
+
_parse_jsonl_events,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Map ``ReviewerConfig.permissions`` (yolo | trusted-execute)
|
|
43
|
+
# to the corresponding flag set for ``codex exec``. ``safe`` is
|
|
44
|
+
# deliberately NOT mapped — see ``_validate_permissions`` for why.
|
|
45
|
+
#
|
|
46
|
+
# ``yolo`` uses the combined shorthand
|
|
47
|
+
# ``--dangerously-bypass-approvals-and-sandbox`` (sandbox bypass +
|
|
48
|
+
# never-prompt in one flag).
|
|
49
|
+
#
|
|
50
|
+
# ``trusted-execute`` uses ``-s workspace-write`` (writes inside the worktree
|
|
51
|
+
# are auto-approved) paired with ``-c approval_policy=never`` (the
|
|
52
|
+
# generic config override, since ``codex exec`` has no ``-a`` flag. The
|
|
53
|
+
# ``-a/--ask-for-approval`` flag exists on the top-level ``codex`` command but
|
|
54
|
+
# not on the ``exec`` subcommand.
|
|
55
|
+
_YOLO_FLAG = "--dangerously-bypass-approvals-and-sandbox"
|
|
56
|
+
_TRUSTED_SANDBOX = "workspace-write"
|
|
57
|
+
_TRUSTED_APPROVAL_CONFIG = "approval_policy=never"
|
|
58
|
+
|
|
59
|
+
# `codex login status` exit codes per the discovery doc:
|
|
60
|
+
# 0 — Logged in (stdout "Logged in using ChatGPT" or similar)
|
|
61
|
+
# 1 — Not logged in (stdout "Not logged in")
|
|
62
|
+
_CODEX_AUTH_CHECK_ARGV: list[str] = ["codex", "login", "status"]
|
|
63
|
+
_CODEX_AUTH_CHECK_TIMEOUT_SECONDS: float = 10.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_codex_auth(binary_missing_message: str) -> None:
|
|
67
|
+
try:
|
|
68
|
+
result = run_subprocess(
|
|
69
|
+
_CODEX_AUTH_CHECK_ARGV,
|
|
70
|
+
timeout=_CODEX_AUTH_CHECK_TIMEOUT_SECONDS,
|
|
71
|
+
)
|
|
72
|
+
except SubprocessNotFoundError as exc:
|
|
73
|
+
raise ReviewerInvocationError(
|
|
74
|
+
binary_missing_message,
|
|
75
|
+
returncode=-1,
|
|
76
|
+
stdout="",
|
|
77
|
+
stderr="",
|
|
78
|
+
api_error_status=None,
|
|
79
|
+
) from exc
|
|
80
|
+
except SubprocessTimeoutError as exc:
|
|
81
|
+
raise ReviewerInvocationError(
|
|
82
|
+
f"codex login status timed out after {exc.timeout}s — "
|
|
83
|
+
f"codex may be hung or the auth file may be corrupt. "
|
|
84
|
+
f"Try `codex login` to re-authenticate.",
|
|
85
|
+
returncode=-1,
|
|
86
|
+
stdout=exc.stdout,
|
|
87
|
+
stderr=exc.stderr,
|
|
88
|
+
api_error_status=None,
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
if result.returncode != 0:
|
|
92
|
+
detail = result.stdout.strip() or result.stderr.strip() or "auth check failed"
|
|
93
|
+
raise ReviewerInvocationError(
|
|
94
|
+
f"codex auth check failed: {detail[:200]} — run `codex login` to authenticate",
|
|
95
|
+
returncode=result.returncode,
|
|
96
|
+
stdout=result.stdout,
|
|
97
|
+
stderr=result.stderr,
|
|
98
|
+
api_error_status=None,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CodexAdapter:
|
|
103
|
+
"""ReviewerAdapter for the OpenAI ``codex`` CLI.
|
|
104
|
+
|
|
105
|
+
``build_invocation`` produces a ``codex exec`` argv that pins the
|
|
106
|
+
model via ``--model``, sets reasoning effort via the
|
|
107
|
+
``-c model_reasoning_effort=<level>`` config override (codex has
|
|
108
|
+
no dedicated ``--effort`` flag — see the discovery doc), maps
|
|
109
|
+
permissions to the combined
|
|
110
|
+
``--dangerously-bypass-approvals-and-sandbox`` flag for ``yolo``
|
|
111
|
+
or ``-s workspace-write -c approval_policy=never`` for
|
|
112
|
+
``trusted-execute``, scopes file access to the worktree via ``-C`` and
|
|
113
|
+
``--add-dir``, and requests JSONL output via ``--json``.
|
|
114
|
+
|
|
115
|
+
``parse_output`` validates that the subprocess succeeded, parses
|
|
116
|
+
the JSONL event stream, treats terminal failures (``turn.failed``,
|
|
117
|
+
non-zero rc, or a bare ``error`` stream with no recovered
|
|
118
|
+
``agent_message``) as invocation errors, and on success extracts the LAST
|
|
119
|
+
``agent_message`` event's ``.item.text`` to hand to
|
|
120
|
+
:func:`~syncade.findings.parse_reviewer_output`. The parser is
|
|
121
|
+
already robust to markdown-fenced JSON, which the model may
|
|
122
|
+
emit regardless of prompt instructions.
|
|
123
|
+
|
|
124
|
+
``check_auth`` runs ``codex login status`` — a fast filesystem
|
|
125
|
+
check (no network) — and raises if auth is missing, so the
|
|
126
|
+
dispatcher's pre-flight phase short-circuits the whole batch
|
|
127
|
+
instead of letting codex burn 10+ seconds of 401 retries.
|
|
128
|
+
|
|
129
|
+
The adapter never shells out itself in ``build_invocation`` or
|
|
130
|
+
``parse_output`` — :class:`Invocation` is data for the dispatcher
|
|
131
|
+
to execute. ``check_auth`` is the one exception; it MUST shell
|
|
132
|
+
out because it's checking subprocess-visible state.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
name = "openai"
|
|
136
|
+
|
|
137
|
+
def check_auth(self) -> None:
|
|
138
|
+
"""Verify ``codex login status`` for reviewer subprocesses."""
|
|
139
|
+
_check_codex_auth(
|
|
140
|
+
binary_missing_message=(
|
|
141
|
+
"codex binary not found on PATH — install codex-cli to "
|
|
142
|
+
"run reviewer subprocesses (the OpenAI Codex CLI, not a "
|
|
143
|
+
"third-party fork)"
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def build_invocation(
|
|
148
|
+
self,
|
|
149
|
+
reviewer_config: ReviewerConfig,
|
|
150
|
+
worktree_path: Path,
|
|
151
|
+
prompt: str,
|
|
152
|
+
) -> Invocation:
|
|
153
|
+
"""Construct the ``codex exec`` invocation for a single reviewer run.
|
|
154
|
+
|
|
155
|
+
The argv matches the form documented in
|
|
156
|
+
the CLI output format:
|
|
157
|
+
|
|
158
|
+
- ``codex exec``
|
|
159
|
+
- ``--json``: emit JSONL events on stdout (cleanest parsing target)
|
|
160
|
+
- ``--model <id>``: model from the reviewer config (full name
|
|
161
|
+
like ``gpt-5.5``)
|
|
162
|
+
- ``-c model_reasoning_effort=<level>``: maps from ``thinking``
|
|
163
|
+
(see :attr:`syncade.config.ReviewerConfig.thinking` for the
|
|
164
|
+
canonical list of accepted values). Codex has no dedicated
|
|
165
|
+
``--effort`` flag; the generic ``-c`` config override is
|
|
166
|
+
the documented path.
|
|
167
|
+
- ``--dangerously-bypass-approvals-and-sandbox`` for ``yolo``,
|
|
168
|
+
or ``-s workspace-write -c approval_policy=never`` for
|
|
169
|
+
``trusted-execute`` (``codex exec`` has no
|
|
170
|
+
``-a/--ask-for-approval`` flag — that flag exists on the
|
|
171
|
+
top-level ``codex`` command only — so the ``approval_policy``
|
|
172
|
+
config key is the documented exec-subcommand path).
|
|
173
|
+
``permissions="safe"`` is **rejected** —
|
|
174
|
+
see :meth:`_validate_permissions`.
|
|
175
|
+
- ``-C <worktree>``: codex's working-root flag (analogous to
|
|
176
|
+
claude's ``cwd`` behavior)
|
|
177
|
+
- ``--add-dir <worktree>``: grants the reviewer tool-access
|
|
178
|
+
to its worktree (explicit is safer than implicit, same as
|
|
179
|
+
AnthropicAdapter)
|
|
180
|
+
- prompt on STDIN (PR-h-field-01 item 1): ``codex exec`` reads the prompt
|
|
181
|
+
from stdin when no positional PROMPT is given; argv is flag-only
|
|
182
|
+
|
|
183
|
+
The subprocess runs with ``cwd = worktree_path`` and inherits
|
|
184
|
+
the caller's environment so existing ``codex`` auth (in
|
|
185
|
+
``~/.codex/auth.json``) flows through.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ValueError: If ``reviewer_config.provider`` is not
|
|
189
|
+
``"openai"`` — guards against the dispatcher
|
|
190
|
+
misrouting a non-Codex config to this adapter.
|
|
191
|
+
ValueError: If ``reviewer_config.permissions`` is
|
|
192
|
+
``"safe"`` — the corresponding
|
|
193
|
+
``approval_policy=untrusted`` mode prompts for tool use
|
|
194
|
+
and ``codex exec`` is
|
|
195
|
+
non-interactive. Surface loudly rather than hang.
|
|
196
|
+
"""
|
|
197
|
+
self._validate_provider(reviewer_config.provider)
|
|
198
|
+
self._validate_permissions(reviewer_config.permissions)
|
|
199
|
+
# Structural backstop: refuse to spawn codex for a non-auto declaration the probed
|
|
200
|
+
# login does not honour, on any path that skipped the CLI auth gate. No-op on the
|
|
201
|
+
# gated CLI path. See syncade.auth_preflight.
|
|
202
|
+
assert_codex_reality_honours_declaration(reviewer_config)
|
|
203
|
+
|
|
204
|
+
argv: list[str] = [
|
|
205
|
+
"codex",
|
|
206
|
+
"exec",
|
|
207
|
+
"--json",
|
|
208
|
+
"--model",
|
|
209
|
+
reviewer_config.model,
|
|
210
|
+
"-c",
|
|
211
|
+
f"model_reasoning_effort={reviewer_config.thinking}",
|
|
212
|
+
]
|
|
213
|
+
# Permission flags — yolo uses the combined shorthand;
|
|
214
|
+
# trusted-execute uses `-s workspace-write` paired with
|
|
215
|
+
# `-c approval_policy=never` (the generic config-override
|
|
216
|
+
# mechanism, since `codex exec` has no `-a/--ask-for-approval`
|
|
217
|
+
# flag). Safe and stale/unknown values are refused above.
|
|
218
|
+
if reviewer_config.permissions == "yolo":
|
|
219
|
+
argv.append(_YOLO_FLAG)
|
|
220
|
+
else: # trusted-execute
|
|
221
|
+
argv.extend(["-s", _TRUSTED_SANDBOX, "-c", _TRUSTED_APPROVAL_CONFIG])
|
|
222
|
+
# The prompt goes on STDIN, never argv — see AnthropicAdapter.build_invocation.
|
|
223
|
+
# `codex exec` reads instructions from stdin when no positional PROMPT is given.
|
|
224
|
+
argv.extend(
|
|
225
|
+
[
|
|
226
|
+
"-C",
|
|
227
|
+
str(worktree_path),
|
|
228
|
+
"--add-dir",
|
|
229
|
+
str(worktree_path),
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
return Invocation(
|
|
233
|
+
argv=argv,
|
|
234
|
+
cwd=worktree_path,
|
|
235
|
+
env=apply_auth_to_env(worktree_scoped_env(worktree_path), reviewer_config),
|
|
236
|
+
stdin_text=prompt,
|
|
237
|
+
timeout_seconds=None,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _validate_provider(provider: str) -> None:
|
|
242
|
+
"""Refuse a config whose ``provider`` is not ``"openai"``.
|
|
243
|
+
|
|
244
|
+
Same defensive guard as
|
|
245
|
+
:meth:`syncade.adapters.anthropic.AnthropicAdapter._validate_provider` —
|
|
246
|
+
fail loudly at build time if the dispatcher misroutes a config,
|
|
247
|
+
rather than letting the subprocess fail with a confusing
|
|
248
|
+
``codex`` CLI error after the model/effort/permission flags
|
|
249
|
+
from the wrong provider get spliced into argv.
|
|
250
|
+
"""
|
|
251
|
+
if provider != "openai":
|
|
252
|
+
raise ValueError( # GENERIC_ERR_OK: config guard preserves existing ValueError API.
|
|
253
|
+
f"CodexAdapter received a ReviewerConfig with "
|
|
254
|
+
f"provider={provider!r}; expected 'openai'. The "
|
|
255
|
+
f"dispatcher should route configs to the adapter whose "
|
|
256
|
+
f"name matches the config's provider field."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _validate_permissions(permissions: str) -> None:
|
|
261
|
+
"""Refuse ``permissions='safe'`` for the Codex adapter.
|
|
262
|
+
|
|
263
|
+
The natural ``approval_policy=untrusted`` mapping prompts for
|
|
264
|
+
any tool use outside a small auto-trusted set, and ``codex
|
|
265
|
+
exec`` is non-interactive so prompts cannot be answered. The
|
|
266
|
+
reviewer subprocess would hang on the first non-trusted command
|
|
267
|
+
until the dispatcher's timeout fires. Raise here so the
|
|
268
|
+
misconfiguration is a fast, legible error rather than a
|
|
269
|
+
20-minute wait.
|
|
270
|
+
"""
|
|
271
|
+
if permissions == "safe":
|
|
272
|
+
raise ValueError( # GENERIC_ERR_OK: config guard preserves existing ValueError API.
|
|
273
|
+
"CodexAdapter cannot run a reviewer with permissions='safe' "
|
|
274
|
+
"headlessly: the corresponding `approval_policy=untrusted` "
|
|
275
|
+
"mode prompts for tool use and `codex exec` cannot answer "
|
|
276
|
+
"prompts. Use 'trusted-execute' or 'yolo'."
|
|
277
|
+
)
|
|
278
|
+
if permissions not in {"trusted-execute", "yolo"}:
|
|
279
|
+
raise ValueError( # GENERIC_ERR_OK: config guard preserves existing ValueError API.
|
|
280
|
+
"CodexAdapter received unsupported reviewer "
|
|
281
|
+
f"permissions={permissions!r}; expected one of "
|
|
282
|
+
"'trusted-execute', 'yolo'."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def parse_output(self, result: SubprocessResult) -> ReviewerOutput:
|
|
286
|
+
"""Parse a finished ``codex exec --json`` subprocess result.
|
|
287
|
+
|
|
288
|
+
Decision tree for codex's JSONL stream:
|
|
289
|
+
|
|
290
|
+
1. Extract the final ``agent_message`` text via
|
|
291
|
+
:meth:`extract_final_text` — which itself
|
|
292
|
+
parses the JSONL events, detects subprocess failures
|
|
293
|
+
(``turn.failed`` events, non-zero ``returncode``, unrecovered
|
|
294
|
+
bare ``error`` streams, auth signatures) and raises
|
|
295
|
+
:class:`ReviewerInvocationError` for them. On
|
|
296
|
+
no-``agent_message`` (codex succeeded but emitted nothing
|
|
297
|
+
useful) the helper raises whatever
|
|
298
|
+
``empty_output_exception_class`` was passed — for
|
|
299
|
+
reviewer dispatch that's :class:`ReviewerOutputError`
|
|
300
|
+
(exit-70 territory).
|
|
301
|
+
2. Hand the extracted text to
|
|
302
|
+
:func:`~syncade.findings.parse_reviewer_output` — which
|
|
303
|
+
handles markdown-fenced JSON, JSON-in-prose, and the
|
|
304
|
+
JSX-shaped prose snippets.
|
|
305
|
+
|
|
306
|
+
The reusable JSONL → text extraction lets :mod:`syncade.synthesizer`
|
|
307
|
+
drive the same codex pipeline and parse the result as a
|
|
308
|
+
:class:`~syncade.synthesis.SynthesizerOutput`.
|
|
309
|
+
"""
|
|
310
|
+
final_text = self.extract_final_text(
|
|
311
|
+
result,
|
|
312
|
+
empty_output_exception_class=ReviewerOutputError,
|
|
313
|
+
)
|
|
314
|
+
return parse_reviewer_output(final_text)
|
|
315
|
+
|
|
316
|
+
def extract_final_text(
|
|
317
|
+
self,
|
|
318
|
+
result: SubprocessResult,
|
|
319
|
+
*,
|
|
320
|
+
empty_output_exception_class: type[Exception],
|
|
321
|
+
) -> str:
|
|
322
|
+
"""Extract the final ``agent_message`` text from a
|
|
323
|
+
``codex exec --json`` subprocess result.
|
|
324
|
+
|
|
325
|
+
Reusable across callers that need codex's output but parse it
|
|
326
|
+
into different typed shapes. The synthesizer uses this to
|
|
327
|
+
feed the text to :func:`syncade.synthesis.parse_synthesizer_output`
|
|
328
|
+
while preserving the existing reviewer-dispatch behavior of
|
|
329
|
+
:meth:`parse_output` (which uses
|
|
330
|
+
:func:`syncade.findings.parse_reviewer_output`).
|
|
331
|
+
|
|
332
|
+
Steps:
|
|
333
|
+
|
|
334
|
+
1. Parse ``stdout`` as JSONL — one event per line. Skip blank
|
|
335
|
+
lines and non-JSON garbage silently (defensive).
|
|
336
|
+
2. Collect agent messages and terminal failure signals:
|
|
337
|
+
|
|
338
|
+
- Any event of type ``"turn.failed"``.
|
|
339
|
+
- A non-zero ``returncode``.
|
|
340
|
+
- A bare ``"error"`` event only when no agent message was
|
|
341
|
+
produced. Codex may emit transient reconnect ``error`` events and
|
|
342
|
+
then recover with a completed turn; those are not fatal.
|
|
343
|
+
3. If a terminal failure signal is present, raise
|
|
344
|
+
:class:`ReviewerInvocationError` — the failure shape is
|
|
345
|
+
codex-side (auth / network / API / process), not phase-
|
|
346
|
+
specific, so the reviewer-invocation exception applies to
|
|
347
|
+
both reviewer and synthesizer dispatch.
|
|
348
|
+
4. Otherwise, return the LAST ``item.completed`` event whose
|
|
349
|
+
``item.type`` is ``agent_message`` and return its
|
|
350
|
+
``item.text``.
|
|
351
|
+
5. If no ``agent_message`` event is present (codex succeeded
|
|
352
|
+
but emitted nothing useful), raise an instance of
|
|
353
|
+
``empty_output_exception_class`` — the caller passes
|
|
354
|
+
the phase-appropriate class so the user's exit-70 diagnostic
|
|
355
|
+
tells them which ``.stdout`` to open.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
result: The :class:`SubprocessResult` from running the
|
|
359
|
+
codex subprocess.
|
|
360
|
+
empty_output_exception_class: Exception class to
|
|
361
|
+
instantiate when no agent_message is found. Reviewer
|
|
362
|
+
dispatch passes :class:`ReviewerOutputError`;
|
|
363
|
+
synthesizer dispatch passes
|
|
364
|
+
:class:`syncade.synthesis.SynthesizerOutputError`.
|
|
365
|
+
Both map to exit 70 but the message names the phase.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
The final ``agent_message`` text on success.
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
ReviewerInvocationError: On any subprocess-side failure
|
|
372
|
+
(failure events, non-zero rc, auth signature).
|
|
373
|
+
``empty_output_exception_class``: When no
|
|
374
|
+
``agent_message`` is present in the JSONL stream.
|
|
375
|
+
"""
|
|
376
|
+
events = _parse_jsonl_events(result.stdout)
|
|
377
|
+
|
|
378
|
+
turn_failed_events = [e for e in events if e.get("type") == "turn.failed"]
|
|
379
|
+
bare_error_events = [e for e in events if e.get("type") == "error"]
|
|
380
|
+
agent_messages = [
|
|
381
|
+
e
|
|
382
|
+
for e in events
|
|
383
|
+
if e.get("type") == "item.completed"
|
|
384
|
+
and isinstance(e.get("item"), dict)
|
|
385
|
+
and e["item"].get("type") == "agent_message"
|
|
386
|
+
and isinstance(e["item"].get("text"), str)
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
terminal_failure = (
|
|
390
|
+
bool(turn_failed_events)
|
|
391
|
+
or result.returncode != 0
|
|
392
|
+
or (bool(bare_error_events) and not agent_messages)
|
|
393
|
+
)
|
|
394
|
+
if turn_failed_events or result.returncode != 0:
|
|
395
|
+
failure_events = [*bare_error_events, *turn_failed_events]
|
|
396
|
+
elif bare_error_events and not agent_messages:
|
|
397
|
+
failure_events = bare_error_events
|
|
398
|
+
else:
|
|
399
|
+
failure_events = []
|
|
400
|
+
|
|
401
|
+
if terminal_failure:
|
|
402
|
+
message = _extract_failure_message(failure_events, result)
|
|
403
|
+
is_auth = _looks_like_auth_failure(message, failure_events)
|
|
404
|
+
api_error_status = 401 if is_auth else None
|
|
405
|
+
if is_auth:
|
|
406
|
+
raised_message = (
|
|
407
|
+
f"codex auth failed (rc={result.returncode}): "
|
|
408
|
+
f"{message[:200]} — run `codex login` to reauthenticate"
|
|
409
|
+
)
|
|
410
|
+
else:
|
|
411
|
+
raised_message = f"codex failed (rc={result.returncode}): {message[:300]}"
|
|
412
|
+
raise ReviewerInvocationError(
|
|
413
|
+
raised_message,
|
|
414
|
+
returncode=result.returncode,
|
|
415
|
+
stdout=result.stdout,
|
|
416
|
+
stderr=result.stderr,
|
|
417
|
+
api_error_status=api_error_status,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if not agent_messages:
|
|
421
|
+
raise empty_output_exception_class(
|
|
422
|
+
f"codex completed but emitted no agent_message event; "
|
|
423
|
+
f"stdout: {result.stdout[:200]!r}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Multiple agent_messages are possible in multi-turn flows; the
|
|
427
|
+
# reviewer's verdict is always the final one.
|
|
428
|
+
return agent_messages[-1]["item"]["text"]
|
|
429
|
+
|
|
430
|
+
def extract_response_text(self, raw_stdout: str) -> str:
|
|
431
|
+
"""Extract the assistant's response text from a ``codex exec
|
|
432
|
+
--json`` JSONL stdout.
|
|
433
|
+
|
|
434
|
+
Thin wrapper around
|
|
435
|
+
:meth:`extract_final_text` that:
|
|
436
|
+
|
|
437
|
+
- Synthesizes a :class:`SubprocessResult` with
|
|
438
|
+
``returncode=0`` (the on-disk artifact doesn't preserve the
|
|
439
|
+
original returncode; we assume success because a failed
|
|
440
|
+
codex round would have terminated the loop before the
|
|
441
|
+
stdout was archived for cross-round replay).
|
|
442
|
+
- Defaults the ``empty_output_exception_class`` argument
|
|
443
|
+
to :class:`~syncade.findings.ReviewerOutputError` — the
|
|
444
|
+
general-purpose exit-70 exception, mirroring what
|
|
445
|
+
:meth:`parse_output` passes when running the reviewer
|
|
446
|
+
pipeline.
|
|
447
|
+
|
|
448
|
+
Symmetric with
|
|
449
|
+
:meth:`syncade.adapters.anthropic.AnthropicAdapter.extract_response_text`
|
|
450
|
+
— both adapters expose the same single-method interface for
|
|
451
|
+
the orchestrator's prior-round-context plumbing
|
|
452
|
+
(:mod:`syncade.orchestrator.prior_round`) to dispatch into.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
raw_stdout: The raw stdout of a finished ``codex exec
|
|
456
|
+
--json`` invocation. Expected shape: one JSON event
|
|
457
|
+
per line; the final ``item.completed`` event with
|
|
458
|
+
``item.type == "agent_message"`` carries the response
|
|
459
|
+
text in ``item.text``.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
The assistant's final agent_message text.
|
|
463
|
+
|
|
464
|
+
Raises:
|
|
465
|
+
ReviewerInvocationError: If the JSONL stream contains
|
|
466
|
+
explicit terminal failure events (``turn.failed`` or an
|
|
467
|
+
unrecovered bare ``error`` stream).
|
|
468
|
+
In practice this shouldn't happen for cross-round
|
|
469
|
+
replay (the loop terminates on codex subprocess
|
|
470
|
+
failures) but the helper is robust.
|
|
471
|
+
ReviewerOutputError: If no ``agent_message`` event is
|
|
472
|
+
present in the JSONL stream — the default exception
|
|
473
|
+
class.
|
|
474
|
+
"""
|
|
475
|
+
synthesized = SubprocessResult(
|
|
476
|
+
returncode=0,
|
|
477
|
+
stdout=raw_stdout,
|
|
478
|
+
stderr="",
|
|
479
|
+
duration_seconds=0.0,
|
|
480
|
+
)
|
|
481
|
+
return self.extract_final_text(
|
|
482
|
+
synthesized,
|
|
483
|
+
empty_output_exception_class=ReviewerOutputError,
|
|
484
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""JSONL-event parsing + failure classification for the codex adapter.
|
|
2
|
+
|
|
3
|
+
``openai.py``'s ``extract_final_text`` calls these as module
|
|
4
|
+
functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from syncade.process import SubprocessResult
|
|
13
|
+
|
|
14
|
+
_AUTH_FAILURE_MARKERS: Final[tuple[str, ...]] = (
|
|
15
|
+
"401 unauthorized",
|
|
16
|
+
"unauthorized",
|
|
17
|
+
"missing bearer",
|
|
18
|
+
"basic authentication",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_jsonl_events(stdout: str) -> list[dict]:
|
|
23
|
+
"""Parse JSONL events from codex's stdout.
|
|
24
|
+
|
|
25
|
+
Each non-blank line is parsed independently; lines that fail
|
|
26
|
+
to parse as JSON, or parse to something that isn't a dict, are
|
|
27
|
+
silently skipped. This is defensive — real
|
|
28
|
+
``codex exec --json`` emits well-formed JSONL only, but a
|
|
29
|
+
future CLI update that leaks a non-JSON line shouldn't break
|
|
30
|
+
the whole parse.
|
|
31
|
+
"""
|
|
32
|
+
events: list[dict] = []
|
|
33
|
+
for line in stdout.splitlines():
|
|
34
|
+
line = line.strip()
|
|
35
|
+
if not line:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
parsed = json.loads(line)
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
continue
|
|
41
|
+
if isinstance(parsed, dict):
|
|
42
|
+
events.append(parsed)
|
|
43
|
+
return events
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_failure_message(
|
|
47
|
+
failure_events: list[dict],
|
|
48
|
+
result: SubprocessResult,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Pull the most informative message out of codex's failure
|
|
51
|
+
events.
|
|
52
|
+
|
|
53
|
+
Order of preference:
|
|
54
|
+
1. The LAST ``turn.failed`` event's ``error.message`` (this
|
|
55
|
+
is the terminal failure message).
|
|
56
|
+
2. The LAST ``error`` event's ``message``.
|
|
57
|
+
3. ``stderr`` content (CLI-level failures like unknown flag).
|
|
58
|
+
4. ``stdout`` snippet as a fallback.
|
|
59
|
+
5. A generic "exited with code N" if nothing useful is
|
|
60
|
+
available.
|
|
61
|
+
"""
|
|
62
|
+
# Try turn.failed.error.message first, PREFIXED with error.type when present.
|
|
63
|
+
#
|
|
64
|
+
# codex carries its failure kind as a TYPED variant (`UsageLimitReached`, `QuotaExceeded`,
|
|
65
|
+
# …) beside a message that may be generic. Reducing the event to its message alone discards
|
|
66
|
+
# the only unambiguous signal in it, and downstream classification then has to guess from
|
|
67
|
+
# prose. Keeping the type in the text is the smallest change that preserves it: the message
|
|
68
|
+
# stays human-readable and `retry.is_usage_limit_error` gets an exact term to match instead
|
|
69
|
+
# of a substring of English (PR-h-field-02 dogfood, blocker 3).
|
|
70
|
+
for event in reversed(failure_events):
|
|
71
|
+
if event.get("type") == "turn.failed":
|
|
72
|
+
error = event.get("error")
|
|
73
|
+
if isinstance(error, dict):
|
|
74
|
+
msg = error.get("message")
|
|
75
|
+
if isinstance(msg, str) and msg:
|
|
76
|
+
kind = error.get("type")
|
|
77
|
+
if isinstance(kind, str) and kind and kind not in msg:
|
|
78
|
+
return f"{kind}: {msg}"
|
|
79
|
+
return msg
|
|
80
|
+
# Then bare error event messages
|
|
81
|
+
for event in reversed(failure_events):
|
|
82
|
+
if event.get("type") == "error":
|
|
83
|
+
msg = event.get("message")
|
|
84
|
+
if isinstance(msg, str) and msg:
|
|
85
|
+
return msg
|
|
86
|
+
# Fall back to stderr (CLI-level failures land here)
|
|
87
|
+
if result.stderr.strip():
|
|
88
|
+
return result.stderr.strip()
|
|
89
|
+
if result.stdout.strip():
|
|
90
|
+
return result.stdout.strip()
|
|
91
|
+
return f"codex exited with code {result.returncode}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _looks_like_auth_failure(message: str, events: list[dict]) -> bool:
|
|
95
|
+
"""Detect codex's auth-failure pattern.
|
|
96
|
+
|
|
97
|
+
Codex's missing-auth shape emits multiple error events whose
|
|
98
|
+
messages contain "401 Unauthorized" and/or missing bearer/basic
|
|
99
|
+
authentication text. Classify as auth failure only when one of
|
|
100
|
+
those auth-specific markers is in the consolidated message or any
|
|
101
|
+
event's text.
|
|
102
|
+
"""
|
|
103
|
+
if _contains_auth_failure_marker(message):
|
|
104
|
+
return True
|
|
105
|
+
for event in events:
|
|
106
|
+
for value in (
|
|
107
|
+
event.get("message"),
|
|
108
|
+
(event.get("error") or {}).get("message")
|
|
109
|
+
if isinstance(event.get("error"), dict)
|
|
110
|
+
else None,
|
|
111
|
+
):
|
|
112
|
+
if isinstance(value, str) and _contains_auth_failure_marker(value):
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _contains_auth_failure_marker(text: str) -> bool:
|
|
118
|
+
normalized = text.lower()
|
|
119
|
+
return any(marker in normalized for marker in _AUTH_FAILURE_MARKERS)
|