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,90 @@
|
|
|
1
|
+
"""Refuse unusable write targets BEFORE auto-init mutates the operator's directory.
|
|
2
|
+
|
|
3
|
+
Both directories checked here are created later — ``worktree_base`` by the loop's worktree
|
|
4
|
+
provisioning, ``.syncade/runs/`` by the run-dir mkdir. Left alone, an unusable one surfaces
|
|
5
|
+
as a ``WorktreeError``/exit-60 or an uncaught ``PermissionError`` *after* the baseline commit,
|
|
6
|
+
so a refused run would have created a repository on its way to failing. Checking here keeps
|
|
7
|
+
that refusal free.
|
|
8
|
+
|
|
9
|
+
Split out of ``cli/__init__.py``: it is one self-contained concern (*can syncade write where
|
|
10
|
+
it is about to write?*) with one output, and the dogfood panel called out `_run` for holding
|
|
11
|
+
validation, mutation, dispatch and cleanup at once.
|
|
12
|
+
|
|
13
|
+
``exists()`` follows symlinks and reports a DANGLING link as absent, which would read as
|
|
14
|
+
"absent but creatable" and let a broken link silently become a creation target. Every check
|
|
15
|
+
below tests ``is_symlink()`` first for that reason.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from syncade.exit_codes import CONFIG_ERROR, WORKTREE_ERROR
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unusable(path: Path, *, need_read: bool = False) -> str | None:
|
|
28
|
+
"""Why ``path`` cannot be written to, as a message SUFFIX, or ``None`` if it can.
|
|
29
|
+
|
|
30
|
+
The suffix carries its own separator so callers concatenate rather than choosing one.
|
|
31
|
+
"""
|
|
32
|
+
if path.is_symlink() and not path.exists():
|
|
33
|
+
return " is a dangling symlink"
|
|
34
|
+
if path.exists() and not path.is_dir():
|
|
35
|
+
return " exists but is not a directory"
|
|
36
|
+
if path.is_dir():
|
|
37
|
+
if need_read and not os.access(path, os.R_OK | os.W_OK | os.X_OK):
|
|
38
|
+
return ": directory is not readable and writable"
|
|
39
|
+
if not need_read and not os.access(path, os.W_OK | os.X_OK):
|
|
40
|
+
return ": directory is not writable"
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_write_targets(worktree_base: Path, repo_root: Path) -> int | None:
|
|
45
|
+
"""Return an exit code if a write target is unusable, else ``None``.
|
|
46
|
+
|
|
47
|
+
``worktree_base`` problems are CONFIG errors (the operator configured the path);
|
|
48
|
+
``.syncade`` problems are environment errors, matching where each value comes from.
|
|
49
|
+
"""
|
|
50
|
+
reason = _unusable(worktree_base)
|
|
51
|
+
if reason is not None:
|
|
52
|
+
print(
|
|
53
|
+
f"[syncade] config error: worktree-base {str(worktree_base)!r}{reason}",
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
return CONFIG_ERROR
|
|
57
|
+
|
|
58
|
+
# worktree_base is created on demand, so an absent one shifts the question to its parent.
|
|
59
|
+
if not worktree_base.exists():
|
|
60
|
+
parent = worktree_base.parent
|
|
61
|
+
if not parent.exists():
|
|
62
|
+
print(
|
|
63
|
+
f"[syncade] config error: worktree-base {str(worktree_base)!r}: "
|
|
64
|
+
f"parent directory {str(parent)!r} does not exist",
|
|
65
|
+
file=sys.stderr,
|
|
66
|
+
)
|
|
67
|
+
return CONFIG_ERROR
|
|
68
|
+
if not parent.is_dir():
|
|
69
|
+
print(
|
|
70
|
+
f"[syncade] config error: worktree-base {str(worktree_base)!r}: "
|
|
71
|
+
f"parent {str(parent)!r} exists but is not a directory",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
return CONFIG_ERROR
|
|
75
|
+
if not os.access(parent, os.W_OK | os.X_OK):
|
|
76
|
+
print(
|
|
77
|
+
f"[syncade] config error: worktree-base {str(worktree_base)!r}: "
|
|
78
|
+
f"parent directory {str(parent)!r} is not writable",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
return CONFIG_ERROR
|
|
82
|
+
|
|
83
|
+
syncade_dir = repo_root / ".syncade"
|
|
84
|
+
# runs/ needs READ too: resume and the run-id collision loop enumerate it.
|
|
85
|
+
for path, need_read in ((syncade_dir, False), (syncade_dir / "runs", True)):
|
|
86
|
+
reason = _unusable(path, need_read=need_read)
|
|
87
|
+
if reason is not None:
|
|
88
|
+
print(f"[syncade] error: {str(path)!r}{reason}", file=sys.stderr)
|
|
89
|
+
return WORKTREE_ERROR
|
|
90
|
+
return None
|
syncade/cli/resolve.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Diff-base / spec-source resolution helpers for the CLI.
|
|
2
|
+
|
|
3
|
+
These turn a ``--scope`` token or an ``--openspec`` request into the concrete
|
|
4
|
+
inputs the loop consumes, printing an operator-facing message and returning
|
|
5
|
+
``None`` when the request can't be resolved.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from syncade.logging import Logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _current_branch(repo_root: Path) -> str | None:
|
|
18
|
+
"""The current branch name via ``git rev-parse --abbrev-ref HEAD``, or
|
|
19
|
+
``None`` for detached HEAD / failure (used to key the last-reviewed lookup)."""
|
|
20
|
+
from syncade.process import run_subprocess
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
result = run_subprocess(
|
|
24
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_root, timeout=10.0
|
|
25
|
+
)
|
|
26
|
+
except Exception:
|
|
27
|
+
return None
|
|
28
|
+
name = result.stdout.strip()
|
|
29
|
+
return name if result.returncode == 0 and name and name != "HEAD" else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_scope_base(repo_root: Path, scope: str, logger: Logger) -> str | None:
|
|
33
|
+
"""Resolve a ``--scope`` token to a concrete base SHA, or ``None`` on
|
|
34
|
+
an unresolvable scope (a message is printed; the caller stops before the
|
|
35
|
+
loop). ``since-last-review`` reads the per-branch recorded SHA."""
|
|
36
|
+
from syncade.base_resolution import BaseResolutionError, resolve_scope
|
|
37
|
+
from syncade.persistence import read_last_reviewed
|
|
38
|
+
|
|
39
|
+
branch = _current_branch(repo_root)
|
|
40
|
+
last = read_last_reviewed(repo_root, branch) if branch else None
|
|
41
|
+
try:
|
|
42
|
+
resolved = resolve_scope(repo_root, scope, last_reviewed_sha=last)
|
|
43
|
+
except BaseResolutionError as exc:
|
|
44
|
+
print(f"[syncade] scope error: {exc}", file=sys.stderr)
|
|
45
|
+
return None
|
|
46
|
+
if resolved.note:
|
|
47
|
+
# Scope fallback notes are operator-facing invariants that must surface
|
|
48
|
+
# even in --quiet mode; bypass logger.warning (suppressed when quiet).
|
|
49
|
+
print(f"[syncade] scope: {resolved.note}", file=sys.stderr)
|
|
50
|
+
return resolved.base_sha
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cli_proves_commit(
|
|
54
|
+
repo_root: Path,
|
|
55
|
+
base_ref: str | None,
|
|
56
|
+
strip_files: Iterable[str],
|
|
57
|
+
*,
|
|
58
|
+
two_dot: bool = False,
|
|
59
|
+
) -> bool:
|
|
60
|
+
"""True ONLY when the CLI can prove this run will produce reviewable changes.
|
|
61
|
+
|
|
62
|
+
The pre-auth commit guard exists so a doomed committing run does not first pay for a
|
|
63
|
+
provider auth probe. Whether a run commits depends on the FILTERED diff, which the CLI
|
|
64
|
+
cannot compute — it has not snapshotted. Four earlier attempts asked the opposite
|
|
65
|
+
question ("can I prove there is NO change?") and each was wrong for a different input,
|
|
66
|
+
because being wrong there REFUSES A VALID RUN (PR-h-02d.5).
|
|
67
|
+
|
|
68
|
+
So the question is inverted and the answer is conservative: return True only when
|
|
69
|
+
certain, and let ``run_review`` decide otherwise. A wrong answer here costs one auth
|
|
70
|
+
probe; it can never refuse a run the library would accept.
|
|
71
|
+
|
|
72
|
+
Certain case: no base — reviewers see full HEAD, non-empty in any repo with a commit.
|
|
73
|
+
|
|
74
|
+
Everything else (any diff-shaping base) defers. ``git diff --name-only`` cannot
|
|
75
|
+
replicate the authoritative classifier: it C-quotes non-ASCII paths (so strip-list
|
|
76
|
+
basenames do not match the quoted form), and paths containing `` b/`` produce ambiguous
|
|
77
|
+
``diff --git`` headers that ``run_review`` classifies as ``diff_malformed`` with zero
|
|
78
|
+
dispatches. Both shapes produce false proofs of commit under the basename approach,
|
|
79
|
+
causing the CLI to refuse runs the library accepts.
|
|
80
|
+
"""
|
|
81
|
+
return base_ref is None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_openspec_pr_doc(repo_root: Path, change_id: str | None, logger: Logger) -> Path | None:
|
|
85
|
+
"""Resolve a ``--openspec`` request to a concrete ``pr_doc_path``, or
|
|
86
|
+
``None`` on an unresolvable proposal (a message is printed; the caller stops
|
|
87
|
+
before the loop). Reads the OpenSpec proposal folder's markdown directly
|
|
88
|
+
(consume-don't-depend — no ``openspec`` binary), assembles it into a spec, and
|
|
89
|
+
writes it to a staging ``.md`` tempfile. The CLI asks ``run_review`` to copy
|
|
90
|
+
that staged file into the run directory before ``run-init.json`` is written,
|
|
91
|
+
then removes the staging tempfile after the run."""
|
|
92
|
+
import tempfile
|
|
93
|
+
|
|
94
|
+
from syncade.spec_source import (
|
|
95
|
+
SpecSourceError,
|
|
96
|
+
assemble_openspec_spec,
|
|
97
|
+
resolve_openspec_change,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
resolved_id = resolve_openspec_change(repo_root, change_id)
|
|
102
|
+
spec = assemble_openspec_spec(repo_root, resolved_id)
|
|
103
|
+
except SpecSourceError as exc:
|
|
104
|
+
print(f"[syncade] openspec error: {exc}", file=sys.stderr)
|
|
105
|
+
return None
|
|
106
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
107
|
+
mode="w",
|
|
108
|
+
suffix=".md",
|
|
109
|
+
prefix=f"syncade-openspec-{resolved_id}-",
|
|
110
|
+
delete=False,
|
|
111
|
+
encoding="utf-8",
|
|
112
|
+
)
|
|
113
|
+
tmp.write(spec)
|
|
114
|
+
tmp.close()
|
|
115
|
+
logger.warning(f"openspec: reviewing proposal {resolved_id!r} (assembled spec)")
|
|
116
|
+
return Path(tmp.name)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""The ``syncade --resume <run-id>`` handler.
|
|
2
|
+
|
|
3
|
+
Lives in its own module to keep :mod:`syncade.cli.modes` under the file-length
|
|
4
|
+
cap: ``--resume`` is the largest one-shot handler (it rebuilds a ResumePlan from
|
|
5
|
+
on-disk artifacts and re-enters the full review loop), and piling it alongside
|
|
6
|
+
the other mode dispatchers is exactly what makes an entry-point policy easy to
|
|
7
|
+
miss. ``modes`` re-exports :func:`_run_resume` so ``main()``'s dispatch is
|
|
8
|
+
unchanged.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from syncade.cli.auth_gate import auth_gate
|
|
19
|
+
from syncade.cli.config_overrides import apply_worktree_base_override
|
|
20
|
+
from syncade.config_auth import REVIEW_BLOCKS
|
|
21
|
+
from syncade.config_loader import ConfigError, load_config
|
|
22
|
+
from syncade.exit_codes import CONFIG_ERROR, WORKTREE_ERROR
|
|
23
|
+
from syncade.logging import Logger
|
|
24
|
+
from syncade.snapshot import SnapshotError
|
|
25
|
+
from syncade.worktree import WorktreeError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _original_budget(config_snapshot_path) -> dict:
|
|
29
|
+
"""The ORIGINAL run's configured budget, read from its run-init.json config snapshot.
|
|
30
|
+
|
|
31
|
+
Returns ``{'budget_tokens': ..., 'budget_usd': ...}`` with ONLY the dimensions the original
|
|
32
|
+
run actually set (a CLI ``--budget-*`` was folded into that snapshot at launch). ``{}`` on a
|
|
33
|
+
missing path, any read/parse failure, or a malformed value — budget inheritance is best-effort
|
|
34
|
+
and must never block a resume or bypass LoopConfig validators."""
|
|
35
|
+
if config_snapshot_path is None:
|
|
36
|
+
return {}
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(Path(config_snapshot_path).read_text(encoding="utf-8"))
|
|
39
|
+
loop = data.get("config_snapshot", {}).get("loop", {}) or {}
|
|
40
|
+
result = {}
|
|
41
|
+
for k in ("budget_tokens", "budget_usd"):
|
|
42
|
+
v = loop.get(k)
|
|
43
|
+
if v is None:
|
|
44
|
+
continue
|
|
45
|
+
if isinstance(v, bool):
|
|
46
|
+
continue
|
|
47
|
+
# budget_tokens requires a plain int (LoopConfig._strict_budget_tokens rejects
|
|
48
|
+
# floats — including 0.0, NaN, and Infinity — so inheriting them via model_copy
|
|
49
|
+
# would bypass the schema validator). budget_usd allows int/float but must be
|
|
50
|
+
# finite and non-negative (LoopConfig._budget_usd_isfinite rejects NaN/Inf).
|
|
51
|
+
if k == "budget_tokens":
|
|
52
|
+
if not isinstance(v, int) or v < 0:
|
|
53
|
+
continue
|
|
54
|
+
else:
|
|
55
|
+
if not isinstance(v, (int, float)) or not math.isfinite(v) or v < 0:
|
|
56
|
+
continue
|
|
57
|
+
result[k] = v
|
|
58
|
+
return result
|
|
59
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
60
|
+
# AttributeError covers a malformed config_snapshot/loop that isn't a dict.
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_resume_budget(config, args, plan):
|
|
65
|
+
"""Resolve the resumed run's budget and announce it (PR-v2-11).
|
|
66
|
+
|
|
67
|
+
Precedence PER DIMENSION: CLI ``--budget-*`` > current ``config.toml`` > the ORIGINAL run's
|
|
68
|
+
ceiling > none. Inheriting the original ceiling is Finding 2's fix: a run launched with a
|
|
69
|
+
CLI-only ``--budget-tokens`` must NOT silently resume with no guardrail just because resume
|
|
70
|
+
reloads config from disk. Only the CEILING is inherited — the TALLY is still fresh (bounds
|
|
71
|
+
only the resumed spend). The notice fires even under ``--quiet`` (a spend-authorization fact,
|
|
72
|
+
like the resolved auth mode) so the operator is never surprised by the resumed budget."""
|
|
73
|
+
cli = {
|
|
74
|
+
field: value
|
|
75
|
+
for field, value in (
|
|
76
|
+
("budget_tokens", args.budget_tokens),
|
|
77
|
+
("budget_usd", args.budget_usd),
|
|
78
|
+
)
|
|
79
|
+
if value is not None
|
|
80
|
+
}
|
|
81
|
+
if cli:
|
|
82
|
+
config = config.model_copy(update={"loop": config.loop.model_copy(update=cli)})
|
|
83
|
+
|
|
84
|
+
# Inherit when the reloaded config did not EXPLICITLY set this dimension. Testing for
|
|
85
|
+
# None was equivalent until budget_tokens gained a default (PR-h-field-06) — after which
|
|
86
|
+
# it is never None, so inheritance silently stopped firing and a run launched at 3,500
|
|
87
|
+
# tokens resumed at the 50,000,000 default. Not unguarded, but guarded 14,000x looser,
|
|
88
|
+
# which is the same surprise Finding 2 exists to prevent. `model_fields_set` is the
|
|
89
|
+
# distinction the layered loader preserves: an omitted key is absent from it, an
|
|
90
|
+
# explicitly-configured one is present, whatever its value.
|
|
91
|
+
original = _original_budget(plan.config_snapshot_path)
|
|
92
|
+
inherited = {
|
|
93
|
+
dim: original[dim]
|
|
94
|
+
for dim in ("budget_tokens", "budget_usd")
|
|
95
|
+
if dim not in config.loop.model_fields_set and original.get(dim) is not None
|
|
96
|
+
}
|
|
97
|
+
if inherited:
|
|
98
|
+
config = config.model_copy(update={"loop": config.loop.model_copy(update=inherited)})
|
|
99
|
+
|
|
100
|
+
if config.loop.budget_tokens or config.loop.budget_usd: # 0 sentinels = no active ceiling
|
|
101
|
+
msg = (
|
|
102
|
+
"[syncade] note: --resume applies a FRESH budget to this run's spend; the original "
|
|
103
|
+
"run's tokens/cost are NOT carried over, so original + resume can exceed a single "
|
|
104
|
+
"budget ceiling (resuming re-authorizes the spend)."
|
|
105
|
+
)
|
|
106
|
+
if inherited:
|
|
107
|
+
names = ", ".join(f"{k}={v}" for k, v in inherited.items())
|
|
108
|
+
msg += (
|
|
109
|
+
f" No budget was set for this resume, so the original run's ceiling was "
|
|
110
|
+
f"inherited ({names}); pass --budget-tokens/--budget-usd to override."
|
|
111
|
+
)
|
|
112
|
+
print(msg, file=sys.stderr)
|
|
113
|
+
return config
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _report_hard_kill(run_dir: Path, run_status) -> None:
|
|
117
|
+
"""Say so when the run being resumed was HARD-KILLED (PR-h-field-02).
|
|
118
|
+
|
|
119
|
+
``run_status.is_stale_running`` has existed since the breadcrumb landed and, until now,
|
|
120
|
+
nothing in the product called it — a ``running`` state against a dead pid was detectable and
|
|
121
|
+
never reported. Three field runs died exactly that way, and the operator's only clue was a
|
|
122
|
+
status file still claiming the run was in progress.
|
|
123
|
+
|
|
124
|
+
Nothing in-process can finalize that file after SIGKILL, by definition. What CAN be fixed is
|
|
125
|
+
the silence: resume is where someone goes after a run vanishes, so resume is where it should
|
|
126
|
+
be told what it is looking at.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
status = json.loads((run_dir / "status.json").read_text(encoding="utf-8"))
|
|
130
|
+
except (OSError, ValueError):
|
|
131
|
+
return
|
|
132
|
+
if not run_status.is_stale_running(status):
|
|
133
|
+
return
|
|
134
|
+
phase = status.get("phase") or "an unrecorded phase"
|
|
135
|
+
print(
|
|
136
|
+
f"[syncade] the run you are resuming was HARD-KILLED during '{phase}' — its status file "
|
|
137
|
+
f"still reads 'running' because nothing in the process survived to finalize it "
|
|
138
|
+
f"(SIGKILL, OOM, or the machine going down). Completed rounds are intact and will be "
|
|
139
|
+
f"reused; the interrupted round is dropped and retried.",
|
|
140
|
+
file=sys.stderr,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run_resume(args) -> int:
|
|
145
|
+
"""Dispatch ``syncade --resume <run-id>``.
|
|
146
|
+
|
|
147
|
+
Resolves the target run-id (specific or 'latest'), builds a
|
|
148
|
+
:class:`ResumePlan` from on-disk artifacts, and invokes
|
|
149
|
+
:func:`run_review` with the resume_plan threaded in. Maps the
|
|
150
|
+
resume-specific exceptions to exit codes:
|
|
151
|
+
|
|
152
|
+
- Config load failure → 50 (CONFIG_ERROR).
|
|
153
|
+
- repo_root discovery failure → 60 (WORKTREE_ERROR).
|
|
154
|
+
- :class:`ResumeError` (run-id not found / not eligible /
|
|
155
|
+
malformed) → 60.
|
|
156
|
+
- :class:`TreeDriftError` (drift without --force-drift) → 60.
|
|
157
|
+
- Any other orchestrator exception bubbles up to ``main()``
|
|
158
|
+
where it gets the same mapping as the fresh-run path.
|
|
159
|
+
|
|
160
|
+
The actual loop runs through the existing
|
|
161
|
+
:func:`syncade.orchestrator.run_review` with
|
|
162
|
+
``resume_plan=plan, force_drift=args.force_drift``; its
|
|
163
|
+
exit code is the resumed run's verdict.
|
|
164
|
+
"""
|
|
165
|
+
# Import the resume helpers lazily — operators not running
|
|
166
|
+
# --resume don't need to pay the import cost.
|
|
167
|
+
from syncade import run_status
|
|
168
|
+
from syncade.orchestrator import run_review
|
|
169
|
+
from syncade.orchestrator.resume import (
|
|
170
|
+
ResumeError,
|
|
171
|
+
plan_resume,
|
|
172
|
+
read_resume_decision,
|
|
173
|
+
resolve_resume_target,
|
|
174
|
+
)
|
|
175
|
+
from syncade.snapshot import discover_repo_root
|
|
176
|
+
|
|
177
|
+
# Discover the repo root for runs_root resolution and for any
|
|
178
|
+
# downstream code that wants to know "what tree are we in".
|
|
179
|
+
repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
|
|
180
|
+
try:
|
|
181
|
+
repo_root = discover_repo_root(repo_root_hint)
|
|
182
|
+
except SnapshotError as exc:
|
|
183
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
184
|
+
return WORKTREE_ERROR
|
|
185
|
+
|
|
186
|
+
def _emit_deprecation(message: str) -> None:
|
|
187
|
+
print(f"[syncade] {message}", file=sys.stderr)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
config = load_config(repo_root, preset=args.preset, deprecation_callback=_emit_deprecation)
|
|
191
|
+
except ConfigError as exc:
|
|
192
|
+
print(f"[syncade] config error: {exc}", file=sys.stderr)
|
|
193
|
+
return CONFIG_ERROR
|
|
194
|
+
|
|
195
|
+
# --max-rounds overrides the original run's cap (same model_copy as the fresh handler).
|
|
196
|
+
if args.max_rounds is not None:
|
|
197
|
+
new_loop = config.loop.model_copy(update={"max_rounds": args.max_rounds})
|
|
198
|
+
config = config.model_copy(update={"loop": new_loop})
|
|
199
|
+
|
|
200
|
+
runs_root = repo_root / ".syncade" / "runs"
|
|
201
|
+
|
|
202
|
+
# Read the current branch to filter "latest" by branch (detached HEAD → None).
|
|
203
|
+
current_branch: str | None = None
|
|
204
|
+
try:
|
|
205
|
+
from syncade.orchestrator.resume import _current_head_branch
|
|
206
|
+
|
|
207
|
+
current_branch = _current_head_branch(repo_root)
|
|
208
|
+
except Exception:
|
|
209
|
+
# Best-effort; a git failure falls back to no branch filter (drift check catches it).
|
|
210
|
+
current_branch = None
|
|
211
|
+
|
|
212
|
+
# Resolve the resume target + plan BEFORE the guard and auth_gate: the guard needs the
|
|
213
|
+
# EFFECTIVE cap the resumed run will use (max(config, plan.max_rounds)), and a resume
|
|
214
|
+
# error should surface without spawning a provider auth probe.
|
|
215
|
+
try:
|
|
216
|
+
run_id = resolve_resume_target(runs_root, args.resume, current_branch)
|
|
217
|
+
plan = plan_resume(
|
|
218
|
+
repo_root,
|
|
219
|
+
runs_root / run_id,
|
|
220
|
+
max_rounds_override=config.loop.max_rounds if args.max_rounds is not None else None,
|
|
221
|
+
)
|
|
222
|
+
# If the resumed round escalated, read the operator's decision (refuses with
|
|
223
|
+
# ResumeError when none was recorded). Read BEFORE run_review drops the round dir.
|
|
224
|
+
operator_decision = read_resume_decision(runs_root / run_id, plan.resumed_round)
|
|
225
|
+
_report_hard_kill(runs_root / run_id, run_status)
|
|
226
|
+
except ResumeError as exc:
|
|
227
|
+
print(f"[syncade] resume error: {exc}", file=sys.stderr)
|
|
228
|
+
return WORKTREE_ERROR
|
|
229
|
+
|
|
230
|
+
config = _resolve_resume_budget(config, args, plan)
|
|
231
|
+
# Honor the configured/overridden worktree base on resume too (dogfood B2): without this a
|
|
232
|
+
# resumed run silently falls back to /tmp/syncade even when the run relocated its worktrees.
|
|
233
|
+
config = apply_worktree_base_override(config, args)
|
|
234
|
+
|
|
235
|
+
# Default-branch refusal (PR-v2-26, D1(c)). will_commit uses the EFFECTIVE cap
|
|
236
|
+
# run_review will use — a single-pass resume (effective == 1) commits nothing and is
|
|
237
|
+
# exempt; a multi-round resume with a base defers to run_review (auth probe first);
|
|
238
|
+
# only a baseless multi-round resume is refused here before auth. Deriving it from
|
|
239
|
+
# the current config/CLI max_rounds alone would be wrong: a run launched at max_rounds=3
|
|
240
|
+
# but resumed with the config drifted to 1 still commits (effective == 3).
|
|
241
|
+
from syncade.orchestrator.branch_guard import current_branch_name, guard_default_branch
|
|
242
|
+
|
|
243
|
+
effective_max_rounds = max(config.loop.max_rounds, plan.max_rounds)
|
|
244
|
+
# Refuse ONLY when we can PROVE the resumed run commits (D1(c), PR-h-02d.5) — the same
|
|
245
|
+
# inversion as the fresh path. Literal `base_oid == HEAD` equality was too narrow: a
|
|
246
|
+
# resumed run whose pinned base is NOT HEAD but whose diff filters empty takes the
|
|
247
|
+
# no_changes_to_review path, and refusing it before auth diverged from run_review.
|
|
248
|
+
#
|
|
249
|
+
# `two_dot=True` is correct and NOT the merge-base hazard the previous comment warned
|
|
250
|
+
# about: plan.base_oid is the ALREADY-RESOLVED effective diff base, and a resumed run
|
|
251
|
+
# re-snapshots with three_dot=False against it (loop.py). So the literal `base..HEAD`
|
|
252
|
+
# range is exactly what run_review will diff — re-deriving a merge base here is what
|
|
253
|
+
# would be wrong.
|
|
254
|
+
from .resolve import _cli_proves_commit
|
|
255
|
+
|
|
256
|
+
_resume_will_commit = effective_max_rounds > 1 and _cli_proves_commit(
|
|
257
|
+
repo_root, plan.base_oid, config.review.strip_repo_context_files, two_dot=True
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
guard_default_branch(
|
|
261
|
+
repo_root,
|
|
262
|
+
current_branch_name(repo_root),
|
|
263
|
+
allow=args.allow_default_branch,
|
|
264
|
+
will_commit=_resume_will_commit,
|
|
265
|
+
)
|
|
266
|
+
except WorktreeError as exc:
|
|
267
|
+
print(f"[syncade] worktree error: {exc}", file=sys.stderr)
|
|
268
|
+
return WORKTREE_ERROR
|
|
269
|
+
|
|
270
|
+
gate = auth_gate(config, REVIEW_BLOCKS)
|
|
271
|
+
if gate is not None:
|
|
272
|
+
return gate
|
|
273
|
+
|
|
274
|
+
logger = Logger("quiet" if args.quiet else "normal")
|
|
275
|
+
if not args.quiet:
|
|
276
|
+
print(
|
|
277
|
+
f"[syncade] resuming run {plan.run_id} at round "
|
|
278
|
+
f"{plan.resumed_round} (completed: {plan.completed_rounds}; "
|
|
279
|
+
f"expected snapshot SHA: {plan.expected_sha[:12]})"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
with run_status.install_signal_handlers():
|
|
283
|
+
try:
|
|
284
|
+
result = run_review(
|
|
285
|
+
repo_root=repo_root,
|
|
286
|
+
pr_doc_path=plan.pr_doc_path,
|
|
287
|
+
config=config,
|
|
288
|
+
base_ref=plan.base_oid if plan.base_oid is not None else plan.base_ref,
|
|
289
|
+
timeout_seconds=args.timeout,
|
|
290
|
+
logger=logger,
|
|
291
|
+
force_dirty=args.force_dirty,
|
|
292
|
+
allow_default_branch=args.allow_default_branch,
|
|
293
|
+
resume_plan=plan,
|
|
294
|
+
force_drift=args.force_drift,
|
|
295
|
+
operator_decision=operator_decision,
|
|
296
|
+
worktree_base=config.worktree_base,
|
|
297
|
+
)
|
|
298
|
+
except FileNotFoundError as exc:
|
|
299
|
+
print(f"[syncade] error: {exc}", file=sys.stderr)
|
|
300
|
+
return 2
|
|
301
|
+
except NotADirectoryError as exc:
|
|
302
|
+
print(f"[syncade] error: {exc}", file=sys.stderr)
|
|
303
|
+
return 2
|
|
304
|
+
except SnapshotError as exc:
|
|
305
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
306
|
+
# These typed handlers intercept before the catch-all, so each finalizes
|
|
307
|
+
# the breadcrumb itself — else a clean exit-60 leaves status.json `running`
|
|
308
|
+
# and reads as a hard kill.
|
|
309
|
+
run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
|
|
310
|
+
return WORKTREE_ERROR
|
|
311
|
+
except ResumeError as exc:
|
|
312
|
+
print(f"[syncade] resume error: {exc}", file=sys.stderr)
|
|
313
|
+
run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
|
|
314
|
+
return WORKTREE_ERROR
|
|
315
|
+
except WorktreeError as exc:
|
|
316
|
+
print(f"[syncade] worktree error: {exc}", file=sys.stderr)
|
|
317
|
+
run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
|
|
318
|
+
return WORKTREE_ERROR
|
|
319
|
+
except KeyboardInterrupt:
|
|
320
|
+
return run_status.finalize_signal()
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
run_status.finalize_active(f"exception:{type(exc).__name__}", None)
|
|
323
|
+
raise
|
|
324
|
+
return result.exit_code
|