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
syncade/findings_json.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Verdict-block selection for cold-actor output parsing.
|
|
2
|
+
|
|
3
|
+
Every cold actor (reviewer, synthesizer, auditor, drafter) answers with prose
|
|
4
|
+
plus a JSON verdict. This module owns the single question *which bytes ARE the
|
|
5
|
+
verdict* — and answers it with exactly one candidate, never a search for
|
|
6
|
+
whichever block happens to validate.
|
|
7
|
+
|
|
8
|
+
The rule (2026-07-27 audit rank 1 / PR-h-01):
|
|
9
|
+
|
|
10
|
+
1. **Mask code samples.** The content of every fence with a non-empty,
|
|
11
|
+
non-``json`` label (```` ```python ````, ```` ```js ````, ```` ```json5 ````)
|
|
12
|
+
is blanked out. A labeled fence is an illustration by definition, so it is
|
|
13
|
+
excluded from *all* candidate discovery — not merely from the fence scan.
|
|
14
|
+
2. **A ``json``-labeled fence is authoritative.** When one exists, the LAST one
|
|
15
|
+
is the verdict and nothing else in the response is considered — not bare
|
|
16
|
+
objects, not unlabeled fences. (Multiple are NOT an error: the anthropic
|
|
17
|
+
adapter joins the text of every result turn, so a normal multi-turn claude
|
|
18
|
+
response legitimately carries more than one.)
|
|
19
|
+
3. **Only when there is no ``json`` fence** do unlabeled fences and bare
|
|
20
|
+
top-level objects compete, by latest document position.
|
|
21
|
+
4. **Otherwise the whole masked response.**
|
|
22
|
+
5. A **duplicate JSON key** in the selected block fails rather than resolving
|
|
23
|
+
last-wins (see :func:`_reject_duplicate_keys`).
|
|
24
|
+
6. If the selected block does not decode, the parse **fails** (exit 70). There
|
|
25
|
+
is deliberately no fallback to an earlier block that validates.
|
|
26
|
+
|
|
27
|
+
Every clause above is the scar of a reproduced false SHIP, and each is argued at
|
|
28
|
+
the code that enforces it rather than re-argued here. The shape of the history:
|
|
29
|
+
the original parser returned the first of many candidates that *validated*, so
|
|
30
|
+
an invalid intended verdict silently fell back to an earlier example. Fixing
|
|
31
|
+
that by POSITION alone then let three separate things written AFTER the verdict
|
|
32
|
+
replace it. Position cannot distinguish a verdict from an afterthought; a label
|
|
33
|
+
can, which is why rule 2 exists. None of these needed an adversarial model, and
|
|
34
|
+
several punished a reviewer for being helpful.
|
|
35
|
+
|
|
36
|
+
The bare-object scan is *kept* (rule 3) because it is load-bearing for a real
|
|
37
|
+
recorded case:
|
|
38
|
+
``tests/fixtures/pr-5.6-parser-regression/claude-reviewer-prose-with-jsx.stdout``
|
|
39
|
+
is genuine claude output with no fences at all — narrative containing
|
|
40
|
+
``style={{ color: 'var(--mm-amber)' }}`` followed by a bare verdict object.
|
|
41
|
+
|
|
42
|
+
Two accepted residuals, both requiring the actor to violate its template, and
|
|
43
|
+
both pinned as ``KNOWN_RESIDUAL`` tests rather than papered over:
|
|
44
|
+
|
|
45
|
+
- an illustration in a TRAILING ``json`` fence still replaces the verdict.
|
|
46
|
+
Failing closed on multiple ``json`` fences was implemented and then reverted
|
|
47
|
+
on evidence: a recorded 2026-05-30 run shows a normal claude response whose
|
|
48
|
+
joined result turns carry two differing ``json`` fences, so refusing them
|
|
49
|
+
would burn real rounds. Every reviewer template now warns against trailing
|
|
50
|
+
illustrations explicitly.
|
|
51
|
+
- an actor that labels an EXAMPLE ``json`` and leaves its real verdict
|
|
52
|
+
unlabeled or bare loses to the example.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import copy
|
|
58
|
+
import json
|
|
59
|
+
import logging
|
|
60
|
+
import re
|
|
61
|
+
from collections.abc import Callable
|
|
62
|
+
from typing import TypeVar
|
|
63
|
+
|
|
64
|
+
from pydantic import ValidationError
|
|
65
|
+
|
|
66
|
+
_log = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
# Triple-backtick fence: opening ```, an optional language label, optional
|
|
69
|
+
# trailing spaces, newline, the content (non-greedy), then a closing ``` THAT
|
|
70
|
+
# MUST BE PRECEDED BY A NEWLINE.
|
|
71
|
+
#
|
|
72
|
+
# The label class is deliberately `[^\s`]*` rather than `[a-zA-Z]*`. With the
|
|
73
|
+
# alphabetic-only class, `json5`, `my-language`, and `c++` matched NO fence at
|
|
74
|
+
# all — so their contents were invisible to the mask and leaked back in through
|
|
75
|
+
# the bare-object scan. That made the rule arbitrary: a verdict mislabeled
|
|
76
|
+
# ```python failed closed, while the same verdict mislabeled ```json5 was
|
|
77
|
+
# silently accepted, and a trailing ```json5 illustration could still override a
|
|
78
|
+
# real verdict. Every labeled fence is now a code sample; only an empty label or
|
|
79
|
+
# exactly `json` marks a verdict fence.
|
|
80
|
+
#
|
|
81
|
+
# The required newline before the closing ``` is load-bearing, not cosmetic.
|
|
82
|
+
# Without it, a reviewer whose own finding text mentions ``` closed the fence
|
|
83
|
+
# early — mid-string — and the truncated block failed to parse, losing a real
|
|
84
|
+
# verdict to exit 70. Requiring the newline makes that impossible rather than
|
|
85
|
+
# unlikely: JSON forbids a raw newline inside a string value (it must be the
|
|
86
|
+
# two-character `\n` escape), so every newline in the content is necessarily
|
|
87
|
+
# OUTSIDE a string, and therefore a ``` appearing inside a string value can
|
|
88
|
+
# never terminate the fence.
|
|
89
|
+
_FENCE_RE = re.compile(r"```([^\s`]*)[ \t]*\r?\n(.*?)\r?\n```", re.DOTALL)
|
|
90
|
+
# Matches only the opening line of a fence (label + newline), without requiring
|
|
91
|
+
# a closing fence. Used to detect unclosed openers that _FENCE_RE never matches.
|
|
92
|
+
_FENCE_OPEN_RE = re.compile(r"```([^\s`]*)[ \t]*\r?\n")
|
|
93
|
+
_NON_NEWLINE_RE = re.compile(r"[^\n]")
|
|
94
|
+
_JSON_DECODER = json.JSONDecoder()
|
|
95
|
+
|
|
96
|
+
_SNIPPET_LEN = 200
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class VerdictBlockError(Exception):
|
|
100
|
+
"""No decodable verdict block in a cold actor's response.
|
|
101
|
+
|
|
102
|
+
Carries a human-readable reason (which block was selected, why it failed).
|
|
103
|
+
Each parser catches this and re-raises its own phase-named error type so
|
|
104
|
+
exit 70 can say *which* actor to debug, while the reason text stays
|
|
105
|
+
identical across all four.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
|
|
110
|
+
"""``object_pairs_hook`` that refuses a repeated key instead of last-wins.
|
|
111
|
+
|
|
112
|
+
PR-h-01 increment B. ``json.loads`` silently resolves
|
|
113
|
+
``{"verdict": "NO-SHIP", "verdict": "SHIP"}`` to the LAST value, so a
|
|
114
|
+
duplicated key was a one-token path from a blocker verdict to a false
|
|
115
|
+
SHIP — and it survives every downstream schema check, because by the time
|
|
116
|
+
pydantic sees the dict there is only one ``verdict``.
|
|
117
|
+
|
|
118
|
+
A model that emits the same key twice has already lost the property we
|
|
119
|
+
need, so this fails the parse rather than picking a winner.
|
|
120
|
+
"""
|
|
121
|
+
seen: set[str] = set()
|
|
122
|
+
for key, _ in pairs:
|
|
123
|
+
if key in seen:
|
|
124
|
+
raise VerdictBlockError(
|
|
125
|
+
f"duplicate JSON key {key!r} in the verdict block. json.loads "
|
|
126
|
+
f"would silently keep the last value, so a repeated key could "
|
|
127
|
+
f"flip a field (a duplicated 'verdict' turns NO-SHIP into SHIP). "
|
|
128
|
+
f"Emit each key exactly once."
|
|
129
|
+
)
|
|
130
|
+
seen.add(key)
|
|
131
|
+
return dict(pairs)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _mask_fence_interiors(raw: str, *, labeled_only: bool) -> str:
|
|
135
|
+
"""Blank the CONTENT of fences, preserving length and line structure so
|
|
136
|
+
positions and snippets stay meaningful.
|
|
137
|
+
|
|
138
|
+
``labeled_only=True`` masks only non-``json``-labeled fences
|
|
139
|
+
(```` ```python ````, ```` ```js ````, ```` ```json5 ````): those are
|
|
140
|
+
illustrations, so nothing inside them may become a verdict candidate.
|
|
141
|
+
Masking rather than skipping at fence level is what stops the bare-object
|
|
142
|
+
scan from reaching back into a code sample — the exact hole that let a
|
|
143
|
+
```` ```python ````-wrapped example be parsed as a verdict.
|
|
144
|
+
|
|
145
|
+
``labeled_only=False`` masks every fence, and is used to scope the
|
|
146
|
+
bare-object scan to text OUTSIDE any fence, so a fence's own contents are
|
|
147
|
+
counted once (as a fence candidate) rather than twice.
|
|
148
|
+
"""
|
|
149
|
+
parts: list[str] = []
|
|
150
|
+
last = 0
|
|
151
|
+
# Record both the opener start AND the closer start of every matched fence.
|
|
152
|
+
# A closer is the ``` that ends the match; it starts at match.end()-3.
|
|
153
|
+
# The unclosed-opener pass below uses this set to skip those lines so that
|
|
154
|
+
# a closer ``` (which looks like an unlabeled opener) is never mistaken for
|
|
155
|
+
# a new unclosed opener.
|
|
156
|
+
matched_positions: set[int] = set()
|
|
157
|
+
for match in _FENCE_RE.finditer(raw):
|
|
158
|
+
matched_positions.add(match.start()) # opener
|
|
159
|
+
matched_positions.add(match.end() - 3) # closer (last 3 chars are ```)
|
|
160
|
+
label = match.group(1).lower()
|
|
161
|
+
if labeled_only and (not label or label == "json"):
|
|
162
|
+
continue
|
|
163
|
+
parts.append(raw[last : match.start(2)])
|
|
164
|
+
parts.append(_NON_NEWLINE_RE.sub(" ", match.group(2)))
|
|
165
|
+
last = match.end(2)
|
|
166
|
+
parts.append(raw[last:])
|
|
167
|
+
result = "".join(parts)
|
|
168
|
+
|
|
169
|
+
# Mask unclosed fence openers: _FENCE_RE requires a closing ``` line so it
|
|
170
|
+
# never matches a truncated/unclosed fence. Without this pass the interior
|
|
171
|
+
# of an unclosed ```python opener leaks into the bare-object scan.
|
|
172
|
+
# Scan `result` (not `raw`): content inside matched fences is already masked
|
|
173
|
+
# to spaces there, so it cannot produce false inner-fence opener matches.
|
|
174
|
+
# `matched_positions` covers opener AND closer lines of every matched fence.
|
|
175
|
+
for m in _FENCE_OPEN_RE.finditer(result):
|
|
176
|
+
if m.start() in matched_positions:
|
|
177
|
+
continue
|
|
178
|
+
label = m.group(1).lower()
|
|
179
|
+
if labeled_only and (not label or label == "json"):
|
|
180
|
+
continue
|
|
181
|
+
cs = m.end()
|
|
182
|
+
if cs < len(result):
|
|
183
|
+
result = result[:cs] + _NON_NEWLINE_RE.sub(" ", result[cs:])
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _find_fenced_json_candidates(raw: str) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
|
|
188
|
+
"""Return ``(json_labeled, unlabeled)`` fences as ``(start_pos, content)``
|
|
189
|
+
lists, each in document order. Labeled-but-not-``json`` fences are code
|
|
190
|
+
samples and appear in neither.
|
|
191
|
+
"""
|
|
192
|
+
labeled: list[tuple[int, str]] = []
|
|
193
|
+
unlabeled: list[tuple[int, str]] = []
|
|
194
|
+
for match in _FENCE_RE.finditer(raw):
|
|
195
|
+
label = match.group(1).lower()
|
|
196
|
+
if label and label != "json":
|
|
197
|
+
continue
|
|
198
|
+
content = match.group(2).rstrip("\r\n")
|
|
199
|
+
(labeled if label == "json" else unlabeled).append((match.start(2), content))
|
|
200
|
+
return labeled, unlabeled
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _find_last_json_object(raw: str) -> tuple[int, str] | None:
|
|
204
|
+
"""Return ``(start_pos, source_text)`` of the LAST top-level JSON object in
|
|
205
|
+
``raw``, or ``None``.
|
|
206
|
+
|
|
207
|
+
Find-then-parse-or-skip scan: when a ``{`` does not start valid JSON the
|
|
208
|
+
scanner advances one character and keeps looking, so an unmatched brace in
|
|
209
|
+
prose (``if (x) { do something``) cannot swallow the rest of the document.
|
|
210
|
+
Only the last object is returned — the caller gets one candidate, never a
|
|
211
|
+
chain to fall back through.
|
|
212
|
+
"""
|
|
213
|
+
found: tuple[int, str] | None = None
|
|
214
|
+
pos, n = 0, len(raw)
|
|
215
|
+
while pos < n:
|
|
216
|
+
start = raw.find("{", pos)
|
|
217
|
+
if start == -1:
|
|
218
|
+
break
|
|
219
|
+
try:
|
|
220
|
+
parsed, end = _JSON_DECODER.raw_decode(raw, start)
|
|
221
|
+
except (json.JSONDecodeError, RecursionError):
|
|
222
|
+
# RecursionError: deeply-nested junk in prose is not a verdict; skip
|
|
223
|
+
# it like any other non-JSON rather than aborting the scan.
|
|
224
|
+
pos = start + 1
|
|
225
|
+
continue
|
|
226
|
+
if isinstance(parsed, dict):
|
|
227
|
+
found = (start, raw[start:end])
|
|
228
|
+
pos = max(end, start + 1)
|
|
229
|
+
return found
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _select_verdict_block(raw: str) -> tuple[str | None, str]:
|
|
233
|
+
"""Return ``(block, description)`` for the one block that IS the verdict.
|
|
234
|
+
|
|
235
|
+
A ```` ```json ````-labeled fence is AUTHORITATIVE: every template tells the
|
|
236
|
+
actor to put its verdict in exactly one, so when one exists nothing else in
|
|
237
|
+
the response is a candidate. Multiple are NOT an error — the last one wins
|
|
238
|
+
(see the comment at the return site). Only when there is no ``json`` fence at
|
|
239
|
+
all do unlabeled fences and bare objects compete, by latest document position.
|
|
240
|
+
|
|
241
|
+
That precedence — rather than position across all kinds — is what closes
|
|
242
|
+
three separately-reproduced false SHIPs, each of which let something the
|
|
243
|
+
reviewer wrote AFTER its verdict replace the verdict: a trailing bare object,
|
|
244
|
+
a trailing unlabeled fence, and an ordinary JSON snippet in closing prose
|
|
245
|
+
(``Fix: add {"strict": true} to tsconfig.json``), which additionally burned a
|
|
246
|
+
whole round at exit 70. Position alone cannot tell a verdict from a
|
|
247
|
+
afterthought; a label can.
|
|
248
|
+
|
|
249
|
+
The cost is a residual in the mirror direction: an actor that labels an
|
|
250
|
+
EXAMPLE ``json`` and leaves its real verdict unlabeled or bare loses to the
|
|
251
|
+
example. That inverts every template instruction, and unlike the cases above
|
|
252
|
+
it cannot happen to an actor that simply follows the contract.
|
|
253
|
+
"""
|
|
254
|
+
masked = _mask_fence_interiors(raw, labeled_only=True)
|
|
255
|
+
labeled, unlabeled = _find_fenced_json_candidates(masked)
|
|
256
|
+
|
|
257
|
+
# Detect unclosed fence openers (```json or unlabeled ```) that appear after
|
|
258
|
+
# the last matched fence of the same kind. An unclosed opener is the intended
|
|
259
|
+
# final verdict block; since it has no closing ```, it cannot be decoded, and
|
|
260
|
+
# the parse must fail rather than fall back to an earlier block.
|
|
261
|
+
# Scan `masked` rather than `raw`: content inside non-json labeled fences is
|
|
262
|
+
# already blanked there, so a ```json comment inside a ```python block cannot
|
|
263
|
+
# produce a false unclosed-opener trigger.
|
|
264
|
+
_matched_positions: set[int] = set()
|
|
265
|
+
_last_json_end = -1
|
|
266
|
+
_last_unlabeled_end = -1
|
|
267
|
+
for _m in _FENCE_RE.finditer(raw):
|
|
268
|
+
_matched_positions.add(_m.start())
|
|
269
|
+
_matched_positions.add(_m.end() - 3)
|
|
270
|
+
_lbl = _m.group(1).lower()
|
|
271
|
+
if _lbl == "json":
|
|
272
|
+
_last_json_end = _m.end()
|
|
273
|
+
elif not _lbl:
|
|
274
|
+
_last_unlabeled_end = _m.end()
|
|
275
|
+
for _m in _FENCE_OPEN_RE.finditer(masked):
|
|
276
|
+
if _m.start() in _matched_positions:
|
|
277
|
+
continue
|
|
278
|
+
_lbl = _m.group(1).lower()
|
|
279
|
+
if _lbl == "json" and _m.start() > _last_json_end:
|
|
280
|
+
return None, "the last ```json fence (unclosed — no closing ``` found)"
|
|
281
|
+
# Only fail closed on an unclosed unlabeled opener when there are no
|
|
282
|
+
# closed json fences — if a json fence exists, it wins regardless.
|
|
283
|
+
if not _lbl and not labeled and _m.start() > _last_unlabeled_end:
|
|
284
|
+
return None, "the last unlabeled fence (unclosed — no closing ``` found)"
|
|
285
|
+
|
|
286
|
+
if labeled:
|
|
287
|
+
# LAST ```json fence wins, and multiple are NOT treated as ambiguous.
|
|
288
|
+
# Failing closed on two of them was tried and reverted on evidence: the
|
|
289
|
+
# anthropic adapter deliberately joins the text of EVERY result turn
|
|
290
|
+
# (claude emits spurious epilogue turns after delivering a verdict, and
|
|
291
|
+
# taking only the terminal turn lost real verdicts — see
|
|
292
|
+
# `adapters/anthropic.py`), so a normal multi-turn claude response
|
|
293
|
+
# legitimately contains two DIFFERING ```json fences whose last one is
|
|
294
|
+
# the real verdict. A recorded 2026-05-30 run has exactly that shape;
|
|
295
|
+
# refusing it would burn real rounds to close a residual the templates
|
|
296
|
+
# already forbid.
|
|
297
|
+
return labeled[-1][1].strip() or None, "the last ```json fence"
|
|
298
|
+
|
|
299
|
+
# Unlabeled fences beat bare objects. A bare object appearing AFTER the
|
|
300
|
+
# verdict fence must not override it — position alone cannot distinguish
|
|
301
|
+
# a verdict from an afterthought in closing prose. An unlabeled fence is
|
|
302
|
+
# a deliberate structural choice; bare objects in prose are not.
|
|
303
|
+
if unlabeled:
|
|
304
|
+
return unlabeled[-1][1].strip() or None, "the last unlabeled fence"
|
|
305
|
+
|
|
306
|
+
# Bare objects are scanned OUTSIDE every fence so a fence's contents compete
|
|
307
|
+
# once, as a fence, rather than twice.
|
|
308
|
+
bare = _find_last_json_object(_mask_fence_interiors(raw, labeled_only=False))
|
|
309
|
+
if bare is not None:
|
|
310
|
+
return bare[1].strip() or None, "the last bare JSON object"
|
|
311
|
+
|
|
312
|
+
return masked.strip() or None, "the whole response (no ```json fence or JSON object found)"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _decode_verdict_object(raw: str) -> object:
|
|
316
|
+
"""Select the verdict block and JSON-decode it.
|
|
317
|
+
|
|
318
|
+
Raises :class:`VerdictBlockError` with an operator-readable reason when
|
|
319
|
+
there is no block, the block is not valid JSON, or it decodes to something
|
|
320
|
+
other than a JSON object.
|
|
321
|
+
"""
|
|
322
|
+
block, which = _select_verdict_block(raw)
|
|
323
|
+
if block is None:
|
|
324
|
+
raise VerdictBlockError(
|
|
325
|
+
f"{which} is empty (first {_SNIPPET_LEN} chars of the response: {raw[:_SNIPPET_LEN]!r})"
|
|
326
|
+
)
|
|
327
|
+
try:
|
|
328
|
+
parsed = json.loads(block, object_pairs_hook=_reject_duplicate_keys)
|
|
329
|
+
except RecursionError as exc:
|
|
330
|
+
# json's C/py decoders recurse per nesting level. Left uncaught this
|
|
331
|
+
# escapes as a bare RecursionError, which the dispatcher's broad handler
|
|
332
|
+
# turns into exit 40 ("subprocess failed") — the wrong story for output
|
|
333
|
+
# that arrived fine and simply cannot be parsed.
|
|
334
|
+
raise VerdictBlockError(
|
|
335
|
+
f"{which} nests too deeply to decode ({exc}); treated as unparseable output"
|
|
336
|
+
) from exc
|
|
337
|
+
except json.JSONDecodeError as exc:
|
|
338
|
+
raise VerdictBlockError(
|
|
339
|
+
f"{which} is not valid JSON ({exc}); no earlier block is considered. "
|
|
340
|
+
f"Block: {block[:_SNIPPET_LEN]!r}"
|
|
341
|
+
) from exc
|
|
342
|
+
if not isinstance(parsed, dict):
|
|
343
|
+
raise VerdictBlockError(
|
|
344
|
+
f"{which} decoded to {type(parsed).__name__}, not a JSON object. "
|
|
345
|
+
f"Block: {block[:_SNIPPET_LEN]!r}"
|
|
346
|
+
)
|
|
347
|
+
return parsed
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
_T = TypeVar("_T")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def decode_and_validate(
|
|
354
|
+
raw: str,
|
|
355
|
+
*,
|
|
356
|
+
validate: Callable[[object], _T],
|
|
357
|
+
error: type[Exception],
|
|
358
|
+
label: str,
|
|
359
|
+
model_name: str,
|
|
360
|
+
artifact: str,
|
|
361
|
+
) -> _T:
|
|
362
|
+
"""Select the verdict block, decode it, and validate it — for all four
|
|
363
|
+
cold-actor parsers.
|
|
364
|
+
|
|
365
|
+
Every parser needs the same two-step failure story, differing only in which
|
|
366
|
+
exception type names the phase and where the raw response was persisted.
|
|
367
|
+
Copying it produced four independently-editable copies of
|
|
368
|
+
``"no earlier block is considered"``, a string five tests assert on, which
|
|
369
|
+
is exactly the drift the shared selector exists to prevent.
|
|
370
|
+
|
|
371
|
+
``validate`` raises :class:`pydantic.ValidationError` on rejection; the
|
|
372
|
+
synthesizer passes a wrapper that tries its known-deviation repair first.
|
|
373
|
+
|
|
374
|
+
Raises ``error`` — the caller's phase-named type — so exit 70 still says
|
|
375
|
+
which actor to debug.
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
parsed = _decode_verdict_object(raw)
|
|
379
|
+
except VerdictBlockError as exc:
|
|
380
|
+
raise error(
|
|
381
|
+
f"{label} output had no parseable {model_name} JSON: {exc}; "
|
|
382
|
+
f"the raw response is preserved at {artifact}."
|
|
383
|
+
) from exc
|
|
384
|
+
try:
|
|
385
|
+
return validate(parsed)
|
|
386
|
+
except ValidationError as exc:
|
|
387
|
+
raise error(
|
|
388
|
+
f"the {label}'s verdict block is not a valid {model_name} "
|
|
389
|
+
f"({exc.error_count()} schema error(s)): {exc}; no earlier block is "
|
|
390
|
+
f"considered. The raw response is preserved at {artifact}."
|
|
391
|
+
) from exc
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _drop_key_at(payload: object, loc: tuple[object, ...]) -> bool:
|
|
395
|
+
"""Delete the key/index named by a pydantic error ``loc``. False if it is not there."""
|
|
396
|
+
for step in loc[:-1]:
|
|
397
|
+
if isinstance(payload, dict) and step in payload:
|
|
398
|
+
payload = payload[step]
|
|
399
|
+
elif isinstance(payload, list) and isinstance(step, int) and step < len(payload):
|
|
400
|
+
payload = payload[step]
|
|
401
|
+
else:
|
|
402
|
+
return False
|
|
403
|
+
last = loc[-1]
|
|
404
|
+
if isinstance(payload, dict) and last in payload:
|
|
405
|
+
del payload[last]
|
|
406
|
+
return True
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def validate_dropping_forbidden_extras(
|
|
411
|
+
payload: object, validate: Callable[[object], _T], *, label: str
|
|
412
|
+
) -> _T:
|
|
413
|
+
"""Validate strictly; if the ONLY defect is forbidden extra keys, drop them and retry.
|
|
414
|
+
|
|
415
|
+
A model that returns a complete, correct verdict and adds one advisory key should not cost
|
|
416
|
+
the run. Measured (PR-h-field-05): a single ``recommended_fix`` on one finding rejected a
|
|
417
|
+
review worth 1,325,087 tokens, and took the other reviewer's 936,113 with it because the
|
|
418
|
+
judge is skipped when any reviewer fails.
|
|
419
|
+
|
|
420
|
+
**Eligibility is the SHAPE OF THE FAILURE, never a list of key names** — the rule
|
|
421
|
+
:mod:`syncade.synthesis_repair` states for its own repairs, because a name list rots and an
|
|
422
|
+
enumeration of what a model might invent is unbounded. Every error must be
|
|
423
|
+
``extra_forbidden``; one error of any other type and the whole payload still raises.
|
|
424
|
+
|
|
425
|
+
That single condition is what keeps this narrow, and it excludes the dangerous case BY
|
|
426
|
+
CONSTRUCTION rather than by remembering to: a model that RENAMES a required field produces
|
|
427
|
+
a ``missing`` error beside the extra one, so mixed types never repair. Wrong types,
|
|
428
|
+
out-of-range values and blank required strings are untouched.
|
|
429
|
+
|
|
430
|
+
Dropping is a real loss and is therefore WARNED, naming every key. Unlike the synthesizer's
|
|
431
|
+
repairs — a duplicated coordinate, a cluster that groups nothing — an extra key may carry
|
|
432
|
+
content. The verdict cannot depend on it (severity, evidence and description are all
|
|
433
|
+
required fields that survive), so the trade is right; it is still a trade, and a silent one
|
|
434
|
+
would be a schema quietly bent.
|
|
435
|
+
"""
|
|
436
|
+
try:
|
|
437
|
+
return validate(payload)
|
|
438
|
+
except ValidationError as exc:
|
|
439
|
+
errors = exc.errors()
|
|
440
|
+
if not errors or any(e.get("type") != "extra_forbidden" for e in errors):
|
|
441
|
+
raise
|
|
442
|
+
repaired = copy.deepcopy(payload)
|
|
443
|
+
dropped = [
|
|
444
|
+
".".join(str(p) for p in e["loc"])
|
|
445
|
+
for e in errors
|
|
446
|
+
if _drop_key_at(repaired, tuple(e["loc"]))
|
|
447
|
+
]
|
|
448
|
+
if not dropped:
|
|
449
|
+
raise
|
|
450
|
+
_log.warning(
|
|
451
|
+
"%s: dropped %d key(s) the schema forbids, so the verdict could be used: %s",
|
|
452
|
+
label,
|
|
453
|
+
len(dropped),
|
|
454
|
+
", ".join(dropped),
|
|
455
|
+
)
|
|
456
|
+
return validate(repaired)
|
syncade/gc.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Run-bloat GC planning plus the public ``syncade.gc`` API.
|
|
2
|
+
|
|
3
|
+
``syncade --gc`` prunes bulk transcripts from ``.syncade/runs/<run-id>/`` and
|
|
4
|
+
removes identity-checked ``/tmp/syncade/<run-id>/`` worktree leftovers, and safely
|
|
5
|
+
reaps orphaned reviewer/producer subprocesses left behind by an abnormal parent exit.
|
|
6
|
+
Run history (structured artifacts) is never deleted.
|
|
7
|
+
|
|
8
|
+
Planning stays here because it is pure and auditable. Destructive execution and
|
|
9
|
+
process reaping live in :mod:`syncade.gc_execute`; shared protection checks live
|
|
10
|
+
in :mod:`syncade.gc_protection`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from syncade.gc_execute import (
|
|
20
|
+
_git_worktree_prune,
|
|
21
|
+
_lsof_pids_in_tree,
|
|
22
|
+
_parse_lsof_pids,
|
|
23
|
+
_pid_cwd_is_still_in_tree,
|
|
24
|
+
_reap_and_remove_tree,
|
|
25
|
+
_reap_processes_in_tree,
|
|
26
|
+
execute_gc,
|
|
27
|
+
)
|
|
28
|
+
from syncade.gc_protection import (
|
|
29
|
+
current_protected_run_ids as _current_protected_run_ids,
|
|
30
|
+
)
|
|
31
|
+
from syncade.gc_protection import (
|
|
32
|
+
gc_should_conservatively_protect as _gc_should_conservatively_protect,
|
|
33
|
+
)
|
|
34
|
+
from syncade.gc_protection import (
|
|
35
|
+
orphan_worktree_still_orphan_now as _orphan_worktree_still_orphan_now,
|
|
36
|
+
)
|
|
37
|
+
from syncade.gc_protection import (
|
|
38
|
+
protected_run_ids_for_gc as _protected_run_ids_for_gc,
|
|
39
|
+
)
|
|
40
|
+
from syncade.gc_protection import run_dir_protected_now as _run_dir_protected_now
|
|
41
|
+
from syncade.gc_protection import run_dir_slimmable_now as _run_dir_slimmable_now
|
|
42
|
+
from syncade.gc_protection import run_id_protected_now as _run_id_protected_now
|
|
43
|
+
from syncade.gc_protection import safe_iter_subdirs as _safe_iter_subdirs
|
|
44
|
+
from syncade.gc_types import GcPlan, GcReport
|
|
45
|
+
from syncade.gc_worktrees import (
|
|
46
|
+
existing_worktree_trees,
|
|
47
|
+
repo_owned_orphan_trees,
|
|
48
|
+
tree_contains_repo_root,
|
|
49
|
+
tree_identity,
|
|
50
|
+
)
|
|
51
|
+
from syncade.persistence import RUN_INIT_FILENAME
|
|
52
|
+
from syncade.worktree import DEFAULT_WORKTREE_BASE
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"DEFAULT_KEEP",
|
|
56
|
+
"DEFAULT_MAX_AGE_DAYS",
|
|
57
|
+
"GcPlan",
|
|
58
|
+
"GcReport",
|
|
59
|
+
"_current_protected_run_ids",
|
|
60
|
+
"_gc_should_conservatively_protect",
|
|
61
|
+
"_git_worktree_prune",
|
|
62
|
+
"_lsof_pids_in_tree",
|
|
63
|
+
"_orphan_worktree_still_orphan_now",
|
|
64
|
+
"_parse_lsof_pids",
|
|
65
|
+
"_pid_cwd_is_still_in_tree",
|
|
66
|
+
"_protected_run_ids_for_gc",
|
|
67
|
+
"_reap_and_remove_tree",
|
|
68
|
+
"_reap_processes_in_tree",
|
|
69
|
+
"_run_dir_slimmable_now",
|
|
70
|
+
"_run_dir_protected_now",
|
|
71
|
+
"_run_id_protected_now",
|
|
72
|
+
"_safe_iter_subdirs",
|
|
73
|
+
"autoprune_transcripts",
|
|
74
|
+
"execute_gc",
|
|
75
|
+
"plan_gc",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
DEFAULT_KEEP: int = 20
|
|
79
|
+
"""Default number of most-recent non-protected runs to keep."""
|
|
80
|
+
|
|
81
|
+
DEFAULT_MAX_AGE_DAYS: int = 0
|
|
82
|
+
"""Default age floor in days. ``0`` disables the age gate."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def autoprune_transcripts(
|
|
86
|
+
repo_root: Path,
|
|
87
|
+
*,
|
|
88
|
+
keep: int = DEFAULT_KEEP,
|
|
89
|
+
max_age_days: int = DEFAULT_MAX_AGE_DAYS,
|
|
90
|
+
) -> GcReport:
|
|
91
|
+
"""Prune old runs' transcripts. Called at the start of every fresh loop so
|
|
92
|
+
``.syncade/runs/`` stays bounded without anyone remembering ``syncade --gc``.
|
|
93
|
+
|
|
94
|
+
**Deliberately narrower than ``--gc``: transcripts only.** It does not remove
|
|
95
|
+
worktrees, shell out to ``lsof``/``git worktree prune``, or reap processes. Those
|
|
96
|
+
are the slow and destructive half of GC, and a loop's opening moments — with a
|
|
97
|
+
concurrent syncade possibly mid-flight — are the wrong place for them. Disk growth
|
|
98
|
+
is what auto-prune exists to bound, and disk growth is transcripts (90.9% of the
|
|
99
|
+
corpus). ``--gc`` remains the explicit, full-power maintenance mode.
|
|
100
|
+
|
|
101
|
+
Bounded in practice, not just in intent: measured at **165 ms cold / 69 ms warm**
|
|
102
|
+
over the real 261-run corpus with 224 already-slim candidates (the worst case,
|
|
103
|
+
where every candidate is re-walked and nothing is freed). That is noise against a
|
|
104
|
+
review loop measured in minutes, so there is no artificial per-run cap — a cap
|
|
105
|
+
would only leave a backlog that never drains.
|
|
106
|
+
|
|
107
|
+
Protection is inherited whole from :func:`plan_gc`: resume-eligible runs, runs
|
|
108
|
+
with a live status breadcrumb, and the newest ``keep`` runs are never touched.
|
|
109
|
+
"""
|
|
110
|
+
plan = plan_gc(repo_root, keep=keep, max_age_days=max_age_days, skip_worktrees=True)
|
|
111
|
+
return execute_gc(plan, dry_run=False, repo_root=repo_root)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def plan_gc(
|
|
115
|
+
repo_root: Path,
|
|
116
|
+
*,
|
|
117
|
+
keep: int = DEFAULT_KEEP,
|
|
118
|
+
max_age_days: int = DEFAULT_MAX_AGE_DAYS,
|
|
119
|
+
worktree_base: Path = DEFAULT_WORKTREE_BASE,
|
|
120
|
+
skip_worktrees: bool = False,
|
|
121
|
+
) -> GcPlan:
|
|
122
|
+
"""Partition ``.syncade/runs/`` into protected vs slimmable.
|
|
123
|
+
|
|
124
|
+
``skip_worktrees=True`` omits all worktree and orphan discovery, avoiding
|
|
125
|
+
the ``git worktree list`` subprocess and the ``/tmp/syncade/`` walk. Used by
|
|
126
|
+
:func:`autoprune_transcripts` so routine loop startup never touches worktree
|
|
127
|
+
planning — that is the slow and destructive half of GC, unsuitable for the
|
|
128
|
+
opening moments of a loop.
|
|
129
|
+
"""
|
|
130
|
+
runs_root = repo_root / ".syncade" / "runs"
|
|
131
|
+
|
|
132
|
+
run_dirs = _safe_iter_subdirs(runs_root)
|
|
133
|
+
protected = _protected_run_ids_for_gc(runs_root, run_dirs)
|
|
134
|
+
|
|
135
|
+
candidates = [d for d in run_dirs if d.name not in protected]
|
|
136
|
+
candidates.sort(key=_run_sort_key, reverse=True)
|
|
137
|
+
|
|
138
|
+
to_slim = _select_for_slimming(candidates, keep=keep, max_age_days=max_age_days)
|
|
139
|
+
slim_names = [d.name for d in to_slim]
|
|
140
|
+
|
|
141
|
+
if skip_worktrees:
|
|
142
|
+
return GcPlan(
|
|
143
|
+
protected_run_ids=sorted(protected),
|
|
144
|
+
runs_to_slim=slim_names,
|
|
145
|
+
worktree_trees_to_remove=[],
|
|
146
|
+
orphan_worktree_trees=[],
|
|
147
|
+
worktree_tree_identities={},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
worktree_trees = [
|
|
151
|
+
tree
|
|
152
|
+
for tree in existing_worktree_trees(worktree_base, slim_names)
|
|
153
|
+
if not tree_contains_repo_root(tree, repo_root)
|
|
154
|
+
]
|
|
155
|
+
known_run_ids = {d.name for d in run_dirs} | protected
|
|
156
|
+
orphan_trees = repo_owned_orphan_trees(
|
|
157
|
+
repo_root, _safe_iter_subdirs(worktree_base), known_run_ids
|
|
158
|
+
)
|
|
159
|
+
tree_identities = {
|
|
160
|
+
tree: identity
|
|
161
|
+
for tree in [*worktree_trees, *orphan_trees]
|
|
162
|
+
if (identity := tree_identity(tree)) is not None
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return GcPlan(
|
|
166
|
+
protected_run_ids=sorted(protected),
|
|
167
|
+
runs_to_slim=slim_names,
|
|
168
|
+
worktree_trees_to_remove=worktree_trees,
|
|
169
|
+
orphan_worktree_trees=orphan_trees,
|
|
170
|
+
worktree_tree_identities=tree_identities,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _run_sort_key(run_dir: Path) -> float:
|
|
175
|
+
started = _started_at_timestamp(run_dir)
|
|
176
|
+
if started is not None:
|
|
177
|
+
return started
|
|
178
|
+
try:
|
|
179
|
+
return run_dir.stat().st_mtime
|
|
180
|
+
except OSError:
|
|
181
|
+
return 0.0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _started_at_timestamp(run_dir: Path) -> float | None:
|
|
185
|
+
run_init = run_dir / RUN_INIT_FILENAME
|
|
186
|
+
try:
|
|
187
|
+
data = json.loads(run_init.read_text(encoding="utf-8"))
|
|
188
|
+
except (OSError, json.JSONDecodeError):
|
|
189
|
+
return None
|
|
190
|
+
raw = data.get("started_at_utc")
|
|
191
|
+
if not isinstance(raw, str):
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
dt = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
|
195
|
+
except ValueError:
|
|
196
|
+
return None
|
|
197
|
+
return dt.timestamp()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _select_for_slimming(
|
|
201
|
+
candidates_newest_first: list[Path],
|
|
202
|
+
*,
|
|
203
|
+
keep: int,
|
|
204
|
+
max_age_days: int,
|
|
205
|
+
) -> list[Path]:
|
|
206
|
+
beyond_keep = candidates_newest_first[max(keep, 0) :]
|
|
207
|
+
if max_age_days <= 0:
|
|
208
|
+
return list(beyond_keep)
|
|
209
|
+
|
|
210
|
+
cutoff = datetime.now(UTC).timestamp() - (max_age_days * 86400)
|
|
211
|
+
return [d for d in beyond_keep if _run_sort_key(d) < cutoff]
|