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
@@ -0,0 +1,146 @@
1
+ """Producer escalation channel.
2
+
3
+ The producer's commit/stall/subprocess-error outcomes need a separate way to
4
+ say *"I did what I could; finding X is an operator decision, not a code defect I
5
+ can fix."*
6
+
7
+ This module adds that channel as data only: a small structured record
8
+ (:class:`ProducerEscalation`) and a parser
9
+ (:func:`parse_producer_escalation`) that extracts it from the producer's
10
+ free-form narrative. The producer signals an escalation by emitting a
11
+ sentinel-delimited JSON block:
12
+
13
+ .. code-block:: text
14
+
15
+ <<<SYNCADE-ESCALATE>>>
16
+ {"finding_indices": [0, 2], "finding": "...", "decision": "...",
17
+ "options": ["A", "B"], "rationale": "..."}
18
+ <<<END-SYNCADE-ESCALATE>>>
19
+
20
+ The evidence bar is enforced *structurally*: every field is required and non-empty, and
21
+ ``options`` must be a non-empty list of non-empty strings. A malformed or
22
+ incomplete block is NOT an escalation — :func:`parse_producer_escalation`
23
+ returns ``None`` and the producer run is treated as an ordinary stall.
24
+
25
+ ``finding_indices`` is the set of active-blocker indices into the
26
+ round's ``SynthesizerOutput.consolidated_findings`` that this single
27
+ operator decision resolves. The parser enforces it structurally — a non-empty list of
28
+ unique non-negative ints — but does NOT check the indices against the
29
+ round's findings (it has no access to them). The loop terminator does the
30
+ in-range / is-active-blocker cross-check and honors the escalation only
31
+ when ``finding_indices`` covers *every* active blocker in the round; see
32
+ :func:`syncade.orchestrator.escalation_coverage.escalation_covers_active_blockers`.
33
+
34
+ This module is pure data + parsing; it does not drive the loop. The loop handles
35
+ the ``escalated`` producer outcome, ``decision_needed`` termination reason,
36
+ ``decision-needed.md``, and resume-with-decision.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ from dataclasses import dataclass
43
+
44
+ ESCALATE_OPEN = "<<<SYNCADE-ESCALATE>>>"
45
+ """Opening sentinel for the producer's escalation block. Hardcoded
46
+ verbatim in ``templates/producer.md`` — the prompt and this parser MUST
47
+ agree, pinned by ``test_prompts.py``'s sentinel-sync assertion."""
48
+
49
+ ESCALATE_CLOSE = "<<<END-SYNCADE-ESCALATE>>>"
50
+ """Closing sentinel for the producer's escalation block."""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ProducerEscalation:
55
+ """One producer escalation: a finding the producer determined is an
56
+ operator decision rather than a code defect it can fix.
57
+
58
+ Attributes:
59
+ finding_indices: The indices into the round's
60
+ ``SynthesizerOutput.consolidated_findings`` that this single
61
+ operator decision resolves. Non-empty, unique, each
62
+ ``>= 0``. Structural-only here; the loop terminator checks them
63
+ against the round's actual findings and honors the escalation
64
+ only when they cover every active blocker.
65
+ finding: A one-line reference to the finding being escalated.
66
+ decision: The specific decision the operator must make.
67
+ options: The concrete options the operator chooses among
68
+ (non-empty; each a non-empty string).
69
+ rationale: The reproduction-backed justification for why this is
70
+ an operator decision, not a code fix (the evidence bar).
71
+ """
72
+
73
+ finding_indices: list[int]
74
+ finding: str
75
+ decision: str
76
+ options: list[str]
77
+ rationale: str
78
+
79
+
80
+ def parse_producer_escalation(narrative_text: str) -> ProducerEscalation | None:
81
+ """Extract a :class:`ProducerEscalation` from the producer's narrative,
82
+ or ``None`` when there is no well-formed escalation block.
83
+
84
+ Finds the first ``ESCALATE_OPEN`` … ``ESCALATE_CLOSE`` pair and parses
85
+ the JSON between them. Returns ``None`` (→ treated as a plain stall)
86
+ on any of: no sentinels, an unterminated block, non-JSON content, a
87
+ non-object payload, a missing/empty required field, an ``options``
88
+ that isn't a non-empty list of non-empty strings, or a
89
+ ``finding_indices`` that isn't a non-empty list of unique
90
+ non-negative ints. The strictness IS the evidence bar — an escalation
91
+ that doesn't carry finding_indices + finding + decision + options +
92
+ rationale is not an escalation.
93
+ """
94
+ start = narrative_text.find(ESCALATE_OPEN)
95
+ if start == -1:
96
+ return None
97
+ body_start = start + len(ESCALATE_OPEN)
98
+ end = narrative_text.find(ESCALATE_CLOSE, body_start)
99
+ if end == -1:
100
+ return None
101
+ blob = narrative_text[body_start:end].strip()
102
+ try:
103
+ data = json.loads(blob)
104
+ except (json.JSONDecodeError, ValueError):
105
+ return None
106
+ if not isinstance(data, dict):
107
+ return None
108
+
109
+ finding_indices = data.get("finding_indices")
110
+ finding = data.get("finding")
111
+ decision = data.get("decision")
112
+ options = data.get("options")
113
+ rationale = data.get("rationale")
114
+
115
+ if not (isinstance(finding, str) and finding.strip()):
116
+ return None
117
+ if not (isinstance(decision, str) and decision.strip()):
118
+ return None
119
+ if not (isinstance(rationale, str) and rationale.strip()):
120
+ return None
121
+ if not (
122
+ isinstance(options, list)
123
+ and options
124
+ and all(isinstance(o, str) and o.strip() for o in options)
125
+ ):
126
+ return None
127
+ # finding_indices: a non-empty list of unique non-negative ints.
128
+ # ``isinstance(True, int)`` is True, so bools are excluded explicitly
129
+ # (a JSON ``true`` must not masquerade as index 1). In-range and
130
+ # is-active-blocker checks are NOT done here — the parser has no access
131
+ # to the round's findings; the loop terminator does that cross-check.
132
+ if not (
133
+ isinstance(finding_indices, list)
134
+ and finding_indices
135
+ and all(isinstance(i, int) and not isinstance(i, bool) and i >= 0 for i in finding_indices)
136
+ and len(set(finding_indices)) == len(finding_indices)
137
+ ):
138
+ return None
139
+
140
+ return ProducerEscalation(
141
+ finding_indices=list(finding_indices),
142
+ finding=finding.strip(),
143
+ decision=decision.strip(),
144
+ options=[o.strip() for o in options],
145
+ rationale=rationale.strip(),
146
+ )
@@ -0,0 +1,199 @@
1
+ """Git-side helpers for the producer phase.
2
+
3
+ Split out of ``producer.py`` when it crossed the blocking 500-LOC cap (PR-v2-24). These
4
+ three are one cohesive concern -- reading the worktree's HEAD and staging the producer's
5
+ inputs -- and are entirely separable from the run loop that drives the subprocess.
6
+
7
+ ``_read_worktree_head`` is the load-bearing one: HEAD before vs after the subprocess is
8
+ how the orchestrator distinguishes "committed" from "stalled", which is the invariant
9
+ that producer work is accepted ONLY through commits.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import filecmp
16
+ import hashlib
17
+ import shutil
18
+ from pathlib import Path
19
+
20
+ from syncade.adapters.producer import ProducerOutput
21
+ from syncade.git_object_id import is_full_git_object_id
22
+ from syncade.process import (
23
+ SubprocessError,
24
+ SubprocessNotFoundError,
25
+ SubprocessTimeoutError,
26
+ run_subprocess,
27
+ )
28
+ from syncade.producer_result import ProducerResult
29
+
30
+
31
+ def _read_worktree_head(worktree_path: Path) -> str:
32
+ """Return ``git rev-parse HEAD`` in ``worktree_path``.
33
+
34
+ Internal helper used at both ends of the producer run to derive
35
+ the (starting_sha, ending_sha) tuple stall detection compares.
36
+ Failures bubble as :class:`SubprocessError` — the orchestrator
37
+ treats them as subprocess-error outcomes (exit 40) since a
38
+ worktree that can't be queried for HEAD isn't usable for the
39
+ next round either.
40
+
41
+ Uses :func:`syncade.process.run_subprocess` (not
42
+ :func:`subprocess.run` directly) so the shared subprocess
43
+ machinery (timeout handling, error classification, process-
44
+ group cleanup) is exercised consistently with the rest of
45
+ syncade.
46
+ """
47
+ try:
48
+ result = run_subprocess(
49
+ ["git", "rev-parse", "HEAD"],
50
+ cwd=worktree_path,
51
+ timeout=10.0,
52
+ )
53
+ except SubprocessNotFoundError as exc:
54
+ # ``git`` itself missing is an environment failure — bubble.
55
+ raise SubprocessError(
56
+ f"producer: git binary missing while reading worktree HEAD at {worktree_path}: {exc}"
57
+ ) from exc
58
+ except SubprocessTimeoutError as exc:
59
+ raise SubprocessError(
60
+ f"producer: git rev-parse HEAD timed out at {worktree_path} (timeout={exc.timeout}s)"
61
+ ) from exc
62
+ if result.returncode != 0:
63
+ raise SubprocessError(
64
+ f"producer: git rev-parse HEAD failed at {worktree_path}: "
65
+ f"rc={result.returncode}, stderr: {result.stderr.strip()[:200]!r}"
66
+ )
67
+ sha = result.stdout.strip()
68
+ if not is_full_git_object_id(sha):
69
+ raise SubprocessError(
70
+ f"producer: git rev-parse HEAD at {worktree_path} returned "
71
+ f"unexpected value {sha!r} (expected full SHA-1/SHA-256 object ID)"
72
+ )
73
+ return sha
74
+
75
+
76
+ def _best_effort_head_after_failure(worktree_path: Path, fallback_sha: str) -> str:
77
+ try:
78
+ return _read_worktree_head(worktree_path)
79
+ except SubprocessError:
80
+ return fallback_sha
81
+
82
+
83
+ def _authoritative_head(worktree_path: Path) -> str | None:
84
+ """The worktree's real HEAD, or ``None`` when it cannot be read.
85
+
86
+ Unlike the ``ending_sha`` an error-path :class:`ProducerResult` carries — which
87
+ :func:`_best_effort_head_after_failure` may have COLLAPSED to ``starting_sha`` when its own
88
+ read failed — this reports the truth: a moved HEAD as itself, an unreadable HEAD as ``None``
89
+ (never silently as ``starting_sha``). That distinction is load-bearing for the retry gate
90
+ (PR-v2-22 Q3): resetting on a HEAD we merely failed to observe would destroy a real commit.
91
+ """
92
+ try:
93
+ return _read_worktree_head(worktree_path)
94
+ except SubprocessError:
95
+ return None
96
+
97
+
98
+ def _accept_committed_after_error(result: ProducerResult, *, ending_sha: str) -> ProducerResult:
99
+ """Re-classify a ``subprocess_error`` whose producer ACTUALLY committed (HEAD moved to
100
+ ``ending_sha``) into a ``committed`` outcome, so the orchestrator fast-forwards the branch
101
+ instead of dropping the work at exit 40 (PR-v2-22 C1 — *never discard a made commit*).
102
+
103
+ ``committed`` requires ``output is not None`` and ``error is None`` (:class:`ProducerResult`
104
+ invariant), so the trailing error is folded into a synthesized narrative rather than kept on
105
+ ``.error``. The branch-advance path independently re-gates descendant-ness (``git merge-base
106
+ --is-ancestor``), so a non-descendant move is still caught downstream.
107
+ """
108
+ err = result.error
109
+ detail = f"{type(err).__name__}: {err}" if err is not None else "an unspecified error"
110
+ narrative = (
111
+ f"[syncade] The producer committed (HEAD -> {ending_sha[:12]}) and its session then ended "
112
+ f"with {detail}. The commit is accepted; the trailing error did not discard it."
113
+ )
114
+ return dataclasses.replace(
115
+ result,
116
+ outcome="committed",
117
+ ending_sha=ending_sha,
118
+ output=ProducerOutput(narrative_text=narrative),
119
+ error=None,
120
+ )
121
+
122
+
123
+ def _reset_worktree(worktree_path: Path, starting_sha: str) -> None:
124
+ """Discard whatever a crashed producer attempt left in the worktree, so a transient RETRY
125
+ starts from EXACTLY the state the first attempt saw (PR-v2-22 C2). ``git reset --hard
126
+ <starting_sha>`` drops tracked/staged edits + any partial commit; ``git clean -ffd`` drops
127
+ untracked files + dirs, including nested git repositories (the second ``-f`` is required;
128
+ a single ``-f`` silently leaves nested repos in place with exit code 0).
129
+
130
+ NOT ``git clean -ffdx``: a producer only edits TRACKED source, and ``-x`` would ALSO nuke the
131
+ gitignored worktree-env scaffolding (the ``sitecustomize`` shim + ``PYTHONPATH=<wt>/src``
132
+ layout) the retried subprocess needs to import ``syncade`` — a self-inflicted failure. Only
133
+ reached with ``HEAD == starting_sha`` (the caller's HEAD-gate never resets over a real
134
+ commit, C1).
135
+
136
+ Raises :class:`SubprocessError` (or its subclasses) on any failure — a non-zero exit code,
137
+ launch error, or timeout — so the caller aborts the retry rather than proceeding on a dirty
138
+ worktree. A silently-ignored reset failure would allow attempt 1's partial edits to survive
139
+ into attempt 2, violating C2.
140
+ """
141
+ for argv in (["git", "reset", "--hard", starting_sha], ["git", "clean", "-ffd"]):
142
+ result = run_subprocess(argv, cwd=worktree_path, timeout=10.0)
143
+ if result.returncode != 0:
144
+ raise SubprocessError(
145
+ f"producer: {argv[1]} failed in {worktree_path}: "
146
+ f"rc={result.returncode}, stderr={result.stderr.strip()[:200]!r}"
147
+ )
148
+
149
+
150
+ def _stage_producer_input(src: Path, *, worktree_path: Path, repo_root: Path) -> str:
151
+ """Stage one producer input inside the worktree; return a
152
+ worktree-relative reference to it.
153
+
154
+ Confinement (H4): the producer runs ``yolo`` with cwd = its own
155
+ worktree. Handing it an ABSOLUTE main-repo or run-artifact path
156
+ lures it OUT of that isolated worktree to read or edit the live
157
+ operator repo — the same escape the reviewer path was closed
158
+ against structurally (see ``orchestrator/round.py``). So every input
159
+ is copied INTO the worktree and referenced by a path relative to the
160
+ worktree root, which the producer resolves against its own cwd.
161
+
162
+ Mirrors the reviewer PR-doc staging in ``round.py``: an input that is
163
+ a tracked in-repo file is already present at the same relative path
164
+ (the copy is a no-op); a run artifact (``findings.md``,
165
+ ``test-run.stdout``) is copied in; an out-of-repo PR doc is staged
166
+ under a reserved, collision-free ``.syncade-inputs/<digest>-<name>``
167
+ path so a same-named tracked file cannot shadow it (H2). Run
168
+ artifacts carry their repo-relative ``.syncade/runs/...`` path, so
169
+ they land under the worktree's gitignored ``.syncade/runs/`` and
170
+ cannot be swept into a producer commit. ``Path.relative_to`` yields a
171
+ forward-only subpath (never ``..``), so the staged ref can never
172
+ escape the worktree.
173
+ """
174
+ try:
175
+ ref = str(src.relative_to(repo_root))
176
+ except ValueError:
177
+ # Out-of-repo input (e.g. an external PR doc): a bare basename
178
+ # would be SHADOWED by a tracked worktree file of the same name —
179
+ # the no-op-if-exists copy below would be skipped and the producer
180
+ # would read the WRONG (tracked) file. Stage it under a reserved,
181
+ # collision-free ref and copy unconditionally, mirroring the
182
+ # reviewer PR-doc staging in round.py (H2).
183
+ digest = hashlib.sha256(str(src).encode("utf-8")).hexdigest()[:16]
184
+ ref = f".syncade-inputs/{digest}-{src.name}"
185
+ dest = worktree_path / ref
186
+ dest.parent.mkdir(parents=True, exist_ok=True)
187
+ shutil.copy2(src, dest)
188
+ return ref
189
+ dest = worktree_path / ref
190
+ # Refresh from the pristine source when the staged copy is MISSING or has been MUTATED (PR-v2-22
191
+ # F8): a run-artifact input corrupted by a crashed prior attempt SURVIVES the gitignored reset
192
+ # (`.syncade/runs/` is untouched by `git clean -ffd`), so a retry must re-copy it or read
193
+ # corrupted input (a C2 violation). The content compare keeps the happy path byte-identical (no
194
+ # copy when it already matches — including the same-root case where dest IS src, which also
195
+ # avoids SameFileError).
196
+ if not dest.exists() or not filecmp.cmp(src, dest, shallow=False):
197
+ dest.parent.mkdir(parents=True, exist_ok=True)
198
+ shutil.copy2(src, dest)
199
+ return ref
@@ -0,0 +1,205 @@
1
+ """Producer subprocess result model.
2
+
3
+ :data:`ProducerOutcome` (the four-way outcome literal) and :class:`ProducerResult`
4
+ (the frozen dataclass with the exactly-one-of contract enforced in
5
+ ``__post_init__``). ``producer.py`` re-imports both so
6
+ ``syncade.producer.ProducerResult`` / ``syncade.producer.ProducerOutcome``
7
+ import paths are unchanged.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Literal
14
+
15
+ from syncade.adapters.producer import ProducerOutput
16
+ from syncade.process import SubprocessResult
17
+ from syncade.producer_escalation import ProducerEscalation
18
+ from syncade.usage import Usage
19
+
20
+ ProducerOutcome = Literal["committed", "stalled", "subprocess_error", "escalated"]
21
+ """Three-way outcome of one producer subprocess run.
22
+
23
+ - ``"committed"`` — the subprocess completed cleanly AND the
24
+ worktree's HEAD moved forward (``ending_sha != starting_sha``).
25
+ The orchestrator advances the operator's branch to ``ending_sha``
26
+ and the next round snapshots the new HEAD.
27
+ - ``"stalled"`` — the subprocess completed cleanly (no
28
+ :class:`ReviewerInvocationError`, no timeout) BUT the worktree's
29
+ HEAD did NOT move. The producer either made no changes or made
30
+ changes without committing. The orchestrator terminates the loop
31
+ with exit 30 + ``termination_reason="producer_stalled"`` — the
32
+ next round would dispatch reviewers against an identical diff,
33
+ burning compute for no signal delta.
34
+ - ``"subprocess_error"`` — the producer subprocess itself failed
35
+ (:class:`ReviewerInvocationError` from the adapter,
36
+ :class:`SubprocessTimeoutError`,
37
+ :class:`SubprocessNotFoundError`, or any other
38
+ :class:`SubprocessError` subclass). The orchestrator terminates
39
+ with exit 40 + ``termination_reason="producer_subprocess_error"``.
40
+ ``raw_subprocess_result`` preserves any partial output captured
41
+ before the failure (same pattern as reviewer / synthesizer
42
+ subprocess errors). ``ending_sha`` may differ from ``starting_sha``
43
+ when the subprocess failed after moving HEAD; that is an
44
+ indeterminate producer commit, not a successful ``"committed"``
45
+ outcome.
46
+ - ``"escalated"`` — the subprocess completed cleanly, HEAD did
47
+ NOT move (a stall mechanically), but the producer emitted a
48
+ well-formed escalation block flagging a finding as an operator
49
+ decision rather than a code defect it can fix. A stall-variant.
50
+ whether the orchestrator HONORS this outcome is decided at the
51
+ terminator, not here — it honors (exit 10 +
52
+ ``termination_reason="decision_needed"`` + ``decision-needed.md``)
53
+ ONLY when the escalation's ``finding_indices`` cover every active
54
+ blocker in the round (mechanical set-containment over
55
+ ``consolidated_findings``; see
56
+ :func:`syncade.orchestrator.escalation_coverage.escalation_covers_active_blockers`).
57
+ An escalation that leaves an active blocker uncovered — or references a
58
+ non-blocker / out-of-range index — is treated as an ordinary stall
59
+ (exit 30, ``producer_stalled``, no ``decision-needed.md``). Either way
60
+ escalation does NOT override the mechanical verdict (the round is still
61
+ NO-SHIP from ``consolidated_findings``); it is an advisory channel
62
+ telling the operator *what decision* unblocks the loop. See
63
+ :mod:`syncade.producer_escalation`.
64
+ """
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ProducerResult:
69
+ """Outcome of one producer subprocess run.
70
+
71
+ Mirrors :class:`syncade.synthesizer.SynthesizerResult` and
72
+ :class:`syncade.test_runner.TestRunResult` shape so persistence
73
+ can treat all subprocess-result types with the same vocabulary
74
+ (output / error / raw_subprocess_result / duration_seconds).
75
+
76
+ Attributes:
77
+ outcome: ``"committed"`` | ``"stalled"`` | ``"subprocess_error"``
78
+ | ``"escalated"``. See :data:`ProducerOutcome` for the
79
+ contract (incl. the coverage condition that decides
80
+ whether an ``"escalated"`` outcome is honored at the terminator).
81
+ starting_sha: The worktree HEAD before the subprocess ran.
82
+ Always equal to the round-start snapshot's
83
+ ``commit_sha``. Persisted into ``manifest.json``'s
84
+ ``producer`` section.
85
+ ending_sha: The worktree HEAD after the subprocess returned.
86
+ Equal to ``starting_sha`` on ``"stalled"``; differs on
87
+ ``"committed"``. On ``"subprocess_error"`` it is the best
88
+ observed HEAD after the failure and may differ from
89
+ ``starting_sha`` when the producer moved HEAD before failing.
90
+ Persisted into ``producer.commit.txt`` for shell-script
91
+ consumers; also surfaces as the producer's commit SHA
92
+ in ``loop-summary.md``'s commit series.
93
+ duration_seconds: Wall-clock duration. ``0.0`` for failures
94
+ that fire before the subprocess starts (e.g.
95
+ :class:`ProducerSetupError` from a bad worktree HEAD
96
+ read); subprocess-error path records the time spent
97
+ attempting the subprocess.
98
+ output: The :class:`ProducerOutput` on the ``"committed"``
99
+ and ``"stalled"`` paths; ``None`` on ``"subprocess_error"``.
100
+ error: The exception that fired on ``"subprocess_error"``;
101
+ ``None`` otherwise.
102
+ raw_subprocess_result: The :class:`SubprocessResult` from
103
+ the producer subprocess. Preserved on ``"committed"``,
104
+ ``"stalled"``, and timeout paths (synthesized from
105
+ :class:`SubprocessTimeoutError`'s partial output, same
106
+ convention as :class:`ReviewerRunResult` and
107
+ :class:`SynthesizerResult`). ``None`` only on
108
+ :class:`SubprocessNotFoundError` (the process never
109
+ started, so there's no partial output to preserve).
110
+
111
+ The ``__post_init__`` enforces an exactly-one-of-three contract
112
+ across the (outcome, output, error, ending_sha-vs-starting_sha)
113
+ state: a ``"committed"`` result MUST have ``output is not None``
114
+ AND ``error is None`` AND ``ending_sha != starting_sha``; a
115
+ ``"stalled"`` result MUST have ``output is not None`` AND
116
+ ``error is None`` AND ``ending_sha == starting_sha``; a
117
+ ``"subprocess_error"`` result MUST have ``output is None`` AND
118
+ ``error is not None``. A ``"subprocess_error"`` may have a moved
119
+ ``ending_sha`` so persistence can report an indeterminate producer
120
+ commit truthfully.
121
+ Any other combination is a caller bug — surface it as
122
+ ValueError at construction time rather than letting it produce
123
+ a misleading manifest.
124
+ """
125
+
126
+ outcome: ProducerOutcome
127
+ starting_sha: str
128
+ ending_sha: str
129
+ duration_seconds: float
130
+ output: ProducerOutput | None
131
+ error: Exception | None
132
+ raw_subprocess_result: SubprocessResult | None = field(default=None)
133
+ escalation: ProducerEscalation | None = field(default=None)
134
+ """the structured escalation, set IFF ``outcome == "escalated"``.
135
+ ``None`` on every other outcome (enforced in ``__post_init__``)."""
136
+ usage: Usage | None = field(default=None)
137
+ provider: str | None = None
138
+ model: str | None = None
139
+ retries: int = 0
140
+ """Number of EXTRA producer subprocess attempts consumed riding out transient provider
141
+ errors (PR-v2-22). ``0`` when the first attempt settled it. Mirrors
142
+ :attr:`ReviewerRunResult.retries` / :attr:`SynthesizerResult.retries` so the round manifest
143
+ can sum ONE ``retried`` count across all three model legs."""
144
+
145
+ def __post_init__(self) -> None:
146
+ sha_moved = self.ending_sha != self.starting_sha
147
+ # escalation is set exactly when (and only when) the outcome
148
+ # is "escalated". Cross-cutting guard before the per-outcome table.
149
+ if (self.escalation is not None) != (self.outcome == "escalated"):
150
+ raise ValueError(
151
+ "ProducerResult: escalation must be set IFF "
152
+ "outcome=='escalated' (got outcome="
153
+ f"{self.outcome!r}, escalation="
154
+ f"{'set' if self.escalation is not None else 'None'})"
155
+ )
156
+ if self.outcome == "escalated":
157
+ if self.output is None:
158
+ raise ValueError("ProducerResult(outcome='escalated') requires output is not None")
159
+ if self.error is not None:
160
+ raise ValueError("ProducerResult(outcome='escalated') requires error is None")
161
+ if sha_moved:
162
+ raise ValueError(
163
+ "ProducerResult(outcome='escalated') requires ending_sha "
164
+ "== starting_sha (escalation does not commit; HEAD must "
165
+ "not have moved)"
166
+ )
167
+ return
168
+ if self.outcome == "committed":
169
+ if self.output is None:
170
+ raise ValueError("ProducerResult(outcome='committed') requires output is not None")
171
+ if self.error is not None:
172
+ raise ValueError("ProducerResult(outcome='committed') requires error is None")
173
+ if not sha_moved:
174
+ raise ValueError(
175
+ "ProducerResult(outcome='committed') requires ending_sha "
176
+ "!= starting_sha (HEAD must have moved to claim "
177
+ "'committed')"
178
+ )
179
+ return
180
+ if self.outcome == "stalled":
181
+ if self.output is None:
182
+ raise ValueError("ProducerResult(outcome='stalled') requires output is not None")
183
+ if self.error is not None:
184
+ raise ValueError("ProducerResult(outcome='stalled') requires error is None")
185
+ if sha_moved:
186
+ raise ValueError(
187
+ "ProducerResult(outcome='stalled') requires ending_sha "
188
+ "== starting_sha (HEAD must not have moved to claim "
189
+ "'stalled')"
190
+ )
191
+ return
192
+ if self.outcome == "subprocess_error":
193
+ if self.output is not None:
194
+ raise ValueError(
195
+ "ProducerResult(outcome='subprocess_error') requires output is None"
196
+ )
197
+ if self.error is None:
198
+ raise ValueError(
199
+ "ProducerResult(outcome='subprocess_error') requires error is not None"
200
+ )
201
+ return
202
+ # Defensive — Literal narrows ``outcome`` at type-check time,
203
+ # but a caller bypassing the type hint (e.g. a dict-fed
204
+ # dataclass construction) could land here.
205
+ raise ValueError(f"ProducerResult: unknown outcome {self.outcome!r}")