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
syncade/producer.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Producer subprocess phase.
|
|
2
|
+
|
|
3
|
+
Runs ONE producer subprocess after a NO-SHIP round, fed the
|
|
4
|
+
just-completed round's ``findings.md`` + (when applicable)
|
|
5
|
+
``test-run.stdout`` + the PR spec. The producer is expected to
|
|
6
|
+
make file edits and commit them; the orchestrator's stall
|
|
7
|
+
detection compares ``git rev-parse HEAD`` before and after the
|
|
8
|
+
subprocess to distinguish "committed" (HEAD moved) from "stalled"
|
|
9
|
+
(HEAD didn't move, no commit happened).
|
|
10
|
+
|
|
11
|
+
Module name parallels :mod:`syncade.synthesizer`: the orchestrator
|
|
12
|
+
treats reviewer, synthesizer, test-leg, and producer outcomes with
|
|
13
|
+
the same vocabulary (output | error, raw_subprocess_result,
|
|
14
|
+
duration_seconds, one-shot __post_init__ discipline).
|
|
15
|
+
|
|
16
|
+
**Architectural invariants this module enforces (vs. relies on):**
|
|
17
|
+
|
|
18
|
+
- *Producer worktree is provisioned by the orchestrator.* This
|
|
19
|
+
module reads the SHA at start + end to detect commits but never
|
|
20
|
+
creates/destroys the worktree; the orchestrator owns lifecycle, so
|
|
21
|
+
a round-N stall leaves the worktree for round N+1 (or inspection).
|
|
22
|
+
|
|
23
|
+
- *No structured-output parse.* Producers emit free-form narrative,
|
|
24
|
+
not JSON. The adapter's ``parse_output`` extracts the narrative
|
|
25
|
+
text; this module just preserves it on :class:`ProducerOutput` and
|
|
26
|
+
hands the value back to the orchestrator. There's no equivalent
|
|
27
|
+
of :func:`syncade.synthesis.parse_synthesizer_output` here.
|
|
28
|
+
|
|
29
|
+
- *Stall detection compares SHAs only.* Uncommitted edits are
|
|
30
|
+
invisible to git history and thus to the next round's reviewers —
|
|
31
|
+
they didn't happen for the loop. The prompt tells the model to
|
|
32
|
+
commit; the orchestrator never auto-commits (that would forge a
|
|
33
|
+
commit even with no edits, obscuring the stall signal).
|
|
34
|
+
|
|
35
|
+
- *The orchestrator passes ``starting_sha`` explicitly.* The worktree
|
|
36
|
+
is provisioned at the round-start ``commit_sha`` (detached HEAD);
|
|
37
|
+
this module verifies HEAD matches it at entry, then re-reads HEAD at
|
|
38
|
+
exit to derive ``ending_sha``.
|
|
39
|
+
|
|
40
|
+
- *Partial-output preservation on subprocess errors.* As in
|
|
41
|
+
``SynthesizerResult`` / ``ReviewerRunResult``: on timeout,
|
|
42
|
+
``raw_subprocess_result`` is synthesized from the
|
|
43
|
+
:class:`SubprocessTimeoutError`'s partial stdout/stderr (sentinel
|
|
44
|
+
``returncode=-1``) so persistence still writes the ``.stdout`` /
|
|
45
|
+
``.stderr`` files; on ``SubprocessNotFoundError`` the process
|
|
46
|
+
never ran, so it is ``None``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import dataclasses
|
|
52
|
+
import time
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
from syncade import retry
|
|
56
|
+
from syncade.adapters.base import ReviewerInvocationError
|
|
57
|
+
from syncade.adapters.producer import ProducerAdapter
|
|
58
|
+
from syncade.adapters.producer import ProducerOutput as ProducerOutput
|
|
59
|
+
from syncade.config import ProducerConfig
|
|
60
|
+
from syncade.findings import ReviewerOutputError
|
|
61
|
+
from syncade.pricing_config import PricingConfig
|
|
62
|
+
from syncade.process import (
|
|
63
|
+
SubprocessError,
|
|
64
|
+
)
|
|
65
|
+
from syncade.producer_escalation import ProducerEscalation as ProducerEscalation
|
|
66
|
+
from syncade.producer_git import (
|
|
67
|
+
_accept_committed_after_error,
|
|
68
|
+
_authoritative_head,
|
|
69
|
+
_reset_worktree,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# ProducerResult is used directly (run_producer constructs it); ProducerOutcome is
|
|
73
|
+
# re-exported (redundant-alias form so ruff's F401 autofix keeps it) to preserve
|
|
74
|
+
# the `from syncade.producer import ProducerOutcome` public import path.
|
|
75
|
+
from syncade.producer_result import ProducerOutcome as ProducerOutcome
|
|
76
|
+
from syncade.producer_result import ProducerResult
|
|
77
|
+
from syncade.prompts import (
|
|
78
|
+
_NO_OPERATOR_DECISION_SENTINEL,
|
|
79
|
+
_NO_PRIOR_COMMITS_SENTINEL,
|
|
80
|
+
_NO_PRIOR_ROUND_SENTINEL,
|
|
81
|
+
)
|
|
82
|
+
from syncade.usage import Usage, _add_usage
|
|
83
|
+
|
|
84
|
+
# Errors that mean the producer's SESSION RAN but its output was the problem — so a commit may
|
|
85
|
+
# have landed first (unlike a timeout/setup failure, where none could). Gates the C1 reconcile.
|
|
86
|
+
_SESSION_ERRORS = (ReviewerInvocationError, ReviewerOutputError)
|
|
87
|
+
|
|
88
|
+
from syncade.producer_attempt import _run_producer_once # noqa: E402
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_producer(
|
|
92
|
+
*,
|
|
93
|
+
worktree_path: Path,
|
|
94
|
+
starting_sha: str,
|
|
95
|
+
pr_doc_path: Path,
|
|
96
|
+
findings_md_path: Path,
|
|
97
|
+
test_run_stdout_path: Path | None,
|
|
98
|
+
producer_config: ProducerConfig,
|
|
99
|
+
timeout_seconds: float,
|
|
100
|
+
round_number: int,
|
|
101
|
+
max_rounds: int,
|
|
102
|
+
repo_root: Path,
|
|
103
|
+
adapter: ProducerAdapter | None = None,
|
|
104
|
+
prior_round_output: str = _NO_PRIOR_ROUND_SENTINEL,
|
|
105
|
+
pricing: PricingConfig | None = None,
|
|
106
|
+
prior_round_commits: str = _NO_PRIOR_COMMITS_SENTINEL,
|
|
107
|
+
operator_decision: str = _NO_OPERATOR_DECISION_SENTINEL,
|
|
108
|
+
max_retries: int = retry.MAX_RETRIES,
|
|
109
|
+
capture_dir: Path | None = None,
|
|
110
|
+
) -> ProducerResult:
|
|
111
|
+
"""Run the producer with a bounded, side-effect-safe transient retry (PR-v2-22).
|
|
112
|
+
|
|
113
|
+
Thin wrapper over :func:`_run_producer_once`. A transient blip (429/5xx/dropped socket, a
|
|
114
|
+
``ReviewerInvocationError``) is retried up to ``max_retries`` times (``[retry]``) with backoff
|
|
115
|
+
instead of aborting at exit 40. The producer has SIDE EFFECTS, so — see the inline notes —
|
|
116
|
+
**C1** accepts a committed-then-errored session as ``committed`` rather than discarding it
|
|
117
|
+
(:func:`_accept_committed_after_error`; a forced timeout is excluded), **Q3** reads HEAD
|
|
118
|
+
authoritatively (:func:`_authoritative_head`) so an unreadable HEAD isn't mistaken for "no
|
|
119
|
+
commit", **C2** resets to ``starting_sha`` per retry, **C3** retries transient errors only.
|
|
120
|
+
``ProducerResult.retries`` is the extra-attempt count (0 on the happy path).
|
|
121
|
+
"""
|
|
122
|
+
once_kwargs = dict(
|
|
123
|
+
worktree_path=worktree_path,
|
|
124
|
+
starting_sha=starting_sha,
|
|
125
|
+
pr_doc_path=pr_doc_path,
|
|
126
|
+
findings_md_path=findings_md_path,
|
|
127
|
+
test_run_stdout_path=test_run_stdout_path,
|
|
128
|
+
producer_config=producer_config,
|
|
129
|
+
timeout_seconds=timeout_seconds,
|
|
130
|
+
round_number=round_number,
|
|
131
|
+
max_rounds=max_rounds,
|
|
132
|
+
repo_root=repo_root,
|
|
133
|
+
adapter=adapter,
|
|
134
|
+
prior_round_output=prior_round_output,
|
|
135
|
+
pricing=pricing,
|
|
136
|
+
prior_round_commits=prior_round_commits,
|
|
137
|
+
operator_decision=operator_decision,
|
|
138
|
+
capture_dir=capture_dir,
|
|
139
|
+
)
|
|
140
|
+
run_start = time.monotonic()
|
|
141
|
+
retries = 0
|
|
142
|
+
accumulated_usage: Usage | None = None
|
|
143
|
+
result = _run_producer_once(**once_kwargs)
|
|
144
|
+
accumulated_usage = _add_usage(accumulated_usage, result.usage)
|
|
145
|
+
while (
|
|
146
|
+
retries < max_retries
|
|
147
|
+
and result.outcome == "subprocess_error"
|
|
148
|
+
and result.error is not None
|
|
149
|
+
and retry.is_transient_api_error(result.error)
|
|
150
|
+
# Q3: reset+retry ONLY on a PROVEN no-commit. A moved HEAD (committed) or an unreadable
|
|
151
|
+
# HEAD (indeterminate) both fail this equality, so the reset never clobbers a commit.
|
|
152
|
+
and _authoritative_head(worktree_path) == starting_sha
|
|
153
|
+
):
|
|
154
|
+
# C2: if the reset fails (non-zero exit or launch error), the worktree is in an
|
|
155
|
+
# indeterminate state — do NOT retry on top of partial state. Break and return the
|
|
156
|
+
# last transient error result, preserving C2 (every retry starts from starting_sha).
|
|
157
|
+
try:
|
|
158
|
+
_reset_worktree(worktree_path, starting_sha)
|
|
159
|
+
except SubprocessError:
|
|
160
|
+
break
|
|
161
|
+
retry.backoff_sleep(retries + 1)
|
|
162
|
+
retries += 1
|
|
163
|
+
result = _run_producer_once(**once_kwargs)
|
|
164
|
+
accumulated_usage = _add_usage(accumulated_usage, result.usage)
|
|
165
|
+
# C1: if the session ran and errored on its output (`_SESSION_ERRORS`) it may have committed
|
|
166
|
+
# first — a moved HEAD is a real commit, so accept it instead of dropping the work at exit 40.
|
|
167
|
+
# That error class EXCLUDES a forced timeout (a hung producer's partial commit stays a
|
|
168
|
+
# subprocess_error, per test_producer_timeout), a starting-sha mismatch, and a missing binary —
|
|
169
|
+
# none is a completed-session-then-error. The mismatch precondition is checked BEFORE the
|
|
170
|
+
# subprocess, so reaching a session error proves the worktree started at starting_sha; any HEAD
|
|
171
|
+
# move is thus a genuine descendant commit. An unreadable HEAD (None) stays a subprocess_error:
|
|
172
|
+
# we never fabricate a commit we cannot see (Q3).
|
|
173
|
+
if result.outcome == "subprocess_error" and isinstance(result.error, _SESSION_ERRORS):
|
|
174
|
+
head = _authoritative_head(worktree_path)
|
|
175
|
+
if head is not None and head != starting_sha:
|
|
176
|
+
result = _accept_committed_after_error(result, ending_sha=head)
|
|
177
|
+
# C (PR-v2-22): surface the retry's TRUE cost — usage accumulated across EVERY attempt (a
|
|
178
|
+
# dropped 429 attempt still burned tokens; under-counting could nudge a run past its budget)
|
|
179
|
+
# and, WHEN RETRIED, wall-clock duration spanning all attempts + resets + backoff sleeps. On
|
|
180
|
+
# the happy path (retries == 0) usage sums to the one attempt and duration stays the inner
|
|
181
|
+
# measure, so the result is byte-identical to pre-retry (C5); overriding only on retry.
|
|
182
|
+
return dataclasses.replace(
|
|
183
|
+
result,
|
|
184
|
+
retries=retries,
|
|
185
|
+
usage=accumulated_usage,
|
|
186
|
+
duration_seconds=(
|
|
187
|
+
result.duration_seconds if retries == 0 else time.monotonic() - run_start
|
|
188
|
+
),
|
|
189
|
+
)
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""One producer attempt — the body, without the retry policy around it.
|
|
2
|
+
|
|
3
|
+
Split from ``producer`` in PR-h-field-03 when streaming the producer's output pushed that
|
|
4
|
+
module past the 500-LOC cap it was sitting exactly on. The seam is real rather than
|
|
5
|
+
convenient: ``producer.run_producer`` owns the RETRY POLICY (what counts as transient, how
|
|
6
|
+
many attempts, resetting the worktree between them), and this owns ONE ATTEMPT. Nothing here
|
|
7
|
+
knows it may be called again.
|
|
8
|
+
|
|
9
|
+
Imported one-way — ``producer`` imports this; this must never import ``producer``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from syncade.adapters.base import ReviewerInvocationError
|
|
18
|
+
from syncade.adapters.producer import ProducerAdapter, get_producer_adapter
|
|
19
|
+
from syncade.adapters.producer import ProducerOutput as ProducerOutput
|
|
20
|
+
from syncade.config import ProducerConfig
|
|
21
|
+
from syncade.findings import ReviewerOutputError
|
|
22
|
+
from syncade.pricing_config import PricingConfig
|
|
23
|
+
from syncade.process import (
|
|
24
|
+
SubprocessError,
|
|
25
|
+
SubprocessNotFoundError,
|
|
26
|
+
SubprocessResult,
|
|
27
|
+
SubprocessTimeoutError,
|
|
28
|
+
run_subprocess,
|
|
29
|
+
)
|
|
30
|
+
from syncade.producer_escalation import ProducerEscalation as ProducerEscalation
|
|
31
|
+
from syncade.producer_escalation import parse_producer_escalation
|
|
32
|
+
from syncade.producer_git import (
|
|
33
|
+
_best_effort_head_after_failure,
|
|
34
|
+
_read_worktree_head,
|
|
35
|
+
_stage_producer_input,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# ProducerResult is used directly (run_producer constructs it); ProducerOutcome is
|
|
39
|
+
# re-exported (redundant-alias form so ruff's F401 autofix keeps it) to preserve
|
|
40
|
+
# the `from syncade.producer import ProducerOutcome` public import path.
|
|
41
|
+
from syncade.producer_result import ProducerOutcome as ProducerOutcome
|
|
42
|
+
from syncade.producer_result import ProducerResult
|
|
43
|
+
from syncade.prompts import (
|
|
44
|
+
_NO_OPERATOR_DECISION_SENTINEL,
|
|
45
|
+
_NO_PRIOR_COMMITS_SENTINEL,
|
|
46
|
+
_NO_PRIOR_ROUND_SENTINEL,
|
|
47
|
+
load_producer_template,
|
|
48
|
+
render_producer_prompt,
|
|
49
|
+
)
|
|
50
|
+
from syncade.usage import _auth_mode, usage_for
|
|
51
|
+
|
|
52
|
+
# Errors that mean the producer's SESSION RAN but its output was the problem — so a commit may
|
|
53
|
+
# have landed first (unlike a timeout/setup failure, where none could). Gates the C1 reconcile.
|
|
54
|
+
_SESSION_ERRORS = (ReviewerInvocationError, ReviewerOutputError)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_producer_once(
|
|
58
|
+
*,
|
|
59
|
+
worktree_path: Path,
|
|
60
|
+
starting_sha: str,
|
|
61
|
+
pr_doc_path: Path,
|
|
62
|
+
findings_md_path: Path,
|
|
63
|
+
test_run_stdout_path: Path | None,
|
|
64
|
+
producer_config: ProducerConfig,
|
|
65
|
+
timeout_seconds: float,
|
|
66
|
+
round_number: int,
|
|
67
|
+
max_rounds: int,
|
|
68
|
+
repo_root: Path,
|
|
69
|
+
adapter: ProducerAdapter | None = None,
|
|
70
|
+
prior_round_output: str = _NO_PRIOR_ROUND_SENTINEL,
|
|
71
|
+
pricing: PricingConfig | None = None,
|
|
72
|
+
prior_round_commits: str = _NO_PRIOR_COMMITS_SENTINEL,
|
|
73
|
+
operator_decision: str = _NO_OPERATOR_DECISION_SENTINEL,
|
|
74
|
+
capture_dir: Path | None = None,
|
|
75
|
+
) -> ProducerResult:
|
|
76
|
+
"""Dispatch ONE producer subprocess against ``worktree_path``.
|
|
77
|
+
|
|
78
|
+
Lifecycle:
|
|
79
|
+
|
|
80
|
+
1. Verify the worktree's current HEAD equals ``starting_sha``
|
|
81
|
+
(catches a misuse where the orchestrator handed a worktree
|
|
82
|
+
that doesn't match the round-start snapshot).
|
|
83
|
+
2. Stage each input (PR doc, findings.md, optional test trace)
|
|
84
|
+
INSIDE the worktree and render the producer prompt with
|
|
85
|
+
worktree-relative paths: load the template via
|
|
86
|
+
:func:`syncade.prompts.load_producer_template` (per-repo
|
|
87
|
+
override at ``.syncade/templates/producer.md`` wins over the
|
|
88
|
+
packaged default), substitute the worktree-relative placeholders.
|
|
89
|
+
3. Build the producer invocation via the adapter (default:
|
|
90
|
+
fresh adapter from :func:`syncade.adapters.producer.get_producer_adapter`
|
|
91
|
+
routed by ``producer_config.provider``).
|
|
92
|
+
4. Run the subprocess via :func:`syncade.process.run_subprocess`.
|
|
93
|
+
Producer-side errors (timeout, binary missing, generic OS
|
|
94
|
+
launch failure) yield ``outcome="subprocess_error"``.
|
|
95
|
+
5. Adapter parses the output — failures
|
|
96
|
+
(:class:`ReviewerInvocationError`,
|
|
97
|
+
:class:`ReviewerOutputError`) also yield
|
|
98
|
+
``outcome="subprocess_error"``.
|
|
99
|
+
6. Read the worktree's HEAD post-subprocess; if it equals
|
|
100
|
+
``starting_sha`` → ``outcome="stalled"``, else
|
|
101
|
+
``outcome="committed"``.
|
|
102
|
+
|
|
103
|
+
Stall detection compares git SHAs only. File edits without
|
|
104
|
+
commits don't count — uncommitted work is invisible to git
|
|
105
|
+
history and therefore invisible to the next round's reviewers.
|
|
106
|
+
This module does NOT auto-commit on the producer's behalf;
|
|
107
|
+
commit-message discipline lives in the producer prompt template
|
|
108
|
+
(see ``syncade/templates/producer.md``).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
worktree_path: The producer worktree's path. Provisioned by
|
|
112
|
+
the orchestrator at ``starting_sha`` (detached HEAD).
|
|
113
|
+
This module does not create or destroy it; the
|
|
114
|
+
orchestrator owns the lifecycle so a stalled producer's
|
|
115
|
+
worktree stays inspectable on disk.
|
|
116
|
+
starting_sha: The full object ID the worktree was checked out
|
|
117
|
+
at. Must match the worktree's HEAD at function entry —
|
|
118
|
+
mismatch is treated as a subprocess-error outcome.
|
|
119
|
+
pr_doc_path: Path to the PR spec the producer should
|
|
120
|
+
implement against. Staged into the worktree and substituted
|
|
121
|
+
into the template's ``{pr_doc_path}`` placeholder as a
|
|
122
|
+
worktree-relative path (confinement: the producer never sees
|
|
123
|
+
the absolute main-repo path).
|
|
124
|
+
findings_md_path: Path to the just-completed round's
|
|
125
|
+
``findings.md``. Staged into the worktree and substituted
|
|
126
|
+
into the template's ``{findings_md_path}`` placeholder as a
|
|
127
|
+
worktree-relative path — the producer reads it for the
|
|
128
|
+
synthesizer's consolidated findings + per-reviewer narrative
|
|
129
|
+
summaries.
|
|
130
|
+
test_run_stdout_path: Path to the just-completed round's
|
|
131
|
+
``test-run.stdout`` when the test leg ran and failed
|
|
132
|
+
(``test_result.outcome == "failed"`` on the preceding
|
|
133
|
+
round). Staged into the worktree and substituted as a
|
|
134
|
+
worktree-relative path when present; ``None`` otherwise —
|
|
135
|
+
substituted as the literal ``"(no test failure this round)"``
|
|
136
|
+
sentinel (the renderer handles the None → sentinel
|
|
137
|
+
replacement strictly through format_map).
|
|
138
|
+
producer_config: The :class:`ProducerConfig` from
|
|
139
|
+
``.syncade/config.toml``. Used for argv construction
|
|
140
|
+
(provider, model, thinking, permissions) and
|
|
141
|
+
``timeout_seconds`` (when None, the orchestrator passes
|
|
142
|
+
its resolved fallback; this module does not re-resolve).
|
|
143
|
+
timeout_seconds: Per-producer-round wall-clock timeout.
|
|
144
|
+
Already resolved by the orchestrator (CLI override >
|
|
145
|
+
``producer.timeout_seconds`` > ``loop.timeout_seconds``).
|
|
146
|
+
round_number: Which round this producer is for (0-indexed,
|
|
147
|
+
so the first producer-after-round-0 receives
|
|
148
|
+
``round_number=0``). Substituted into the template's
|
|
149
|
+
``{round_number}`` placeholder for operator-context
|
|
150
|
+
clarity.
|
|
151
|
+
max_rounds: The configured ``[loop] max_rounds`` value.
|
|
152
|
+
Substituted into the template's ``{max_rounds}``
|
|
153
|
+
placeholder so the producer can see "you're in round 1
|
|
154
|
+
of 3" and budget effort accordingly.
|
|
155
|
+
repo_root: The git repo root. Used to look up the per-repo
|
|
156
|
+
template override at
|
|
157
|
+
``<repo_root>/.syncade/templates/producer.md`` and to compute
|
|
158
|
+
worktree-relative refs for the staged inputs.
|
|
159
|
+
adapter: Optional adapter implementing
|
|
160
|
+
:class:`~syncade.adapters.producer.ProducerAdapter`. When
|
|
161
|
+
``None``, a fresh adapter is constructed via
|
|
162
|
+
:func:`syncade.adapters.producer.get_producer_adapter`
|
|
163
|
+
using ``producer_config.provider``. Tests pass a
|
|
164
|
+
:class:`~syncade.adapters.fake.FakeProducerAdapter`.
|
|
165
|
+
prior_round_output: cross-round context. The producer's
|
|
166
|
+
OWN prior-round response text (extracted from
|
|
167
|
+
``<run-id>/round-(N-1)/producer.stdout`` by the
|
|
168
|
+
orchestrator's
|
|
169
|
+
:mod:`syncade.orchestrator.prior_round` helpers). Defaults
|
|
170
|
+
to :data:`syncade.prompts._NO_PRIOR_ROUND_SENTINEL` so
|
|
171
|
+
``round_number == 0`` callers work unchanged. Substituted into the
|
|
172
|
+
template's
|
|
173
|
+
``{prior_round_output}`` placeholder.
|
|
174
|
+
prior_round_commits: cross-round context. The commit
|
|
175
|
+
subjects of the prior round's producer commits (derived
|
|
176
|
+
via ``git log -1 --format=%s`` in the operator's repo by
|
|
177
|
+
the orchestrator). Defaults to
|
|
178
|
+
:data:`syncade.prompts._NO_PRIOR_COMMITS_SENTINEL` for
|
|
179
|
+
``round_number == 0``. Substituted into the template's
|
|
180
|
+
``{prior_round_commits}`` placeholder.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
:class:`ProducerResult` with the three-outcome contract
|
|
184
|
+
enforced in ``__post_init__``.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
No exceptions — all failure modes map to ``outcome="subprocess_error"``
|
|
188
|
+
so persistence writes artifacts uniformly.
|
|
189
|
+
"""
|
|
190
|
+
run_start = time.monotonic()
|
|
191
|
+
|
|
192
|
+
def _result(**kwargs: object) -> ProducerResult:
|
|
193
|
+
kwargs.setdefault("provider", producer_config.provider)
|
|
194
|
+
kwargs.setdefault("model", producer_config.model)
|
|
195
|
+
return ProducerResult(**kwargs)
|
|
196
|
+
|
|
197
|
+
# --- Adapter lookup ----------------------------------------------
|
|
198
|
+
if adapter is None:
|
|
199
|
+
try:
|
|
200
|
+
adapter = get_producer_adapter(producer_config.provider)
|
|
201
|
+
except ValueError as exc:
|
|
202
|
+
# Bad provider string — same surface as the reviewer
|
|
203
|
+
# registry: ValueError naming the bad input. Map to
|
|
204
|
+
# subprocess_error so the persistence layer writes the
|
|
205
|
+
# error.txt with the actionable message.
|
|
206
|
+
return _result(
|
|
207
|
+
outcome="subprocess_error",
|
|
208
|
+
starting_sha=starting_sha,
|
|
209
|
+
ending_sha=starting_sha,
|
|
210
|
+
duration_seconds=time.monotonic() - run_start,
|
|
211
|
+
output=None,
|
|
212
|
+
error=exc,
|
|
213
|
+
raw_subprocess_result=None,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# --- Verify the worktree HEAD matches starting_sha ---------------
|
|
217
|
+
# Catches the orchestrator misuse case where a wrong worktree
|
|
218
|
+
# was passed. Surfaces as subprocess_error so the operator gets
|
|
219
|
+
# a clean .error.txt rather than a confused diff post-mortem.
|
|
220
|
+
try:
|
|
221
|
+
observed_starting = _read_worktree_head(worktree_path)
|
|
222
|
+
except SubprocessError as exc:
|
|
223
|
+
return _result(
|
|
224
|
+
outcome="subprocess_error",
|
|
225
|
+
starting_sha=starting_sha,
|
|
226
|
+
ending_sha=starting_sha,
|
|
227
|
+
duration_seconds=time.monotonic() - run_start,
|
|
228
|
+
output=None,
|
|
229
|
+
error=exc,
|
|
230
|
+
raw_subprocess_result=None,
|
|
231
|
+
)
|
|
232
|
+
if observed_starting != starting_sha:
|
|
233
|
+
return _result(
|
|
234
|
+
outcome="subprocess_error",
|
|
235
|
+
starting_sha=starting_sha,
|
|
236
|
+
ending_sha=starting_sha,
|
|
237
|
+
duration_seconds=time.monotonic() - run_start,
|
|
238
|
+
output=None,
|
|
239
|
+
error=SubprocessError(
|
|
240
|
+
f"producer: worktree {worktree_path} HEAD is "
|
|
241
|
+
f"{observed_starting!r} but expected "
|
|
242
|
+
f"{starting_sha!r} (starting_sha mismatch). "
|
|
243
|
+
"The orchestrator must hand a worktree checked out "
|
|
244
|
+
"at the round-start snapshot's commit_sha."
|
|
245
|
+
),
|
|
246
|
+
raw_subprocess_result=None,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# --- Render the prompt -------------------------------------------
|
|
250
|
+
# Confinement (H4): stage every input INSIDE the worktree and render
|
|
251
|
+
# WORKTREE-RELATIVE refs. A yolo producer handed an absolute main-repo
|
|
252
|
+
# or run-artifact path can be lured out of its isolated worktree to
|
|
253
|
+
# touch the live repo (the reviewer analog is closed structurally in
|
|
254
|
+
# round.py). ``worktree_path`` itself stays absolute — it is the
|
|
255
|
+
# producer's own sandbox, not a main-repo path, and the template's
|
|
256
|
+
# boundary checks compare it against ``git rev-parse --show-toplevel``.
|
|
257
|
+
try:
|
|
258
|
+
template = load_producer_template(repo_root)
|
|
259
|
+
pr_doc_ref = _stage_producer_input(
|
|
260
|
+
pr_doc_path, worktree_path=worktree_path, repo_root=repo_root
|
|
261
|
+
)
|
|
262
|
+
findings_ref = _stage_producer_input(
|
|
263
|
+
findings_md_path, worktree_path=worktree_path, repo_root=repo_root
|
|
264
|
+
)
|
|
265
|
+
# the renderer accepts None directly and substitutes the
|
|
266
|
+
# "(no test failure this round)" sentinel itself.
|
|
267
|
+
test_ref = (
|
|
268
|
+
_stage_producer_input(
|
|
269
|
+
test_run_stdout_path, worktree_path=worktree_path, repo_root=repo_root
|
|
270
|
+
)
|
|
271
|
+
if test_run_stdout_path is not None
|
|
272
|
+
else None
|
|
273
|
+
)
|
|
274
|
+
prompt = render_producer_prompt(
|
|
275
|
+
template,
|
|
276
|
+
pr_doc_path=pr_doc_ref,
|
|
277
|
+
findings_md_path=findings_ref,
|
|
278
|
+
test_run_stdout_path=test_ref,
|
|
279
|
+
worktree_path=str(worktree_path),
|
|
280
|
+
round_number=round_number,
|
|
281
|
+
max_rounds=max_rounds,
|
|
282
|
+
prior_round_output=prior_round_output,
|
|
283
|
+
prior_round_commits=prior_round_commits,
|
|
284
|
+
operator_decision=operator_decision,
|
|
285
|
+
)
|
|
286
|
+
except (KeyError, ValueError, OSError) as exc:
|
|
287
|
+
return _result(
|
|
288
|
+
outcome="subprocess_error",
|
|
289
|
+
starting_sha=starting_sha,
|
|
290
|
+
ending_sha=starting_sha,
|
|
291
|
+
duration_seconds=time.monotonic() - run_start,
|
|
292
|
+
output=None,
|
|
293
|
+
error=SubprocessError(f"producer prompt setup failed: {exc}"),
|
|
294
|
+
raw_subprocess_result=None,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# --- Build the invocation ----------------------------------------
|
|
298
|
+
try:
|
|
299
|
+
invocation = adapter.build_invocation(producer_config, worktree_path, prompt)
|
|
300
|
+
except ValueError as exc:
|
|
301
|
+
# Same narrow catch as the synthesizer: only ValueError
|
|
302
|
+
# legitimately comes out of build_invocation (provider /
|
|
303
|
+
# permissions validation). Programming bugs (TypeError,
|
|
304
|
+
# AttributeError) bubble through so they get a stack trace
|
|
305
|
+
# instead of being bucketed as a polite subprocess_error.
|
|
306
|
+
return _result(
|
|
307
|
+
outcome="subprocess_error",
|
|
308
|
+
starting_sha=starting_sha,
|
|
309
|
+
ending_sha=starting_sha,
|
|
310
|
+
duration_seconds=time.monotonic() - run_start,
|
|
311
|
+
output=None,
|
|
312
|
+
error=exc,
|
|
313
|
+
raw_subprocess_result=None,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# --- Run the subprocess ------------------------------------------
|
|
317
|
+
subprocess_result: SubprocessResult | None = None
|
|
318
|
+
try:
|
|
319
|
+
subprocess_result = run_subprocess(
|
|
320
|
+
invocation.argv,
|
|
321
|
+
cwd=invocation.cwd,
|
|
322
|
+
env=invocation.env,
|
|
323
|
+
timeout=timeout_seconds,
|
|
324
|
+
input_text=invocation.stdin_text,
|
|
325
|
+
# Tee'd to <round>/producer.{stdout,stderr} as produced (PR-h-field-03): a real
|
|
326
|
+
# timeout left this at ZERO bytes after 40 minutes of model work.
|
|
327
|
+
capture_prefix=capture_dir / "producer" if capture_dir is not None else None,
|
|
328
|
+
)
|
|
329
|
+
except SubprocessTimeoutError as exc:
|
|
330
|
+
elapsed = time.monotonic() - run_start
|
|
331
|
+
ending_sha = _best_effort_head_after_failure(worktree_path, starting_sha)
|
|
332
|
+
partial = SubprocessResult(
|
|
333
|
+
returncode=-1,
|
|
334
|
+
stdout=exc.stdout,
|
|
335
|
+
stderr=exc.stderr,
|
|
336
|
+
duration_seconds=elapsed,
|
|
337
|
+
)
|
|
338
|
+
return _result(
|
|
339
|
+
outcome="subprocess_error",
|
|
340
|
+
starting_sha=starting_sha,
|
|
341
|
+
ending_sha=ending_sha,
|
|
342
|
+
duration_seconds=elapsed,
|
|
343
|
+
output=None,
|
|
344
|
+
error=exc,
|
|
345
|
+
raw_subprocess_result=partial,
|
|
346
|
+
usage=usage_for(
|
|
347
|
+
partial,
|
|
348
|
+
producer_config.provider,
|
|
349
|
+
producer_config.model,
|
|
350
|
+
pricing,
|
|
351
|
+
_auth_mode(producer_config),
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
except SubprocessNotFoundError as exc:
|
|
355
|
+
# Binary missing — subprocess never started.
|
|
356
|
+
return _result(
|
|
357
|
+
outcome="subprocess_error",
|
|
358
|
+
starting_sha=starting_sha,
|
|
359
|
+
ending_sha=starting_sha,
|
|
360
|
+
duration_seconds=time.monotonic() - run_start,
|
|
361
|
+
output=None,
|
|
362
|
+
error=exc,
|
|
363
|
+
raw_subprocess_result=None,
|
|
364
|
+
)
|
|
365
|
+
except SubprocessError as exc:
|
|
366
|
+
# Other launch failures (bad cwd, OS error).
|
|
367
|
+
return _result(
|
|
368
|
+
outcome="subprocess_error",
|
|
369
|
+
starting_sha=starting_sha,
|
|
370
|
+
ending_sha=starting_sha,
|
|
371
|
+
duration_seconds=time.monotonic() - run_start,
|
|
372
|
+
output=None,
|
|
373
|
+
error=exc,
|
|
374
|
+
raw_subprocess_result=None,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# The subprocess completed — extract usage BEFORE parse/HEAD-read so those
|
|
378
|
+
# failure paths still record it too (dogfood finding #5).
|
|
379
|
+
producer_usage = usage_for(
|
|
380
|
+
subprocess_result,
|
|
381
|
+
producer_config.provider,
|
|
382
|
+
producer_config.model,
|
|
383
|
+
pricing,
|
|
384
|
+
_auth_mode(producer_config),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# --- Parse the output --------------------------------------------
|
|
388
|
+
try:
|
|
389
|
+
producer_output = adapter.parse_output(subprocess_result)
|
|
390
|
+
except (ReviewerInvocationError, ReviewerOutputError) as exc:
|
|
391
|
+
ending_sha = _best_effort_head_after_failure(worktree_path, starting_sha)
|
|
392
|
+
return _result(
|
|
393
|
+
outcome="subprocess_error",
|
|
394
|
+
starting_sha=starting_sha,
|
|
395
|
+
ending_sha=ending_sha,
|
|
396
|
+
duration_seconds=time.monotonic() - run_start,
|
|
397
|
+
output=None,
|
|
398
|
+
error=exc,
|
|
399
|
+
raw_subprocess_result=subprocess_result,
|
|
400
|
+
usage=producer_usage,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# --- Stall detection: did HEAD move? -----------------------------
|
|
404
|
+
try:
|
|
405
|
+
ending_sha = _read_worktree_head(worktree_path)
|
|
406
|
+
except SubprocessError as exc:
|
|
407
|
+
# Reading HEAD after a successful subprocess is unusual to
|
|
408
|
+
# fail. Treat as subprocess_error — the orchestrator can't
|
|
409
|
+
# advance the branch if it can't read the SHA.
|
|
410
|
+
return _result(
|
|
411
|
+
outcome="subprocess_error",
|
|
412
|
+
starting_sha=starting_sha,
|
|
413
|
+
ending_sha=starting_sha,
|
|
414
|
+
duration_seconds=time.monotonic() - run_start,
|
|
415
|
+
output=None,
|
|
416
|
+
error=exc,
|
|
417
|
+
raw_subprocess_result=subprocess_result,
|
|
418
|
+
usage=producer_usage,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
elapsed = time.monotonic() - run_start
|
|
422
|
+
if ending_sha == starting_sha:
|
|
423
|
+
# No commit. The producer subprocess exited cleanly but didn't
|
|
424
|
+
# move HEAD. if the producer emitted a well-formed
|
|
425
|
+
# escalation block, this is a deliberate "operator decision
|
|
426
|
+
# needed" signal (escalated), not a silent stall. A malformed /
|
|
427
|
+
# absent block parses to None → ordinary stall (next round would
|
|
428
|
+
# see identical input).
|
|
429
|
+
escalation = parse_producer_escalation(producer_output.narrative_text)
|
|
430
|
+
if escalation is not None:
|
|
431
|
+
return _result(
|
|
432
|
+
outcome="escalated",
|
|
433
|
+
starting_sha=starting_sha,
|
|
434
|
+
ending_sha=ending_sha,
|
|
435
|
+
duration_seconds=elapsed,
|
|
436
|
+
output=producer_output,
|
|
437
|
+
error=None,
|
|
438
|
+
raw_subprocess_result=subprocess_result,
|
|
439
|
+
usage=producer_usage,
|
|
440
|
+
escalation=escalation,
|
|
441
|
+
)
|
|
442
|
+
return _result(
|
|
443
|
+
outcome="stalled",
|
|
444
|
+
starting_sha=starting_sha,
|
|
445
|
+
ending_sha=ending_sha,
|
|
446
|
+
duration_seconds=elapsed,
|
|
447
|
+
output=producer_output,
|
|
448
|
+
error=None,
|
|
449
|
+
raw_subprocess_result=subprocess_result,
|
|
450
|
+
usage=producer_usage,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# HEAD moved → the producer committed.
|
|
454
|
+
return _result(
|
|
455
|
+
outcome="committed",
|
|
456
|
+
starting_sha=starting_sha,
|
|
457
|
+
ending_sha=ending_sha,
|
|
458
|
+
duration_seconds=elapsed,
|
|
459
|
+
output=producer_output,
|
|
460
|
+
error=None,
|
|
461
|
+
raw_subprocess_result=subprocess_result,
|
|
462
|
+
usage=producer_usage,
|
|
463
|
+
)
|