syncade 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Next-steps + empty-series text tables and small render helpers for
|
|
2
|
+
loop-summary.md.
|
|
3
|
+
|
|
4
|
+
Holds the per-termination-reason next-steps guidance, the empty-commit-series
|
|
5
|
+
notes, and the four small render helpers (`_round_verdict_label`,
|
|
6
|
+
`_producer_commit_subject`, `_empty_commit_series_note`,
|
|
7
|
+
`_round_duration_seconds`). ``loop_summary.py`` keeps the headline labels and
|
|
8
|
+
``persist_loop_summary`` and imports these helpers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_LOOP_NEXT_STEPS: dict[str, str] = {
|
|
16
|
+
"ship": (
|
|
17
|
+
"- Loop terminated SHIP — the operator's branch has been "
|
|
18
|
+
"advanced to the latest producer commit (if any). Inspect "
|
|
19
|
+
"`loop-summary.md`'s commit series above to see what landed "
|
|
20
|
+
"on your branch; the operator's branch is at the SHIP "
|
|
21
|
+
"round's snapshot SHA. `findings.md` in the SHIP round's "
|
|
22
|
+
"directory carries the consolidated review (zero "
|
|
23
|
+
"blockers); the operator's repo is ready to push."
|
|
24
|
+
),
|
|
25
|
+
"no_changes_to_review": (
|
|
26
|
+
"- The diff resolved to empty before any reviewer was dispatched: "
|
|
27
|
+
"the base ref resolved but no reviewable changes were found "
|
|
28
|
+
"(either no files changed, or every changed file was a repo-context "
|
|
29
|
+
"file stripped from the reviewer diff). No model work was spent. "
|
|
30
|
+
"If you expected changes to be reviewed, check that the correct "
|
|
31
|
+
"`--base` / `--scope` is specified and that the changed files are "
|
|
32
|
+
"not all listed in `strip_repo_context_files`."
|
|
33
|
+
),
|
|
34
|
+
"producer_emptied_diff": (
|
|
35
|
+
"- The producer's commits in a prior round removed all reviewable "
|
|
36
|
+
"changes: the diff from the original base to the current HEAD is now "
|
|
37
|
+
"empty (all sections were either legitimate repo-context files or the "
|
|
38
|
+
"producer reverted the reviewable changes). Prior rounds DID spend "
|
|
39
|
+
"model work. If this is unexpected, inspect the per-round commit series "
|
|
40
|
+
"in `loop-summary.md` and the producer's commits on your branch."
|
|
41
|
+
),
|
|
42
|
+
"findings_present": (
|
|
43
|
+
"- The single-pass run found remaining work. Read the round's "
|
|
44
|
+
"`findings.md` for the active blockers or failed checks, address them, "
|
|
45
|
+
"and re-run syncade when ready."
|
|
46
|
+
),
|
|
47
|
+
"max_rounds_reached": (
|
|
48
|
+
"- The loop exhausted ``max_rounds`` (the configured cap) "
|
|
49
|
+
"without converging. Read the FINAL round's `findings.md` "
|
|
50
|
+
"for the remaining active blockers — they may require a "
|
|
51
|
+
"human eye to resolve. Consider: (a) bumping ``max_rounds`` "
|
|
52
|
+
"in `.syncade/config.toml`, (b) addressing the findings "
|
|
53
|
+
"manually and re-running, or (c) inspecting the per-round "
|
|
54
|
+
"directories to see what the producer attempted at each "
|
|
55
|
+
"step. The producer's commits ARE on your branch — if you "
|
|
56
|
+
"want to roll them back, use `git reset --hard "
|
|
57
|
+
"<round-0-starting-sha>` (see the commit series above)."
|
|
58
|
+
),
|
|
59
|
+
"provider_usage_limit": (
|
|
60
|
+
"- The loop stopped because the PROVIDER refused on an exhausted usage limit — not "
|
|
61
|
+
"your configured budget, and not a fault in the code under review. It stopped at a "
|
|
62
|
+
"phase boundary, so completed rounds and their artifacts are intact. Retrying "
|
|
63
|
+
"immediately would fail the same way: the window has not moved. Next: consume a reset "
|
|
64
|
+
"(`codex` → `/usage`) or wait for the window, then `syncade --resume <run-id>` to "
|
|
65
|
+
"continue from the completed rounds rather than paying for them twice. Any producer "
|
|
66
|
+
"commits so far ARE on your branch."
|
|
67
|
+
),
|
|
68
|
+
"budget_exceeded": (
|
|
69
|
+
"- The loop stopped because the running token or cost tally crossed your "
|
|
70
|
+
"configured ``[loop]`` budget (``--budget-tokens`` / ``--budget-usd``). "
|
|
71
|
+
"It aborted at a phase boundary, so NO provider call was interrupted "
|
|
72
|
+
"and the crossing round's `findings.md` / artifacts are complete. See "
|
|
73
|
+
"the **Budget** section above for the API-EQUIVALENT tally — a "
|
|
74
|
+
"VALUATION of the work, not billed money (PR-v2-24), and a LOWER BOUND "
|
|
75
|
+
"if any actor's cost was unpriced. Next: raise the budget in "
|
|
76
|
+
"`.syncade/config.toml` (or via the flag) and re-run, or address the "
|
|
77
|
+
"round's findings manually. Any producer commits so far ARE on your "
|
|
78
|
+
"branch."
|
|
79
|
+
),
|
|
80
|
+
"producer_stalled": (
|
|
81
|
+
"- The producer subprocess exited cleanly but didn't commit "
|
|
82
|
+
"— either it made no edits, or it made edits without "
|
|
83
|
+
"committing. Read the producer's `producer.stdout` in the "
|
|
84
|
+
"round directory to see what the producer was trying to "
|
|
85
|
+
"do; common causes are under-specified findings ('this is "
|
|
86
|
+
"wrong but the spec doesn't say what's right') or the "
|
|
87
|
+
"producer concluding the existing code is correct. The "
|
|
88
|
+
"operator's next step is to clarify the spec / address the "
|
|
89
|
+
"finding manually and re-run."
|
|
90
|
+
),
|
|
91
|
+
"producer_subprocess_error": (
|
|
92
|
+
"- The producer subprocess failed before it could attempt "
|
|
93
|
+
"the fix. Read `producer.stderr` and `producer.error.txt` "
|
|
94
|
+
"in the round directory for the failure shape; common "
|
|
95
|
+
"causes are auth (run `claude login` / `codex login`), "
|
|
96
|
+
"network errors (retry), or a missing CLI binary. The "
|
|
97
|
+
"operator's branch was NOT advanced for this round."
|
|
98
|
+
),
|
|
99
|
+
"reviewer_failure": (
|
|
100
|
+
"- A reviewer subprocess failed before the loop could even "
|
|
101
|
+
"compute a verdict for the round. Read the per-reviewer "
|
|
102
|
+
"`.error.txt` files in the round directory; the failure "
|
|
103
|
+
"shape (timeout / auth / binary missing) determines the "
|
|
104
|
+
"fix. The loop did not advance any branch — the operator's "
|
|
105
|
+
"tree is unchanged from where it started."
|
|
106
|
+
),
|
|
107
|
+
"synth_failure": (
|
|
108
|
+
"- The synthesizer subprocess failed. Read "
|
|
109
|
+
"`synthesizer.error.txt` and `synthesizer.stderr` in the "
|
|
110
|
+
"round directory. The loop did not advance any branch."
|
|
111
|
+
),
|
|
112
|
+
"test_subprocess_error": (
|
|
113
|
+
"- The test re-run subprocess failed (binary missing, "
|
|
114
|
+
"timeout, OS launch error). Read `test-run.stderr` and the "
|
|
115
|
+
"manifest's `test_run.error_type` in the round directory. "
|
|
116
|
+
"The synth was clean for this round; the test failure is "
|
|
117
|
+
"environmental, not code-side."
|
|
118
|
+
),
|
|
119
|
+
"check_subprocess_error": (
|
|
120
|
+
"- A blocking mechanical check's subprocess could not run to "
|
|
121
|
+
"completion (every reviewer succeeded; the synthesizer "
|
|
122
|
+
"succeeded). The `## Mechanical checks` section of the round's "
|
|
123
|
+
"`findings.md` names which check; read that check's "
|
|
124
|
+
"`<name>.check.stderr` and the manifest's `checks[].error_type` "
|
|
125
|
+
"in the round directory. Common shapes are a missing binary "
|
|
126
|
+
"(the check `command` references a tool not on PATH in the "
|
|
127
|
+
"check worktree) or a timeout. The loop did not advance any "
|
|
128
|
+
"branch — the check signal is indeterminate until the "
|
|
129
|
+
"environment problem is fixed."
|
|
130
|
+
),
|
|
131
|
+
"decision_needed": (
|
|
132
|
+
"- A NO-SHIP round's producer ESCALATED a finding it determined "
|
|
133
|
+
"is an operator decision (a spec/design conflict), not a code "
|
|
134
|
+
"defect it can fix. The loop checkpointed and terminated (exit "
|
|
135
|
+
"10). Read `decision-needed.md` at the run root for the "
|
|
136
|
+
"producer's case + concrete options; record your decision in "
|
|
137
|
+
"`decision.txt` and run `syncade --resume <run-id>` to continue "
|
|
138
|
+
"(the escalated round re-runs with your decision fed to the "
|
|
139
|
+
"producer). The mechanical verdict is unchanged — the finding "
|
|
140
|
+
"stays open until the decision is applied."
|
|
141
|
+
),
|
|
142
|
+
"blockers_all_deactivated": (
|
|
143
|
+
"- Two or more independent reviewers EACH raised a blocker, and the "
|
|
144
|
+
"synthesizer deactivated every one of them (dismissed, downgraded, or "
|
|
145
|
+
"split into separate single-reviewer findings). That may be correct, "
|
|
146
|
+
"but discarding all independent corroboration is not a call the "
|
|
147
|
+
"mechanical verdict will make silently, so the loop terminated at exit "
|
|
148
|
+
"10 instead of reporting a SHIP it cannot justify. No producer ran. "
|
|
149
|
+
"Read `decision-needed.md` at the run root: it quotes what each "
|
|
150
|
+
"reviewer actually said next to what the synthesizer did with it. If "
|
|
151
|
+
"the synthesizer was right, this round is effectively a SHIP; if it "
|
|
152
|
+
"was wrong about any one of them, that concern is real and unfixed. "
|
|
153
|
+
"There is nothing to resume — no blocker is active for a producer to "
|
|
154
|
+
"fix, so `decision.txt` and `--resume` do not apply here."
|
|
155
|
+
),
|
|
156
|
+
"worktree_error": (
|
|
157
|
+
"- A worktree could not be provisioned. Common cause: "
|
|
158
|
+
"stale `<worktree_base>/<run-id>/` from a prior interrupted "
|
|
159
|
+
"run. The loop did not advance any branch."
|
|
160
|
+
),
|
|
161
|
+
"diff_too_large": (
|
|
162
|
+
"- The reviewer-facing diff exceeded `[loop] max_diff_bytes`, so syncade refused "
|
|
163
|
+
"before dispatching any reviewer — nothing was spent. The measured size and the "
|
|
164
|
+
"ceiling are in the refusing round's `diff-refused.txt`. Syncade refuses rather "
|
|
165
|
+
"than truncating: a verdict on a deliberately partial diff is a verdict on the "
|
|
166
|
+
"wrong code. Narrow `--base` to a smaller range, split the PR, or raise "
|
|
167
|
+
"`[loop] max_diff_bytes` in `.syncade/config.toml`."
|
|
168
|
+
),
|
|
169
|
+
"prompt_too_large": (
|
|
170
|
+
"- An assembled reviewer prompt exceeded the provider's character ceiling "
|
|
171
|
+
"(1,048,576 chars for codex), so syncade refused before dispatching any reviewer "
|
|
172
|
+
"— nothing was spent. The affected reviewer and the measured size are in the "
|
|
173
|
+
"refusing round's `diff-refused.txt`. The assembled prompt includes the diff, "
|
|
174
|
+
"the reviewer template, and any prior-round context. Reduce prompt size by "
|
|
175
|
+
"narrowing `--base`, trimming the reviewer template, or lowering "
|
|
176
|
+
"`[loop] max_diff_bytes` in `.syncade/config.toml`."
|
|
177
|
+
),
|
|
178
|
+
"diff_malformed": (
|
|
179
|
+
"- The reviewer-facing diff had section(s) with unidentifiable "
|
|
180
|
+
"headers (unparseable, malformed C-quoted escape, or invalid UTF-8). "
|
|
181
|
+
"The dropped headers are in the refusing round's `diff-refused.txt` and "
|
|
182
|
+
"in its `manifest.json` under `diff_filter_refusal_headers`. "
|
|
183
|
+
"No model cost was incurred."
|
|
184
|
+
),
|
|
185
|
+
"parse_failure": (
|
|
186
|
+
"- A reviewer or synthesizer ran cleanly but its output "
|
|
187
|
+
"didn't parse. The raw subprocess output is preserved at "
|
|
188
|
+
"the round's `.stdout` / `.error.txt` files; the verdict "
|
|
189
|
+
"may still be readable by hand."
|
|
190
|
+
),
|
|
191
|
+
"config_error": (
|
|
192
|
+
"- The configuration is invalid. Read the per-reviewer "
|
|
193
|
+
"`.error.txt` files; common causes are unknown provider "
|
|
194
|
+
"names in `[[reviewers]]` blocks."
|
|
195
|
+
),
|
|
196
|
+
}
|
|
197
|
+
"""Per-:data:`syncade.orchestrator.TerminationReason` next-steps
|
|
198
|
+
guidance for loop-summary.md. Mirrors the per-exit-code
|
|
199
|
+
:data:`_NEXT_STEPS` table but keyed on the loop-level termination
|
|
200
|
+
reason rather than per-round exit code."""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _round_verdict_label(round_result) -> str:
|
|
204
|
+
"""Human-readable verdict label for one round."""
|
|
205
|
+
if getattr(round_result, "no_changes_to_review", False):
|
|
206
|
+
return "nothing to review"
|
|
207
|
+
if round_result.round_exit_code == 0:
|
|
208
|
+
return "SHIP"
|
|
209
|
+
if round_result.round_exit_code == 30:
|
|
210
|
+
return "NO-SHIP"
|
|
211
|
+
if round_result.round_exit_code == 10:
|
|
212
|
+
return "DECISION NEEDED"
|
|
213
|
+
return f"ERROR (exit {round_result.round_exit_code})"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _producer_commit_subject(repo_root: Path | None, ending_sha: str) -> str:
|
|
217
|
+
"""look up the commit subject for the
|
|
218
|
+
producer's commit so the loop-summary commit series can render
|
|
219
|
+
it inline.
|
|
220
|
+
|
|
221
|
+
Returns the commit's subject line on success, or an empty
|
|
222
|
+
string on any failure (missing repo_root, git not on PATH,
|
|
223
|
+
ref not found, etc.). Failures degrade the rendering to the
|
|
224
|
+
subject-less form rather than crashing the summary write —
|
|
225
|
+
the loop-summary.md is operator-facing and best-effort is
|
|
226
|
+
appropriate for the subject lookup.
|
|
227
|
+
|
|
228
|
+
Uses :func:`syncade.process.run_subprocess` for the same
|
|
229
|
+
timeout / process-group cleanup discipline the rest of
|
|
230
|
+
syncade relies on. Capped at a 5-second timeout — looking up
|
|
231
|
+
a commit subject is sub-second on every reasonable repo, and
|
|
232
|
+
timing out is a clearer signal than blocking the summary
|
|
233
|
+
write indefinitely.
|
|
234
|
+
"""
|
|
235
|
+
if repo_root is None or not ending_sha:
|
|
236
|
+
return ""
|
|
237
|
+
try:
|
|
238
|
+
from syncade.process import run_subprocess
|
|
239
|
+
|
|
240
|
+
result = run_subprocess(
|
|
241
|
+
["git", "log", "-1", "--pretty=format:%s", ending_sha],
|
|
242
|
+
cwd=repo_root,
|
|
243
|
+
timeout=5.0,
|
|
244
|
+
)
|
|
245
|
+
except Exception:
|
|
246
|
+
return ""
|
|
247
|
+
if result.returncode != 0:
|
|
248
|
+
return ""
|
|
249
|
+
return result.stdout.strip()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
_EMPTY_SERIES_REASON_NOTES: dict[str, str] = {
|
|
253
|
+
"ship": "- (no producer commits — round 0 shipped without needing a fix)",
|
|
254
|
+
"no_changes_to_review": (
|
|
255
|
+
"- (no producer commits — the diff was empty; no reviewers or producer were dispatched)"
|
|
256
|
+
),
|
|
257
|
+
"producer_emptied_diff": (
|
|
258
|
+
"- (see prior-round producer commits above"
|
|
259
|
+
" — they reduced the reviewable change set to empty)"
|
|
260
|
+
),
|
|
261
|
+
"findings_present": "- (no producer commits — single-pass run ended with findings present)",
|
|
262
|
+
"max_rounds_reached": (
|
|
263
|
+
"- (no producer commits landed — every producer round stalled or errored before committing)"
|
|
264
|
+
),
|
|
265
|
+
"budget_exceeded": (
|
|
266
|
+
"- (no producer commits — the budget was crossed before a producer round committed)"
|
|
267
|
+
),
|
|
268
|
+
"provider_usage_limit": (
|
|
269
|
+
"- (no producer commits — the provider's usage limit was hit before a producer round "
|
|
270
|
+
"committed)"
|
|
271
|
+
),
|
|
272
|
+
"producer_stalled": (
|
|
273
|
+
"- (no producer commits — the producer subprocess stalled without committing on this round)"
|
|
274
|
+
),
|
|
275
|
+
"producer_subprocess_error": (
|
|
276
|
+
"- (no producer commits — the producer subprocess failed before it could commit)"
|
|
277
|
+
),
|
|
278
|
+
"reviewer_failure": (
|
|
279
|
+
"- (no producer commits — a reviewer subprocess failed before any producer round could run)"
|
|
280
|
+
),
|
|
281
|
+
"synth_failure": (
|
|
282
|
+
"- (no producer commits — the synthesizer subprocess failed "
|
|
283
|
+
"before any producer round could run)"
|
|
284
|
+
),
|
|
285
|
+
"test_subprocess_error": (
|
|
286
|
+
"- (no producer commits — the test re-run subprocess failed "
|
|
287
|
+
"before any producer round could run)"
|
|
288
|
+
),
|
|
289
|
+
"check_subprocess_error": (
|
|
290
|
+
"- (no producer commits — a blocking mechanical check's "
|
|
291
|
+
"subprocess failed before any producer round could run)"
|
|
292
|
+
),
|
|
293
|
+
"decision_needed": (
|
|
294
|
+
"- (no producer commits — the producer escalated a finding for an "
|
|
295
|
+
"operator decision instead of committing a fix)"
|
|
296
|
+
),
|
|
297
|
+
"blockers_all_deactivated": (
|
|
298
|
+
"- (no producer commits — the round ended at the reviewers/synthesizer "
|
|
299
|
+
"stage; no producer ran)"
|
|
300
|
+
),
|
|
301
|
+
"worktree_error": (
|
|
302
|
+
"- (no producer commits — worktree provisioning failed before any producer round could run)"
|
|
303
|
+
),
|
|
304
|
+
"diff_malformed": (
|
|
305
|
+
"- (no producer commits — the diff filter refused the run before any reviewer dispatched)"
|
|
306
|
+
),
|
|
307
|
+
"diff_too_large": (
|
|
308
|
+
"- (no producer commits — the diff exceeded [loop] max_diff_bytes and the run was "
|
|
309
|
+
"refused before any reviewer dispatched)"
|
|
310
|
+
),
|
|
311
|
+
"prompt_too_large": (
|
|
312
|
+
"- (no producer commits — an assembled reviewer prompt exceeded the provider ceiling "
|
|
313
|
+
"and the run was refused before any reviewer dispatched)"
|
|
314
|
+
),
|
|
315
|
+
"parse_failure": (
|
|
316
|
+
"- (no producer commits — a reviewer / synthesizer output "
|
|
317
|
+
"couldn't be parsed; no producer round ran)"
|
|
318
|
+
),
|
|
319
|
+
"config_error": (
|
|
320
|
+
"- (no producer commits — the configuration is invalid; no producer round ran)"
|
|
321
|
+
),
|
|
322
|
+
}
|
|
323
|
+
"""Per-:data:`TerminationReason` empty-commit-series wording."""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _empty_commit_series_note(termination_reason: str) -> str:
|
|
327
|
+
"""render the empty-commit-series line for
|
|
328
|
+
the loop summary based on the loop's termination reason.
|
|
329
|
+
|
|
330
|
+
Falls back to a neutral string when the reason isn't in the
|
|
331
|
+
table — defensive against a future :data:`TerminationReason`
|
|
332
|
+
expansion that lands before this table is updated.
|
|
333
|
+
"""
|
|
334
|
+
return _EMPTY_SERIES_REASON_NOTES.get(
|
|
335
|
+
termination_reason,
|
|
336
|
+
"- (no producer commits this run)",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _round_duration_seconds(round_result) -> float:
|
|
341
|
+
"""Sum the per-phase durations the round captured. Approximate
|
|
342
|
+
(excludes worktree provisioning and persistence overhead) but
|
|
343
|
+
gives an operator-meaningful "how long did this round take to
|
|
344
|
+
review" number."""
|
|
345
|
+
total = 0.0
|
|
346
|
+
if round_result.dispatch_result is not None:
|
|
347
|
+
total += round_result.dispatch_result.total_duration_seconds
|
|
348
|
+
if round_result.synth_result is not None:
|
|
349
|
+
total += round_result.synth_result.duration_seconds
|
|
350
|
+
if round_result.test_result is not None:
|
|
351
|
+
total += round_result.test_result.duration_seconds
|
|
352
|
+
if round_result.producer_result is not None:
|
|
353
|
+
total += round_result.producer_result.duration_seconds
|
|
354
|
+
return total
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _run_usages(rounds: list) -> list:
|
|
358
|
+
"""Every model-actor ``Usage`` across all rounds (reviewers + judge + producer).
|
|
359
|
+
|
|
360
|
+
Mirrors :func:`syncade.orchestrator.budget.round_usages` — duplicated (it is ~8 lines)
|
|
361
|
+
because persistence must NOT import ``orchestrator`` (the orchestrator imports
|
|
362
|
+
persistence; the reverse would cycle)."""
|
|
363
|
+
usages: list = []
|
|
364
|
+
for r in rounds:
|
|
365
|
+
usages.extend(x.usage for x in r.dispatch_result.results if x.usage is not None)
|
|
366
|
+
if r.synth_result is not None and r.synth_result.usage is not None:
|
|
367
|
+
usages.append(r.synth_result.usage)
|
|
368
|
+
if r.producer_result is not None and r.producer_result.usage is not None:
|
|
369
|
+
usages.append(r.producer_result.usage)
|
|
370
|
+
return usages
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _budget_section(
|
|
374
|
+
usages: list,
|
|
375
|
+
budget_tokens: int | None,
|
|
376
|
+
budget_usd: float | None,
|
|
377
|
+
budget_ceiling: str | None = None,
|
|
378
|
+
) -> list:
|
|
379
|
+
"""The ``## Budget`` block for a ``budget_exceeded`` run (PR-v2-11).
|
|
380
|
+
|
|
381
|
+
``usages`` is the loop's ENFORCEMENT tally — the exact list of ``Usage`` records the budget
|
|
382
|
+
check summed. On a ``--resume`` this is the FRESH resumed-run spend, NOT the rehydrated
|
|
383
|
+
original rounds (the enforcement tally starts empty on resume), so the reported number is
|
|
384
|
+
the one that actually tripped the resumed budget. The configured ceiling(s) + that tally
|
|
385
|
+
are rendered through :mod:`syncade.billing` so the numbers AND the money-vs-valuation
|
|
386
|
+
wording are IDENTICAL to ``--metrics`` and each round's ``summary.md`` (C1: one number,
|
|
387
|
+
three surfaces). ``billing.render`` supplies the lower-bound honesty when any actor's cost
|
|
388
|
+
is unpriced (C4)."""
|
|
389
|
+
from syncade import billing
|
|
390
|
+
|
|
391
|
+
total_tokens = sum(u.total_tokens for u in usages)
|
|
392
|
+
# Name the ceiling that actually tripped. Both budgets set → first-to-trip (tokens are
|
|
393
|
+
# checked first); ``budget_ceiling`` carries over_budget's authoritative answer, so a
|
|
394
|
+
# token-only crossing never mis-reports "cost" and vice versa.
|
|
395
|
+
if budget_ceiling == "budget_tokens":
|
|
396
|
+
crossed = "the running TOKEN tally crossed your configured `budget_tokens` ceiling"
|
|
397
|
+
elif budget_ceiling == "budget_usd":
|
|
398
|
+
crossed = "the running COST tally crossed your configured `budget_usd` ceiling"
|
|
399
|
+
else:
|
|
400
|
+
crossed = "the running tally crossed your configured budget"
|
|
401
|
+
lines: list = [
|
|
402
|
+
"## Budget",
|
|
403
|
+
"",
|
|
404
|
+
f"The loop stopped because {crossed}. It aborted at a dispatch boundary — no running "
|
|
405
|
+
"provider call was interrupted — so the tally can exceed the ceiling by up to one "
|
|
406
|
+
"review-bundle (reviewers + judge) or one producer, whichever was in flight when it "
|
|
407
|
+
"crossed.",
|
|
408
|
+
"",
|
|
409
|
+
"**Configured ceiling:**",
|
|
410
|
+
]
|
|
411
|
+
tok_mark = " ← CROSSED" if budget_ceiling == "budget_tokens" else ""
|
|
412
|
+
usd_mark = " ← CROSSED" if budget_ceiling == "budget_usd" else ""
|
|
413
|
+
if budget_tokens: # 0 is the opt-out sentinel (PR-h-field-06), not a real ceiling
|
|
414
|
+
lines.append(
|
|
415
|
+
f"- total tokens ≤ {budget_tokens:,} (`budget_tokens` — tightest bound; exact "
|
|
416
|
+
f"unless an actor reported no usage){tok_mark}"
|
|
417
|
+
)
|
|
418
|
+
if budget_usd: # 0 is the opt-out sentinel (PR-h-field-06), not a real ceiling
|
|
419
|
+
lines.append(
|
|
420
|
+
f"- API-equivalent cost ≤ ${budget_usd:.4f} (`budget_usd`, a LOWER-BOUND "
|
|
421
|
+
f"tally){usd_mark}"
|
|
422
|
+
)
|
|
423
|
+
if budget_tokens is None and budget_usd is None:
|
|
424
|
+
lines.append("- (ceiling value not recorded)")
|
|
425
|
+
lines += ["", f"**Tally this run:** {total_tokens:,} tokens", ""]
|
|
426
|
+
lines += billing.render(billing.from_usages(usages), bullet=True)
|
|
427
|
+
lines.append("")
|
|
428
|
+
return lines
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Producer subprocess persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<round_dir>/producer.{stdout,stderr,commit.txt[,error.txt]}``
|
|
4
|
+
and the matching round-manifest entry. The orchestrator only calls
|
|
5
|
+
these on rounds where the producer actually ran (NO-SHIP rounds with
|
|
6
|
+
``max_rounds > 1``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import traceback
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from syncade.producer import ProducerResult
|
|
16
|
+
from syncade.usage import usage_fields
|
|
17
|
+
|
|
18
|
+
from ._atomic import atomic_write_text
|
|
19
|
+
from ._validation import _validate_reviewer_filename_basename
|
|
20
|
+
|
|
21
|
+
# Hardcoded basename for the producer artifacts. There is exactly one
|
|
22
|
+
# producer run per round (where the producer ran at all), so the
|
|
23
|
+
# hardcoded basename mirrors the synthesizer's single-instance design.
|
|
24
|
+
PRODUCER_NAME = "producer"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ProducerArtifactPaths:
|
|
29
|
+
"""Where the producer subprocess's artifacts land on disk.
|
|
30
|
+
|
|
31
|
+
Returned by :func:`persist_producer_result` and attached to a
|
|
32
|
+
:class:`RoundArtifacts` so the orchestrator + CLI can address
|
|
33
|
+
the files without re-deriving the layout convention.
|
|
34
|
+
|
|
35
|
+
All four paths are absolute and rooted at the round directory.
|
|
36
|
+
The producer doesn't emit structured JSON like the reviewer +
|
|
37
|
+
synthesizer phases, so there's no ``parsed.json`` — the
|
|
38
|
+
narrative text goes into ``producer.stdout`` directly.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
stdout: Path
|
|
42
|
+
stderr: Path
|
|
43
|
+
commit_sha: Path
|
|
44
|
+
error: Path | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def persist_producer_result(
|
|
48
|
+
round_dir: Path,
|
|
49
|
+
producer_result: ProducerResult,
|
|
50
|
+
) -> ProducerArtifactPaths:
|
|
51
|
+
"""Write the producer subprocess's outputs to
|
|
52
|
+
``<round_dir>/producer.{stdout,stderr,commit.txt[,error.txt]}``.
|
|
53
|
+
|
|
54
|
+
Mirrors :func:`persist_synthesizer_result`'s and
|
|
55
|
+
:func:`persist_test_run_result`'s file-layout conventions so a
|
|
56
|
+
tool inspecting the round directory sees producer artifacts in
|
|
57
|
+
the same shape as the per-reviewer, synthesizer, and test-leg
|
|
58
|
+
artifacts.
|
|
59
|
+
|
|
60
|
+
Files written:
|
|
61
|
+
|
|
62
|
+
- ``producer.stdout`` — the producer's narrative text from
|
|
63
|
+
:attr:`ProducerOutput.narrative_text` on ``"committed"`` /
|
|
64
|
+
``"stalled"`` outcomes; the raw subprocess stdout when
|
|
65
|
+
``raw_subprocess_result`` is preserved (timeout, parse failure);
|
|
66
|
+
empty string when the subprocess never ran
|
|
67
|
+
(``SubprocessNotFoundError``).
|
|
68
|
+
- ``producer.stderr`` — the raw subprocess stderr when available;
|
|
69
|
+
empty string when ``raw_subprocess_result is None``.
|
|
70
|
+
- ``producer.commit.txt`` — one-line hex string: the
|
|
71
|
+
:attr:`ProducerResult.ending_sha`. On ``"stalled"`` this equals
|
|
72
|
+
:attr:`ProducerResult.starting_sha`; on ``"committed"`` it
|
|
73
|
+
differs. On ``"subprocess_error"`` it may differ when the
|
|
74
|
+
producer moved HEAD before failing; that file then includes an
|
|
75
|
+
indeterminate-commit note. Same shell-grep pattern as
|
|
76
|
+
``test-run.exit-code.txt``: a shell-script consumer
|
|
77
|
+
(CI integration, ``grep``) can pull the SHA without parsing
|
|
78
|
+
JSON.
|
|
79
|
+
- ``producer.error.txt`` — written ONLY on
|
|
80
|
+
``outcome="subprocess_error"``; carries the exception class
|
|
81
|
+
name + message + traceback (when available). Matches the
|
|
82
|
+
reviewer + synthesizer ``.error.txt`` shape so downstream
|
|
83
|
+
tooling treats all subprocess failures uniformly.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
round_dir: The round directory to write into. Must already
|
|
87
|
+
exist (the orchestrator creates it during round setup).
|
|
88
|
+
producer_result: The :class:`ProducerResult` from
|
|
89
|
+
:func:`syncade.producer.run_producer`. The caller (the
|
|
90
|
+
orchestrator) only calls this on rounds where the
|
|
91
|
+
producer actually ran; rounds without a producer
|
|
92
|
+
(SHIP at round 0, max-rounds-reached) never reach here.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
:class:`ProducerArtifactPaths` naming all written files
|
|
96
|
+
(``error`` is ``None`` on the success / stall paths).
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
FileNotFoundError: If ``round_dir`` does not exist (caller
|
|
100
|
+
bug — the orchestrator creates it during round setup).
|
|
101
|
+
"""
|
|
102
|
+
_validate_reviewer_filename_basename(PRODUCER_NAME)
|
|
103
|
+
if not round_dir.is_dir():
|
|
104
|
+
raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
|
|
105
|
+
|
|
106
|
+
base = round_dir / PRODUCER_NAME
|
|
107
|
+
stdout_path = base.with_suffix(".stdout")
|
|
108
|
+
stderr_path = base.with_suffix(".stderr")
|
|
109
|
+
# The commit-SHA file uses a compound suffix (.commit.txt) so the
|
|
110
|
+
# ``shell-grep <round_dir>/producer.commit.txt`` recipe stays
|
|
111
|
+
# legible. Path.with_suffix would replace ``.stdout`` whole;
|
|
112
|
+
# build the name directly.
|
|
113
|
+
commit_sha_path = round_dir / f"{PRODUCER_NAME}.commit.txt"
|
|
114
|
+
|
|
115
|
+
# stdout: prefer the producer's narrative_text on success/stall;
|
|
116
|
+
# fall back to the raw subprocess stdout on the subprocess-error
|
|
117
|
+
# path (where the adapter never produced a ProducerOutput).
|
|
118
|
+
if producer_result.output is not None:
|
|
119
|
+
stdout_text = producer_result.output.narrative_text
|
|
120
|
+
elif producer_result.raw_subprocess_result is not None:
|
|
121
|
+
stdout_text = producer_result.raw_subprocess_result.stdout
|
|
122
|
+
else:
|
|
123
|
+
stdout_text = ""
|
|
124
|
+
atomic_write_text(stdout_path, stdout_text)
|
|
125
|
+
|
|
126
|
+
raw_result = producer_result.raw_subprocess_result
|
|
127
|
+
stderr_text = raw_result.stderr if raw_result is not None else ""
|
|
128
|
+
atomic_write_text(stderr_path, stderr_text)
|
|
129
|
+
|
|
130
|
+
# One-line hex string + trailing newline — shell-grep convention
|
|
131
|
+
# matches test-run.exit-code.txt.
|
|
132
|
+
commit_sha_text = f"{producer_result.ending_sha}\n"
|
|
133
|
+
indeterminate_commit = (
|
|
134
|
+
producer_result.outcome == "subprocess_error"
|
|
135
|
+
and producer_result.ending_sha != producer_result.starting_sha
|
|
136
|
+
)
|
|
137
|
+
atomic_write_text(commit_sha_path, commit_sha_text)
|
|
138
|
+
|
|
139
|
+
error_path: Path | None = None
|
|
140
|
+
if producer_result.error is not None:
|
|
141
|
+
exc = producer_result.error
|
|
142
|
+
lines = [
|
|
143
|
+
f"{type(exc).__name__}: {exc}",
|
|
144
|
+
"",
|
|
145
|
+
]
|
|
146
|
+
tb = exc.__traceback__
|
|
147
|
+
if tb is not None:
|
|
148
|
+
lines.extend(traceback.format_exception(type(exc), exc, tb))
|
|
149
|
+
else:
|
|
150
|
+
lines.append("(no traceback available — exception was constructed, not raised)")
|
|
151
|
+
error_path = base.with_suffix(".error.txt")
|
|
152
|
+
if indeterminate_commit:
|
|
153
|
+
lines.extend(
|
|
154
|
+
[
|
|
155
|
+
"",
|
|
156
|
+
"Indeterminate producer commit: HEAD moved from "
|
|
157
|
+
f"{producer_result.starting_sha} to {producer_result.ending_sha} "
|
|
158
|
+
"before the subprocess_error outcome.",
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
atomic_write_text(error_path, "\n".join(lines))
|
|
162
|
+
|
|
163
|
+
return ProducerArtifactPaths(
|
|
164
|
+
stdout=stdout_path,
|
|
165
|
+
stderr=stderr_path,
|
|
166
|
+
commit_sha=commit_sha_path,
|
|
167
|
+
error=error_path,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _producer_manifest_entry(
|
|
172
|
+
producer_result: ProducerResult | None,
|
|
173
|
+
producer_config_provider: str | None = None,
|
|
174
|
+
producer_config_model: str | None = None,
|
|
175
|
+
) -> dict[str, object] | None:
|
|
176
|
+
"""Build the ``producer`` section of the round manifest.
|
|
177
|
+
|
|
178
|
+
Returns ``None`` when the producer phase was skipped (round
|
|
179
|
+
SHIPped at the verdict stage; this round was the last round
|
|
180
|
+
of the loop). Returns a dict with the documented synthesizer schema
|
|
181
|
+
otherwise.
|
|
182
|
+
|
|
183
|
+
Schema:
|
|
184
|
+
|
|
185
|
+
.. code-block:: json
|
|
186
|
+
|
|
187
|
+
{
|
|
188
|
+
"outcome": "committed" | "stalled" | "subprocess_error" | "escalated",
|
|
189
|
+
"provider": "anthropic",
|
|
190
|
+
"model": "claude-sonnet-4-6",
|
|
191
|
+
"starting_sha": "<full-sha1-or-sha256-hex>",
|
|
192
|
+
"ending_sha": "<full-sha1-or-sha256-hex>",
|
|
193
|
+
"duration_seconds": float,
|
|
194
|
+
"retried": int, // OMITTED when retries == 0 (C5: happy-path byte-identical)
|
|
195
|
+
"stdout_path": "producer.stdout",
|
|
196
|
+
"stderr_path": "producer.stderr",
|
|
197
|
+
"commit_sha_path": "producer.commit.txt",
|
|
198
|
+
"error_path": null | "producer.error.txt",
|
|
199
|
+
"error_type": null | "SubprocessTimeoutError" | ...,
|
|
200
|
+
"escalation": { "finding", "decision", "options", "rationale" }
|
|
201
|
+
// present ONLY on outcome=="escalated"; omitted otherwise
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
The provider + model are echoed from the producer config so a
|
|
205
|
+
consumer reading manifest.json knows which adapter ran without
|
|
206
|
+
cross-referencing back to ``.syncade/config.toml``.
|
|
207
|
+
"""
|
|
208
|
+
if producer_result is None:
|
|
209
|
+
return None
|
|
210
|
+
provider = producer_result.provider or producer_config_provider
|
|
211
|
+
model = producer_result.model or producer_config_model
|
|
212
|
+
entry: dict[str, object] = {
|
|
213
|
+
"outcome": producer_result.outcome,
|
|
214
|
+
"provider": provider,
|
|
215
|
+
"model": model,
|
|
216
|
+
"starting_sha": producer_result.starting_sha,
|
|
217
|
+
"ending_sha": producer_result.ending_sha,
|
|
218
|
+
"duration_seconds": producer_result.duration_seconds,
|
|
219
|
+
**usage_fields(producer_result.usage),
|
|
220
|
+
"stdout_path": f"{PRODUCER_NAME}.stdout",
|
|
221
|
+
"stderr_path": f"{PRODUCER_NAME}.stderr",
|
|
222
|
+
"commit_sha_path": f"{PRODUCER_NAME}.commit.txt",
|
|
223
|
+
"error_path": (f"{PRODUCER_NAME}.error.txt" if producer_result.error is not None else None),
|
|
224
|
+
"error_type": (
|
|
225
|
+
type(producer_result.error).__name__ if producer_result.error is not None else None
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
# C5: omit "retried" entirely when no retries occurred so the happy-path
|
|
229
|
+
# producer block is byte-identical to pre-PR-v2-22. Presence implies > 0.
|
|
230
|
+
if producer_result.retries > 0:
|
|
231
|
+
entry["retried"] = producer_result.retries
|
|
232
|
+
if (
|
|
233
|
+
producer_result.outcome == "subprocess_error"
|
|
234
|
+
and producer_result.ending_sha != producer_result.starting_sha
|
|
235
|
+
):
|
|
236
|
+
entry["indeterminate_commit"] = True
|
|
237
|
+
# (QA finding 3): on the escalated outcome, carry the structured
|
|
238
|
+
# escalation so a tool reading manifest.json alone can reconstruct WHY the
|
|
239
|
+
# loop paused for a decision (not just decision-needed.md / producer.stdout).
|
|
240
|
+
# The key is OMITTED on every other outcome (escalation is None) → the
|
|
241
|
+
# committed/stalled/subprocess_error manifest entries are byte-identical.
|
|
242
|
+
if producer_result.escalation is not None:
|
|
243
|
+
esc = producer_result.escalation
|
|
244
|
+
entry["escalation"] = {
|
|
245
|
+
"finding": esc.finding,
|
|
246
|
+
"decision": esc.decision,
|
|
247
|
+
"options": list(esc.options),
|
|
248
|
+
"rationale": esc.rationale,
|
|
249
|
+
}
|
|
250
|
+
return entry
|