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.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. 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
+ )