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,165 @@
|
|
|
1
|
+
"""Loop-level loop-manifest.json persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<run_dir>/loop-manifest.json`` — the top-level
|
|
4
|
+
machine-readable manifest with the multi-round rounds[] array. Per-
|
|
5
|
+
round manifests still live at ``round-N/manifest.json``; this top-
|
|
6
|
+
level loop-manifest aggregates the per-round entries and adds the
|
|
7
|
+
loop-level fields (``final_exit_code``, ``final_round``,
|
|
8
|
+
``termination_reason``, ``max_rounds``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from syncade import __version__
|
|
17
|
+
|
|
18
|
+
from ._atomic import atomic_write_json
|
|
19
|
+
from .checks import _check_manifest_entry
|
|
20
|
+
from .producer import _producer_manifest_entry
|
|
21
|
+
from .reviewer import _reviewer_manifest_entry
|
|
22
|
+
from .synth import _synthesizer_manifest_entry
|
|
23
|
+
from .test_run import _test_run_manifest_entry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def persist_loop_manifest(
|
|
27
|
+
run_dir: Path,
|
|
28
|
+
*,
|
|
29
|
+
final_exit_code: int,
|
|
30
|
+
final_round: int,
|
|
31
|
+
termination_reason: str,
|
|
32
|
+
rounds: list, # list[RoundResult]
|
|
33
|
+
max_rounds: int,
|
|
34
|
+
started_at: datetime,
|
|
35
|
+
producer_provider: str | None,
|
|
36
|
+
producer_model: str | None,
|
|
37
|
+
) -> Path:
|
|
38
|
+
"""Write ``<run_dir>/loop-manifest.json`` — the top-level
|
|
39
|
+
machine-readable manifest with the multi-round rounds[] array.
|
|
40
|
+
|
|
41
|
+
Per-round manifests still live at ``round-N/manifest.json``; the top-level
|
|
42
|
+
loop-manifest aggregates the per-round entries and adds the
|
|
43
|
+
loop-level fields (``final_exit_code``, ``final_round``,
|
|
44
|
+
``termination_reason``, ``max_rounds``).
|
|
45
|
+
|
|
46
|
+
Schema:
|
|
47
|
+
|
|
48
|
+
.. code-block:: json
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
"syncade_version": "0.x.y",
|
|
52
|
+
"run_id": "...",
|
|
53
|
+
"started_at_utc": "...",
|
|
54
|
+
"max_rounds": 3,
|
|
55
|
+
"final_exit_code": 0,
|
|
56
|
+
"final_round": 1,
|
|
57
|
+
"termination_reason": "ship",
|
|
58
|
+
"rounds": [
|
|
59
|
+
{
|
|
60
|
+
"round": 0,
|
|
61
|
+
"snapshot": {...},
|
|
62
|
+
"reviewers": [...],
|
|
63
|
+
"synthesizer": {...},
|
|
64
|
+
"test_run": null | {...},
|
|
65
|
+
"test_skip_reason": null | "...",
|
|
66
|
+
"producer": null | {...},
|
|
67
|
+
"round_exit_code": 30
|
|
68
|
+
},
|
|
69
|
+
{... round 1 ...}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
run_dir: Top-level run directory.
|
|
75
|
+
final_exit_code: The loop's final exit code.
|
|
76
|
+
final_round: 0-indexed round the loop terminated at.
|
|
77
|
+
termination_reason: Categorical termination label.
|
|
78
|
+
rounds: List of RoundResult objects from the orchestrator.
|
|
79
|
+
max_rounds: Configured ``[loop] max_rounds`` ceiling.
|
|
80
|
+
started_at: Run start instant.
|
|
81
|
+
producer_provider: ``config.producer.provider``, echoed
|
|
82
|
+
into each round's producer section.
|
|
83
|
+
producer_model: ``config.producer.model``.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Path of the written ``loop-manifest.json``.
|
|
87
|
+
"""
|
|
88
|
+
if not run_dir.is_dir():
|
|
89
|
+
raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
|
|
90
|
+
|
|
91
|
+
run_id = run_dir.name
|
|
92
|
+
round_entries: list[dict[str, object]] = []
|
|
93
|
+
for r in rounds:
|
|
94
|
+
entry: dict[str, object] = {
|
|
95
|
+
"round": r.round_idx,
|
|
96
|
+
"snapshot": {
|
|
97
|
+
"commit_sha": r.snapshot.commit_sha,
|
|
98
|
+
"branch": r.snapshot.branch,
|
|
99
|
+
"base_ref": r.snapshot.base_ref,
|
|
100
|
+
"base_oid": r.snapshot.base_oid,
|
|
101
|
+
"diff_present": bool(r.snapshot.diff_text),
|
|
102
|
+
# Mirror the per-round manifest's size history fields.
|
|
103
|
+
# Both are None when not measured (diff_malformed, resume); see
|
|
104
|
+
# round_manifest.py for the full rationale.
|
|
105
|
+
"diff_bytes": r.raw_diff_bytes,
|
|
106
|
+
"diff_bytes_reviewed": r.filtered_diff_bytes,
|
|
107
|
+
},
|
|
108
|
+
"reviewers": (
|
|
109
|
+
[_reviewer_manifest_entry(rr) for rr in r.dispatch_result.results]
|
|
110
|
+
if r.dispatch_result is not None
|
|
111
|
+
else []
|
|
112
|
+
),
|
|
113
|
+
"synthesizer": _synthesizer_manifest_entry(r.synth_result),
|
|
114
|
+
"test_run": _test_run_manifest_entry(r.test_result),
|
|
115
|
+
"test_skip_reason": r.test_skip_reason if r.test_result is None else None,
|
|
116
|
+
"producer": _producer_manifest_entry(
|
|
117
|
+
r.producer_result,
|
|
118
|
+
producer_config_provider=producer_provider,
|
|
119
|
+
producer_config_model=producer_model,
|
|
120
|
+
),
|
|
121
|
+
"round_exit_code": r.round_exit_code,
|
|
122
|
+
}
|
|
123
|
+
# Mirror the per-round manifest (round_manifest.py): append the
|
|
124
|
+
# checks array ONLY when checks ran, so a zero-check round's
|
|
125
|
+
# loop entry stays byte-identical and the two surfaces agree on
|
|
126
|
+
# checks[] presence/shape.
|
|
127
|
+
if r.check_results:
|
|
128
|
+
entry["checks"] = [_check_manifest_entry(c) for c in r.check_results]
|
|
129
|
+
# Mirror the per-round manifest's refusal discriminator fields so the loop
|
|
130
|
+
# manifest is self-sufficient for tooling that wants to classify per-round
|
|
131
|
+
# refusal causes without reading individual round-N/manifest.json files.
|
|
132
|
+
if r.fail_closed_headers is not None:
|
|
133
|
+
entry["diff_filter_refusal_headers"] = r.fail_closed_headers
|
|
134
|
+
# Derive refusal_reason the same way round_no_changes.py does: fail_closed_headers
|
|
135
|
+
# → diff_malformed; oversize_diff_bytes → diff_too_large; oversize_prompt_chars →
|
|
136
|
+
# prompt_too_large. Only one is set per run; written only when non-None.
|
|
137
|
+
_refusal_reason: str | None
|
|
138
|
+
if r.fail_closed_headers is not None:
|
|
139
|
+
_refusal_reason = "diff_malformed"
|
|
140
|
+
elif r.oversize_diff_bytes is not None:
|
|
141
|
+
_refusal_reason = "diff_too_large"
|
|
142
|
+
elif r.oversize_prompt_chars is not None:
|
|
143
|
+
_refusal_reason = "prompt_too_large"
|
|
144
|
+
else:
|
|
145
|
+
_refusal_reason = None
|
|
146
|
+
if _refusal_reason is not None:
|
|
147
|
+
entry["refusal_reason"] = _refusal_reason
|
|
148
|
+
if r.oversize_prompt_chars is not None:
|
|
149
|
+
entry["oversize_prompt_chars"] = r.oversize_prompt_chars
|
|
150
|
+
round_entries.append(entry)
|
|
151
|
+
|
|
152
|
+
manifest = {
|
|
153
|
+
"syncade_version": __version__,
|
|
154
|
+
"run_id": run_id,
|
|
155
|
+
"started_at_utc": started_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
156
|
+
"max_rounds": max_rounds,
|
|
157
|
+
"final_exit_code": final_exit_code,
|
|
158
|
+
"final_round": final_round,
|
|
159
|
+
"termination_reason": termination_reason,
|
|
160
|
+
"rounds": round_entries,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
path = run_dir / "loop-manifest.json"
|
|
164
|
+
atomic_write_json(path, manifest, sort_keys=False)
|
|
165
|
+
return path
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Loop-level loop-summary.md persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<run_dir>/loop-summary.md`` — the multi-round summary
|
|
4
|
+
artifact. Top-level alongside the per-round directories. Rolls up
|
|
5
|
+
every round + final verdict + the SHA series the loop's producer
|
|
6
|
+
commits produced.
|
|
7
|
+
|
|
8
|
+
the per-termination-reason next-steps + empty-series text tables and
|
|
9
|
+
the four small render helpers live in :mod:`.loop_summary_text`; this module
|
|
10
|
+
keeps the headline labels + ``persist_loop_summary``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ._atomic import atomic_write_text
|
|
19
|
+
from .checks import _LOOP_NEXT_STEPS_CHECK_FAILURE, _final_round_blocking_check_failure
|
|
20
|
+
from .loop_summary_text import (
|
|
21
|
+
_LOOP_NEXT_STEPS,
|
|
22
|
+
_budget_section,
|
|
23
|
+
_empty_commit_series_note,
|
|
24
|
+
_producer_commit_subject,
|
|
25
|
+
_round_duration_seconds,
|
|
26
|
+
_round_verdict_label,
|
|
27
|
+
_run_usages,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_TERMINATION_REASON_LABELS: dict[str, str] = {
|
|
31
|
+
"ship": "SHIP",
|
|
32
|
+
"no_changes_to_review": "nothing to review",
|
|
33
|
+
"producer_emptied_diff": "nothing to review (producer emptied all changes)",
|
|
34
|
+
"findings_present": "findings present",
|
|
35
|
+
"max_rounds_reached": "max rounds reached",
|
|
36
|
+
"budget_exceeded": "budget exceeded",
|
|
37
|
+
"provider_usage_limit": "provider usage limit reached",
|
|
38
|
+
"producer_stalled": "producer stalled",
|
|
39
|
+
"producer_subprocess_error": "producer subprocess error",
|
|
40
|
+
"reviewer_failure": "reviewer failure",
|
|
41
|
+
"synth_failure": "synthesizer failure",
|
|
42
|
+
"test_subprocess_error": "test subprocess error",
|
|
43
|
+
"check_subprocess_error": "blocking-check subprocess error",
|
|
44
|
+
"decision_needed": "decision needed (producer escalation)",
|
|
45
|
+
"blockers_all_deactivated": "decision needed (reviewers' blockers all deactivated)",
|
|
46
|
+
"worktree_error": "worktree provisioning error",
|
|
47
|
+
"diff_malformed": "diff filter refusal (unidentifiable headers)",
|
|
48
|
+
"diff_too_large": "diff exceeds [loop] max_diff_bytes",
|
|
49
|
+
"prompt_too_large": "assembled reviewer prompt exceeds provider ceiling",
|
|
50
|
+
"parse_failure": "output parse failure",
|
|
51
|
+
"config_error": "config error",
|
|
52
|
+
}
|
|
53
|
+
"""Human-readable label for each :data:`syncade.orchestrator.TerminationReason`.
|
|
54
|
+
Used in loop-summary.md's headline so the operator sees a plain-
|
|
55
|
+
English description rather than the categorical machine-readable
|
|
56
|
+
slug."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def persist_loop_summary(
|
|
60
|
+
run_dir: Path,
|
|
61
|
+
*,
|
|
62
|
+
final_exit_code: int,
|
|
63
|
+
final_round: int,
|
|
64
|
+
termination_reason: str,
|
|
65
|
+
rounds: list, # list[RoundResult]; typed as list to avoid circular import
|
|
66
|
+
max_rounds: int,
|
|
67
|
+
repo_root: Path | None = None,
|
|
68
|
+
started_at: datetime,
|
|
69
|
+
completed_at: datetime,
|
|
70
|
+
budget_tokens: int | None = None,
|
|
71
|
+
budget_usd: float | None = None,
|
|
72
|
+
budget_usages: list | None = None,
|
|
73
|
+
budget_ceiling: str | None = None,
|
|
74
|
+
) -> Path:
|
|
75
|
+
"""Write ``<run_dir>/loop-summary.md`` — the multi-round
|
|
76
|
+
summary.
|
|
77
|
+
|
|
78
|
+
Top-level artifact alongside the per-round directories. Rolls
|
|
79
|
+
up every round + final verdict + the SHA series the loop's
|
|
80
|
+
producer commits produced. The operator reading
|
|
81
|
+
``loop-summary.md`` sees the whole loop in one document
|
|
82
|
+
without traversing N per-round `summary.md` files.
|
|
83
|
+
|
|
84
|
+
For ``max_rounds=1`` runs (single-pass back-compat), the
|
|
85
|
+
summary still fires but renders a single round section + the
|
|
86
|
+
trivial commit series ("no commits — round 0 shipped" or
|
|
87
|
+
similar).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
run_dir: Top-level run directory (``<repo>/.syncade/runs/<id>/``).
|
|
91
|
+
final_exit_code: The loop's final exit code (see
|
|
92
|
+
:mod:`syncade.exit_codes`).
|
|
93
|
+
final_round: 0-indexed round that terminated the loop.
|
|
94
|
+
termination_reason: Categorical label (see
|
|
95
|
+
:data:`syncade.orchestrator.TerminationReason`).
|
|
96
|
+
rounds: List of :class:`syncade.orchestrator.RoundResult`,
|
|
97
|
+
one per round executed. ``len(rounds) == final_round + 1``.
|
|
98
|
+
max_rounds: Configured ``[loop] max_rounds`` ceiling.
|
|
99
|
+
started_at: Run start instant (captured once at the top of
|
|
100
|
+
``run_review``).
|
|
101
|
+
completed_at: Run-end instant. The wall-clock duration is
|
|
102
|
+
``completed_at - started_at``.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Path of the written ``loop-summary.md``.
|
|
106
|
+
"""
|
|
107
|
+
if not run_dir.is_dir():
|
|
108
|
+
raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
|
|
109
|
+
|
|
110
|
+
run_id = run_dir.name
|
|
111
|
+
started = started_at.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
112
|
+
duration = completed_at - started_at
|
|
113
|
+
total_seconds = int(duration.total_seconds())
|
|
114
|
+
hh, rem = divmod(total_seconds, 3600)
|
|
115
|
+
mm, ss = divmod(rem, 60)
|
|
116
|
+
duration_str = f"{hh:02d}:{mm:02d}:{ss:02d}"
|
|
117
|
+
|
|
118
|
+
# Final verdict label — mirrors the findings.md verdict
|
|
119
|
+
# convention: SHIP for exit 0, NO-SHIP for exit 30 (real
|
|
120
|
+
# findings), ABORT for environmental failures (40 / 60).
|
|
121
|
+
# no_changes_to_review / producer_emptied_diff exit 0 but are NOT SHIPs — the
|
|
122
|
+
# final round dispatched no reviewers (no approval was rendered this round).
|
|
123
|
+
if final_exit_code == 0 and termination_reason in (
|
|
124
|
+
"no_changes_to_review",
|
|
125
|
+
"producer_emptied_diff",
|
|
126
|
+
):
|
|
127
|
+
verdict_label = "NOTHING TO REVIEW"
|
|
128
|
+
elif final_exit_code == 0:
|
|
129
|
+
verdict_label = "SHIP"
|
|
130
|
+
elif final_exit_code == 30:
|
|
131
|
+
verdict_label = "NO-SHIP"
|
|
132
|
+
elif final_exit_code == 20:
|
|
133
|
+
verdict_label = "NO-SHIP"
|
|
134
|
+
elif final_exit_code == 25:
|
|
135
|
+
# Budget abort. Still NO-SHIP: the run did not converge, it was stopped
|
|
136
|
+
# at a token/dollar budget ceiling with findings potentially remaining.
|
|
137
|
+
verdict_label = "NO-SHIP"
|
|
138
|
+
elif final_exit_code == 10:
|
|
139
|
+
# producer escalation. Still NO-SHIP (escalation does not
|
|
140
|
+
# override the mechanical verdict); the termination reason carries
|
|
141
|
+
# the "decision needed" signal.
|
|
142
|
+
verdict_label = "NO-SHIP"
|
|
143
|
+
elif final_exit_code in (40, 60, 70):
|
|
144
|
+
verdict_label = "ABORT"
|
|
145
|
+
elif final_exit_code == 50:
|
|
146
|
+
verdict_label = "ABORT"
|
|
147
|
+
else:
|
|
148
|
+
verdict_label = "UNKNOWN"
|
|
149
|
+
|
|
150
|
+
reason_label = _TERMINATION_REASON_LABELS.get(termination_reason, termination_reason)
|
|
151
|
+
if termination_reason == "ship":
|
|
152
|
+
reason_label = f"ship (round {final_round})"
|
|
153
|
+
elif termination_reason == "budget_exceeded" and budget_ceiling is not None:
|
|
154
|
+
# Name WHICH ceiling in the headline too — a token-only abort must not read as a cost
|
|
155
|
+
# stop (Finding: the generic "cost ceiling hit" contradicted the Budget section).
|
|
156
|
+
which = "token" if budget_ceiling == "budget_tokens" else "cost"
|
|
157
|
+
reason_label = f"budget exceeded ({which} ceiling)"
|
|
158
|
+
|
|
159
|
+
lines: list[str] = [
|
|
160
|
+
f"# Syncade run {run_id} — loop summary",
|
|
161
|
+
"",
|
|
162
|
+
f"**Final verdict:** {verdict_label} ",
|
|
163
|
+
f"**Termination reason:** {reason_label} ",
|
|
164
|
+
f"**Final exit code:** {final_exit_code} ",
|
|
165
|
+
]
|
|
166
|
+
# SHA annotation. The loop summary covers multiple rounds
|
|
167
|
+
# so a single "Generated against SHA" line would be ambiguous —
|
|
168
|
+
# the commit series below already lists every per-round snapshot.
|
|
169
|
+
# The headline gets the round-0 starting SHA (the operator's pre-
|
|
170
|
+
# loop tree state), which is the question a re-reader is most
|
|
171
|
+
# likely to have. The empty-``rounds`` branch is defensive; in
|
|
172
|
+
# practice the orchestrator only writes loop-summary.md after at
|
|
173
|
+
# least one round ran.
|
|
174
|
+
if rounds:
|
|
175
|
+
first_round_sha = rounds[0].snapshot.commit_sha
|
|
176
|
+
if first_round_sha:
|
|
177
|
+
lines.append(
|
|
178
|
+
f"**Round 0 starting SHA:** `{first_round_sha[:12]}` (full: `{first_round_sha}`) "
|
|
179
|
+
)
|
|
180
|
+
lines.extend(
|
|
181
|
+
[
|
|
182
|
+
f"**Rounds executed:** {len(rounds)} of {max_rounds} ",
|
|
183
|
+
f"**Started:** {started} ",
|
|
184
|
+
f"**Total wall-clock:** {duration_str}",
|
|
185
|
+
"",
|
|
186
|
+
]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# --- Per-round sections ---------------------------------------
|
|
190
|
+
for r in rounds:
|
|
191
|
+
round_verdict = _round_verdict_label(r)
|
|
192
|
+
round_duration_s = _round_duration_seconds(r)
|
|
193
|
+
lines.append(f"## Round {r.round_idx} — {round_verdict} ({round_duration_s:.1f}s)")
|
|
194
|
+
lines.append("")
|
|
195
|
+
# Reviewers — count consolidated findings or report failure
|
|
196
|
+
if getattr(r, "no_changes_to_review", False):
|
|
197
|
+
lines.append("- Reviewers: not dispatched (diff was empty before review)")
|
|
198
|
+
elif r.fail_closed_headers:
|
|
199
|
+
lines.append("- Reviewers: not dispatched (diff refused — unidentifiable headers)")
|
|
200
|
+
elif r.oversize_diff_bytes is not None:
|
|
201
|
+
lines.append(
|
|
202
|
+
f"- Reviewers: not dispatched (diff too large — {r.oversize_diff_bytes:,} bytes)"
|
|
203
|
+
)
|
|
204
|
+
elif r.oversize_prompt_chars is not None:
|
|
205
|
+
lines.append(
|
|
206
|
+
f"- Reviewers: not dispatched (assembled prompt too large — "
|
|
207
|
+
f"{r.oversize_prompt_chars:,} chars)"
|
|
208
|
+
)
|
|
209
|
+
elif r.dispatch_result is not None and r.dispatch_result.all_succeeded:
|
|
210
|
+
n_reviewers = len(r.dispatch_result.successes)
|
|
211
|
+
lines.append(f"- Reviewers: {n_reviewers} succeeded")
|
|
212
|
+
elif r.dispatch_result is not None:
|
|
213
|
+
n_failed = len(r.dispatch_result.failures)
|
|
214
|
+
lines.append(f"- Reviewers: {n_failed} failed")
|
|
215
|
+
else:
|
|
216
|
+
lines.append("- Reviewers: did not run")
|
|
217
|
+
# Synth
|
|
218
|
+
_is_refusal = (
|
|
219
|
+
r.fail_closed_headers is not None
|
|
220
|
+
or r.oversize_diff_bytes is not None
|
|
221
|
+
or r.oversize_prompt_chars is not None
|
|
222
|
+
)
|
|
223
|
+
if r.synth_result is None and _is_refusal:
|
|
224
|
+
lines.append("- Synthesizer: not applicable (run refused before dispatch)")
|
|
225
|
+
elif r.synth_result is None:
|
|
226
|
+
lines.append("- Synthesizer: skipped")
|
|
227
|
+
elif r.synth_result.output is not None:
|
|
228
|
+
active = sum(1 for f in r.synth_result.output.consolidated_findings if not f.dismissed)
|
|
229
|
+
blockers = sum(
|
|
230
|
+
1
|
|
231
|
+
for f in r.synth_result.output.consolidated_findings
|
|
232
|
+
if not f.dismissed and f.severity == "blocker"
|
|
233
|
+
)
|
|
234
|
+
lines.append(f"- Synthesizer: {active} active finding(s), {blockers} blocker(s)")
|
|
235
|
+
else:
|
|
236
|
+
err = type(r.synth_result.error).__name__
|
|
237
|
+
lines.append(f"- Synthesizer: failed ({err})")
|
|
238
|
+
# Test re-run
|
|
239
|
+
if r.test_result is None and _is_refusal:
|
|
240
|
+
lines.append("- Test re-run: not applicable (run refused before dispatch)")
|
|
241
|
+
elif r.test_result is None:
|
|
242
|
+
lines.append(f"- Test re-run: skipped ({r.test_skip_reason or 'unknown'})")
|
|
243
|
+
elif r.test_result.outcome == "subprocess_error":
|
|
244
|
+
err = type(r.test_result.error).__name__ if r.test_result.error else "Unknown"
|
|
245
|
+
lines.append(f"- Test re-run: subprocess_error ({err})")
|
|
246
|
+
else:
|
|
247
|
+
lines.append(f"- Test re-run: {r.test_result.outcome} (exit {r.test_result.exit_code})")
|
|
248
|
+
# Producer
|
|
249
|
+
if r.producer_result is None:
|
|
250
|
+
lines.append("- Producer: did not run (loop terminated)")
|
|
251
|
+
elif r.producer_result.outcome == "committed":
|
|
252
|
+
short_sha = r.producer_result.ending_sha[:12]
|
|
253
|
+
lines.append(f"- Producer: committed `{short_sha}`")
|
|
254
|
+
elif r.producer_result.outcome == "stalled":
|
|
255
|
+
lines.append("- Producer: stalled (no commit)")
|
|
256
|
+
elif r.producer_result.outcome == "escalated":
|
|
257
|
+
# an escalated producer always terminates the loop, so this
|
|
258
|
+
# round is the terminating round and ``termination_reason`` carries
|
|
259
|
+
# the coverage guard's disposition. "decision_needed" → the
|
|
260
|
+
# escalation covered every active blocker and was honored (exit 10);
|
|
261
|
+
# any other reason → it left a blocker uncovered and was rejected,
|
|
262
|
+
# so the loop treated the round as a stall (exit 30). Render the
|
|
263
|
+
# rejected case as such rather than as an honored decision checkpoint.
|
|
264
|
+
if termination_reason == "decision_needed":
|
|
265
|
+
lines.append("- Producer: escalated (operator decision needed)")
|
|
266
|
+
else:
|
|
267
|
+
lines.append(
|
|
268
|
+
"- Producer: escalated but not honored — left active "
|
|
269
|
+
"blocker(s) uncovered (treated as stall)"
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
err = type(r.producer_result.error).__name__ if r.producer_result.error else "Unknown"
|
|
273
|
+
lines.append(f"- Producer: subprocess_error ({err})")
|
|
274
|
+
# Per-round artifacts
|
|
275
|
+
lines.append(f"- Per-round artifacts: [round-{r.round_idx}/](round-{r.round_idx}/)")
|
|
276
|
+
lines.append("")
|
|
277
|
+
|
|
278
|
+
# --- Commit series produced by this loop ----------------------
|
|
279
|
+
lines.append("## Commit series produced by this loop")
|
|
280
|
+
lines.append("")
|
|
281
|
+
# one-line lead-in. The headline Round 0 SHA invites the
|
|
282
|
+
# operator to look here for the rest of the SHAs; the lead-in
|
|
283
|
+
# makes that link explicit instead of dumping the operator into a
|
|
284
|
+
# bullet list with no framing.
|
|
285
|
+
lines.append(
|
|
286
|
+
"Each entry is one git commit on the operator's branch, in "
|
|
287
|
+
"chronological order. The first entry is the pre-loop tree "
|
|
288
|
+
"state; subsequent entries are producer commits."
|
|
289
|
+
)
|
|
290
|
+
lines.append("")
|
|
291
|
+
if not rounds:
|
|
292
|
+
lines.append("- (no rounds executed)")
|
|
293
|
+
else:
|
|
294
|
+
# First round's starting SHA = operator's pre-loop SHA
|
|
295
|
+
first_round = rounds[0]
|
|
296
|
+
starting_sha = first_round.snapshot.commit_sha
|
|
297
|
+
lines.append(f"- `{starting_sha[:12]}` — round 0 starting SHA (operator's commit)")
|
|
298
|
+
any_commits = False
|
|
299
|
+
for r in rounds:
|
|
300
|
+
if r.producer_result is None or r.producer_result.outcome != "committed":
|
|
301
|
+
continue
|
|
302
|
+
any_commits = True
|
|
303
|
+
ending = r.producer_result.ending_sha[:12]
|
|
304
|
+
# include the producer commit's
|
|
305
|
+
# subject line so the operator can scan the series
|
|
306
|
+
# without dropping to ``git log``. Looked up via
|
|
307
|
+
# ``git log -1 --pretty=format:'%s' <ending_sha>`` —
|
|
308
|
+
# wrapped in try/except so any git failure (missing
|
|
309
|
+
# repo_root, weird ref state) degrades to the
|
|
310
|
+
# subject-less form rather than crashing the summary
|
|
311
|
+
# write.
|
|
312
|
+
subject = _producer_commit_subject(repo_root, r.producer_result.ending_sha)
|
|
313
|
+
if subject:
|
|
314
|
+
lines.append(f'- `{ending}` — round {r.round_idx} producer ("{subject}")')
|
|
315
|
+
else:
|
|
316
|
+
lines.append(f"- `{ending}` — round {r.round_idx} producer")
|
|
317
|
+
if not any_commits:
|
|
318
|
+
# phrasing branches on termination
|
|
319
|
+
# reason so the empty-series wording matches what
|
|
320
|
+
# actually happened.
|
|
321
|
+
lines.append(_empty_commit_series_note(termination_reason))
|
|
322
|
+
lines.append("")
|
|
323
|
+
|
|
324
|
+
# --- Budget (only on a budget abort) --------------------------
|
|
325
|
+
if termination_reason == "budget_exceeded":
|
|
326
|
+
# Prefer the loop's ENFORCEMENT tally (fresh on resume — excludes rehydrated original
|
|
327
|
+
# rounds, so the reported number is the one that tripped). Fall back to summing all
|
|
328
|
+
# rounds only when a caller (a direct-construction test) didn't thread it — equivalent
|
|
329
|
+
# for a non-resumed run, where every round IS this-process spend.
|
|
330
|
+
usages = budget_usages if budget_usages is not None else _run_usages(rounds)
|
|
331
|
+
lines.extend(_budget_section(usages, budget_tokens, budget_usd, budget_ceiling))
|
|
332
|
+
|
|
333
|
+
# --- Next steps -----------------------------------------------
|
|
334
|
+
lines.append("## Next steps")
|
|
335
|
+
lines.append("")
|
|
336
|
+
# validation fix: a synth-clean BLOCKING-check NO-SHIP on the final round
|
|
337
|
+
# gets check-pointing guidance — the termination_reason-keyed text (e.g.
|
|
338
|
+
# max_rounds_reached's "read active blockers") is misleading when the
|
|
339
|
+
# NO-SHIP came from the mechanical lane, not the synthesizer.
|
|
340
|
+
if _final_round_blocking_check_failure(rounds):
|
|
341
|
+
next_steps = _LOOP_NEXT_STEPS_CHECK_FAILURE
|
|
342
|
+
else:
|
|
343
|
+
next_steps = _LOOP_NEXT_STEPS.get(
|
|
344
|
+
termination_reason,
|
|
345
|
+
"- See `manifest.json` and the per-round directories for details.",
|
|
346
|
+
)
|
|
347
|
+
lines.append(next_steps)
|
|
348
|
+
lines.append("")
|
|
349
|
+
|
|
350
|
+
summary_path = run_dir / "loop-summary.md"
|
|
351
|
+
atomic_write_text(summary_path, "\n".join(lines))
|
|
352
|
+
return summary_path
|