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,336 @@
|
|
|
1
|
+
"""Prior-round context extraction helpers.
|
|
2
|
+
|
|
3
|
+
The reviewer and producer prompts support ``{prior_round_output}`` (and,
|
|
4
|
+
for producer, ``{prior_round_commits}``) placeholders. This module is the
|
|
5
|
+
data-loading bridge: read the prior round's persisted
|
|
6
|
+
``.stdout`` artifacts off disk, extract the response text via the
|
|
7
|
+
matching adapter's helper, and return the text (or a documented
|
|
8
|
+
framing fallback when the prior artifact is missing / unparseable).
|
|
9
|
+
|
|
10
|
+
Cross-PR isolation is structural: every ``syncade <pr-doc>`` invocation
|
|
11
|
+
runs under a fresh ``<run-id>``, so this module only ever sees stdouts
|
|
12
|
+
from prior rounds of the CURRENT run. No file path constructed here can
|
|
13
|
+
escape ``<run_dir>``.
|
|
14
|
+
|
|
15
|
+
Per-reviewer isolation is also structural: each reviewer's stdout lives
|
|
16
|
+
at ``<round-dir>/<reviewer_name>.stdout``, so a round-N claude reviewer
|
|
17
|
+
loading ``<round-(N-1)>/<claude_name>.stdout`` cannot see the codex
|
|
18
|
+
reviewer's output. The orchestrator (``round.py``) drives one extraction
|
|
19
|
+
per reviewer by their own name.
|
|
20
|
+
|
|
21
|
+
T4 formalized the per-adapter ``extract_response_text``
|
|
22
|
+
interface; this module dispatches by provider name to the matching
|
|
23
|
+
adapter's method. Both ``AnthropicAdapter`` and ``CodexAdapter`` now
|
|
24
|
+
expose a symmetric ``extract_response_text(self, raw_stdout: str) -> str``
|
|
25
|
+
method.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from syncade.adapters.anthropic import AnthropicAdapter
|
|
34
|
+
from syncade.adapters.openai import CodexAdapter
|
|
35
|
+
|
|
36
|
+
# Sentinels rendered when the prior-round artifact is missing or
|
|
37
|
+
# unrecoverable. The reviewer / producer prompt prose tells the model
|
|
38
|
+
# how to interpret these strings, so the operator gets a coherent
|
|
39
|
+
# prompt instead of an empty placeholder.
|
|
40
|
+
_MISSING_ARTIFACT_FRAMING = (
|
|
41
|
+
"(prior round artifact not found on disk; treat this round as a fresh attempt)"
|
|
42
|
+
)
|
|
43
|
+
"""Returned when ``<round-(N-1)>/<basename>.stdout`` doesn't exist or
|
|
44
|
+
can't be read. The framing tells the reviewer / producer that no prior
|
|
45
|
+
context is available and they should proceed as if starting fresh."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_response_text_from_stdout(provider: str, raw_stdout: str) -> str:
|
|
49
|
+
"""Extract the assistant's response text from a reviewer / producer
|
|
50
|
+
subprocess's raw stdout.
|
|
51
|
+
|
|
52
|
+
Dispatches by provider name to the matching adapter's symmetric
|
|
53
|
+
:meth:`extract_response_text` helper:
|
|
54
|
+
|
|
55
|
+
- ``anthropic`` →
|
|
56
|
+
:meth:`syncade.adapters.anthropic.AnthropicAdapter.extract_response_text`
|
|
57
|
+
- ``openai`` →
|
|
58
|
+
:meth:`syncade.adapters.openai.CodexAdapter.extract_response_text`
|
|
59
|
+
|
|
60
|
+
Empty stdout short-circuits to returning ``""`` — calling the
|
|
61
|
+
adapter's extractor on empty input would raise (codex would say
|
|
62
|
+
"no agent_message event") which the broad except below would
|
|
63
|
+
catch and fall back to raw_stdout anyway. The short-circuit makes
|
|
64
|
+
the empty-stdout path explicit.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
provider: The reviewer / producer config's ``provider`` string.
|
|
68
|
+
Must be ``"anthropic"`` or ``"openai"`` to extract; any
|
|
69
|
+
other value falls through to returning the raw stdout
|
|
70
|
+
unchanged (the unknown-provider safe default — letting the
|
|
71
|
+
model see something is better than letting it see nothing).
|
|
72
|
+
raw_stdout: The contents of the prior round's
|
|
73
|
+
``<reviewer_name>.stdout`` or ``producer.stdout`` artifact.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The extracted response text. Falls back to ``raw_stdout``
|
|
77
|
+
verbatim on any per-adapter extraction failure (malformed
|
|
78
|
+
envelope, missing ``result`` field, codex extraction raised).
|
|
79
|
+
The fallback is deliberate — for cross-round context, having
|
|
80
|
+
the model see the raw envelope is better than rendering an
|
|
81
|
+
empty string.
|
|
82
|
+
"""
|
|
83
|
+
# Empty stdout: pass through. The adapter helpers raise on empty
|
|
84
|
+
# input; this short-circuit is cheaper than try/except.
|
|
85
|
+
if not raw_stdout:
|
|
86
|
+
return raw_stdout
|
|
87
|
+
try:
|
|
88
|
+
if provider == "anthropic":
|
|
89
|
+
return AnthropicAdapter().extract_response_text(raw_stdout)
|
|
90
|
+
if provider == "openai":
|
|
91
|
+
return CodexAdapter().extract_response_text(raw_stdout)
|
|
92
|
+
except Exception: # noqa: BLE001 — best-effort: see docstring
|
|
93
|
+
return raw_stdout
|
|
94
|
+
# Unknown provider — pass the raw stdout through. The reviewer /
|
|
95
|
+
# producer prompt will show it verbatim. Better than an empty
|
|
96
|
+
# placeholder that loses cross-round signal entirely.
|
|
97
|
+
return raw_stdout
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_prior_reviewer_response_text(
|
|
101
|
+
*,
|
|
102
|
+
prior_round_dir: Path,
|
|
103
|
+
reviewer_name: str,
|
|
104
|
+
reviewer_provider: str,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Load and extract the prior round's response text for ONE reviewer.
|
|
107
|
+
|
|
108
|
+
Reads ``<prior_round_dir>/<reviewer_name>.stdout`` and extracts the
|
|
109
|
+
assistant's response text via the matching adapter's helper.
|
|
110
|
+
Per-reviewer isolation: this function is called per reviewer name,
|
|
111
|
+
so round-N claude-reviewer's load reads claude's prior stdout, NOT
|
|
112
|
+
codex's. The orchestrator (round.py) drives the loop.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
prior_round_dir: Path to ``<run_dir>/round-(N-1)``. Caller
|
|
116
|
+
(round.py) constructs this from ``run_dir / f"round-{round_idx - 1}"``.
|
|
117
|
+
reviewer_name: The :class:`~syncade.config.ReviewerConfig.name`.
|
|
118
|
+
Becomes the basename of the on-disk stdout file.
|
|
119
|
+
reviewer_provider: The :class:`~syncade.config.ReviewerConfig.provider`
|
|
120
|
+
(``"anthropic"`` or ``"openai"``). Selects the extraction
|
|
121
|
+
helper.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The extracted prior-round response text, or
|
|
125
|
+
:data:`_MISSING_ARTIFACT_FRAMING` when the stdout artifact is
|
|
126
|
+
missing on disk. Defensive: a happy-path multi-round loop will
|
|
127
|
+
always have the prior stdout present (a failed reviewer would
|
|
128
|
+
have terminated the loop before reaching the next round), but
|
|
129
|
+
the missing-artifact path keeps the orchestrator robust
|
|
130
|
+
against on-disk corruption / manual artifact cleanup.
|
|
131
|
+
"""
|
|
132
|
+
stdout_path = prior_round_dir / f"{reviewer_name}.stdout"
|
|
133
|
+
if not stdout_path.is_file():
|
|
134
|
+
return _MISSING_ARTIFACT_FRAMING
|
|
135
|
+
try:
|
|
136
|
+
raw_stdout = stdout_path.read_text(encoding="utf-8")
|
|
137
|
+
except OSError:
|
|
138
|
+
return _MISSING_ARTIFACT_FRAMING
|
|
139
|
+
return _extract_response_text_from_stdout(reviewer_provider, raw_stdout)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_prior_producer_outcome(prior_round_dir: Path) -> str | None:
|
|
143
|
+
"""Read the prior round's producer outcome from manifest.json.
|
|
144
|
+
|
|
145
|
+
Returns one of ``"committed"`` / ``"stalled"`` /
|
|
146
|
+
``"subprocess_error"`` when the manifest has a producer block, or
|
|
147
|
+
``None`` when the manifest is missing/unreadable, the producer
|
|
148
|
+
block isn't present, or the outcome field isn't a recognized
|
|
149
|
+
string. The caller treats ``None`` as "outcome unknown — don't
|
|
150
|
+
prefix" so a missing-manifest scenario doesn't pollute the
|
|
151
|
+
rendered prompt with a misleading framing message.
|
|
152
|
+
"""
|
|
153
|
+
manifest_path = prior_round_dir / "manifest.json"
|
|
154
|
+
if not manifest_path.is_file():
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
158
|
+
except (OSError, json.JSONDecodeError):
|
|
159
|
+
return None
|
|
160
|
+
producer = manifest.get("producer")
|
|
161
|
+
if not isinstance(producer, dict):
|
|
162
|
+
return None
|
|
163
|
+
outcome = producer.get("outcome")
|
|
164
|
+
if outcome in ("committed", "stalled", "subprocess_error"):
|
|
165
|
+
return outcome
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load_prior_producer_response_text(
|
|
170
|
+
*,
|
|
171
|
+
prior_round_dir: Path,
|
|
172
|
+
) -> str:
|
|
173
|
+
"""Load the prior round's producer response text.
|
|
174
|
+
|
|
175
|
+
Reads ``<prior_round_dir>/producer.stdout`` and returns its content.
|
|
176
|
+
UNLIKE :func:`load_prior_reviewer_response_text`, no per-adapter
|
|
177
|
+
envelope-strip is needed — the producer persistence layer
|
|
178
|
+
(:func:`syncade.persistence.persist_producer_result`) writes
|
|
179
|
+
``producer_result.output.narrative_text`` to ``producer.stdout``
|
|
180
|
+
on the ``committed`` / ``stalled`` paths (where the producer
|
|
181
|
+
adapter's :meth:`parse_output` already extracted the response text
|
|
182
|
+
from the raw envelope) and writes the raw subprocess stdout on the
|
|
183
|
+
``subprocess_error`` path (the adapter never produced a
|
|
184
|
+
:class:`ProducerOutput`).
|
|
185
|
+
|
|
186
|
+
Asymmetry vs. reviewer artifacts (load-bearing):
|
|
187
|
+
|
|
188
|
+
- **Reviewer** ``.stdout`` files hold the **raw subprocess stdout**
|
|
189
|
+
(anthropic JSON envelope / codex JSONL stream). The orchestrator
|
|
190
|
+
runs the per-adapter ``extract_response_text`` to get the
|
|
191
|
+
assistant's text.
|
|
192
|
+
- **Producer** ``.stdout`` files hold the **already-extracted
|
|
193
|
+
narrative text** (on committed/stalled) or raw partial stdout
|
|
194
|
+
(on subprocess_error). Re-extracting the narrative path would
|
|
195
|
+
either (a) be a no-op because the narrative isn't envelope-
|
|
196
|
+
shaped, or (b) silently truncate the narrative if it happens to
|
|
197
|
+
look like a valid envelope (e.g. a producer narrating with a
|
|
198
|
+
JSON dict at the top level). Skipping extraction is correct and
|
|
199
|
+
defensive.
|
|
200
|
+
|
|
201
|
+
**the design framing for non-committed prior outcomes:** when the
|
|
202
|
+
prior round's manifest records ``producer.outcome`` of ``stalled``
|
|
203
|
+
or ``subprocess_error``, the helper prefixes the returned text with
|
|
204
|
+
a one-line framing message
|
|
205
|
+
(``"(prior round producer ended with outcome=<X>; partial output
|
|
206
|
+
below)\\n\\n"``) so the round-N producer prose ("if your prior
|
|
207
|
+
attempt errored, treat as a fresh round") has a concrete signal to
|
|
208
|
+
interpret. The framing applies whether the partial output is empty
|
|
209
|
+
or has content. In the production loop the orchestrator terminates
|
|
210
|
+
on ``stalled`` / ``subprocess_error`` so round-N producer wouldn't
|
|
211
|
+
normally see these, but a future loop update that retries past a
|
|
212
|
+
stall would surface them; the framing keeps the prompt coherent
|
|
213
|
+
either way.
|
|
214
|
+
|
|
215
|
+
Edge cases mirror the reviewer loader's:
|
|
216
|
+
|
|
217
|
+
- producer.stdout missing → :data:`_MISSING_ARTIFACT_FRAMING`.
|
|
218
|
+
- producer.stdout empty + committed/unknown outcome → empty string.
|
|
219
|
+
- producer.stdout empty + stalled/subprocess_error → framing
|
|
220
|
+
message alone (the framing is itself the signal).
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
prior_round_dir: Path to ``<run_dir>/round-(N-1)``.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The prior round's producer narrative text (with framing
|
|
227
|
+
prefix on non-committed outcomes), or
|
|
228
|
+
:data:`_MISSING_ARTIFACT_FRAMING` when producer.stdout
|
|
229
|
+
doesn't exist on disk.
|
|
230
|
+
"""
|
|
231
|
+
stdout_path = prior_round_dir / "producer.stdout"
|
|
232
|
+
if not stdout_path.is_file():
|
|
233
|
+
return _MISSING_ARTIFACT_FRAMING
|
|
234
|
+
try:
|
|
235
|
+
raw = stdout_path.read_text(encoding="utf-8")
|
|
236
|
+
except OSError:
|
|
237
|
+
return _MISSING_ARTIFACT_FRAMING
|
|
238
|
+
outcome = _read_prior_producer_outcome(prior_round_dir)
|
|
239
|
+
if outcome in ("stalled", "subprocess_error"):
|
|
240
|
+
return f"(prior round producer ended with outcome={outcome}; partial output below)\n\n{raw}"
|
|
241
|
+
return raw
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def load_prior_producer_commit_subjects(
|
|
245
|
+
*,
|
|
246
|
+
prior_round_dir: Path,
|
|
247
|
+
repo_root: Path,
|
|
248
|
+
) -> str:
|
|
249
|
+
"""Load the commit subjects of the prior round's producer commits.
|
|
250
|
+
|
|
251
|
+
Reads two artifacts the orchestrator persists for the prior round:
|
|
252
|
+
|
|
253
|
+
- ``<prior_round_dir>/manifest.json``'s ``snapshot.commit_sha`` —
|
|
254
|
+
the SHA the prior round's producer worktree was provisioned at
|
|
255
|
+
(the producer's STARTING sha).
|
|
256
|
+
- ``<prior_round_dir>/producer.commit.txt`` — the SHA the prior
|
|
257
|
+
producer left HEAD at (the producer's ENDING sha; written by
|
|
258
|
+
:func:`syncade.persistence.persist_producer_result`).
|
|
259
|
+
|
|
260
|
+
With both SHAs in hand, runs ``git log --pretty=format:%s
|
|
261
|
+
<starting>..<ending>`` in the operator's repo to get every commit
|
|
262
|
+
subject the producer landed last round.
|
|
263
|
+
|
|
264
|
+
The lookup runs in the OPERATOR'S REPO, not the producer worktree.
|
|
265
|
+
By the time round-N producer runs, branch-advance has promoted the
|
|
266
|
+
prior commits onto the operator's branch, so ``git log`` against
|
|
267
|
+
``repo_root`` sees them. The producer worktree is fresh per round
|
|
268
|
+
and wouldn't have history before its own provisioning SHA.
|
|
269
|
+
|
|
270
|
+
Reading the starting SHA from the prior round's manifest (rather
|
|
271
|
+
than threading it through ``_run_producer_phase`` from the loop
|
|
272
|
+
driver) keeps the producer-phase signature unchanged at the cost
|
|
273
|
+
of one small JSON read per round > 0.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
prior_round_dir: Path to ``<run_dir>/round-(N-1)``.
|
|
277
|
+
repo_root: The operator's repo root — where ``git log`` runs.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Newline-separated commit subjects, oldest first reading
|
|
281
|
+
top-down (``git log`` is newest-first by default; the helper
|
|
282
|
+
reverses to chronological order for legibility). When any
|
|
283
|
+
artifact is missing or git returns nothing useful, returns a
|
|
284
|
+
sentinel string framed as the absence of commits.
|
|
285
|
+
"""
|
|
286
|
+
commit_txt_path = prior_round_dir / "producer.commit.txt"
|
|
287
|
+
if not commit_txt_path.is_file():
|
|
288
|
+
return "(prior round producer.commit.txt not found)"
|
|
289
|
+
try:
|
|
290
|
+
ending_sha = commit_txt_path.read_text(encoding="utf-8").strip()
|
|
291
|
+
except OSError:
|
|
292
|
+
return "(prior round producer.commit.txt unreadable)"
|
|
293
|
+
if not ending_sha:
|
|
294
|
+
return "(prior round producer made no commits)"
|
|
295
|
+
|
|
296
|
+
# Read the prior round's manifest to get the starting SHA. The
|
|
297
|
+
# manifest is always present alongside producer.commit.txt on the
|
|
298
|
+
# happy path (persist_round_manifest fires before _run_producer_phase
|
|
299
|
+
# can attach a producer.commit.txt).
|
|
300
|
+
manifest_path = prior_round_dir / "manifest.json"
|
|
301
|
+
if not manifest_path.is_file():
|
|
302
|
+
return "(prior round manifest.json not found)"
|
|
303
|
+
try:
|
|
304
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
305
|
+
except (OSError, json.JSONDecodeError):
|
|
306
|
+
return "(prior round manifest.json unreadable)"
|
|
307
|
+
snapshot_dict = manifest.get("snapshot")
|
|
308
|
+
if not isinstance(snapshot_dict, dict):
|
|
309
|
+
return "(prior round manifest.json missing snapshot block)"
|
|
310
|
+
starting_sha = snapshot_dict.get("commit_sha")
|
|
311
|
+
if not isinstance(starting_sha, str) or not starting_sha:
|
|
312
|
+
return "(prior round manifest.json missing snapshot.commit_sha)"
|
|
313
|
+
if ending_sha == starting_sha:
|
|
314
|
+
return "(prior round producer made no commits)"
|
|
315
|
+
|
|
316
|
+
# Use run_subprocess for consistency with the rest of syncade —
|
|
317
|
+
# same timeout / process-group cleanup discipline.
|
|
318
|
+
from syncade.process import run_subprocess
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
result = run_subprocess(
|
|
322
|
+
["git", "log", "--pretty=format:%s", f"{starting_sha}..{ending_sha}"],
|
|
323
|
+
cwd=repo_root,
|
|
324
|
+
timeout=5.0,
|
|
325
|
+
)
|
|
326
|
+
except Exception as exc: # noqa: BLE001 — best-effort lookup
|
|
327
|
+
return f"(git log lookup failed: {type(exc).__name__})"
|
|
328
|
+
if result.returncode != 0:
|
|
329
|
+
return f"(git log returned rc={result.returncode})"
|
|
330
|
+
subjects = result.stdout.strip()
|
|
331
|
+
if not subjects:
|
|
332
|
+
return "(prior round producer commits: git log returned no subjects)"
|
|
333
|
+
# git log is newest-first; reverse to chronological so the
|
|
334
|
+
# producer reads commits in the order they were applied.
|
|
335
|
+
lines = subjects.splitlines()
|
|
336
|
+
return "\n".join(reversed(lines))
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Per-round producer provisioning and dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from syncade.adapters.producer import ProducerAdapter
|
|
8
|
+
from syncade.config import SyncadeConfig
|
|
9
|
+
from syncade.logging import Logger
|
|
10
|
+
from syncade.producer import ProducerResult, run_producer
|
|
11
|
+
from syncade.prompts import (
|
|
12
|
+
_NO_OPERATOR_DECISION_SENTINEL,
|
|
13
|
+
_NO_PRIOR_COMMITS_SENTINEL,
|
|
14
|
+
_NO_PRIOR_ROUND_SENTINEL,
|
|
15
|
+
)
|
|
16
|
+
from syncade.snapshot import Snapshot
|
|
17
|
+
from syncade.worktree import WorktreeManager
|
|
18
|
+
|
|
19
|
+
from .prior_round import (
|
|
20
|
+
load_prior_producer_commit_subjects,
|
|
21
|
+
load_prior_producer_response_text,
|
|
22
|
+
)
|
|
23
|
+
from .results import RoundResult
|
|
24
|
+
|
|
25
|
+
# Producer-worktree basename. Reserved because a reviewer named ``"producer"``
|
|
26
|
+
# would collide with the producer worktree path when the loop provisions one.
|
|
27
|
+
PRODUCER_WORKTREE_NAME = "producer"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run_producer_phase(
|
|
31
|
+
*,
|
|
32
|
+
round_idx: int,
|
|
33
|
+
round_result: RoundResult,
|
|
34
|
+
run_id: str,
|
|
35
|
+
run_dir: Path,
|
|
36
|
+
round_dir: Path,
|
|
37
|
+
snapshot: Snapshot,
|
|
38
|
+
repo_root: Path,
|
|
39
|
+
pr_doc_path: Path,
|
|
40
|
+
config: SyncadeConfig,
|
|
41
|
+
resolved_producer_timeout: float,
|
|
42
|
+
producer_adapter: ProducerAdapter | None,
|
|
43
|
+
worktree_base: Path,
|
|
44
|
+
logger: Logger,
|
|
45
|
+
managers_to_cleanup: list[WorktreeManager],
|
|
46
|
+
operator_decision: str | None = None,
|
|
47
|
+
) -> ProducerResult:
|
|
48
|
+
"""Provision a producer worktree at the round's snapshot SHA and
|
|
49
|
+
dispatch one producer subprocess.
|
|
50
|
+
|
|
51
|
+
``operator_decision`` is the recorded decision when this round resumes
|
|
52
|
+
after a producer escalation. The default sentinel applies on every
|
|
53
|
+
non-resumed round and is threaded straight through to
|
|
54
|
+
:func:`syncade.producer.run_producer`.
|
|
55
|
+
|
|
56
|
+
The producer worktree is the asymmetric one: it's provisioned
|
|
57
|
+
with ``strip_files=()`` so ``CLAUDE.md`` AND ``AGENTS.md`` ARE
|
|
58
|
+
preserved because the producer writes code in the operator's repo and
|
|
59
|
+
needs the same project context the operator has.
|
|
60
|
+
|
|
61
|
+
The producer worktree lives at
|
|
62
|
+
``<worktree_base>/<run-id>/round-N/producer/`` — a separate
|
|
63
|
+
:class:`WorktreeManager` (with a longer ``run_id`` containing
|
|
64
|
+
the round subscript) keeps the producer's worktree alongside
|
|
65
|
+
the reviewer + test worktrees for THIS round, not pooled at
|
|
66
|
+
the run level.
|
|
67
|
+
|
|
68
|
+
Returns the :class:`ProducerResult` from
|
|
69
|
+
:func:`syncade.producer.run_producer`. The orchestrator
|
|
70
|
+
persists it and folds the outcome into the loop-continuation
|
|
71
|
+
decision.
|
|
72
|
+
"""
|
|
73
|
+
producer_worktree_id = f"{run_id}/round-{round_idx}/producer-worktree"
|
|
74
|
+
# Defer cleanup so stalled or decision-needed producer worktrees stay on
|
|
75
|
+
# disk for inspection. Final cleanup is decided at loop end.
|
|
76
|
+
producer_manager = WorktreeManager(
|
|
77
|
+
repo_root,
|
|
78
|
+
producer_worktree_id,
|
|
79
|
+
base_dir=worktree_base,
|
|
80
|
+
defer_cleanup=True,
|
|
81
|
+
)
|
|
82
|
+
managers_to_cleanup.append(producer_manager)
|
|
83
|
+
with producer_manager as manager:
|
|
84
|
+
# strip_files=[] — preserve CLAUDE.md / AGENTS.md so the
|
|
85
|
+
# producer sees the same project context the operator has.
|
|
86
|
+
# (Cast: WorktreeManager.create's signature is
|
|
87
|
+
# ``strip_files: list[str] | None``; pass [] explicitly to
|
|
88
|
+
# opt into "no stripping" rather than relying on the
|
|
89
|
+
# implicit-None fallback being equivalent.)
|
|
90
|
+
producer_worktree = manager.create(
|
|
91
|
+
reviewer_name=PRODUCER_WORKTREE_NAME,
|
|
92
|
+
commit_sha=snapshot.commit_sha,
|
|
93
|
+
strip_files=[],
|
|
94
|
+
)
|
|
95
|
+
logger.event(f" worktree ready: {PRODUCER_WORKTREE_NAME} -> {producer_worktree.path}")
|
|
96
|
+
|
|
97
|
+
# The findings.md path the round-N persistence wrote. Must
|
|
98
|
+
# exist by here — the producer only runs on NO-SHIP rounds,
|
|
99
|
+
# and findings.md is written on every synth-success path.
|
|
100
|
+
findings_md = round_result.artifacts.findings_md_path
|
|
101
|
+
if findings_md is None:
|
|
102
|
+
# Defensive: NO-SHIP rounds without findings.md
|
|
103
|
+
# shouldn't reach the producer (the only NO-SHIP
|
|
104
|
+
# paths are synth-blocker or test-failed; synth-blocker
|
|
105
|
+
# writes findings.md, test-failed writes findings.md
|
|
106
|
+
# because synth was clean).
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
f"producer phase reached for round {round_idx} with no "
|
|
109
|
+
f"findings.md — orchestrator contract violated"
|
|
110
|
+
)
|
|
111
|
+
test_stdout = (
|
|
112
|
+
round_result.artifacts.test_run_paths.stdout
|
|
113
|
+
if (
|
|
114
|
+
round_result.artifacts.test_run_paths is not None
|
|
115
|
+
and round_result.test_result is not None
|
|
116
|
+
and round_result.test_result.outcome == "failed"
|
|
117
|
+
)
|
|
118
|
+
else None
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Round 0 producer sees default prior-round/commit sentinels. Round N>0
|
|
122
|
+
# producer reads its own prior response plus landed commit subjects from
|
|
123
|
+
# the operator repo; each producer worktree itself is fresh per round.
|
|
124
|
+
#
|
|
125
|
+
# The prior round's starting SHA (needed for the git log
|
|
126
|
+
# range) is read from the prior round's manifest.json
|
|
127
|
+
# inside ``load_prior_producer_commit_subjects`` — keeps
|
|
128
|
+
# the producer-phase signature stable.
|
|
129
|
+
if round_idx == 0:
|
|
130
|
+
prior_output: str = _NO_PRIOR_ROUND_SENTINEL
|
|
131
|
+
prior_commits: str = _NO_PRIOR_COMMITS_SENTINEL
|
|
132
|
+
else:
|
|
133
|
+
prior_round_dir = run_dir / f"round-{round_idx - 1}"
|
|
134
|
+
# Producer.stdout already contains extracted narrative
|
|
135
|
+
# text (persistence writes output.narrative_text, not the
|
|
136
|
+
# raw envelope) — no per-adapter envelope-strip needed.
|
|
137
|
+
# See load_prior_producer_response_text's docstring for
|
|
138
|
+
# the reviewer-vs-producer asymmetry.
|
|
139
|
+
prior_output = load_prior_producer_response_text(
|
|
140
|
+
prior_round_dir=prior_round_dir,
|
|
141
|
+
)
|
|
142
|
+
prior_commits = load_prior_producer_commit_subjects(
|
|
143
|
+
prior_round_dir=prior_round_dir,
|
|
144
|
+
repo_root=repo_root,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return run_producer(
|
|
148
|
+
worktree_path=producer_worktree.path,
|
|
149
|
+
starting_sha=snapshot.commit_sha,
|
|
150
|
+
pr_doc_path=pr_doc_path,
|
|
151
|
+
findings_md_path=findings_md,
|
|
152
|
+
test_run_stdout_path=test_stdout,
|
|
153
|
+
producer_config=config.producer,
|
|
154
|
+
pricing=config.pricing,
|
|
155
|
+
timeout_seconds=resolved_producer_timeout,
|
|
156
|
+
round_number=round_idx,
|
|
157
|
+
max_rounds=config.loop.max_rounds,
|
|
158
|
+
repo_root=repo_root,
|
|
159
|
+
adapter=producer_adapter,
|
|
160
|
+
prior_round_output=prior_output,
|
|
161
|
+
prior_round_commits=prior_commits,
|
|
162
|
+
operator_decision=(
|
|
163
|
+
operator_decision
|
|
164
|
+
if operator_decision is not None
|
|
165
|
+
else _NO_OPERATOR_DECISION_SENTINEL
|
|
166
|
+
),
|
|
167
|
+
max_retries=config.retry.max_retries,
|
|
168
|
+
capture_dir=round_dir,
|
|
169
|
+
)
|