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,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
|
+
)
|
syncade/producer_git.py
ADDED
|
@@ -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}")
|