devlyn-cli 2.9.2 → 2.10.0
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.
- package/AGENTS.md +3 -3
- package/CLAUDE.md +4 -4
- package/README.md +6 -6
- package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
- package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
- package/bin/devlyn.js +68 -7
- package/config/skills/_shared/adapters/README.md +2 -2
- package/config/skills/_shared/adapters/grok.md +77 -0
- package/config/skills/_shared/archive_run.py +35 -1
- package/config/skills/_shared/collect-codex-findings.py +15 -10
- package/config/skills/_shared/engine-doctor.sh +6 -3
- package/config/skills/_shared/engine-preflight.md +12 -5
- package/config/skills/_shared/resolve-bootstrap.py +630 -0
- package/config/skills/_shared/resolve-stop-hook.py +325 -0
- package/config/skills/_shared/state-phase-write.py +2284 -41
- package/config/skills/_shared/terminal-claim-check.py +537 -0
- package/config/skills/_shared/verify-merge-findings.py +363 -16
- package/config/skills/devlyn:engines/SKILL.md +1 -1
- package/config/skills/devlyn:queue/SKILL.md +12 -0
- package/config/skills/devlyn:resolve/SKILL.md +55 -73
- package/config/skills/devlyn:resolve/references/free-form-mode.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
- package/config/skills/devlyn:resolve/references/phases/verify.md +65 -96
- package/config/skills/devlyn:resolve/references/state-schema.md +29 -22
- package/optional-skills/devlyn:reap/SKILL.md +1 -1
- package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
- package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
- package/package.json +1 -1
- package/scripts/lint-skills.sh +131 -67
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
Usage:
|
|
5
5
|
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement spawn \
|
|
6
6
|
--round 1 --triggered-by verify [--pre-sha <sha>] [--engine claude] [--model <id>]
|
|
7
|
+
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement durability-enforce \
|
|
8
|
+
--round 1 --origin-phase verify
|
|
7
9
|
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement complete \
|
|
8
|
-
--verdict PASS [--post-sha <sha>] [--findings-file <path>] [--log-file <path>]
|
|
10
|
+
--verdict PASS [--post-sha <sha>] [--findings-file <path>] [--log-file <path>] \
|
|
11
|
+
[--engine claude] [--model <requested-id>] [--engine-session-log <path>]
|
|
12
|
+
python3 state-phase-write.py --devlyn-dir .devlyn --phase plan transition \
|
|
13
|
+
--verdict PASS --next-phase implement --next-round 0 --next-engine claude
|
|
9
14
|
|
|
10
15
|
references/state-schema.md#write-protocol is the contract this implements.
|
|
11
16
|
A prior hand-edited fix-loop respawn left `started_at` at its original round's
|
|
@@ -18,15 +23,51 @@ drift apart again.
|
|
|
18
23
|
from __future__ import annotations
|
|
19
24
|
|
|
20
25
|
import argparse
|
|
26
|
+
import copy
|
|
21
27
|
import datetime
|
|
28
|
+
import difflib
|
|
29
|
+
import hashlib
|
|
22
30
|
import json
|
|
31
|
+
import os
|
|
23
32
|
import pathlib
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
24
36
|
import sys
|
|
25
37
|
import tempfile
|
|
26
38
|
|
|
27
39
|
VALID_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
|
|
28
40
|
VALID_TRIGGERS = {"build_gate", "verify"}
|
|
29
|
-
PHASE_NAMES = {"plan", "probe_derive", "implement", "build_gate", "cleanup", "verify", "final_report"}
|
|
41
|
+
PHASE_NAMES = {"plan", "probe_derive", "implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"}
|
|
42
|
+
LEGAL_TRANSITIONS = {
|
|
43
|
+
"plan": {"probe_derive", "implement", "final_report"},
|
|
44
|
+
"probe_derive": {"implement", "final_report"},
|
|
45
|
+
"implement": {"implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"},
|
|
46
|
+
"surface_close": {"build_gate", "cleanup", "verify", "final_report"},
|
|
47
|
+
"build_gate": {"implement", "cleanup", "verify", "final_report"},
|
|
48
|
+
"cleanup": {"verify", "final_report"},
|
|
49
|
+
"verify": {"implement", "final_report"},
|
|
50
|
+
"final_report": set(),
|
|
51
|
+
}
|
|
52
|
+
WORKER_SESSION_ARTIFACT_PHASES = {
|
|
53
|
+
"implement": "implement",
|
|
54
|
+
"surface_close": "surface-close",
|
|
55
|
+
"cleanup": "cleanup",
|
|
56
|
+
}
|
|
57
|
+
MODEL_HEADER_RE = re.compile(r"(?m)^[ \t]*model:[ \t]*(\S+)[ \t]*$")
|
|
58
|
+
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
59
|
+
SURFACE_ROW_RE = re.compile(
|
|
60
|
+
r"^(?P<obligation>UVR-STALE|PATH-TEST): (?:"
|
|
61
|
+
r"(?P<fired>FIRED) (?P<fired_path>.+):(?P<fired_line>[1-9][0-9]*)"
|
|
62
|
+
r"(?: — (?P<fired_evidence>\S.*))?|"
|
|
63
|
+
r"(?P<na>N/A) (?P<na_path>.+?)(?::(?P<na_line>[1-9][0-9]*))?"
|
|
64
|
+
r"(?: — (?P<na_evidence>\S.*))?)$"
|
|
65
|
+
)
|
|
66
|
+
VALIDATION_EXECUTION_RE = re.compile(
|
|
67
|
+
r"npm\s+test|node\s+--test|node\s+-e|node\s+bin/|node\s+tests/|git\s+stash"
|
|
68
|
+
)
|
|
69
|
+
SURFACE_SKIP_REASON = "auto_surface_close_claude_unavailable"
|
|
70
|
+
SURFACE_RECOVERY_REASON = "surface_close_rolled_back_adjudication_malformed"
|
|
30
71
|
|
|
31
72
|
|
|
32
73
|
def reject_json_constant(token: str) -> None:
|
|
@@ -79,6 +120,1005 @@ def write_state(state_path: pathlib.Path, state: dict) -> None:
|
|
|
79
120
|
raise
|
|
80
121
|
|
|
81
122
|
|
|
123
|
+
def parse_string_list(raw: str, label: str) -> list[str]:
|
|
124
|
+
try:
|
|
125
|
+
value = loads_strict_json(raw)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise SystemExit(f"error: {label} is not valid JSON: {exc}") from exc
|
|
128
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
129
|
+
raise SystemExit(f"error: {label} must be a JSON array of strings")
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_git_paths(work: pathlib.Path, *args: str) -> list[str]:
|
|
134
|
+
proc = subprocess.run(
|
|
135
|
+
["git", *args], cwd=work, capture_output=True, check=False,
|
|
136
|
+
)
|
|
137
|
+
if proc.returncode != 0:
|
|
138
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git command failed"
|
|
139
|
+
raise SystemExit(f"error: {detail}")
|
|
140
|
+
return [os.fsdecode(item) for item in proc.stdout.split(b"\0") if item]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def surface_entry(state: dict) -> dict:
|
|
144
|
+
entry = (state.get("phases") or {}).get("surface_close")
|
|
145
|
+
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
146
|
+
raise SystemExit("error: phases.surface_close was never spawned")
|
|
147
|
+
pre_sha = entry.get("pre_sha")
|
|
148
|
+
patch_digest = entry.get("input_patch_sha256")
|
|
149
|
+
prompt_digest = entry.get("prompt_sha256")
|
|
150
|
+
baseline = entry.get("untracked_before")
|
|
151
|
+
if not isinstance(pre_sha, str) or not pre_sha:
|
|
152
|
+
raise SystemExit("error: phases.surface_close.pre_sha is missing")
|
|
153
|
+
if not isinstance(patch_digest, str) or not SHA256_RE.fullmatch(patch_digest):
|
|
154
|
+
raise SystemExit("error: phases.surface_close.input_patch_sha256 must be 64 lowercase hex characters")
|
|
155
|
+
if not isinstance(prompt_digest, str) or not SHA256_RE.fullmatch(prompt_digest):
|
|
156
|
+
raise SystemExit("error: phases.surface_close.prompt_sha256 must be 64 lowercase hex characters")
|
|
157
|
+
if not isinstance(baseline, list) or any(not isinstance(item, str) for item in baseline):
|
|
158
|
+
raise SystemExit("error: phases.surface_close.untracked_before must be a string array")
|
|
159
|
+
return entry
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def devlyn_prefix(work: pathlib.Path, devlyn: pathlib.Path) -> str:
|
|
163
|
+
try:
|
|
164
|
+
return devlyn.resolve().relative_to(work.resolve()).as_posix().strip("/")
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
raise SystemExit("error: --devlyn-dir must be inside --workdir") from exc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def file_sha256(path: pathlib.Path) -> str:
|
|
170
|
+
digest = hashlib.sha256()
|
|
171
|
+
try:
|
|
172
|
+
with path.open("rb") as handle:
|
|
173
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
174
|
+
digest.update(chunk)
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise SystemExit(f"BLOCKED:surface-close-input-mismatch: {path}: {exc}") from exc
|
|
177
|
+
return digest.hexdigest()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def validate_surface_inputs(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> None:
|
|
181
|
+
entry = surface_entry(state)
|
|
182
|
+
source = state.get("source")
|
|
183
|
+
if not isinstance(source, dict):
|
|
184
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: source is missing")
|
|
185
|
+
goal_path = source.get("goal_path")
|
|
186
|
+
goal_digest = source.get("goal_sha256")
|
|
187
|
+
if not isinstance(goal_path, str) or not goal_path or not isinstance(goal_digest, str):
|
|
188
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: Goal metadata is missing")
|
|
189
|
+
goal = work / goal_path
|
|
190
|
+
try:
|
|
191
|
+
goal.resolve().relative_to(work.resolve())
|
|
192
|
+
except (OSError, ValueError) as exc:
|
|
193
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: Goal path escapes worktree") from exc
|
|
194
|
+
patch = devlyn / "surface-close.input.patch"
|
|
195
|
+
if file_sha256(goal) != goal_digest or file_sha256(patch) != entry["input_patch_sha256"]:
|
|
196
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: artifact digest changed")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def validate_surface_prompt(devlyn: pathlib.Path, state: dict) -> None:
|
|
200
|
+
prompt = devlyn / "surface-close.prompt"
|
|
201
|
+
if file_sha256(prompt) != surface_entry(state)["prompt_sha256"]:
|
|
202
|
+
raise SystemExit("BLOCKED:surface-close-prompt-mismatch")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def surface_delta_paths(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> tuple[list[str], list[str]]:
|
|
206
|
+
entry = surface_entry(state)
|
|
207
|
+
pre_sha = entry["pre_sha"]
|
|
208
|
+
prefix = devlyn_prefix(work, devlyn)
|
|
209
|
+
tracked = set(run_git_paths(work, "diff", "--name-only", "-z", pre_sha, "--"))
|
|
210
|
+
untracked_now = set(run_git_paths(work, "ls-files", "--others", "--exclude-standard", "-z"))
|
|
211
|
+
new_untracked = untracked_now - set(entry["untracked_before"])
|
|
212
|
+
|
|
213
|
+
def external(path: str) -> bool:
|
|
214
|
+
return bool(path) and path != prefix and not path.startswith(f"{prefix}/")
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
sorted(path for path in tracked if external(path)),
|
|
218
|
+
sorted(path for path in new_untracked if external(path)),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def ensure_surface_clean_baseline(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> None:
|
|
223
|
+
tracked, new_untracked = surface_delta_paths(work, devlyn, state)
|
|
224
|
+
if tracked or new_untracked:
|
|
225
|
+
detail = json.dumps(sorted(set(tracked + new_untracked)))
|
|
226
|
+
raise SystemExit(f"BLOCKED:surface-close-preexisting-delta: {detail}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def validate_authorized_surface(raw: str) -> list[str]:
|
|
230
|
+
surface = parse_string_list(raw, "--authorized-surface-json")
|
|
231
|
+
if not surface:
|
|
232
|
+
raise SystemExit("error: --authorized-surface-json must not be empty")
|
|
233
|
+
for entry in surface:
|
|
234
|
+
path = entry[:-3] if entry.endswith("/**") else entry
|
|
235
|
+
parts = pathlib.PurePosixPath(path).parts
|
|
236
|
+
if (
|
|
237
|
+
not path or path == "." or path.startswith("./")
|
|
238
|
+
or pathlib.PurePosixPath(path).is_absolute() or ".." in parts
|
|
239
|
+
):
|
|
240
|
+
raise SystemExit(f"error: invalid authorized_surface entry: {entry!r}")
|
|
241
|
+
return surface
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def path_matches_surface(path: str, surface: list[str]) -> bool:
|
|
245
|
+
for entry in surface:
|
|
246
|
+
if entry.endswith("/**"):
|
|
247
|
+
prefix = entry[:-3].rstrip("/")
|
|
248
|
+
if path == prefix or path.startswith(f"{prefix}/"):
|
|
249
|
+
return True
|
|
250
|
+
elif path == entry:
|
|
251
|
+
return True
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def surface_offenders(work: pathlib.Path, devlyn: pathlib.Path, state: dict,
|
|
256
|
+
surface: list[str]) -> list[str]:
|
|
257
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
258
|
+
return sorted(path for path in set(tracked + untracked) if not path_matches_surface(path, surface))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def path_exists_at_commit(work: pathlib.Path, sha: str, path: str) -> bool:
|
|
262
|
+
proc = subprocess.run(
|
|
263
|
+
["git", "ls-tree", "--name-only", "-z", sha, "--", path],
|
|
264
|
+
cwd=work, capture_output=True, check=False,
|
|
265
|
+
)
|
|
266
|
+
if proc.returncode != 0:
|
|
267
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git ls-tree failed"
|
|
268
|
+
raise SystemExit(f"error: {detail}")
|
|
269
|
+
return bool(proc.stdout)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def remove_worktree_path(work: pathlib.Path, path: str) -> None:
|
|
273
|
+
parsed = pathlib.PurePosixPath(path)
|
|
274
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
275
|
+
raise SystemExit(f"error: rollback path escapes worktree: {path!r}")
|
|
276
|
+
target = work / path
|
|
277
|
+
if target.is_symlink() or target.is_file():
|
|
278
|
+
target.unlink()
|
|
279
|
+
elif target.is_dir():
|
|
280
|
+
shutil.rmtree(target)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def rollback_surface_delta(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> list[str]:
|
|
284
|
+
entry = surface_entry(state)
|
|
285
|
+
pre_sha = entry["pre_sha"]
|
|
286
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
287
|
+
restore = [path for path in tracked if path_exists_at_commit(work, pre_sha, path)]
|
|
288
|
+
remove = sorted(set(untracked + [
|
|
289
|
+
path for path in tracked if not path_exists_at_commit(work, pre_sha, path)
|
|
290
|
+
]))
|
|
291
|
+
if restore:
|
|
292
|
+
proc = subprocess.run(
|
|
293
|
+
["git", "restore", f"--source={pre_sha}", "--staged", "--worktree", "--", *restore],
|
|
294
|
+
cwd=work, capture_output=True, check=False,
|
|
295
|
+
)
|
|
296
|
+
if proc.returncode != 0:
|
|
297
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git restore failed"
|
|
298
|
+
raise SystemExit(f"error: {detail}")
|
|
299
|
+
if remove:
|
|
300
|
+
proc = subprocess.run(
|
|
301
|
+
["git", "rm", "-f", "--cached", "--ignore-unmatch", "--", *remove],
|
|
302
|
+
cwd=work, capture_output=True, check=False,
|
|
303
|
+
)
|
|
304
|
+
if proc.returncode != 0:
|
|
305
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git rm --cached failed"
|
|
306
|
+
raise SystemExit(f"error: {detail}")
|
|
307
|
+
for path in remove:
|
|
308
|
+
remove_worktree_path(work, path)
|
|
309
|
+
return sorted(set(restore + remove))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def validate_surface_adjudication(
|
|
313
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
314
|
+
) -> dict[str, str]:
|
|
315
|
+
output = devlyn / "surface-close.stdout"
|
|
316
|
+
try:
|
|
317
|
+
lines = output.read_text(encoding="utf-8").splitlines()
|
|
318
|
+
except (OSError, UnicodeError) as exc:
|
|
319
|
+
raise SystemExit(f"BLOCKED:surface-close-adjudication-malformed: {output}: {exc}") from exc
|
|
320
|
+
|
|
321
|
+
rows: dict[str, tuple[str, str, int | None, str | None, int]] = {}
|
|
322
|
+
for index, line in enumerate(lines):
|
|
323
|
+
if "UVR-STALE:" not in line and "PATH-TEST:" not in line:
|
|
324
|
+
continue
|
|
325
|
+
match = SURFACE_ROW_RE.fullmatch(line)
|
|
326
|
+
if match is None:
|
|
327
|
+
raise SystemExit(
|
|
328
|
+
f"BLOCKED:surface-close-adjudication-malformed: line {index + 1}: {line!r}"
|
|
329
|
+
)
|
|
330
|
+
obligation = match.group("obligation")
|
|
331
|
+
if obligation in rows:
|
|
332
|
+
raise SystemExit(
|
|
333
|
+
f"BLOCKED:surface-close-adjudication-malformed: duplicate {obligation} row"
|
|
334
|
+
)
|
|
335
|
+
status = "FIRED" if match.group("fired") else "N/A"
|
|
336
|
+
evidence = match.group("fired_evidence") or match.group("na_evidence")
|
|
337
|
+
if status == "N/A" and evidence is None:
|
|
338
|
+
raise SystemExit(
|
|
339
|
+
f"BLOCKED:surface-close-adjudication-malformed: {obligation} N/A requires evidence"
|
|
340
|
+
)
|
|
341
|
+
path = match.group("fired_path") or match.group("na_path")
|
|
342
|
+
raw_line = match.group("fired_line") or match.group("na_line")
|
|
343
|
+
line_number = int(raw_line) if raw_line is not None else None
|
|
344
|
+
citation = path if line_number is None else f"{path}:{line_number}"
|
|
345
|
+
if not path_matches_surface(path, surface):
|
|
346
|
+
raise SystemExit(
|
|
347
|
+
f"BLOCKED:surface-close-adjudication-out-of-surface: {citation}"
|
|
348
|
+
)
|
|
349
|
+
cited = work / path
|
|
350
|
+
try:
|
|
351
|
+
cited_lines = cited.read_text(encoding="utf-8").splitlines()
|
|
352
|
+
except (OSError, UnicodeError) as exc:
|
|
353
|
+
raise SystemExit(
|
|
354
|
+
f"BLOCKED:surface-close-adjudication-citation-missing: {citation}: {exc}"
|
|
355
|
+
) from exc
|
|
356
|
+
if line_number is not None and line_number > len(cited_lines):
|
|
357
|
+
raise SystemExit(
|
|
358
|
+
f"BLOCKED:surface-close-adjudication-citation-missing: {path}:{line_number}"
|
|
359
|
+
)
|
|
360
|
+
rows[obligation] = (status, path, line_number, evidence, index)
|
|
361
|
+
|
|
362
|
+
missing = [name for name in ("UVR-STALE", "PATH-TEST") if name not in rows]
|
|
363
|
+
if missing:
|
|
364
|
+
raise SystemExit(
|
|
365
|
+
"BLOCKED:surface-close-adjudication-malformed: missing " + ", ".join(missing)
|
|
366
|
+
)
|
|
367
|
+
pass_lines = [index for index, line in enumerate(lines) if line == "PASS"]
|
|
368
|
+
if len(pass_lines) != 1 or pass_lines[0] <= max(row[4] for row in rows.values()):
|
|
369
|
+
raise SystemExit(
|
|
370
|
+
"BLOCKED:surface-close-adjudication-malformed: exactly one PASS must follow both rows"
|
|
371
|
+
)
|
|
372
|
+
statuses = {name: row[0] for name, row in rows.items()}
|
|
373
|
+
if all(status == "N/A" for status in statuses.values()):
|
|
374
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
375
|
+
if tracked or untracked:
|
|
376
|
+
raise SystemExit("BLOCKED:surface-close-empty-pass-has-delta")
|
|
377
|
+
return statuses
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def surface_transcript_commands(transcript: pathlib.Path) -> list[str]:
|
|
381
|
+
try:
|
|
382
|
+
lines = transcript.read_text(encoding="utf-8").splitlines()
|
|
383
|
+
except (OSError, UnicodeError) as exc:
|
|
384
|
+
raise SystemExit(f"BLOCKED:surface-close-worker-session-invalid: {transcript}: {exc}") from exc
|
|
385
|
+
commands: list[str] = []
|
|
386
|
+
for line_number, line in enumerate(lines, start=1):
|
|
387
|
+
if not line.strip():
|
|
388
|
+
continue
|
|
389
|
+
try:
|
|
390
|
+
event = loads_strict_json(line)
|
|
391
|
+
except ValueError as exc:
|
|
392
|
+
raise SystemExit(
|
|
393
|
+
f"BLOCKED:surface-close-worker-session-invalid: line {line_number}: {exc}"
|
|
394
|
+
) from exc
|
|
395
|
+
if not isinstance(event, dict):
|
|
396
|
+
continue
|
|
397
|
+
message = event.get("message")
|
|
398
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
399
|
+
if isinstance(content, list):
|
|
400
|
+
for item in content:
|
|
401
|
+
if not isinstance(item, dict) or item.get("type") != "tool_use":
|
|
402
|
+
continue
|
|
403
|
+
if item.get("name") != "Bash":
|
|
404
|
+
continue
|
|
405
|
+
tool_input = item.get("input")
|
|
406
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
407
|
+
if isinstance(command, str):
|
|
408
|
+
commands.append(command)
|
|
409
|
+
return commands
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def validate_surface_execution(devlyn: pathlib.Path, state: dict) -> None:
|
|
413
|
+
entry = surface_entry(state)
|
|
414
|
+
transcript = devlyn / f"surface-close.worker-session.{entry.get('round')}.jsonl"
|
|
415
|
+
commands = surface_transcript_commands(transcript)
|
|
416
|
+
hits = [command for command in commands if VALIDATION_EXECUTION_RE.search(command)]
|
|
417
|
+
if hits:
|
|
418
|
+
raise SystemExit(
|
|
419
|
+
"BLOCKED:surface-close-validation-execution: " + json.dumps(hits)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def validate_surface_write_audit(
|
|
424
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
425
|
+
) -> list[str]:
|
|
426
|
+
entry = surface_entry(state)
|
|
427
|
+
transcript = devlyn / f"surface-close.worker-session.{entry.get('round')}.jsonl"
|
|
428
|
+
try:
|
|
429
|
+
lines = transcript.read_text(encoding="utf-8").splitlines()
|
|
430
|
+
except (OSError, UnicodeError) as exc:
|
|
431
|
+
raise SystemExit(f"BLOCKED:surface-close-worker-session-invalid: {transcript}: {exc}") from exc
|
|
432
|
+
targets: list[str] = []
|
|
433
|
+
for line_number, line in enumerate(lines, start=1):
|
|
434
|
+
if not line.strip():
|
|
435
|
+
continue
|
|
436
|
+
try:
|
|
437
|
+
event = loads_strict_json(line)
|
|
438
|
+
except ValueError as exc:
|
|
439
|
+
raise SystemExit(
|
|
440
|
+
f"BLOCKED:surface-close-worker-session-invalid: line {line_number}: {exc}"
|
|
441
|
+
) from exc
|
|
442
|
+
message = event.get("message") if isinstance(event, dict) else None
|
|
443
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
444
|
+
if not isinstance(content, list):
|
|
445
|
+
continue
|
|
446
|
+
for item in content:
|
|
447
|
+
if not isinstance(item, dict) or item.get("type") != "tool_use":
|
|
448
|
+
continue
|
|
449
|
+
if item.get("name") not in {"Edit", "Write"}:
|
|
450
|
+
continue
|
|
451
|
+
tool_input = item.get("input")
|
|
452
|
+
raw_target = tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
453
|
+
if not isinstance(raw_target, str) or not raw_target:
|
|
454
|
+
raise SystemExit(
|
|
455
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
456
|
+
f"line {line_number}: Edit/Write target missing"
|
|
457
|
+
)
|
|
458
|
+
target = pathlib.Path(raw_target)
|
|
459
|
+
try:
|
|
460
|
+
resolved = (target if target.is_absolute() else work / target).resolve()
|
|
461
|
+
relative = resolved.relative_to(work.resolve()).as_posix()
|
|
462
|
+
except (OSError, ValueError) as exc:
|
|
463
|
+
raise SystemExit(
|
|
464
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
465
|
+
f"line {line_number}: {raw_target!r}"
|
|
466
|
+
) from exc
|
|
467
|
+
if not path_matches_surface(relative, surface):
|
|
468
|
+
raise SystemExit(
|
|
469
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
470
|
+
f"line {line_number}: {relative!r}"
|
|
471
|
+
)
|
|
472
|
+
targets.append(relative)
|
|
473
|
+
return targets
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def require_surface_adjudication_malformed(
|
|
477
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
478
|
+
) -> None:
|
|
479
|
+
try:
|
|
480
|
+
validate_surface_adjudication(work, devlyn, state, surface)
|
|
481
|
+
except SystemExit as exc:
|
|
482
|
+
if str(exc).startswith("BLOCKED:surface-close-adjudication-malformed:"):
|
|
483
|
+
return
|
|
484
|
+
raise
|
|
485
|
+
raise SystemExit(
|
|
486
|
+
"error: surface-adjudication-recover requires "
|
|
487
|
+
"BLOCKED:surface-close-adjudication-malformed"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def parse_effective_model(session_log: pathlib.Path) -> str:
|
|
492
|
+
try:
|
|
493
|
+
text = session_log.read_text(encoding="utf-8")
|
|
494
|
+
except (OSError, UnicodeError) as exc:
|
|
495
|
+
raise ValueError(f"cannot read engine session log {session_log}: {exc}") from exc
|
|
496
|
+
|
|
497
|
+
header = MODEL_HEADER_RE.search(text)
|
|
498
|
+
if header:
|
|
499
|
+
return header.group(1)
|
|
500
|
+
|
|
501
|
+
models: set[str] = set()
|
|
502
|
+
if session_log.suffix == ".jsonl":
|
|
503
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
504
|
+
if not line.strip():
|
|
505
|
+
continue
|
|
506
|
+
try:
|
|
507
|
+
event = loads_strict_json(line)
|
|
508
|
+
except ValueError as exc:
|
|
509
|
+
raise ValueError(
|
|
510
|
+
f"engine session log {session_log} has malformed JSONL at line {line_number}: {exc}"
|
|
511
|
+
) from exc
|
|
512
|
+
if not isinstance(event, dict) or event.get("type") != "turn_context":
|
|
513
|
+
continue
|
|
514
|
+
payload = event.get("payload")
|
|
515
|
+
model = payload.get("model") if isinstance(payload, dict) else None
|
|
516
|
+
if isinstance(model, str) and model:
|
|
517
|
+
models.add(model)
|
|
518
|
+
else:
|
|
519
|
+
try:
|
|
520
|
+
evidence = loads_strict_json(text)
|
|
521
|
+
except ValueError:
|
|
522
|
+
evidence = None
|
|
523
|
+
model_usage = evidence.get("modelUsage") if isinstance(evidence, dict) else None
|
|
524
|
+
if isinstance(model_usage, dict):
|
|
525
|
+
models.update(model for model in model_usage if isinstance(model, str) and model)
|
|
526
|
+
|
|
527
|
+
if len(models) == 1:
|
|
528
|
+
return next(iter(models))
|
|
529
|
+
if not models:
|
|
530
|
+
raise ValueError(f"engine session log {session_log} has no effective-model evidence")
|
|
531
|
+
raise ValueError(
|
|
532
|
+
f"engine session log {session_log} has conflicting effective models: {sorted(models)}"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _git_output(work: pathlib.Path, *args: str, input_bytes: bytes | None = None) -> bytes:
|
|
537
|
+
proc = subprocess.run(
|
|
538
|
+
["git", *args], cwd=work, input=input_bytes, capture_output=True, check=False,
|
|
539
|
+
)
|
|
540
|
+
if proc.returncode != 0:
|
|
541
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git command failed"
|
|
542
|
+
raise SystemExit(f"BLOCKED:closure-durability-mechanical: {detail}")
|
|
543
|
+
return proc.stdout
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _commit_blob(work: pathlib.Path, sha: str, path: str) -> bytes | None:
|
|
547
|
+
if not path_exists_at_commit(work, sha, path):
|
|
548
|
+
return None
|
|
549
|
+
return _git_output(work, "show", f"{sha}:{path}")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _line_matches(haystack: list[bytes], needle: list[bytes]) -> list[int]:
|
|
553
|
+
if not needle or len(needle) > len(haystack):
|
|
554
|
+
return []
|
|
555
|
+
width = len(needle)
|
|
556
|
+
return [index for index in range(len(haystack) - width + 1)
|
|
557
|
+
if haystack[index:index + width] == needle]
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _nearest(matches: list[int], expected: int) -> int | None:
|
|
561
|
+
return min(matches, key=lambda value: (abs(value - expected), value)) if matches else None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _block_image_index(lines: list[bytes], image: list[bytes], block: dict) -> int | None:
|
|
565
|
+
matches = []
|
|
566
|
+
before = block["_before_lines"]
|
|
567
|
+
after = block["_after_lines"]
|
|
568
|
+
for index in _line_matches(lines, image):
|
|
569
|
+
if before and (index < len(before) or lines[index - len(before):index] != before):
|
|
570
|
+
continue
|
|
571
|
+
end = index + len(image)
|
|
572
|
+
if after and lines[end:end + len(after)] != after:
|
|
573
|
+
continue
|
|
574
|
+
matches.append(index)
|
|
575
|
+
return _nearest(matches, block["_expected_index"])
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _block_anchor_index(lines: list[bytes], block: dict) -> int | None:
|
|
579
|
+
before = block["_before_lines"]
|
|
580
|
+
after = block["_after_lines"]
|
|
581
|
+
if not before and not after:
|
|
582
|
+
return 0 if not lines else None
|
|
583
|
+
candidates = []
|
|
584
|
+
for index in range(len(lines) + 1):
|
|
585
|
+
if before and (index < len(before) or lines[index - len(before):index] != before):
|
|
586
|
+
continue
|
|
587
|
+
if after and lines[index:index + len(after)] != after:
|
|
588
|
+
continue
|
|
589
|
+
candidates.append(index)
|
|
590
|
+
return _nearest(candidates, block["_expected_index"])
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _map_pre_fix_span(block: dict, pre_fix_lines: list[bytes]) -> None:
|
|
594
|
+
post_lines = block["_post_lines"]
|
|
595
|
+
if post_lines:
|
|
596
|
+
index = _block_image_index(pre_fix_lines, post_lines, block)
|
|
597
|
+
else:
|
|
598
|
+
index = _block_anchor_index(pre_fix_lines, block)
|
|
599
|
+
if index is None:
|
|
600
|
+
block["_pre_fix_anchor"] = False
|
|
601
|
+
return
|
|
602
|
+
block["_pre_fix_anchor"] = True
|
|
603
|
+
block["_expected_index"] = index
|
|
604
|
+
width = max(1, len(post_lines))
|
|
605
|
+
block["pre_fix_span"] = [index + 1, index + width]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def surface_change_blocks(work: pathlib.Path, state: dict,
|
|
609
|
+
pre_fix_sha: str | None = None) -> list[dict]:
|
|
610
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
611
|
+
pre_sha = surface.get("pre_sha")
|
|
612
|
+
post_sha = surface.get("post_sha")
|
|
613
|
+
if not isinstance(pre_sha, str) or not pre_sha or not isinstance(post_sha, str) or not post_sha:
|
|
614
|
+
return []
|
|
615
|
+
paths = run_git_paths(work, "diff", "--name-only", "--no-renames", "-z", pre_sha, post_sha, "--")
|
|
616
|
+
blocks = []
|
|
617
|
+
for path in paths:
|
|
618
|
+
parsed = pathlib.PurePosixPath(path)
|
|
619
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
620
|
+
raise SystemExit(f"BLOCKED:closure-durability-mechanical: unsafe surface path {path!r}")
|
|
621
|
+
old_blob = _commit_blob(work, pre_sha, path)
|
|
622
|
+
post_blob = _commit_blob(work, post_sha, path)
|
|
623
|
+
old_lines = [] if old_blob is None else old_blob.splitlines(keepends=True)
|
|
624
|
+
post_lines = [] if post_blob is None else post_blob.splitlines(keepends=True)
|
|
625
|
+
matcher = difflib.SequenceMatcher(None, old_lines, post_lines, autojunk=False)
|
|
626
|
+
opcodes = matcher.get_opcodes()
|
|
627
|
+
for ordinal, (tag, old_start, old_end, new_start, new_end) in enumerate(opcodes):
|
|
628
|
+
if tag == "equal":
|
|
629
|
+
continue
|
|
630
|
+
old_image = old_lines[old_start:old_end]
|
|
631
|
+
post_image = post_lines[new_start:new_end]
|
|
632
|
+
before_lines = []
|
|
633
|
+
after_lines = []
|
|
634
|
+
if ordinal > 0 and opcodes[ordinal - 1][0] == "equal":
|
|
635
|
+
_tag, _i1, _i2, prior_start, prior_end = opcodes[ordinal - 1]
|
|
636
|
+
before_lines = post_lines[max(prior_start, prior_end - 2):prior_end]
|
|
637
|
+
if ordinal + 1 < len(opcodes) and opcodes[ordinal + 1][0] == "equal":
|
|
638
|
+
_tag, _i1, _i2, next_start, next_end = opcodes[ordinal + 1]
|
|
639
|
+
after_lines = post_lines[next_start:min(next_end, next_start + 2)]
|
|
640
|
+
identity = hashlib.sha256(
|
|
641
|
+
path.encode("utf-8", "surrogateescape") + b"\0"
|
|
642
|
+
+ str(old_start + 1).encode() + b":" + str(old_end - old_start).encode() + b"\0"
|
|
643
|
+
+ str(new_start + 1).encode() + b":" + str(new_end - new_start).encode() + b"\0"
|
|
644
|
+
+ b"".join(old_image) + b"\0" + b"".join(post_image)
|
|
645
|
+
).hexdigest()[:16]
|
|
646
|
+
width = max(1, len(post_image))
|
|
647
|
+
block = {
|
|
648
|
+
"id": f"{path}:{ordinal}:{identity}",
|
|
649
|
+
"path": path,
|
|
650
|
+
"pre_fix_span": [new_start + 1, new_start + width],
|
|
651
|
+
"_pre_lines": old_image,
|
|
652
|
+
"_post_lines": post_image,
|
|
653
|
+
"_before_lines": before_lines,
|
|
654
|
+
"_after_lines": after_lines,
|
|
655
|
+
"_expected_index": new_start,
|
|
656
|
+
"_pre_fix_anchor": True,
|
|
657
|
+
"_post_exists": post_blob is not None,
|
|
658
|
+
}
|
|
659
|
+
if pre_fix_sha is not None:
|
|
660
|
+
pre_fix_blob = _commit_blob(work, pre_fix_sha, path)
|
|
661
|
+
_map_pre_fix_span(block, [] if pre_fix_blob is None else pre_fix_blob.splitlines(keepends=True))
|
|
662
|
+
blocks.append(block)
|
|
663
|
+
return blocks
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def classify_surface_block(block: dict, current: bytes | None) -> str:
|
|
667
|
+
lines = [] if current is None else current.splitlines(keepends=True)
|
|
668
|
+
post_lines = block["_post_lines"]
|
|
669
|
+
pre_lines = block["_pre_lines"]
|
|
670
|
+
if post_lines:
|
|
671
|
+
post_index = _block_image_index(lines, post_lines, block)
|
|
672
|
+
if post_index is not None:
|
|
673
|
+
block["_restore_index"] = post_index
|
|
674
|
+
return "SURVIVED"
|
|
675
|
+
if pre_lines:
|
|
676
|
+
pre_index = _block_image_index(lines, pre_lines, block)
|
|
677
|
+
if pre_index is not None:
|
|
678
|
+
block["_restore_index"] = pre_index
|
|
679
|
+
return "REVERTED"
|
|
680
|
+
else:
|
|
681
|
+
anchor = _block_anchor_index(lines, block)
|
|
682
|
+
if anchor is not None:
|
|
683
|
+
block["_restore_index"] = anchor
|
|
684
|
+
return "REVERTED"
|
|
685
|
+
return "EVOLVED"
|
|
686
|
+
pre_index = _block_image_index(lines, pre_lines, block)
|
|
687
|
+
if pre_index is not None:
|
|
688
|
+
block["_restore_index"] = pre_index
|
|
689
|
+
return "REVERTED"
|
|
690
|
+
anchor = _block_anchor_index(lines, block)
|
|
691
|
+
if anchor is not None:
|
|
692
|
+
block["_restore_index"] = anchor
|
|
693
|
+
return "SURVIVED"
|
|
694
|
+
return "EVOLVED"
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def finding_targets_block(block: dict, findings: list[dict]) -> bool:
|
|
698
|
+
start, end = block["pre_fix_span"]
|
|
699
|
+
for finding in findings:
|
|
700
|
+
path = finding.get("path", finding.get("file"))
|
|
701
|
+
line = finding.get("line")
|
|
702
|
+
if path == block["path"] and isinstance(line, int) and not isinstance(line, bool):
|
|
703
|
+
if start <= line <= end:
|
|
704
|
+
return True
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _read_triggering_findings(devlyn: pathlib.Path, origin_phase: str) -> tuple[list[dict], str]:
|
|
709
|
+
name = "build_gate.findings.jsonl" if origin_phase == "build_gate" else "verify-merged.findings.jsonl"
|
|
710
|
+
path = devlyn / name
|
|
711
|
+
try:
|
|
712
|
+
raw = path.read_bytes()
|
|
713
|
+
except OSError as exc:
|
|
714
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {path}: {exc}") from exc
|
|
715
|
+
findings = []
|
|
716
|
+
for line_number, line in enumerate(raw.splitlines(), start=1):
|
|
717
|
+
if not line.strip():
|
|
718
|
+
continue
|
|
719
|
+
try:
|
|
720
|
+
finding = loads_strict_json(line.decode("utf-8"))
|
|
721
|
+
except (UnicodeError, ValueError) as exc:
|
|
722
|
+
raise SystemExit(
|
|
723
|
+
f"BLOCKED:closure-durability-receipt: {path}:{line_number}: {exc}"
|
|
724
|
+
) from exc
|
|
725
|
+
if not isinstance(finding, dict):
|
|
726
|
+
raise SystemExit(
|
|
727
|
+
f"BLOCKED:closure-durability-receipt: {path}:{line_number} is not an object"
|
|
728
|
+
)
|
|
729
|
+
findings.append(finding)
|
|
730
|
+
return findings, hashlib.sha256(raw).hexdigest()
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _worktree_file(work: pathlib.Path, path: str) -> bytes | None:
|
|
734
|
+
target = work / path
|
|
735
|
+
if target.is_file() or target.is_symlink():
|
|
736
|
+
return target.read_bytes()
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _restored_files(work: pathlib.Path, blocks: list[dict]) -> dict[str, bytes | None]:
|
|
741
|
+
desired = {}
|
|
742
|
+
for path in sorted({block["path"] for block in blocks}):
|
|
743
|
+
current = _worktree_file(work, path)
|
|
744
|
+
lines = [] if current is None else current.splitlines(keepends=True)
|
|
745
|
+
path_blocks = sorted(
|
|
746
|
+
(block for block in blocks if block["path"] == path),
|
|
747
|
+
key=lambda block: block["_restore_index"], reverse=True,
|
|
748
|
+
)
|
|
749
|
+
for block in path_blocks:
|
|
750
|
+
index = block["_restore_index"]
|
|
751
|
+
pre_lines = block["_pre_lines"]
|
|
752
|
+
post_lines = block["_post_lines"]
|
|
753
|
+
if lines[index:index + len(pre_lines)] != pre_lines:
|
|
754
|
+
raise SystemExit(
|
|
755
|
+
f"BLOCKED:closure-durability-apply: restore anchor changed for {block['id']}"
|
|
756
|
+
)
|
|
757
|
+
lines[index:index + len(pre_lines)] = post_lines
|
|
758
|
+
desired[path] = b"".join(lines) if lines or any(
|
|
759
|
+
block["_post_exists"] for block in path_blocks
|
|
760
|
+
) else None
|
|
761
|
+
return desired
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _restore_patch(work: pathlib.Path, desired: dict[str, bytes | None]) -> bytes:
|
|
765
|
+
parts = []
|
|
766
|
+
with tempfile.TemporaryDirectory(prefix="closure-durability-") as tmp:
|
|
767
|
+
root = pathlib.Path(tmp)
|
|
768
|
+
for path, wanted in desired.items():
|
|
769
|
+
old = root / "old" / path
|
|
770
|
+
new = root / "new" / path
|
|
771
|
+
current = _worktree_file(work, path)
|
|
772
|
+
if current is not None:
|
|
773
|
+
old.parent.mkdir(parents=True, exist_ok=True)
|
|
774
|
+
old.write_bytes(current)
|
|
775
|
+
if wanted is not None:
|
|
776
|
+
new.parent.mkdir(parents=True, exist_ok=True)
|
|
777
|
+
new.write_bytes(wanted)
|
|
778
|
+
old_arg = str(old.relative_to(root)) if current is not None else "/dev/null"
|
|
779
|
+
new_arg = str(new.relative_to(root)) if wanted is not None else "/dev/null"
|
|
780
|
+
proc = subprocess.run(
|
|
781
|
+
["git", "diff", "--no-index", "--binary", "--src-prefix=a/", "--dst-prefix=b/",
|
|
782
|
+
"--", old_arg, new_arg],
|
|
783
|
+
cwd=root, capture_output=True, check=False,
|
|
784
|
+
)
|
|
785
|
+
if proc.returncode not in (0, 1):
|
|
786
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git diff --no-index failed"
|
|
787
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
788
|
+
patch = proc.stdout
|
|
789
|
+
encoded = path.encode("utf-8", "surrogateescape")
|
|
790
|
+
rewritten = []
|
|
791
|
+
header = True
|
|
792
|
+
for line in patch.splitlines(keepends=True):
|
|
793
|
+
if line.startswith(b"@@ ") or line.startswith(b"GIT binary patch"):
|
|
794
|
+
header = False
|
|
795
|
+
if header and line.startswith((b"diff --git ", b"--- ", b"+++ ", b"Binary files ")):
|
|
796
|
+
for prefix in (b"a/old/", b"a/new/"):
|
|
797
|
+
line = line.replace(prefix + encoded, b"a/" + encoded)
|
|
798
|
+
for prefix in (b"b/old/", b"b/new/"):
|
|
799
|
+
line = line.replace(prefix + encoded, b"b/" + encoded)
|
|
800
|
+
rewritten.append(line)
|
|
801
|
+
parts.append(b"".join(rewritten))
|
|
802
|
+
return b"".join(parts)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _tracked_status(work: pathlib.Path) -> bytes:
|
|
806
|
+
return _git_output(work, "status", "--porcelain=v1", "-z", "--untracked-files=no")
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _apply_restore_patch(work: pathlib.Path, patch: bytes, paths: list[str]) -> None:
|
|
810
|
+
before = _tracked_status(work)
|
|
811
|
+
if before:
|
|
812
|
+
raise SystemExit("BLOCKED:closure-durability-apply: tracked worktree/index is not clean")
|
|
813
|
+
check = subprocess.run(
|
|
814
|
+
["git", "apply", "--check", "--index", "-"], cwd=work,
|
|
815
|
+
input=patch, capture_output=True, check=False,
|
|
816
|
+
)
|
|
817
|
+
if check.returncode != 0:
|
|
818
|
+
if _tracked_status(work) != before:
|
|
819
|
+
raise SystemExit("BLOCKED:closure-durability-apply: preflight mutated tracked state")
|
|
820
|
+
detail = os.fsdecode(check.stderr or check.stdout).strip() or "git apply --check failed"
|
|
821
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
822
|
+
apply = subprocess.run(
|
|
823
|
+
["git", "apply", "--index", "-"], cwd=work,
|
|
824
|
+
input=patch, capture_output=True, check=False,
|
|
825
|
+
)
|
|
826
|
+
if apply.returncode != 0:
|
|
827
|
+
subprocess.run(
|
|
828
|
+
["git", "restore", "--source=HEAD", "--staged", "--worktree", "--", *paths],
|
|
829
|
+
cwd=work, capture_output=True, check=False,
|
|
830
|
+
)
|
|
831
|
+
if _tracked_status(work) != before:
|
|
832
|
+
raise SystemExit("BLOCKED:closure-durability-apply: failed apply left partial mutation")
|
|
833
|
+
detail = os.fsdecode(apply.stderr or apply.stdout).strip() or "git apply failed"
|
|
834
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _write_json_atomic(path: pathlib.Path, value: dict) -> bytes:
|
|
838
|
+
raw = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
839
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".tmp.")
|
|
840
|
+
try:
|
|
841
|
+
with open(fd, "wb") as handle:
|
|
842
|
+
handle.write(raw)
|
|
843
|
+
pathlib.Path(tmp_name).replace(path)
|
|
844
|
+
except BaseException:
|
|
845
|
+
pathlib.Path(tmp_name).unlink(missing_ok=True)
|
|
846
|
+
raise
|
|
847
|
+
return raw
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _surface_durability_ledger(state: dict) -> list[dict]:
|
|
851
|
+
phases = state.setdefault("phases", {})
|
|
852
|
+
surface = phases.get("surface_close")
|
|
853
|
+
if surface is None:
|
|
854
|
+
surface = {}
|
|
855
|
+
phases["surface_close"] = surface
|
|
856
|
+
if not isinstance(surface, dict):
|
|
857
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: phases.surface_close is malformed")
|
|
858
|
+
ledger = surface.setdefault("durability", [])
|
|
859
|
+
if not isinstance(ledger, list) or any(not isinstance(item, dict) for item in ledger):
|
|
860
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: durability ledger is malformed")
|
|
861
|
+
return ledger
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _receipt_blocks(blocks: list[dict]) -> list[dict]:
|
|
865
|
+
return [{
|
|
866
|
+
"id": block["id"],
|
|
867
|
+
"path": block["path"],
|
|
868
|
+
"pre_fix_span": block["pre_fix_span"],
|
|
869
|
+
"classification": block["classification"],
|
|
870
|
+
"finding_targeted": block["finding_targeted"],
|
|
871
|
+
"action": block["action"],
|
|
872
|
+
} for block in blocks]
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _rollback_durability_creation(
|
|
876
|
+
work: pathlib.Path, receipt_path: pathlib.Path, state: dict, receipt: dict,
|
|
877
|
+
) -> None:
|
|
878
|
+
restore_sha = receipt.get("restore_commit_sha")
|
|
879
|
+
fix_sha = receipt["fix_commit_sha"]
|
|
880
|
+
restored_paths = sorted({
|
|
881
|
+
block["path"] for block in receipt.get("blocks", [])
|
|
882
|
+
if block.get("action") == "restored"
|
|
883
|
+
})
|
|
884
|
+
if restore_sha is not None:
|
|
885
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
886
|
+
if head != restore_sha:
|
|
887
|
+
raise SystemExit("BLOCKED:closure-durability-rollback: restore commit is no longer HEAD")
|
|
888
|
+
restore = subprocess.run(
|
|
889
|
+
["git", "restore", f"--source={fix_sha}", "--staged", "--worktree", "--", *restored_paths],
|
|
890
|
+
cwd=work, capture_output=True, check=False,
|
|
891
|
+
)
|
|
892
|
+
if restore.returncode != 0:
|
|
893
|
+
detail = os.fsdecode(restore.stderr or restore.stdout).strip() or "git restore failed"
|
|
894
|
+
raise SystemExit(f"BLOCKED:closure-durability-rollback: {detail}")
|
|
895
|
+
update = subprocess.run(
|
|
896
|
+
["git", "update-ref", "HEAD", fix_sha, restore_sha],
|
|
897
|
+
cwd=work, capture_output=True, check=False,
|
|
898
|
+
)
|
|
899
|
+
if update.returncode != 0:
|
|
900
|
+
subprocess.run(
|
|
901
|
+
["git", "restore", f"--source={restore_sha}", "--staged", "--worktree", "--",
|
|
902
|
+
*restored_paths], cwd=work, capture_output=True, check=False,
|
|
903
|
+
)
|
|
904
|
+
detail = os.fsdecode(update.stderr or update.stdout).strip() or "git update-ref failed"
|
|
905
|
+
raise SystemExit(f"BLOCKED:closure-durability-rollback: {detail}")
|
|
906
|
+
receipt_path.unlink(missing_ok=True)
|
|
907
|
+
ledger = _surface_durability_ledger(state)
|
|
908
|
+
ledger[:] = [
|
|
909
|
+
item for item in ledger
|
|
910
|
+
if not (item.get("round") == receipt["round"]
|
|
911
|
+
and item.get("origin_phase") == receipt["origin_phase"])
|
|
912
|
+
]
|
|
913
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
914
|
+
if head != fix_sha or _tracked_status(work):
|
|
915
|
+
raise SystemExit("BLOCKED:closure-durability-rollback: rollback left partial mutation")
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _validate_durability_receipt(
|
|
919
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, origin_phase: str, round_: int,
|
|
920
|
+
receipt_path: pathlib.Path, findings_digest: str, ledger: list[dict],
|
|
921
|
+
) -> dict | None:
|
|
922
|
+
matches = [item for item in ledger if item.get("round") == round_]
|
|
923
|
+
if not receipt_path.exists() and not matches:
|
|
924
|
+
return None
|
|
925
|
+
if not receipt_path.is_file() or len(matches) != 1:
|
|
926
|
+
raise SystemExit(
|
|
927
|
+
f"BLOCKED:closure-durability-receipt: missing or duplicate round {round_} receipt/ledger"
|
|
928
|
+
)
|
|
929
|
+
try:
|
|
930
|
+
raw = receipt_path.read_bytes()
|
|
931
|
+
receipt = loads_strict_json(raw.decode("utf-8"))
|
|
932
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
933
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {receipt_path}: {exc}") from exc
|
|
934
|
+
required = {
|
|
935
|
+
"schema_version", "round", "origin_phase", "triggering_findings_sha256",
|
|
936
|
+
"surface_close_commit_sha", "pre_fix_sha", "fix_commit_sha", "post_restore_sha",
|
|
937
|
+
"restore_commit_sha", "blocks",
|
|
938
|
+
}
|
|
939
|
+
if not isinstance(receipt, dict) or set(receipt) != required:
|
|
940
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt fields are stale or malformed")
|
|
941
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
942
|
+
expected_surface_sha = surface.get("post_sha") if isinstance(surface, dict) else None
|
|
943
|
+
if (
|
|
944
|
+
receipt["schema_version"] != 1 or receipt["round"] != round_
|
|
945
|
+
or receipt["origin_phase"] != origin_phase
|
|
946
|
+
or receipt["triggering_findings_sha256"] != findings_digest
|
|
947
|
+
or receipt["surface_close_commit_sha"] != expected_surface_sha
|
|
948
|
+
):
|
|
949
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt metadata is stale")
|
|
950
|
+
blocks = receipt.get("blocks")
|
|
951
|
+
if not isinstance(blocks, list):
|
|
952
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: blocks must be an array")
|
|
953
|
+
block_fields = {"id", "path", "pre_fix_span", "classification", "finding_targeted", "action"}
|
|
954
|
+
for block in blocks:
|
|
955
|
+
span = block.get("pre_fix_span") if isinstance(block, dict) else None
|
|
956
|
+
if (
|
|
957
|
+
not isinstance(block, dict) or set(block) != block_fields
|
|
958
|
+
or not isinstance(block.get("id"), str) or not isinstance(block.get("path"), str)
|
|
959
|
+
or not isinstance(span, list) or len(span) != 2
|
|
960
|
+
or any(not isinstance(value, int) or isinstance(value, bool) or value < 1 for value in span)
|
|
961
|
+
or span[0] > span[1]
|
|
962
|
+
or block.get("classification") not in {"SURVIVED", "REVERTED", "EVOLVED"}
|
|
963
|
+
or not isinstance(block.get("finding_targeted"), bool)
|
|
964
|
+
or block.get("action") not in {"restored", "finding-targeted", "none"}
|
|
965
|
+
):
|
|
966
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: block entry is malformed")
|
|
967
|
+
sha_fields = ("pre_fix_sha", "fix_commit_sha", "post_restore_sha")
|
|
968
|
+
if any(not isinstance(receipt.get(field), str) or not re.fullmatch(r"[0-9a-f]{40,64}", receipt[field])
|
|
969
|
+
for field in sha_fields):
|
|
970
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: commit sha is malformed")
|
|
971
|
+
restore_sha = receipt.get("restore_commit_sha")
|
|
972
|
+
if restore_sha is not None and (
|
|
973
|
+
not isinstance(restore_sha, str) or not re.fullmatch(r"[0-9a-f]{40,64}", restore_sha)
|
|
974
|
+
):
|
|
975
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: restore_commit_sha is malformed")
|
|
976
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
977
|
+
if head != receipt["post_restore_sha"] or (restore_sha is not None and restore_sha != head):
|
|
978
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt does not match current HEAD")
|
|
979
|
+
if restore_sha is None and receipt["fix_commit_sha"] != receipt["post_restore_sha"]:
|
|
980
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: no-op receipt changed HEAD")
|
|
981
|
+
if restore_sha is not None:
|
|
982
|
+
parent = _git_output(work, "rev-parse", f"{restore_sha}^").decode().strip()
|
|
983
|
+
subject = _git_output(work, "show", "-s", "--format=%s", restore_sha).decode().strip()
|
|
984
|
+
if parent != receipt["fix_commit_sha"] or subject != f"chore(pipeline): closure-restore round {round_}":
|
|
985
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: restore commit evidence is stale")
|
|
986
|
+
fix_parent = _git_output(work, "rev-parse", f"{receipt['fix_commit_sha']}^").decode().strip()
|
|
987
|
+
fix_subject = _git_output(
|
|
988
|
+
work, "show", "-s", "--format=%s", receipt["fix_commit_sha"]
|
|
989
|
+
).decode().strip()
|
|
990
|
+
if fix_parent != receipt["pre_fix_sha"] or fix_subject != f"chore(pipeline): implement fix round {round_}":
|
|
991
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: fix checkpoint evidence is stale")
|
|
992
|
+
receipt_rel = f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}"
|
|
993
|
+
expected_ledger = {
|
|
994
|
+
"round": round_,
|
|
995
|
+
"origin_phase": origin_phase,
|
|
996
|
+
"receipt_path": receipt_rel,
|
|
997
|
+
"receipt_sha256": hashlib.sha256(raw).hexdigest(),
|
|
998
|
+
"restore_commit_sha": restore_sha,
|
|
999
|
+
}
|
|
1000
|
+
if matches[0] != expected_ledger:
|
|
1001
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: ledger entry is stale")
|
|
1002
|
+
return receipt
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def enforce_closure_durability_reentry(
|
|
1006
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, origin_phase: str, round_: int,
|
|
1007
|
+
*, require_existing: bool = False,
|
|
1008
|
+
) -> dict | None:
|
|
1009
|
+
if round_ < 1:
|
|
1010
|
+
return None
|
|
1011
|
+
if origin_phase not in VALID_TRIGGERS:
|
|
1012
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: invalid origin phase {origin_phase!r}")
|
|
1013
|
+
findings, findings_digest = _read_triggering_findings(devlyn, origin_phase)
|
|
1014
|
+
ledger = _surface_durability_ledger(state)
|
|
1015
|
+
receipt_path = devlyn / f"closure-durability.round-{round_}.json"
|
|
1016
|
+
existing = _validate_durability_receipt(
|
|
1017
|
+
work, devlyn, state, origin_phase, round_, receipt_path, findings_digest, ledger,
|
|
1018
|
+
)
|
|
1019
|
+
if existing is not None:
|
|
1020
|
+
return existing
|
|
1021
|
+
if require_existing:
|
|
1022
|
+
raise SystemExit(
|
|
1023
|
+
f"BLOCKED:closure-durability-receipt: round {round_} checkpoint receipt is missing"
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
if any(item.get("round") == round_ or item.get("origin_phase") == origin_phase
|
|
1027
|
+
and item.get("receipt_path") == f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}"
|
|
1028
|
+
for item in ledger):
|
|
1029
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: append-only ledger collision")
|
|
1030
|
+
if _tracked_status(work):
|
|
1031
|
+
raise SystemExit("BLOCKED:closure-durability-apply: tracked worktree/index is not clean")
|
|
1032
|
+
fix_commit_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1033
|
+
fix_subject = _git_output(work, "show", "-s", "--format=%s", fix_commit_sha).decode().strip()
|
|
1034
|
+
if fix_subject != f"chore(pipeline): implement fix round {round_}":
|
|
1035
|
+
raise SystemExit(
|
|
1036
|
+
f"BLOCKED:closure-durability-receipt: expected fix checkpoint round {round_}, got {fix_subject!r}"
|
|
1037
|
+
)
|
|
1038
|
+
pre_fix_sha = _git_output(work, "rev-parse", f"{fix_commit_sha}^").decode().strip()
|
|
1039
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
1040
|
+
surface_commit_sha = surface.get("post_sha") if isinstance(surface, dict) else None
|
|
1041
|
+
blocks = surface_change_blocks(work, state, pre_fix_sha)
|
|
1042
|
+
restore_blocks = []
|
|
1043
|
+
for block in blocks:
|
|
1044
|
+
classification = classify_surface_block(block, _worktree_file(work, block["path"]))
|
|
1045
|
+
targeted = finding_targets_block(block, findings)
|
|
1046
|
+
block["classification"] = classification
|
|
1047
|
+
block["finding_targeted"] = targeted
|
|
1048
|
+
if classification == "REVERTED" and not targeted:
|
|
1049
|
+
block["action"] = "restored"
|
|
1050
|
+
restore_blocks.append(block)
|
|
1051
|
+
elif targeted:
|
|
1052
|
+
block["action"] = "finding-targeted"
|
|
1053
|
+
else:
|
|
1054
|
+
block["action"] = "none"
|
|
1055
|
+
|
|
1056
|
+
restore_commit_sha = None
|
|
1057
|
+
if restore_blocks:
|
|
1058
|
+
desired = _restored_files(work, restore_blocks)
|
|
1059
|
+
patch = _restore_patch(work, desired)
|
|
1060
|
+
if not patch:
|
|
1061
|
+
raise SystemExit("BLOCKED:closure-durability-apply: restored blocks produced an empty patch")
|
|
1062
|
+
paths = sorted(desired)
|
|
1063
|
+
_apply_restore_patch(work, patch, paths)
|
|
1064
|
+
commit = subprocess.run(
|
|
1065
|
+
["git", "commit", "-m", f"chore(pipeline): closure-restore round {round_}"],
|
|
1066
|
+
cwd=work, capture_output=True, check=False,
|
|
1067
|
+
)
|
|
1068
|
+
if commit.returncode != 0:
|
|
1069
|
+
subprocess.run(
|
|
1070
|
+
["git", "restore", "--source=HEAD", "--staged", "--worktree", "--", *paths],
|
|
1071
|
+
cwd=work, capture_output=True, check=False,
|
|
1072
|
+
)
|
|
1073
|
+
detail = os.fsdecode(commit.stderr or commit.stdout).strip() or "git commit failed"
|
|
1074
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
1075
|
+
restore_commit_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1076
|
+
post_restore_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1077
|
+
receipt = {
|
|
1078
|
+
"schema_version": 1,
|
|
1079
|
+
"round": round_,
|
|
1080
|
+
"origin_phase": origin_phase,
|
|
1081
|
+
"triggering_findings_sha256": findings_digest,
|
|
1082
|
+
"surface_close_commit_sha": surface_commit_sha,
|
|
1083
|
+
"pre_fix_sha": pre_fix_sha,
|
|
1084
|
+
"fix_commit_sha": fix_commit_sha,
|
|
1085
|
+
"post_restore_sha": post_restore_sha,
|
|
1086
|
+
"restore_commit_sha": restore_commit_sha,
|
|
1087
|
+
"blocks": _receipt_blocks(blocks),
|
|
1088
|
+
}
|
|
1089
|
+
try:
|
|
1090
|
+
raw = _write_json_atomic(receipt_path, receipt)
|
|
1091
|
+
ledger.append({
|
|
1092
|
+
"round": round_,
|
|
1093
|
+
"origin_phase": origin_phase,
|
|
1094
|
+
"receipt_path": f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}",
|
|
1095
|
+
"receipt_sha256": hashlib.sha256(raw).hexdigest(),
|
|
1096
|
+
"restore_commit_sha": restore_commit_sha,
|
|
1097
|
+
})
|
|
1098
|
+
except BaseException as exc:
|
|
1099
|
+
_rollback_durability_creation(work, receipt_path, state, receipt)
|
|
1100
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {exc}") from exc
|
|
1101
|
+
return receipt
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _persist_durability_event(
|
|
1105
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, state_path: pathlib.Path,
|
|
1106
|
+
origin_phase: str, round_: int, writer=write_state,
|
|
1107
|
+
) -> dict:
|
|
1108
|
+
receipt_path = devlyn / f"closure-durability.round-{round_}.json"
|
|
1109
|
+
existed = receipt_path.exists()
|
|
1110
|
+
receipt = enforce_closure_durability_reentry(
|
|
1111
|
+
work, devlyn, state, origin_phase, round_,
|
|
1112
|
+
)
|
|
1113
|
+
try:
|
|
1114
|
+
writer(state_path, state)
|
|
1115
|
+
except BaseException:
|
|
1116
|
+
if not existed:
|
|
1117
|
+
_rollback_durability_creation(work, receipt_path, state, receipt)
|
|
1118
|
+
raise
|
|
1119
|
+
return receipt
|
|
1120
|
+
|
|
1121
|
+
|
|
82
1122
|
def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
83
1123
|
# The per-round reset contract covers files, not just JSON fields: a
|
|
84
1124
|
# VERIFY fix-loop respawn reuses the same .devlyn, so a prior round's
|
|
@@ -94,34 +1134,57 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
|
94
1134
|
(devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
|
|
95
1135
|
|
|
96
1136
|
|
|
97
|
-
def
|
|
1137
|
+
def append_phase_history(entry: dict) -> None:
|
|
98
1138
|
if entry.get("started_at") is None:
|
|
99
1139
|
return
|
|
100
|
-
history = entry.get("
|
|
1140
|
+
history = entry.get("history")
|
|
101
1141
|
if not isinstance(history, list):
|
|
102
1142
|
history = []
|
|
103
1143
|
history.append({
|
|
104
|
-
"round": entry.get("round"),
|
|
105
1144
|
"started_at": entry.get("started_at"),
|
|
1145
|
+
"verdict": entry.get("verdict"),
|
|
106
1146
|
"completed_at": entry.get("completed_at"),
|
|
107
1147
|
"duration_ms": entry.get("duration_ms"),
|
|
108
|
-
"verdict": entry.get("verdict"),
|
|
109
|
-
"triggered_by": entry.get("triggered_by"),
|
|
110
|
-
"engine": entry.get("engine"),
|
|
111
1148
|
})
|
|
112
|
-
entry["
|
|
1149
|
+
entry["history"] = history
|
|
113
1150
|
|
|
114
1151
|
|
|
115
1152
|
def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
116
|
-
pre_sha: str | None, engine: str | None, model: str | None
|
|
1153
|
+
pre_sha: str | None, engine: str | None, model: str | None, *,
|
|
1154
|
+
input_patch_sha256: str | None = None,
|
|
1155
|
+
prompt_sha256: str | None = None,
|
|
1156
|
+
untracked_before: list[str] | None = None) -> None:
|
|
117
1157
|
# Merge, don't replace: a phase-gated large run's `exec` progress (or any
|
|
118
1158
|
# other field this script doesn't own) survives a fix-loop respawn.
|
|
1159
|
+
if phase == "surface_close" and engine != "claude":
|
|
1160
|
+
raise SystemExit("error: phases.surface_close spawn requires --engine claude")
|
|
1161
|
+
if phase == "surface_close" and not model:
|
|
1162
|
+
raise SystemExit("error: phases.surface_close spawn requires --model")
|
|
119
1163
|
phases = state.setdefault("phases", {})
|
|
120
1164
|
entry = phases.get(phase)
|
|
1165
|
+
if phase == "surface_close" and isinstance(entry, dict) and (
|
|
1166
|
+
entry.get("started_at") is not None or entry.get("skipped_reason") is not None
|
|
1167
|
+
):
|
|
1168
|
+
raise SystemExit("error: phases.surface_close is one-shot and cannot be re-entered")
|
|
1169
|
+
if phase == "surface_close":
|
|
1170
|
+
if pre_sha is None:
|
|
1171
|
+
raise SystemExit("error: phases.surface_close spawn requires --pre-sha")
|
|
1172
|
+
if input_patch_sha256 is None or not SHA256_RE.fullmatch(input_patch_sha256):
|
|
1173
|
+
raise SystemExit("error: phases.surface_close spawn requires --input-patch-sha256")
|
|
1174
|
+
if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
|
|
1175
|
+
raise SystemExit("error: phases.surface_close spawn requires --prompt-sha256")
|
|
1176
|
+
if untracked_before is None:
|
|
1177
|
+
raise SystemExit("error: phases.surface_close spawn requires --untracked-before-json")
|
|
1178
|
+
elif input_patch_sha256 is not None or prompt_sha256 is not None or untracked_before is not None:
|
|
1179
|
+
raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
|
|
121
1180
|
if not isinstance(entry, dict):
|
|
122
1181
|
entry = {}
|
|
123
1182
|
phases[phase] = entry
|
|
124
|
-
|
|
1183
|
+
if entry.get("started_at") is not None and entry.get("completed_at") is None:
|
|
1184
|
+
raise SystemExit(
|
|
1185
|
+
f"error: phases.{phase} has an open span — complete it before respawn"
|
|
1186
|
+
)
|
|
1187
|
+
append_phase_history(entry)
|
|
125
1188
|
entry["started_at"] = now_iso()
|
|
126
1189
|
entry["completed_at"] = None
|
|
127
1190
|
entry["duration_ms"] = None
|
|
@@ -130,17 +1193,52 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
130
1193
|
entry["verdict"] = None
|
|
131
1194
|
entry["artifacts"] = {"findings_file": None, "log_file": None}
|
|
132
1195
|
entry["sub_verdicts"] = None
|
|
1196
|
+
if phase == "verify":
|
|
1197
|
+
entry["judge_durations_ms"] = None
|
|
133
1198
|
if engine is not None:
|
|
134
1199
|
entry["engine"] = engine
|
|
135
1200
|
if model is not None:
|
|
136
|
-
entry["
|
|
1201
|
+
entry["model_requested"] = model
|
|
1202
|
+
else:
|
|
1203
|
+
entry.setdefault("model_requested", None)
|
|
1204
|
+
entry.pop("model", None)
|
|
1205
|
+
entry["model_effective"] = None
|
|
137
1206
|
if pre_sha is not None:
|
|
138
1207
|
entry["pre_sha"] = pre_sha
|
|
1208
|
+
if phase == "surface_close":
|
|
1209
|
+
entry["input_patch_sha256"] = input_patch_sha256
|
|
1210
|
+
entry["prompt_sha256"] = prompt_sha256
|
|
1211
|
+
entry["untracked_before"] = untracked_before
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def do_surface_skip(state: dict) -> None:
|
|
1215
|
+
phases = state.setdefault("phases", {})
|
|
1216
|
+
existing = phases.get("surface_close")
|
|
1217
|
+
if isinstance(existing, dict) and (
|
|
1218
|
+
existing.get("started_at") is not None or existing.get("skipped_reason") is not None
|
|
1219
|
+
):
|
|
1220
|
+
raise SystemExit("error: phases.surface_close is one-shot and cannot be re-entered")
|
|
1221
|
+
phases["surface_close"] = {
|
|
1222
|
+
"started_at": None,
|
|
1223
|
+
"completed_at": now_iso(),
|
|
1224
|
+
"duration_ms": 0,
|
|
1225
|
+
"round": 0,
|
|
1226
|
+
"triggered_by": None,
|
|
1227
|
+
"verdict": None,
|
|
1228
|
+
"engine": "claude",
|
|
1229
|
+
"model_requested": None,
|
|
1230
|
+
"model_effective": None,
|
|
1231
|
+
"artifacts": {"findings_file": None, "log_file": None},
|
|
1232
|
+
"sub_verdicts": None,
|
|
1233
|
+
"skipped_reason": SURFACE_SKIP_REASON,
|
|
1234
|
+
}
|
|
139
1235
|
|
|
140
1236
|
|
|
141
1237
|
def do_complete(state: dict, phase: str, verdict: str | None,
|
|
142
1238
|
post_sha: str | None, findings_file: str | None, log_file: str | None,
|
|
143
|
-
engine: str | None, model: str | None
|
|
1239
|
+
engine: str | None, model: str | None,
|
|
1240
|
+
engine_session_log: str | None = None,
|
|
1241
|
+
devlyn: pathlib.Path | None = None) -> str | None:
|
|
144
1242
|
phases = state.setdefault("phases", {})
|
|
145
1243
|
entry = phases.get(phase)
|
|
146
1244
|
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
@@ -173,9 +1271,110 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
173
1271
|
if engine is not None:
|
|
174
1272
|
entry["engine"] = engine
|
|
175
1273
|
if model is not None:
|
|
176
|
-
entry["
|
|
1274
|
+
entry["model_requested"] = model
|
|
1275
|
+
else:
|
|
1276
|
+
entry.setdefault("model_requested", None)
|
|
1277
|
+
entry.pop("model", None)
|
|
1278
|
+
|
|
1279
|
+
artifact_phase = WORKER_SESSION_ARTIFACT_PHASES.get(phase)
|
|
1280
|
+
retained_session = None
|
|
1281
|
+
if devlyn is not None and artifact_phase is not None:
|
|
1282
|
+
candidate = devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
|
|
1283
|
+
if candidate.is_file():
|
|
1284
|
+
retained_session = candidate
|
|
1285
|
+
|
|
1286
|
+
attestation_error = None
|
|
1287
|
+
if engine_session_log is None:
|
|
1288
|
+
entry["model_effective"] = None
|
|
1289
|
+
if retained_session is not None:
|
|
1290
|
+
attestation_error = (
|
|
1291
|
+
"BLOCKED:model-attestation-failed: --engine-session-log is required because "
|
|
1292
|
+
f"retained worker session exists: {retained_session}"
|
|
1293
|
+
)
|
|
1294
|
+
else:
|
|
1295
|
+
try:
|
|
1296
|
+
entry["model_effective"] = parse_effective_model(pathlib.Path(engine_session_log))
|
|
1297
|
+
except ValueError as exc:
|
|
1298
|
+
entry["model_effective"] = None
|
|
1299
|
+
attestation_error = f"BLOCKED:model-attestation-failed: {exc}"
|
|
1300
|
+
requested = entry.get("model_requested")
|
|
1301
|
+
effective = entry.get("model_effective")
|
|
1302
|
+
if attestation_error is None and requested is not None and requested != effective:
|
|
1303
|
+
attestation_error = (
|
|
1304
|
+
"BLOCKED:model-attestation-mismatch: "
|
|
1305
|
+
f"requested={requested} effective={effective}"
|
|
1306
|
+
)
|
|
1307
|
+
if attestation_error is not None:
|
|
1308
|
+
entry["verdict"] = "BLOCKED"
|
|
177
1309
|
if post_sha is not None:
|
|
178
1310
|
entry["post_sha"] = post_sha
|
|
1311
|
+
return attestation_error
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def do_transition(
|
|
1315
|
+
state: dict,
|
|
1316
|
+
phase: str,
|
|
1317
|
+
next_phase: str,
|
|
1318
|
+
verdict: str | None,
|
|
1319
|
+
post_sha: str | None,
|
|
1320
|
+
findings_file: str | None,
|
|
1321
|
+
log_file: str | None,
|
|
1322
|
+
engine: str | None,
|
|
1323
|
+
model: str | None,
|
|
1324
|
+
engine_session_log: str | None,
|
|
1325
|
+
devlyn: pathlib.Path,
|
|
1326
|
+
next_round: int,
|
|
1327
|
+
next_triggered_by: str | None,
|
|
1328
|
+
next_pre_sha: str | None,
|
|
1329
|
+
next_engine: str | None,
|
|
1330
|
+
next_model: str | None,
|
|
1331
|
+
*,
|
|
1332
|
+
next_input_patch_sha256: str | None = None,
|
|
1333
|
+
next_prompt_sha256: str | None = None,
|
|
1334
|
+
next_untracked_before: list[str] | None = None,
|
|
1335
|
+
between=None,
|
|
1336
|
+
) -> dict:
|
|
1337
|
+
"""Validate complete + caller-selected spawn against a copy of state.
|
|
1338
|
+
|
|
1339
|
+
The caller owns the next phase and every judgment-bearing argument. This
|
|
1340
|
+
primitive only validates the requested edge and applies both lifecycle
|
|
1341
|
+
mutations to a detached candidate, which the CLI commits with one atomic
|
|
1342
|
+
state-file replacement.
|
|
1343
|
+
"""
|
|
1344
|
+
if next_phase not in LEGAL_TRANSITIONS.get(phase, set()):
|
|
1345
|
+
raise SystemExit(f"error: illegal phase transition: {phase} -> {next_phase}")
|
|
1346
|
+
candidate = copy.deepcopy(state)
|
|
1347
|
+
attestation_error = do_complete(
|
|
1348
|
+
candidate, phase, verdict, post_sha, findings_file, log_file,
|
|
1349
|
+
engine, model, engine_session_log, devlyn,
|
|
1350
|
+
)
|
|
1351
|
+
if attestation_error is not None:
|
|
1352
|
+
raise SystemExit(attestation_error)
|
|
1353
|
+
if between is not None:
|
|
1354
|
+
between()
|
|
1355
|
+
do_spawn(
|
|
1356
|
+
candidate, next_phase, next_round, next_triggered_by,
|
|
1357
|
+
next_pre_sha, next_engine, next_model,
|
|
1358
|
+
input_patch_sha256=next_input_patch_sha256,
|
|
1359
|
+
prompt_sha256=next_prompt_sha256,
|
|
1360
|
+
untracked_before=next_untracked_before,
|
|
1361
|
+
)
|
|
1362
|
+
return candidate
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def do_surface_adjudication_recovery(state: dict, devlyn: pathlib.Path) -> str | None:
|
|
1366
|
+
entry = surface_entry(state)
|
|
1367
|
+
attestation_error = do_complete(
|
|
1368
|
+
state, "surface_close", "BLOCKED", entry["pre_sha"], None,
|
|
1369
|
+
".devlyn/surface-close.stdout", None, None,
|
|
1370
|
+
str(devlyn / "surface-close.output.json"), devlyn,
|
|
1371
|
+
)
|
|
1372
|
+
if attestation_error is not None:
|
|
1373
|
+
return attestation_error
|
|
1374
|
+
entry["verdict"] = None
|
|
1375
|
+
entry["skipped_reason"] = SURFACE_RECOVERY_REASON
|
|
1376
|
+
entry["continued_after_block"] = True
|
|
1377
|
+
return None
|
|
179
1378
|
|
|
180
1379
|
|
|
181
1380
|
def self_test() -> int:
|
|
@@ -197,6 +1396,7 @@ def self_test() -> int:
|
|
|
197
1396
|
do_complete(state, "implement", "NEEDS_WORK", None, None, None, None, "test-model-id")
|
|
198
1397
|
write_state(state_path, state)
|
|
199
1398
|
entry = read_state(state_path)["phases"]["implement"]
|
|
1399
|
+
assert "history" not in entry, "history must be absent before re-entry"
|
|
200
1400
|
assert entry["completed_at"] is not None
|
|
201
1401
|
assert entry["duration_ms"] >= 0
|
|
202
1402
|
assert parse_iso(entry["completed_at"]) >= parse_iso(entry["started_at"])
|
|
@@ -216,15 +1416,13 @@ def self_test() -> int:
|
|
|
216
1416
|
assert respawned["engine"] == "claude", "engine preserved when not re-supplied"
|
|
217
1417
|
assert respawned["round"] == 1
|
|
218
1418
|
assert respawned["triggered_by"] == "verify"
|
|
219
|
-
assert len(respawned["
|
|
220
|
-
history0 = respawned["
|
|
221
|
-
assert history0["round"] == 0
|
|
1419
|
+
assert len(respawned["history"]) == 1
|
|
1420
|
+
history0 = respawned["history"][0]
|
|
222
1421
|
assert history0["started_at"] == round0_started
|
|
1422
|
+
assert history0["verdict"] == "NEEDS_WORK"
|
|
223
1423
|
assert history0["completed_at"] == entry["completed_at"]
|
|
224
1424
|
assert history0["duration_ms"] == entry["duration_ms"]
|
|
225
|
-
assert history0
|
|
226
|
-
assert history0["triggered_by"] is None
|
|
227
|
-
assert history0["engine"] == "claude"
|
|
1425
|
+
assert set(history0) == {"started_at", "verdict", "completed_at", "duration_ms"}
|
|
228
1426
|
|
|
229
1427
|
time.sleep(0.05)
|
|
230
1428
|
state = read_state(state_path)
|
|
@@ -246,6 +1444,163 @@ def self_test() -> int:
|
|
|
246
1444
|
else:
|
|
247
1445
|
raise AssertionError("complete() without a prior spawn() must raise")
|
|
248
1446
|
|
|
1447
|
+
# F11 rs-20260721T065728Z receipt: VERIFY merge wrote BLOCKED, but the
|
|
1448
|
+
# caller skipped complete() before re-entry. Respawn must fail without
|
|
1449
|
+
# archiving the open span as history.
|
|
1450
|
+
f11_open = {
|
|
1451
|
+
"phases": {
|
|
1452
|
+
"verify": {
|
|
1453
|
+
"started_at": "2026-07-21T07:08:11.684Z",
|
|
1454
|
+
"completed_at": None,
|
|
1455
|
+
"duration_ms": None,
|
|
1456
|
+
"verdict": "BLOCKED",
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
before_f11 = json.dumps(f11_open, sort_keys=True)
|
|
1461
|
+
try:
|
|
1462
|
+
do_spawn(f11_open, "verify", 1, "verify", None, "codex", None)
|
|
1463
|
+
except SystemExit as e:
|
|
1464
|
+
assert "open span" in str(e) and "complete it before respawn" in str(e)
|
|
1465
|
+
else:
|
|
1466
|
+
raise AssertionError("respawn over an open F11 span must fail")
|
|
1467
|
+
assert json.dumps(f11_open, sort_keys=True) == before_f11
|
|
1468
|
+
assert "history" not in f11_open["phases"]["verify"]
|
|
1469
|
+
|
|
1470
|
+
# Transition is one state transaction: a forced failure between its
|
|
1471
|
+
# validated halves leaves the authoritative state byte-identical.
|
|
1472
|
+
transition_state = {
|
|
1473
|
+
"phases": {
|
|
1474
|
+
"plan": {
|
|
1475
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1476
|
+
"completed_at": None,
|
|
1477
|
+
"duration_ms": None,
|
|
1478
|
+
"round": 0,
|
|
1479
|
+
"triggered_by": None,
|
|
1480
|
+
"verdict": None,
|
|
1481
|
+
},
|
|
1482
|
+
"implement": None,
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
write_state(state_path, transition_state)
|
|
1486
|
+
transition_before = state_path.read_bytes()
|
|
1487
|
+
|
|
1488
|
+
def fail_between_halves() -> None:
|
|
1489
|
+
raise RuntimeError("forced transition failure")
|
|
1490
|
+
|
|
1491
|
+
try:
|
|
1492
|
+
do_transition(
|
|
1493
|
+
transition_state, "plan", "implement", "PASS", None,
|
|
1494
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1495
|
+
"claude", None, between=fail_between_halves,
|
|
1496
|
+
)
|
|
1497
|
+
except RuntimeError as exc:
|
|
1498
|
+
assert str(exc) == "forced transition failure"
|
|
1499
|
+
else:
|
|
1500
|
+
raise AssertionError("forced transition failure did not fire")
|
|
1501
|
+
assert state_path.read_bytes() == transition_before
|
|
1502
|
+
assert transition_state["phases"]["plan"]["completed_at"] is None
|
|
1503
|
+
print("PASS self-test transition atomicity: forced midpoint failure left state unchanged")
|
|
1504
|
+
|
|
1505
|
+
attestation_state = copy.deepcopy(transition_state)
|
|
1506
|
+
attestation_state["phases"]["plan"]["model_requested"] = "wanted-model"
|
|
1507
|
+
attestation_log = devlyn / "transition-attestation.log"
|
|
1508
|
+
attestation_log.write_text("model: other-model\n", encoding="utf-8")
|
|
1509
|
+
write_state(state_path, attestation_state)
|
|
1510
|
+
attestation_before = state_path.read_bytes()
|
|
1511
|
+
try:
|
|
1512
|
+
do_transition(
|
|
1513
|
+
attestation_state, "plan", "implement", "PASS", None,
|
|
1514
|
+
None, None, None, None, str(attestation_log), devlyn,
|
|
1515
|
+
0, None, None, "claude", None,
|
|
1516
|
+
)
|
|
1517
|
+
except SystemExit as exc:
|
|
1518
|
+
assert "BLOCKED:model-attestation-mismatch" in str(exc)
|
|
1519
|
+
else:
|
|
1520
|
+
raise AssertionError("transition accepted mismatched model attestation")
|
|
1521
|
+
assert state_path.read_bytes() == attestation_before
|
|
1522
|
+
print("PASS self-test transition attestation: mismatch left state unchanged")
|
|
1523
|
+
|
|
1524
|
+
try:
|
|
1525
|
+
do_transition(
|
|
1526
|
+
transition_state, "plan", "cleanup", "PASS", None,
|
|
1527
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1528
|
+
"claude", None,
|
|
1529
|
+
)
|
|
1530
|
+
except SystemExit as exc:
|
|
1531
|
+
assert "illegal phase transition: plan -> cleanup" in str(exc)
|
|
1532
|
+
else:
|
|
1533
|
+
raise AssertionError("transition accepted an illegal phase edge")
|
|
1534
|
+
assert state_path.read_bytes() == attestation_before
|
|
1535
|
+
print("PASS self-test transition legal-edge guard: illegal edge left state unchanged")
|
|
1536
|
+
|
|
1537
|
+
transitioned = do_transition(
|
|
1538
|
+
transition_state, "plan", "implement", "PASS", None,
|
|
1539
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1540
|
+
"claude", None,
|
|
1541
|
+
)
|
|
1542
|
+
write_state(state_path, transitioned)
|
|
1543
|
+
assert transitioned["phases"]["plan"]["verdict"] == "PASS"
|
|
1544
|
+
assert transitioned["phases"]["plan"]["completed_at"] is not None
|
|
1545
|
+
assert transitioned["phases"]["implement"]["started_at"] is not None
|
|
1546
|
+
assert transitioned["phases"]["implement"]["verdict"] is None
|
|
1547
|
+
print("PASS self-test transition happy path: complete + spawn committed together")
|
|
1548
|
+
|
|
1549
|
+
cli_state = {
|
|
1550
|
+
"phases": {
|
|
1551
|
+
"plan": {
|
|
1552
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1553
|
+
"completed_at": None,
|
|
1554
|
+
"duration_ms": None,
|
|
1555
|
+
"round": 0,
|
|
1556
|
+
"triggered_by": None,
|
|
1557
|
+
"verdict": None,
|
|
1558
|
+
},
|
|
1559
|
+
"implement": None,
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
write_state(state_path, cli_state)
|
|
1563
|
+
cli_transition = subprocess.run(
|
|
1564
|
+
[
|
|
1565
|
+
sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
1566
|
+
"--devlyn-dir", str(devlyn), "--phase", "plan", "transition",
|
|
1567
|
+
"--verdict", "PASS", "--next-phase", "implement",
|
|
1568
|
+
"--next-round", "0", "--next-engine", "claude",
|
|
1569
|
+
],
|
|
1570
|
+
capture_output=True, text=True,
|
|
1571
|
+
)
|
|
1572
|
+
assert cli_transition.returncode == 0, cli_transition.stderr
|
|
1573
|
+
cli_receipt = loads_strict_json(cli_transition.stdout)
|
|
1574
|
+
assert cli_receipt["completed_phase"] == "plan"
|
|
1575
|
+
assert cli_receipt["completed_verdict"] == "PASS"
|
|
1576
|
+
assert cli_receipt["next_phase"] == "implement"
|
|
1577
|
+
assert cli_receipt["state_sha256"] == hashlib.sha256(state_path.read_bytes()).hexdigest()
|
|
1578
|
+
print("PASS self-test transition CLI: machine-only JSON receipt")
|
|
1579
|
+
|
|
1580
|
+
open_next = copy.deepcopy(transition_state)
|
|
1581
|
+
open_next["phases"]["implement"] = {
|
|
1582
|
+
"started_at": "2026-01-01T00:00:01.000Z",
|
|
1583
|
+
"completed_at": None,
|
|
1584
|
+
"duration_ms": None,
|
|
1585
|
+
"round": 0,
|
|
1586
|
+
"triggered_by": None,
|
|
1587
|
+
"verdict": None,
|
|
1588
|
+
}
|
|
1589
|
+
write_state(state_path, open_next)
|
|
1590
|
+
open_next_before = state_path.read_bytes()
|
|
1591
|
+
try:
|
|
1592
|
+
do_transition(
|
|
1593
|
+
open_next, "plan", "implement", "PASS", None,
|
|
1594
|
+
None, None, None, None, None, devlyn, 1, None, None,
|
|
1595
|
+
"claude", None,
|
|
1596
|
+
)
|
|
1597
|
+
except SystemExit as exc:
|
|
1598
|
+
assert "open span" in str(exc) and "complete it before respawn" in str(exc)
|
|
1599
|
+
else:
|
|
1600
|
+
raise AssertionError("transition opened a phase that already had an open span")
|
|
1601
|
+
assert state_path.read_bytes() == open_next_before
|
|
1602
|
+
print("PASS self-test transition open-span guard: rejected without mutation")
|
|
1603
|
+
|
|
249
1604
|
# A completed FAIL round must be retained before a fix-loop respawn
|
|
250
1605
|
# resets the live record.
|
|
251
1606
|
write_state(state_path, {"phases": {}})
|
|
@@ -262,17 +1617,19 @@ def self_test() -> int:
|
|
|
262
1617
|
write_state(state_path, state)
|
|
263
1618
|
respawned_fail = read_state(state_path)["phases"]["build_gate"]
|
|
264
1619
|
assert respawned_fail["verdict"] is None
|
|
265
|
-
assert len(respawned_fail["
|
|
266
|
-
assert respawned_fail["
|
|
267
|
-
assert respawned_fail["
|
|
1620
|
+
assert len(respawned_fail["history"]) == 1
|
|
1621
|
+
assert respawned_fail["history"][0]["verdict"] == "FAIL"
|
|
1622
|
+
assert respawned_fail["history"][0]["completed_at"] == failed_round["completed_at"]
|
|
268
1623
|
|
|
269
1624
|
# VERIFY flow: verify-merge-findings.py already wrote verdict; complete()
|
|
270
1625
|
# must preserve it when --verdict is omitted.
|
|
271
1626
|
write_state(state_path, {"phases": {}})
|
|
272
1627
|
state = read_state(state_path)
|
|
273
1628
|
do_spawn(state, "verify", 0, None, None, "claude", None)
|
|
1629
|
+
assert state["phases"]["verify"]["judge_durations_ms"] is None
|
|
274
1630
|
state["phases"]["verify"]["verdict"] = "PASS"
|
|
275
1631
|
state["phases"]["verify"]["sub_verdicts"] = {"mechanical": "PASS", "judge": "PASS"}
|
|
1632
|
+
state["phases"]["verify"]["judge_durations_ms"] = {"judge": 23, "pair_judge": None}
|
|
276
1633
|
write_state(state_path, state)
|
|
277
1634
|
state = read_state(state_path)
|
|
278
1635
|
do_complete(state, "verify", None, None, None, None, None, None)
|
|
@@ -280,6 +1637,7 @@ def self_test() -> int:
|
|
|
280
1637
|
verify_entry = read_state(state_path)["phases"]["verify"]
|
|
281
1638
|
assert verify_entry["verdict"] == "PASS", "complete() must preserve pre-set verdict when omitted"
|
|
282
1639
|
assert verify_entry["sub_verdicts"] == {"mechanical": "PASS", "judge": "PASS"}
|
|
1640
|
+
assert verify_entry["judge_durations_ms"] == {"judge": 23, "pair_judge": None}
|
|
283
1641
|
assert verify_entry["completed_at"] is not None
|
|
284
1642
|
|
|
285
1643
|
# An explicit --verdict for VERIFY must be rejected — its verdict is
|
|
@@ -291,6 +1649,8 @@ def self_test() -> int:
|
|
|
291
1649
|
assert "owned by verify-merge-findings.py" in str(e)
|
|
292
1650
|
else:
|
|
293
1651
|
raise AssertionError("complete() must reject an explicit --verdict for VERIFY")
|
|
1652
|
+
do_spawn(state, "verify", 1, "verify", None, None, None)
|
|
1653
|
+
assert state["phases"]["verify"]["judge_durations_ms"] is None
|
|
294
1654
|
|
|
295
1655
|
# Non-VERIFY phases require --verdict explicitly; complete() must not
|
|
296
1656
|
# silently accept an unset verdict the way VERIFY's omit-to-preserve
|
|
@@ -368,8 +1728,8 @@ def self_test() -> int:
|
|
|
368
1728
|
respawned_exec = read_state(state_path)["phases"]["implement"]
|
|
369
1729
|
assert respawned_exec["exec"]["current"] == 3, "spawn must not clobber unowned fields like exec"
|
|
370
1730
|
assert respawned_exec["verdict"] is None, "respawn still nulls owned fields even with exec present"
|
|
371
|
-
assert len(respawned_exec["
|
|
372
|
-
assert respawned_exec["
|
|
1731
|
+
assert len(respawned_exec["history"]) == 1
|
|
1732
|
+
assert respawned_exec["history"][0]["verdict"] == "PASS"
|
|
373
1733
|
|
|
374
1734
|
# Existing history is append-only; a respawn must not clobber prior
|
|
375
1735
|
# entries that were already preserved from older rounds.
|
|
@@ -383,14 +1743,11 @@ def self_test() -> int:
|
|
|
383
1743
|
"triggered_by": "verify",
|
|
384
1744
|
"verdict": "FAIL",
|
|
385
1745
|
"engine": "codex",
|
|
386
|
-
"
|
|
387
|
-
"round": 1,
|
|
1746
|
+
"history": [{
|
|
388
1747
|
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1748
|
+
"verdict": "FAIL",
|
|
389
1749
|
"completed_at": "2026-01-01T00:00:01.000Z",
|
|
390
1750
|
"duration_ms": 1000,
|
|
391
|
-
"verdict": "FAIL",
|
|
392
|
-
"triggered_by": "build_gate",
|
|
393
|
-
"engine": "claude",
|
|
394
1751
|
}],
|
|
395
1752
|
}
|
|
396
1753
|
}
|
|
@@ -398,11 +1755,10 @@ def self_test() -> int:
|
|
|
398
1755
|
state = read_state(state_path)
|
|
399
1756
|
do_spawn(state, "implement", 3, "verify", None, None, None)
|
|
400
1757
|
write_state(state_path, state)
|
|
401
|
-
history_preserved = read_state(state_path)["phases"]["implement"]["
|
|
1758
|
+
history_preserved = read_state(state_path)["phases"]["implement"]["history"]
|
|
402
1759
|
assert len(history_preserved) == 2
|
|
403
|
-
assert history_preserved[0]["
|
|
404
|
-
assert history_preserved[1]["
|
|
405
|
-
assert history_preserved[1]["engine"] == "codex"
|
|
1760
|
+
assert history_preserved[0]["started_at"] == "2026-01-01T00:00:00.000Z"
|
|
1761
|
+
assert history_preserved[1]["started_at"] == "2026-01-01T00:00:02.000Z"
|
|
406
1762
|
|
|
407
1763
|
# complete() records a post-state commit when a bounded phase needs an
|
|
408
1764
|
# exact diff window for later mechanical checks.
|
|
@@ -417,6 +1773,727 @@ def self_test() -> int:
|
|
|
417
1773
|
assert cleanup_entry["pre_sha"] == "pre-sha"
|
|
418
1774
|
assert cleanup_entry["post_sha"] == "post-sha"
|
|
419
1775
|
|
|
1776
|
+
# SURFACE_CLOSE keeps its one-shot envelope, adjudication grammar,
|
|
1777
|
+
# scope boundary, rollback, and execution prohibition mechanical.
|
|
1778
|
+
work = devlyn / "surface-repo"
|
|
1779
|
+
work.mkdir()
|
|
1780
|
+
subprocess.run(["git", "init", "-q"], cwd=work, check=True)
|
|
1781
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=work, check=True)
|
|
1782
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=work, check=True)
|
|
1783
|
+
(work / "allowed.txt").write_text("base\n", encoding="utf-8")
|
|
1784
|
+
(work / "blocked.txt").write_text("base\n", encoding="utf-8")
|
|
1785
|
+
(work / "schedule").mkdir()
|
|
1786
|
+
(work / "schedule" / "__init__.py").write_text("line\n" * 700, encoding="utf-8")
|
|
1787
|
+
(work / "test_schedule.py").write_text("line\n", encoding="utf-8")
|
|
1788
|
+
(work / "tests").mkdir()
|
|
1789
|
+
(work / "tests" / "cli.test.js").write_text("line\n" * 170, encoding="utf-8")
|
|
1790
|
+
subprocess.run(
|
|
1791
|
+
["git", "add", "--", "allowed.txt", "blocked.txt", "schedule/__init__.py",
|
|
1792
|
+
"test_schedule.py", "tests/cli.test.js"],
|
|
1793
|
+
cwd=work, check=True,
|
|
1794
|
+
)
|
|
1795
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=work, check=True)
|
|
1796
|
+
pre_sha = subprocess.run(
|
|
1797
|
+
["git", "rev-parse", "HEAD"], cwd=work, check=True, capture_output=True, text=True,
|
|
1798
|
+
).stdout.strip()
|
|
1799
|
+
work_devlyn = work / ".devlyn"
|
|
1800
|
+
work_devlyn.mkdir()
|
|
1801
|
+
(work / "kept.txt").write_text("keep\n", encoding="utf-8")
|
|
1802
|
+
goal = work_devlyn / "goal.raw.txt"
|
|
1803
|
+
patch = work_devlyn / "surface-close.input.patch"
|
|
1804
|
+
prompt = work_devlyn / "surface-close.prompt"
|
|
1805
|
+
goal.write_text("goal\n", encoding="utf-8")
|
|
1806
|
+
patch.write_text("patch\n", encoding="utf-8")
|
|
1807
|
+
prompt.write_text("adapter\nbody\ninputs\n", encoding="utf-8")
|
|
1808
|
+
surface_state = {
|
|
1809
|
+
"source": {
|
|
1810
|
+
"goal_path": ".devlyn/goal.raw.txt",
|
|
1811
|
+
"goal_sha256": file_sha256(goal),
|
|
1812
|
+
},
|
|
1813
|
+
"phases": {},
|
|
1814
|
+
}
|
|
1815
|
+
for rejected_engine in (None, "codex"):
|
|
1816
|
+
rejected_state = {"sentinel": True}
|
|
1817
|
+
try:
|
|
1818
|
+
do_spawn(
|
|
1819
|
+
rejected_state, "surface_close", 0, None, pre_sha, rejected_engine, "sonnet",
|
|
1820
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
1821
|
+
untracked_before=["kept.txt"],
|
|
1822
|
+
)
|
|
1823
|
+
except SystemExit as exc:
|
|
1824
|
+
assert "requires --engine claude" in str(exc)
|
|
1825
|
+
else:
|
|
1826
|
+
raise AssertionError("SURFACE_CLOSE accepted a non-Claude engine")
|
|
1827
|
+
assert rejected_state == {"sentinel": True}
|
|
1828
|
+
rejected_state = {"sentinel": True}
|
|
1829
|
+
try:
|
|
1830
|
+
do_spawn(
|
|
1831
|
+
rejected_state, "surface_close", 0, None, pre_sha, "claude", None,
|
|
1832
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
1833
|
+
untracked_before=["kept.txt"],
|
|
1834
|
+
)
|
|
1835
|
+
except SystemExit as exc:
|
|
1836
|
+
assert "phases.surface_close spawn requires --model" in str(exc)
|
|
1837
|
+
else:
|
|
1838
|
+
raise AssertionError("SURFACE_CLOSE accepted a missing requested model")
|
|
1839
|
+
assert rejected_state == {"sentinel": True}
|
|
1840
|
+
do_spawn(
|
|
1841
|
+
surface_state, "surface_close", 0, None, pre_sha, "claude", "sonnet",
|
|
1842
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
1843
|
+
untracked_before=["kept.txt"],
|
|
1844
|
+
)
|
|
1845
|
+
validate_surface_inputs(work, work_devlyn, surface_state)
|
|
1846
|
+
validate_surface_prompt(work_devlyn, surface_state)
|
|
1847
|
+
ensure_surface_clean_baseline(work, work_devlyn, surface_state)
|
|
1848
|
+
surface = validate_authorized_surface(
|
|
1849
|
+
'["allowed.txt", "schedule/**", "test_schedule.py", "tests/**"]'
|
|
1850
|
+
)
|
|
1851
|
+
entry = surface_entry(surface_state)
|
|
1852
|
+
assert entry["prompt_sha256"] == file_sha256(prompt)
|
|
1853
|
+
assert entry["model_requested"] == "sonnet"
|
|
1854
|
+
try:
|
|
1855
|
+
do_spawn(surface_state, "surface_close", 1, None, pre_sha, "claude", "sonnet")
|
|
1856
|
+
except SystemExit as exc:
|
|
1857
|
+
assert "one-shot" in str(exc)
|
|
1858
|
+
else:
|
|
1859
|
+
raise AssertionError("SURFACE_CLOSE re-entry must fail")
|
|
1860
|
+
|
|
1861
|
+
output = work_devlyn / "surface-close.stdout"
|
|
1862
|
+
output.write_text(
|
|
1863
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
1864
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1865
|
+
encoding="utf-8",
|
|
1866
|
+
)
|
|
1867
|
+
assert validate_surface_adjudication(work, work_devlyn, surface_state, surface) == {
|
|
1868
|
+
"UVR-STALE": "FIRED", "PATH-TEST": "FIRED",
|
|
1869
|
+
}
|
|
1870
|
+
output.write_text(
|
|
1871
|
+
"UVR-STALE: FIRED allowed.txt:1 — updated visible text\n"
|
|
1872
|
+
"PATH-TEST: N/A allowed.txt:1 — goal names no uncovered path\nPASS\n",
|
|
1873
|
+
encoding="utf-8",
|
|
1874
|
+
)
|
|
1875
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1876
|
+
|
|
1877
|
+
output.write_text(
|
|
1878
|
+
"PATH-TEST review confirms every success/failure path the goal specifies (chaining in both positions, exact-nth-run cancellation via both `run_pending()` and `run_all()`, first-limit-wins with `.until()` in both directions, `next_run`/`idle_seconds` reflecting removal, validation errors, repeated-call override, per-job independence) is already covered by the added tests — no gap found.\n\n"
|
|
1879
|
+
"UVR-STALE: FIRED schedule/__init__.py:690 — `Job.run()`'s docstring described CancelJob only via `.until()`'s deadline, omitting the new `max_runs` cancellation path added to the same method in this diff (line 715); updated the docstring minimally.\n"
|
|
1880
|
+
"PATH-TEST: N/A test_schedule.py — every success/failure path in the goal (chaining order, exact-nth-run cutoff via both `run_pending()`/`run_all()`, `.until()` first-limit-wins both directions, `next_run`/`idle_seconds` post-removal, validation errors, repeat-call override, per-job independence) already has a covering test in the patch.\n"
|
|
1881
|
+
"PASS\n",
|
|
1882
|
+
encoding="utf-8",
|
|
1883
|
+
)
|
|
1884
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1885
|
+
output.write_text(
|
|
1886
|
+
"UVR-STALE: N/A tests/cli.test.js — USAGE in bin/cli.js was already updated in this patch to document `fulfill-wave --input PATH`; no authorized file has stale interface text.\n"
|
|
1887
|
+
"PATH-TEST: FIRED tests/cli.test.js:166 — goal names \"file-read failures\" as a distinct exit-2 path, implemented via the shared catch in `runFulfillWave`, but untested before this addition.\n"
|
|
1888
|
+
"PASS\n",
|
|
1889
|
+
encoding="utf-8",
|
|
1890
|
+
)
|
|
1891
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1892
|
+
|
|
1893
|
+
rejected_outputs = (
|
|
1894
|
+
("PASS\n", "missing UVR-STALE, PATH-TEST"),
|
|
1895
|
+
(
|
|
1896
|
+
"UVR-STALE: N/A allowed.txt:1\n"
|
|
1897
|
+
"PATH-TEST: N/A allowed.txt:1 — evidence\nPASS\n",
|
|
1898
|
+
"requires evidence",
|
|
1899
|
+
),
|
|
1900
|
+
("UVR-STALE: FIRED allowed.txt:1\nPASS\n", "missing PATH-TEST"),
|
|
1901
|
+
(
|
|
1902
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
1903
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
1904
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1905
|
+
"duplicate UVR-STALE",
|
|
1906
|
+
),
|
|
1907
|
+
(
|
|
1908
|
+
"PASS\nUVR-STALE: FIRED allowed.txt:1\n"
|
|
1909
|
+
"PATH-TEST: FIRED allowed.txt:1\n",
|
|
1910
|
+
"exactly one PASS must follow both rows",
|
|
1911
|
+
),
|
|
1912
|
+
(
|
|
1913
|
+
"UVR-STALE: N/A allowed.txt — \n"
|
|
1914
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1915
|
+
"requires evidence",
|
|
1916
|
+
),
|
|
1917
|
+
(
|
|
1918
|
+
"UVR-STALE: FIRED blocked.txt:1\n"
|
|
1919
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1920
|
+
"out-of-surface",
|
|
1921
|
+
),
|
|
1922
|
+
(
|
|
1923
|
+
"UVR-STALE: FIRED allowed.txt:2\n"
|
|
1924
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1925
|
+
"citation-missing",
|
|
1926
|
+
),
|
|
1927
|
+
)
|
|
1928
|
+
for raw_output, marker in rejected_outputs:
|
|
1929
|
+
output.write_text(raw_output, encoding="utf-8")
|
|
1930
|
+
try:
|
|
1931
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1932
|
+
except SystemExit as exc:
|
|
1933
|
+
assert marker in str(exc), (marker, exc)
|
|
1934
|
+
else:
|
|
1935
|
+
raise AssertionError(f"SURFACE_CLOSE accepted invalid adjudication: {marker}")
|
|
1936
|
+
|
|
1937
|
+
output.write_text(
|
|
1938
|
+
"UVR-STALE: N/A allowed.txt:1 — no stale interface text\n"
|
|
1939
|
+
"PATH-TEST: N/A allowed.txt:1 — requested path already covered\nPASS\n",
|
|
1940
|
+
encoding="utf-8",
|
|
1941
|
+
)
|
|
1942
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1943
|
+
|
|
1944
|
+
transcript = work_devlyn / "surface-close.worker-session.0.jsonl"
|
|
1945
|
+
transcript.write_text(json.dumps({
|
|
1946
|
+
"message": {"content": [{
|
|
1947
|
+
"type": "tool_use", "name": "Bash",
|
|
1948
|
+
"input": {"command": "git diff -- allowed.txt"},
|
|
1949
|
+
}]},
|
|
1950
|
+
}) + "\n", encoding="utf-8")
|
|
1951
|
+
validate_surface_execution(work_devlyn, surface_state)
|
|
1952
|
+
for validation_command in ("npm test", "node bin/cli.js version"):
|
|
1953
|
+
transcript.write_text(json.dumps({
|
|
1954
|
+
"message": {"content": [{
|
|
1955
|
+
"type": "tool_use", "name": "Bash",
|
|
1956
|
+
"input": {"command": validation_command},
|
|
1957
|
+
}]},
|
|
1958
|
+
}) + "\n", encoding="utf-8")
|
|
1959
|
+
try:
|
|
1960
|
+
validate_surface_execution(work_devlyn, surface_state)
|
|
1961
|
+
except SystemExit as exc:
|
|
1962
|
+
assert "validation-execution" in str(exc)
|
|
1963
|
+
else:
|
|
1964
|
+
raise AssertionError(
|
|
1965
|
+
f"SURFACE_CLOSE execution audit accepted {validation_command}"
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
transcript.write_text(
|
|
1969
|
+
json.dumps({"message": {"content": [
|
|
1970
|
+
{"type": "tool_use", "name": "Edit", "input": {"file_path": str(work / "allowed.txt")}},
|
|
1971
|
+
{"type": "tool_use", "name": "Write", "input": {"file_path": "tests/new.txt"}},
|
|
1972
|
+
]}}) + "\n",
|
|
1973
|
+
encoding="utf-8",
|
|
1974
|
+
)
|
|
1975
|
+
assert validate_surface_write_audit(
|
|
1976
|
+
work, work_devlyn, surface_state, surface,
|
|
1977
|
+
) == ["allowed.txt", "tests/new.txt"]
|
|
1978
|
+
for invalid_target in (str(work / "blocked.txt"), str(work_devlyn / "hidden.txt"), "../escape.txt"):
|
|
1979
|
+
transcript.write_text(
|
|
1980
|
+
json.dumps({"message": {"content": [{
|
|
1981
|
+
"type": "tool_use", "name": "Edit",
|
|
1982
|
+
"input": {"file_path": invalid_target},
|
|
1983
|
+
}]}}) + "\n",
|
|
1984
|
+
encoding="utf-8",
|
|
1985
|
+
)
|
|
1986
|
+
try:
|
|
1987
|
+
validate_surface_write_audit(work, work_devlyn, surface_state, surface)
|
|
1988
|
+
except SystemExit as exc:
|
|
1989
|
+
assert "write-audit-violation" in str(exc)
|
|
1990
|
+
else:
|
|
1991
|
+
raise AssertionError(f"SURFACE_CLOSE write audit accepted {invalid_target}")
|
|
1992
|
+
|
|
1993
|
+
wrapper_log = work_devlyn / "surface-close.output.json"
|
|
1994
|
+
wrapper_log.write_text(json.dumps({
|
|
1995
|
+
"result": output.read_text(encoding="utf-8"),
|
|
1996
|
+
"modelUsage": {"sonnet": {"inputTokens": 1}},
|
|
1997
|
+
}) + "\n", encoding="utf-8")
|
|
1998
|
+
completion_state = loads_strict_json(json.dumps(surface_state))
|
|
1999
|
+
assert do_complete(
|
|
2000
|
+
completion_state, "surface_close", "PASS", pre_sha, None,
|
|
2001
|
+
".devlyn/surface-close.stdout", None, None, str(wrapper_log),
|
|
2002
|
+
devlyn=work_devlyn,
|
|
2003
|
+
) is None
|
|
2004
|
+
completed_surface = completion_state["phases"]["surface_close"]
|
|
2005
|
+
assert completed_surface["model_requested"] == "sonnet"
|
|
2006
|
+
assert completed_surface["model_effective"] == "sonnet"
|
|
2007
|
+
assert completed_surface["verdict"] == "PASS"
|
|
2008
|
+
|
|
2009
|
+
recovery_state = loads_strict_json(json.dumps(surface_state))
|
|
2010
|
+
recovery_started_at = recovery_state["phases"]["surface_close"]["started_at"]
|
|
2011
|
+
output.write_text("UVR-STALE: FIRED allowed.txt:1\nPASS\n", encoding="utf-8")
|
|
2012
|
+
(work / "allowed.txt").write_text("surface edit\n", encoding="utf-8")
|
|
2013
|
+
transcript.write_text(
|
|
2014
|
+
json.dumps({"message": {"content": [{
|
|
2015
|
+
"type": "tool_use", "name": "Edit",
|
|
2016
|
+
"input": {"file_path": str(work / "allowed.txt")},
|
|
2017
|
+
}]}}) + "\n",
|
|
2018
|
+
encoding="utf-8",
|
|
2019
|
+
)
|
|
2020
|
+
require_surface_adjudication_malformed(
|
|
2021
|
+
work, work_devlyn, recovery_state, surface,
|
|
2022
|
+
)
|
|
2023
|
+
assert rollback_surface_delta(work, work_devlyn, recovery_state) == ["allowed.txt"]
|
|
2024
|
+
assert validate_surface_write_audit(
|
|
2025
|
+
work, work_devlyn, recovery_state, surface,
|
|
2026
|
+
) == ["allowed.txt"]
|
|
2027
|
+
assert do_surface_adjudication_recovery(recovery_state, work_devlyn) is None
|
|
2028
|
+
recovered = recovery_state["phases"]["surface_close"]
|
|
2029
|
+
assert recovered["started_at"] == recovery_started_at
|
|
2030
|
+
assert isinstance(recovered["duration_ms"], int)
|
|
2031
|
+
assert recovered["verdict"] is None
|
|
2032
|
+
assert recovered["skipped_reason"] == SURFACE_RECOVERY_REASON
|
|
2033
|
+
assert recovered["continued_after_block"] is True
|
|
2034
|
+
|
|
2035
|
+
(work / "allowed.txt").write_text("pre-existing\n", encoding="utf-8")
|
|
2036
|
+
try:
|
|
2037
|
+
ensure_surface_clean_baseline(work, work_devlyn, surface_state)
|
|
2038
|
+
except SystemExit as exc:
|
|
2039
|
+
assert "surface-close-preexisting-delta" in str(exc)
|
|
2040
|
+
else:
|
|
2041
|
+
raise AssertionError("SURFACE_CLOSE accepted a pre-existing tracked delta")
|
|
2042
|
+
subprocess.run(["git", "restore", "--", "allowed.txt"], cwd=work, check=True)
|
|
2043
|
+
(work / "allowed.txt").write_text("changed\n", encoding="utf-8")
|
|
2044
|
+
(work / "blocked.txt").write_text("changed\n", encoding="utf-8")
|
|
2045
|
+
(work / "tests" / "new.txt").write_text("new\n", encoding="utf-8")
|
|
2046
|
+
(work / "escape.txt").write_text("new\n", encoding="utf-8")
|
|
2047
|
+
assert surface_offenders(work, work_devlyn, surface_state, surface) == ["blocked.txt", "escape.txt"]
|
|
2048
|
+
restored = rollback_surface_delta(work, work_devlyn, surface_state)
|
|
2049
|
+
assert restored == ["allowed.txt", "blocked.txt", "escape.txt", "tests/new.txt"]
|
|
2050
|
+
assert (work / "allowed.txt").read_text(encoding="utf-8") == "base\n"
|
|
2051
|
+
assert (work / "blocked.txt").read_text(encoding="utf-8") == "base\n"
|
|
2052
|
+
assert (work / "kept.txt").read_text(encoding="utf-8") == "keep\n"
|
|
2053
|
+
assert not (work / "tests" / "new.txt").exists()
|
|
2054
|
+
assert not (work / "escape.txt").exists()
|
|
2055
|
+
|
|
2056
|
+
skipped_state = {"phases": {}}
|
|
2057
|
+
do_surface_skip(skipped_state)
|
|
2058
|
+
skipped = skipped_state["phases"]["surface_close"]
|
|
2059
|
+
assert skipped["verdict"] is None
|
|
2060
|
+
assert skipped["skipped_reason"] == SURFACE_SKIP_REASON
|
|
2061
|
+
|
|
2062
|
+
# Effective model evidence: engine header line and rollout JSONL.
|
|
2063
|
+
header_log = devlyn / "codex-build.log"
|
|
2064
|
+
header_log.write_text("session\nmodel: gpt-5.6-sol\n", encoding="utf-8")
|
|
2065
|
+
assert parse_effective_model(header_log) == "gpt-5.6-sol"
|
|
2066
|
+
rollout_log = devlyn / "rollout.jsonl"
|
|
2067
|
+
rollout_log.write_text(json.dumps({
|
|
2068
|
+
"type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
|
|
2069
|
+
}) + "\n", encoding="utf-8")
|
|
2070
|
+
assert parse_effective_model(rollout_log) == "gpt-5.6-terra"
|
|
2071
|
+
claude_log = devlyn / "claude-result.json"
|
|
2072
|
+
claude_log.write_text(json.dumps({
|
|
2073
|
+
"modelUsage": {"claude-alpha-1": {"inputTokens": 1}},
|
|
2074
|
+
}) + "\n", encoding="utf-8")
|
|
2075
|
+
assert parse_effective_model(claude_log) == "claude-alpha-1"
|
|
2076
|
+
|
|
2077
|
+
# Requested/effective drift is a persisted, fail-closed attestation.
|
|
2078
|
+
write_state(state_path, {"phases": {}})
|
|
2079
|
+
state = read_state(state_path)
|
|
2080
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.5")
|
|
2081
|
+
mismatch = do_complete(
|
|
2082
|
+
state, "implement", "PASS", None, None, None, None, None, str(rollout_log)
|
|
2083
|
+
)
|
|
2084
|
+
mismatched = state["phases"]["implement"]
|
|
2085
|
+
assert mismatch and "model-attestation-mismatch" in mismatch
|
|
2086
|
+
assert mismatched["model_requested"] == "gpt-5.5"
|
|
2087
|
+
assert mismatched["model_effective"] == "gpt-5.6-terra"
|
|
2088
|
+
assert mismatched["verdict"] == "BLOCKED"
|
|
2089
|
+
|
|
2090
|
+
# A retained mutation-worker session makes the completion flag
|
|
2091
|
+
# mandatory; without a retained file, null remains legal.
|
|
2092
|
+
retained_log = devlyn / "implement.worker-session.0.jsonl"
|
|
2093
|
+
retained_log.write_text(json.dumps({
|
|
2094
|
+
"type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
|
|
2095
|
+
}) + "\n", encoding="utf-8")
|
|
2096
|
+
write_state(state_path, {"phases": {}})
|
|
2097
|
+
state = read_state(state_path)
|
|
2098
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.6-terra")
|
|
2099
|
+
omitted = do_complete(
|
|
2100
|
+
state, "implement", "PASS", None, None, None, None, None,
|
|
2101
|
+
devlyn=devlyn,
|
|
2102
|
+
)
|
|
2103
|
+
assert omitted and "model-attestation-failed" in omitted
|
|
2104
|
+
assert str(retained_log) in omitted
|
|
2105
|
+
assert state["phases"]["implement"]["model_effective"] is None
|
|
2106
|
+
assert state["phases"]["implement"]["verdict"] == "BLOCKED"
|
|
2107
|
+
|
|
2108
|
+
write_state(state_path, {"phases": {}})
|
|
2109
|
+
state = read_state(state_path)
|
|
2110
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.6-terra")
|
|
2111
|
+
assert do_complete(
|
|
2112
|
+
state, "implement", "PASS", None, None, None, None, None,
|
|
2113
|
+
str(retained_log), devlyn=devlyn,
|
|
2114
|
+
) is None
|
|
2115
|
+
assert state["phases"]["implement"]["model_effective"] == "gpt-5.6-terra"
|
|
2116
|
+
assert state["phases"]["implement"]["verdict"] == "PASS"
|
|
2117
|
+
|
|
2118
|
+
retained_log.unlink()
|
|
2119
|
+
write_state(state_path, {"phases": {}})
|
|
2120
|
+
state = read_state(state_path)
|
|
2121
|
+
do_spawn(state, "cleanup", 0, None, None, "claude", "claude-default")
|
|
2122
|
+
assert do_complete(
|
|
2123
|
+
state, "cleanup", "PASS", None, None, None, None, None,
|
|
2124
|
+
devlyn=devlyn,
|
|
2125
|
+
) is None
|
|
2126
|
+
assert state["phases"]["cleanup"]["model_effective"] is None
|
|
2127
|
+
|
|
2128
|
+
# Supplied evidence must parse and never silently record null.
|
|
2129
|
+
write_state(state_path, {"phases": {}})
|
|
2130
|
+
state = read_state(state_path)
|
|
2131
|
+
do_spawn(state, "plan", 0, None, None, "claude", "claude-default")
|
|
2132
|
+
assert do_complete(state, "plan", "PASS", None, None, None, None, None) is None
|
|
2133
|
+
assert state["phases"]["plan"]["model_effective"] is None
|
|
2134
|
+
invalid_log = devlyn / "invalid-session.log"
|
|
2135
|
+
invalid_log.write_text("no model evidence\n", encoding="utf-8")
|
|
2136
|
+
write_state(state_path, {"phases": {}})
|
|
2137
|
+
state = read_state(state_path)
|
|
2138
|
+
do_spawn(state, "plan", 0, None, None, "claude", "claude-default")
|
|
2139
|
+
invalid = do_complete(
|
|
2140
|
+
state, "plan", "PASS", None, None, None, None, None, str(invalid_log)
|
|
2141
|
+
)
|
|
2142
|
+
assert invalid and "model-attestation-failed" in invalid
|
|
2143
|
+
assert state["phases"]["plan"]["model_effective"] is None
|
|
2144
|
+
assert state["phases"]["plan"]["verdict"] == "BLOCKED"
|
|
2145
|
+
|
|
2146
|
+
def write_fixture_tree(repo: pathlib.Path, files: dict[str, str]) -> None:
|
|
2147
|
+
existing = {
|
|
2148
|
+
path.relative_to(repo).as_posix() for path in repo.rglob("*")
|
|
2149
|
+
if path.is_file() and ".git" not in path.parts and ".devlyn" not in path.parts
|
|
2150
|
+
}
|
|
2151
|
+
for path in existing - set(files):
|
|
2152
|
+
(repo / path).unlink()
|
|
2153
|
+
for path, content in files.items():
|
|
2154
|
+
target = repo / path
|
|
2155
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2156
|
+
target.write_text(content, encoding="utf-8")
|
|
2157
|
+
|
|
2158
|
+
def commit_fixture(repo: pathlib.Path, files: dict[str, str], message: str) -> str:
|
|
2159
|
+
write_fixture_tree(repo, files)
|
|
2160
|
+
paths = sorted(set(files) | {
|
|
2161
|
+
path.relative_to(repo).as_posix() for path in repo.rglob("*")
|
|
2162
|
+
if path.is_file() and ".git" not in path.parts and ".devlyn" not in path.parts
|
|
2163
|
+
})
|
|
2164
|
+
subprocess.run(["git", "add", "--all", "--", *paths], cwd=repo, check=True)
|
|
2165
|
+
subprocess.run(["git", "commit", "-qm", message], cwd=repo, check=True)
|
|
2166
|
+
return subprocess.run(
|
|
2167
|
+
["git", "rev-parse", "HEAD"], cwd=repo, check=True,
|
|
2168
|
+
capture_output=True, text=True,
|
|
2169
|
+
).stdout.strip()
|
|
2170
|
+
|
|
2171
|
+
def durability_fixture(
|
|
2172
|
+
name: str, base_files: dict[str, str], surface_files: dict[str, str],
|
|
2173
|
+
fix_files: dict[str, str], origin: str, findings: list[dict], round_: int = 1,
|
|
2174
|
+
with_surface_post: bool = True,
|
|
2175
|
+
) -> tuple[pathlib.Path, pathlib.Path, dict, str, str]:
|
|
2176
|
+
repo = devlyn / name
|
|
2177
|
+
repo.mkdir()
|
|
2178
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
2179
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=repo, check=True)
|
|
2180
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=repo, check=True)
|
|
2181
|
+
base_sha = commit_fixture(repo, base_files, "base")
|
|
2182
|
+
surface_sha = commit_fixture(repo, surface_files, "surface close")
|
|
2183
|
+
fix_sha = commit_fixture(repo, fix_files, f"chore(pipeline): implement fix round {round_}")
|
|
2184
|
+
fixture_devlyn = repo / ".devlyn"
|
|
2185
|
+
fixture_devlyn.mkdir()
|
|
2186
|
+
findings_name = (
|
|
2187
|
+
"build_gate.findings.jsonl" if origin == "build_gate"
|
|
2188
|
+
else "verify-merged.findings.jsonl"
|
|
2189
|
+
)
|
|
2190
|
+
(fixture_devlyn / findings_name).write_text(
|
|
2191
|
+
"".join(json.dumps(finding) + "\n" for finding in findings), encoding="utf-8",
|
|
2192
|
+
)
|
|
2193
|
+
surface_entry = {"pre_sha": base_sha, "durability": []}
|
|
2194
|
+
if with_surface_post:
|
|
2195
|
+
surface_entry["post_sha"] = surface_sha
|
|
2196
|
+
fixture_state = {"phases": {"surface_close": surface_entry}}
|
|
2197
|
+
return repo, fixture_devlyn, fixture_state, fix_sha, surface_sha
|
|
2198
|
+
|
|
2199
|
+
# M-CP 1/8/9: exact -19c topology. The VERIFY finding is line 60,
|
|
2200
|
+
# outside the pre-fix USAGE block; the unrelated real fix survives,
|
|
2201
|
+
# the USAGE revert is restored, and the frozen gate's check-7 function
|
|
2202
|
+
# observes the separate closure-restore commit's final tree.
|
|
2203
|
+
base_lines = [f"line {index}\n" for index in range(1, 66)]
|
|
2204
|
+
base_lines[6] = "const USAGE = `\n"
|
|
2205
|
+
base_lines[7] = "usage: cli <command>\n"
|
|
2206
|
+
base_lines[9] = " version Show version\n"
|
|
2207
|
+
base_lines[10] = "`;\n"
|
|
2208
|
+
base_lines[59] = " return args.indexOf('--format')\n"
|
|
2209
|
+
surface_lines = list(base_lines)
|
|
2210
|
+
surface_lines[9] = " version --format <fmt> Show version\n"
|
|
2211
|
+
fix_lines = list(base_lines)
|
|
2212
|
+
fix_lines[59] = " return args.lastIndexOf('--format')\n"
|
|
2213
|
+
exact_repo, exact_devlyn, exact_state, exact_fix, _ = durability_fixture(
|
|
2214
|
+
"durability-exact", {"bin/cli.js": "".join(base_lines)},
|
|
2215
|
+
{"bin/cli.js": "".join(surface_lines)}, {"bin/cli.js": "".join(fix_lines)},
|
|
2216
|
+
"verify", [{"id": "-19c", "file": "bin/cli.js", "line": 60}],
|
|
2217
|
+
)
|
|
2218
|
+
exact_receipt = enforce_closure_durability_reentry(
|
|
2219
|
+
exact_repo, exact_devlyn, exact_state, "verify", 1,
|
|
2220
|
+
)
|
|
2221
|
+
exact_text = (exact_repo / "bin/cli.js").read_text(encoding="utf-8")
|
|
2222
|
+
assert "version --format <fmt>" in exact_text
|
|
2223
|
+
assert "lastIndexOf('--format')" in exact_text
|
|
2224
|
+
assert exact_receipt and exact_receipt["restore_commit_sha"]
|
|
2225
|
+
assert exact_receipt["fix_commit_sha"] == exact_fix
|
|
2226
|
+
assert subprocess.run(
|
|
2227
|
+
["git", "show", "-s", "--format=%s", "HEAD"], cwd=exact_repo,
|
|
2228
|
+
check=True, capture_output=True, text=True,
|
|
2229
|
+
).stdout.strip() == "chore(pipeline): closure-restore round 1"
|
|
2230
|
+
import importlib.util
|
|
2231
|
+
gate_spec = importlib.util.spec_from_file_location(
|
|
2232
|
+
"f7_carrier_gate", pathlib.Path(__file__).resolve().parents[3]
|
|
2233
|
+
/ "benchmark/ceiling/scripts/f7-carrier-gate.py",
|
|
2234
|
+
)
|
|
2235
|
+
gate = importlib.util.module_from_spec(gate_spec)
|
|
2236
|
+
gate_spec.loader.exec_module(gate)
|
|
2237
|
+
assert gate.check7(exact_text)[0]
|
|
2238
|
+
print("PASS M-CP self-test 1/8/9: -19c restore + file-line targeting + frozen check 7")
|
|
2239
|
+
|
|
2240
|
+
# M-CP 2: BUILD_GATE uses the same durability route.
|
|
2241
|
+
build_repo, build_devlyn, build_state, _, _ = durability_fixture(
|
|
2242
|
+
"durability-build", {"a.txt": "old\n"}, {"a.txt": "surface\n"},
|
|
2243
|
+
{"a.txt": "old\n"}, "build_gate",
|
|
2244
|
+
[{"id": "BG", "file": "a.txt", "line": 8}],
|
|
2245
|
+
)
|
|
2246
|
+
build_state["phases"]["implement"] = {"round": 1, "triggered_by": "build_gate"}
|
|
2247
|
+
write_state(build_devlyn / "pipeline.state.json", build_state)
|
|
2248
|
+
subprocess.run(
|
|
2249
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2250
|
+
"--phase", "implement", "durability-enforce", "--round", "1",
|
|
2251
|
+
"--origin-phase", "build_gate"],
|
|
2252
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2253
|
+
)
|
|
2254
|
+
subprocess.run(
|
|
2255
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2256
|
+
"--phase", "build_gate", "spawn", "--round", "1"],
|
|
2257
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2258
|
+
)
|
|
2259
|
+
# The next phase's first spawn shares rounds.global=1 but is not a
|
|
2260
|
+
# VERIFY re-entry; it must not reinterpret the BUILD_GATE receipt.
|
|
2261
|
+
subprocess.run(
|
|
2262
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2263
|
+
"--phase", "verify", "spawn", "--round", "1"],
|
|
2264
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2265
|
+
)
|
|
2266
|
+
build_receipt = loads_strict_json(
|
|
2267
|
+
(build_devlyn / "closure-durability.round-1.json").read_text(encoding="utf-8")
|
|
2268
|
+
)
|
|
2269
|
+
assert (build_repo / "a.txt").read_text(encoding="utf-8") == "surface\n"
|
|
2270
|
+
assert build_receipt and build_receipt["origin_phase"] == "build_gate"
|
|
2271
|
+
print("PASS M-CP self-test 2: build_gate and verify routes")
|
|
2272
|
+
|
|
2273
|
+
# M-CP 3: block-granular multi-file partial revert mixture.
|
|
2274
|
+
mix_base = {
|
|
2275
|
+
"mix.txt": "A\nkeep-1\nB\nkeep-2\nC\n", "other.txt": "old\n",
|
|
2276
|
+
"context.txt": "before\nold\nafter\n",
|
|
2277
|
+
}
|
|
2278
|
+
mix_surface = {
|
|
2279
|
+
"mix.txt": "A-sc\nkeep-1\nB-sc\nkeep-2\nC-sc\n", "other.txt": "new\n",
|
|
2280
|
+
"context.txt": "before\nnew\nafter\n",
|
|
2281
|
+
}
|
|
2282
|
+
mix_fix = {
|
|
2283
|
+
"mix.txt": "A-sc\nkeep-1\nB-evolved\nkeep-2\nC\n", "other.txt": "old\n",
|
|
2284
|
+
"context.txt": "changed-context\nold\nafter\n",
|
|
2285
|
+
}
|
|
2286
|
+
mix_repo, mix_devlyn, mix_state, _, _ = durability_fixture(
|
|
2287
|
+
"durability-mix", mix_base, mix_surface, mix_fix, "verify", [],
|
|
2288
|
+
)
|
|
2289
|
+
mix_receipt = enforce_closure_durability_reentry(
|
|
2290
|
+
mix_repo, mix_devlyn, mix_state, "verify", 1,
|
|
2291
|
+
)
|
|
2292
|
+
mix_classes = [block["classification"] for block in mix_receipt["blocks"]]
|
|
2293
|
+
assert {"SURVIVED", "EVOLVED", "REVERTED"} <= set(mix_classes), mix_receipt["blocks"]
|
|
2294
|
+
assert (mix_repo / "mix.txt").read_text(encoding="utf-8") == (
|
|
2295
|
+
"A-sc\nkeep-1\nB-evolved\nkeep-2\nC-sc\n"
|
|
2296
|
+
)
|
|
2297
|
+
assert (mix_repo / "other.txt").read_text(encoding="utf-8") == "new\n"
|
|
2298
|
+
assert (mix_repo / "context.txt").read_text(encoding="utf-8") == (
|
|
2299
|
+
"changed-context\nold\nafter\n"
|
|
2300
|
+
)
|
|
2301
|
+
print("PASS M-CP self-test 3: multi-file SURVIVED/EVOLVED/REVERTED partial restore")
|
|
2302
|
+
|
|
2303
|
+
# M-CP 4: a deleted additive SC block is REVERTED and restored.
|
|
2304
|
+
add_repo, add_devlyn, add_state, _, _ = durability_fixture(
|
|
2305
|
+
"durability-add", {"docs/x.txt": "head\ntail\n"},
|
|
2306
|
+
{"docs/x.txt": "head\na/old/docs/x.txt\ntail\n"},
|
|
2307
|
+
{"docs/x.txt": "head\ntail\n"}, "verify", [],
|
|
2308
|
+
)
|
|
2309
|
+
add_receipt = enforce_closure_durability_reentry(add_repo, add_devlyn, add_state, "verify", 1)
|
|
2310
|
+
assert (add_repo / "docs/x.txt").read_text(encoding="utf-8") == (
|
|
2311
|
+
"head\na/old/docs/x.txt\ntail\n"
|
|
2312
|
+
)
|
|
2313
|
+
assert any(block["classification"] == "REVERTED" for block in add_receipt["blocks"])
|
|
2314
|
+
print("PASS M-CP self-test 4: deleted additive block restored")
|
|
2315
|
+
|
|
2316
|
+
# M-CP 5: an exact line-targeted deletion is preserved, not restored.
|
|
2317
|
+
target_repo, target_devlyn, target_state, target_fix, _ = durability_fixture(
|
|
2318
|
+
"durability-target", {"target.txt": "head\ntail\n"},
|
|
2319
|
+
{"target.txt": "head\nconsolidate\ntail\n"}, {"target.txt": "head\ntail\n"},
|
|
2320
|
+
"verify", [{"id": "E1", "path": "target.txt", "line": 2}],
|
|
2321
|
+
)
|
|
2322
|
+
target_receipt = enforce_closure_durability_reentry(
|
|
2323
|
+
target_repo, target_devlyn, target_state, "verify", 1,
|
|
2324
|
+
)
|
|
2325
|
+
assert (target_repo / "target.txt").read_text(encoding="utf-8") == "head\ntail\n"
|
|
2326
|
+
assert target_receipt["restore_commit_sha"] is None
|
|
2327
|
+
assert target_receipt["post_restore_sha"] == target_fix
|
|
2328
|
+
assert target_receipt["blocks"][0]["finding_targeted"] is True
|
|
2329
|
+
print("PASS M-CP self-test 5: finding-targeted deletion not restored")
|
|
2330
|
+
|
|
2331
|
+
# M-CP 6: once ledgered, a missing or stale receipt blocks re-entry.
|
|
2332
|
+
target_path = target_devlyn / "closure-durability.round-1.json"
|
|
2333
|
+
target_raw = target_path.read_bytes()
|
|
2334
|
+
target_state["phases"]["implement"] = {"round": 1, "triggered_by": "verify"}
|
|
2335
|
+
write_state(target_devlyn / "pipeline.state.json", target_state)
|
|
2336
|
+
stale_verify_artifact = target_devlyn / "verify.findings.jsonl"
|
|
2337
|
+
stale_verify_artifact.write_text("stale\n", encoding="utf-8")
|
|
2338
|
+
target_path.unlink()
|
|
2339
|
+
for mode in ("missing", "stale"):
|
|
2340
|
+
if mode == "stale":
|
|
2341
|
+
target_path.write_bytes(target_raw.replace(b'"schema_version": 1', b'"schema_version": 2'))
|
|
2342
|
+
try:
|
|
2343
|
+
enforce_closure_durability_reentry(
|
|
2344
|
+
target_repo, target_devlyn, target_state, "verify", 1,
|
|
2345
|
+
)
|
|
2346
|
+
except SystemExit as exc:
|
|
2347
|
+
assert "closure-durability-receipt" in str(exc)
|
|
2348
|
+
else:
|
|
2349
|
+
raise AssertionError(f"{mode} durability receipt was accepted")
|
|
2350
|
+
if mode == "missing":
|
|
2351
|
+
reentry = subprocess.run(
|
|
2352
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
2353
|
+
"--devlyn-dir", ".devlyn", "--phase", "verify", "spawn",
|
|
2354
|
+
"--round", "1"],
|
|
2355
|
+
cwd=target_repo, capture_output=True, text=True,
|
|
2356
|
+
)
|
|
2357
|
+
assert reentry.returncode != 0
|
|
2358
|
+
assert "closure-durability-receipt" in reentry.stderr
|
|
2359
|
+
assert stale_verify_artifact.exists(), "re-entry guard must run before VERIFY clearing"
|
|
2360
|
+
target_path.write_bytes(target_raw)
|
|
2361
|
+
|
|
2362
|
+
# Skipping the explicit post-fix checkpoint cannot be repaired by the
|
|
2363
|
+
# spawn guard: re-entry is validation-only and both artifacts missing
|
|
2364
|
+
# must block without changing the fix tree.
|
|
2365
|
+
skipped_repo, skipped_devlyn, skipped_state, skipped_fix, _ = durability_fixture(
|
|
2366
|
+
"durability-skipped-checkpoint", {"skip.txt": "base\n"},
|
|
2367
|
+
{"skip.txt": "surface\n"}, {"skip.txt": "base\n"}, "build_gate", [],
|
|
2368
|
+
)
|
|
2369
|
+
skipped_state["phases"]["implement"] = {"round": 1, "triggered_by": "build_gate"}
|
|
2370
|
+
write_state(skipped_devlyn / "pipeline.state.json", skipped_state)
|
|
2371
|
+
skipped = subprocess.run(
|
|
2372
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
2373
|
+
"--devlyn-dir", ".devlyn", "--phase", "build_gate", "spawn", "--round", "1"],
|
|
2374
|
+
cwd=skipped_repo, capture_output=True, text=True,
|
|
2375
|
+
)
|
|
2376
|
+
assert skipped.returncode != 0 and "checkpoint receipt is missing" in skipped.stderr
|
|
2377
|
+
assert not (skipped_devlyn / "closure-durability.round-1.json").exists()
|
|
2378
|
+
assert subprocess.run(
|
|
2379
|
+
["git", "rev-parse", "HEAD"], cwd=skipped_repo, check=True,
|
|
2380
|
+
capture_output=True, text=True,
|
|
2381
|
+
).stdout.strip() == skipped_fix
|
|
2382
|
+
|
|
2383
|
+
receipt_repo, receipt_devlyn, receipt_state, receipt_fix, _ = durability_fixture(
|
|
2384
|
+
"durability-receipt-write-fail", {"receipt.txt": "base\n"},
|
|
2385
|
+
{"receipt.txt": "surface\n"}, {"receipt.txt": "base\n"}, "verify", [],
|
|
2386
|
+
)
|
|
2387
|
+
original_json_writer = globals()["_write_json_atomic"]
|
|
2388
|
+
def fail_receipt_write(_path, _value):
|
|
2389
|
+
raise OSError("injected receipt write failure")
|
|
2390
|
+
globals()["_write_json_atomic"] = fail_receipt_write
|
|
2391
|
+
try:
|
|
2392
|
+
try:
|
|
2393
|
+
enforce_closure_durability_reentry(
|
|
2394
|
+
receipt_repo, receipt_devlyn, receipt_state, "verify", 1,
|
|
2395
|
+
)
|
|
2396
|
+
except SystemExit as exc:
|
|
2397
|
+
assert "injected receipt write failure" in str(exc)
|
|
2398
|
+
else:
|
|
2399
|
+
raise AssertionError("receipt write failure did not fail closed")
|
|
2400
|
+
finally:
|
|
2401
|
+
globals()["_write_json_atomic"] = original_json_writer
|
|
2402
|
+
assert _git_output(receipt_repo, "rev-parse", "HEAD").decode().strip() == receipt_fix
|
|
2403
|
+
assert (receipt_repo / "receipt.txt").read_text(encoding="utf-8") == "base\n"
|
|
2404
|
+
assert not (receipt_devlyn / "closure-durability.round-1.json").exists()
|
|
2405
|
+
assert receipt_state["phases"]["surface_close"]["durability"] == []
|
|
2406
|
+
|
|
2407
|
+
state_repo, state_devlyn, state_state, state_fix, _ = durability_fixture(
|
|
2408
|
+
"durability-state-write-fail", {"state.txt": "base\n"},
|
|
2409
|
+
{"state.txt": "surface\n"}, {"state.txt": "base\n"}, "verify", [],
|
|
2410
|
+
)
|
|
2411
|
+
def fail_state_write(_path, _state):
|
|
2412
|
+
raise OSError("injected state write failure")
|
|
2413
|
+
try:
|
|
2414
|
+
_persist_durability_event(
|
|
2415
|
+
state_repo, state_devlyn, state_state,
|
|
2416
|
+
state_devlyn / "pipeline.state.json", "verify", 1,
|
|
2417
|
+
writer=fail_state_write,
|
|
2418
|
+
)
|
|
2419
|
+
except OSError as exc:
|
|
2420
|
+
assert "injected state write failure" in str(exc)
|
|
2421
|
+
else:
|
|
2422
|
+
raise AssertionError("state write failure did not fail closed")
|
|
2423
|
+
assert _git_output(state_repo, "rev-parse", "HEAD").decode().strip() == state_fix
|
|
2424
|
+
assert (state_repo / "state.txt").read_text(encoding="utf-8") == "base\n"
|
|
2425
|
+
assert not (state_devlyn / "closure-durability.round-1.json").exists()
|
|
2426
|
+
assert state_state["phases"]["surface_close"]["durability"] == []
|
|
2427
|
+
print("PASS M-CP self-test 6: missing/stale receipt fails closed")
|
|
2428
|
+
|
|
2429
|
+
# M-CP 7: failed patch preflight leaves index and worktree byte-clean.
|
|
2430
|
+
before_status = _tracked_status(target_repo)
|
|
2431
|
+
before_bytes = (target_repo / "target.txt").read_bytes()
|
|
2432
|
+
try:
|
|
2433
|
+
_apply_restore_patch(target_repo, b"not a patch\n", ["target.txt"])
|
|
2434
|
+
except SystemExit as exc:
|
|
2435
|
+
assert "closure-durability-apply" in str(exc)
|
|
2436
|
+
else:
|
|
2437
|
+
raise AssertionError("invalid restore patch was accepted")
|
|
2438
|
+
assert _tracked_status(target_repo) == before_status
|
|
2439
|
+
assert (target_repo / "target.txt").read_bytes() == before_bytes
|
|
2440
|
+
|
|
2441
|
+
binary_repo = devlyn / "durability-binary"
|
|
2442
|
+
binary_repo.mkdir()
|
|
2443
|
+
subprocess.run(["git", "init", "-q"], cwd=binary_repo, check=True)
|
|
2444
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=binary_repo, check=True)
|
|
2445
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=binary_repo, check=True)
|
|
2446
|
+
binary_path = binary_repo / "binary.dat"
|
|
2447
|
+
binary_path.write_bytes(b"\x00base\xff\n")
|
|
2448
|
+
subprocess.run(["git", "add", "--", "binary.dat"], cwd=binary_repo, check=True)
|
|
2449
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=binary_repo, check=True)
|
|
2450
|
+
wanted_binary = b"\x00a/old/binary.dat\xffb/new/binary.dat\n"
|
|
2451
|
+
binary_patch = _restore_patch(binary_repo, {"binary.dat": wanted_binary})
|
|
2452
|
+
_apply_restore_patch(binary_repo, binary_patch, ["binary.dat"])
|
|
2453
|
+
assert binary_path.read_bytes() == wanted_binary
|
|
2454
|
+
assert _git_output(binary_repo, "show", ":binary.dat") == wanted_binary
|
|
2455
|
+
|
|
2456
|
+
literal_repo = devlyn / "durability-header-literal"
|
|
2457
|
+
literal_repo.mkdir()
|
|
2458
|
+
subprocess.run(["git", "init", "-q"], cwd=literal_repo, check=True)
|
|
2459
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=literal_repo, check=True)
|
|
2460
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=literal_repo, check=True)
|
|
2461
|
+
literal_path = literal_repo / "literal.txt"
|
|
2462
|
+
literal_path.write_bytes(b"-- a/old/literal.txt\n")
|
|
2463
|
+
subprocess.run(["git", "add", "--", "literal.txt"], cwd=literal_repo, check=True)
|
|
2464
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=literal_repo, check=True)
|
|
2465
|
+
wanted_literal = b"++ b/new/literal.txt\n"
|
|
2466
|
+
literal_patch = _restore_patch(literal_repo, {"literal.txt": wanted_literal})
|
|
2467
|
+
_apply_restore_patch(literal_repo, literal_patch, ["literal.txt"])
|
|
2468
|
+
assert literal_path.read_bytes() == wanted_literal
|
|
2469
|
+
assert _git_output(literal_repo, "show", ":literal.txt") == wanted_literal
|
|
2470
|
+
print("PASS M-CP self-test 7: apply failure leaves zero partial mutation")
|
|
2471
|
+
|
|
2472
|
+
# File-only findings are deliberately non-targeting.
|
|
2473
|
+
file_repo, file_devlyn, file_state, _, _ = durability_fixture(
|
|
2474
|
+
"durability-file-only", {"usage.txt": "usage old\n"},
|
|
2475
|
+
{"usage.txt": "usage --format\n"}, {"usage.txt": "usage old\n"},
|
|
2476
|
+
"verify", [{"id": "file-only", "file": "usage.txt"}],
|
|
2477
|
+
)
|
|
2478
|
+
file_receipt = enforce_closure_durability_reentry(
|
|
2479
|
+
file_repo, file_devlyn, file_state, "verify", 1,
|
|
2480
|
+
)
|
|
2481
|
+
assert (file_repo / "usage.txt").read_text(encoding="utf-8") == "usage --format\n"
|
|
2482
|
+
assert file_receipt["blocks"][0]["finding_targeted"] is False
|
|
2483
|
+
print("PASS M-CP self-test 8: file-only finding cannot mask restore")
|
|
2484
|
+
|
|
2485
|
+
# Skipped/no-post-SHA is receipt-visible and commit-free.
|
|
2486
|
+
noop_repo, noop_devlyn, noop_state, noop_fix, _ = durability_fixture(
|
|
2487
|
+
"durability-noop", {"noop.txt": "base\n"}, {"noop.txt": "surface\n"},
|
|
2488
|
+
{"noop.txt": "fix\n"}, "build_gate", [], with_surface_post=False,
|
|
2489
|
+
)
|
|
2490
|
+
noop_receipt = enforce_closure_durability_reentry(
|
|
2491
|
+
noop_repo, noop_devlyn, noop_state, "build_gate", 1,
|
|
2492
|
+
)
|
|
2493
|
+
assert noop_receipt["blocks"] == [] and noop_receipt["restore_commit_sha"] is None
|
|
2494
|
+
assert noop_receipt["post_restore_sha"] == noop_fix
|
|
2495
|
+
print("PASS M-CP self-test no-op: no post_sha writes receipt without commit")
|
|
2496
|
+
|
|
420
2497
|
return 0
|
|
421
2498
|
|
|
422
2499
|
|
|
@@ -431,6 +2508,9 @@ def main() -> int:
|
|
|
431
2508
|
spawn_p.add_argument("--round", type=int, required=True)
|
|
432
2509
|
spawn_p.add_argument("--triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
|
|
433
2510
|
spawn_p.add_argument("--pre-sha", default=None)
|
|
2511
|
+
spawn_p.add_argument("--input-patch-sha256", default=None)
|
|
2512
|
+
spawn_p.add_argument("--prompt-sha256", default=None)
|
|
2513
|
+
spawn_p.add_argument("--untracked-before-json", default=None)
|
|
434
2514
|
spawn_p.add_argument("--engine", default=None)
|
|
435
2515
|
spawn_p.add_argument("--model", default=None)
|
|
436
2516
|
|
|
@@ -441,13 +2521,45 @@ def main() -> int:
|
|
|
441
2521
|
complete_p.add_argument("--log-file", default=None)
|
|
442
2522
|
complete_p.add_argument("--engine", default=None)
|
|
443
2523
|
complete_p.add_argument("--model", default=None)
|
|
2524
|
+
complete_p.add_argument("--engine-session-log", default=None)
|
|
2525
|
+
|
|
2526
|
+
transition_p = sub.add_parser("transition")
|
|
2527
|
+
transition_p.add_argument("--verdict", choices=sorted(VALID_VERDICTS), default=None)
|
|
2528
|
+
transition_p.add_argument("--post-sha", default=None)
|
|
2529
|
+
transition_p.add_argument("--findings-file", default=None)
|
|
2530
|
+
transition_p.add_argument("--log-file", default=None)
|
|
2531
|
+
transition_p.add_argument("--engine", default=None)
|
|
2532
|
+
transition_p.add_argument("--model", default=None)
|
|
2533
|
+
transition_p.add_argument("--engine-session-log", default=None)
|
|
2534
|
+
transition_p.add_argument("--next-phase", choices=sorted(PHASE_NAMES), required=True)
|
|
2535
|
+
transition_p.add_argument("--next-round", type=int, required=True)
|
|
2536
|
+
transition_p.add_argument("--next-triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
|
|
2537
|
+
transition_p.add_argument("--next-pre-sha", default=None)
|
|
2538
|
+
transition_p.add_argument("--next-input-patch-sha256", default=None)
|
|
2539
|
+
transition_p.add_argument("--next-prompt-sha256", default=None)
|
|
2540
|
+
transition_p.add_argument("--next-untracked-before-json", default=None)
|
|
2541
|
+
transition_p.add_argument("--next-engine", default=None)
|
|
2542
|
+
transition_p.add_argument("--next-model", default=None)
|
|
2543
|
+
|
|
2544
|
+
check_p = sub.add_parser("surface-check")
|
|
2545
|
+
check_p.add_argument("--authorized-surface-json", required=True)
|
|
2546
|
+
recover_p = sub.add_parser("surface-adjudication-recover")
|
|
2547
|
+
recover_p.add_argument("--authorized-surface-json", required=True)
|
|
2548
|
+
sub.add_parser("surface-rollback")
|
|
2549
|
+
sub.add_parser("surface-skip")
|
|
2550
|
+
durability_p = sub.add_parser("durability-enforce")
|
|
2551
|
+
durability_p.add_argument("--round", type=int, required=True)
|
|
2552
|
+
durability_p.add_argument("--origin-phase", choices=sorted(VALID_TRIGGERS), required=True)
|
|
444
2553
|
|
|
445
2554
|
args = ap.parse_args()
|
|
446
2555
|
if args.self_test:
|
|
447
2556
|
return self_test()
|
|
448
2557
|
|
|
449
|
-
|
|
450
|
-
|
|
2558
|
+
surface_events = {
|
|
2559
|
+
"surface-check", "surface-adjudication-recover", "surface-rollback", "surface-skip",
|
|
2560
|
+
}
|
|
2561
|
+
if not args.phase or args.event not in {"spawn", "complete", "transition", "durability-enforce", *surface_events}:
|
|
2562
|
+
ap.error("--phase and a phase event are required unless --self-test")
|
|
451
2563
|
|
|
452
2564
|
devlyn = pathlib.Path(args.devlyn_dir)
|
|
453
2565
|
if not devlyn.is_dir():
|
|
@@ -456,14 +2568,145 @@ def main() -> int:
|
|
|
456
2568
|
state_path = devlyn / "pipeline.state.json"
|
|
457
2569
|
state = read_state(state_path)
|
|
458
2570
|
|
|
459
|
-
if args.event == "
|
|
460
|
-
if args.phase
|
|
2571
|
+
if args.event == "durability-enforce":
|
|
2572
|
+
if args.phase != "implement":
|
|
2573
|
+
ap.error("durability-enforce is valid only for --phase implement")
|
|
2574
|
+
_persist_durability_event(
|
|
2575
|
+
pathlib.Path.cwd(), devlyn, state, state_path, args.origin_phase, args.round,
|
|
2576
|
+
)
|
|
2577
|
+
sys.stdout.write(f"ok: phases.surface_close.durability.round-{args.round}\n")
|
|
2578
|
+
return 0
|
|
2579
|
+
|
|
2580
|
+
if args.event in surface_events:
|
|
2581
|
+
if args.phase != "surface_close":
|
|
2582
|
+
ap.error(f"{args.event} is valid only for --phase surface_close")
|
|
2583
|
+
if args.event == "surface-skip":
|
|
2584
|
+
do_surface_skip(state)
|
|
2585
|
+
write_state(state_path, state)
|
|
2586
|
+
sys.stdout.write("ok: phases.surface_close.surface-skip\n")
|
|
2587
|
+
return 0
|
|
2588
|
+
work = pathlib.Path.cwd()
|
|
2589
|
+
if args.event == "surface-adjudication-recover":
|
|
2590
|
+
validate_surface_inputs(work, devlyn, state)
|
|
2591
|
+
validate_surface_prompt(devlyn, state)
|
|
2592
|
+
surface = validate_authorized_surface(args.authorized_surface_json)
|
|
2593
|
+
offenders = surface_offenders(work, devlyn, state, surface)
|
|
2594
|
+
if offenders:
|
|
2595
|
+
raise SystemExit(
|
|
2596
|
+
"BLOCKED:surface-close-out-of-surface: " + json.dumps(offenders)
|
|
2597
|
+
)
|
|
2598
|
+
validate_surface_execution(devlyn, state)
|
|
2599
|
+
require_surface_adjudication_malformed(work, devlyn, state, surface)
|
|
2600
|
+
rollback_surface_delta(work, devlyn, state)
|
|
2601
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
2602
|
+
if tracked or untracked:
|
|
2603
|
+
raise SystemExit(
|
|
2604
|
+
"BLOCKED:surface-close-rollback-failed: "
|
|
2605
|
+
+ json.dumps(sorted(set(tracked + untracked)))
|
|
2606
|
+
)
|
|
2607
|
+
validate_surface_write_audit(work, devlyn, state, surface)
|
|
2608
|
+
attestation_error = do_surface_adjudication_recovery(state, devlyn)
|
|
2609
|
+
write_state(state_path, state)
|
|
2610
|
+
if attestation_error is not None:
|
|
2611
|
+
sys.stderr.write(attestation_error + "\n")
|
|
2612
|
+
return 1
|
|
2613
|
+
sys.stdout.write("ok: phases.surface_close.surface-adjudication-recover\n")
|
|
2614
|
+
return 0
|
|
2615
|
+
if args.event == "surface-check":
|
|
2616
|
+
validate_surface_inputs(work, devlyn, state)
|
|
2617
|
+
surface = validate_authorized_surface(args.authorized_surface_json)
|
|
2618
|
+
offenders = surface_offenders(work, devlyn, state, surface)
|
|
2619
|
+
if offenders:
|
|
2620
|
+
sys.stderr.write("BLOCKED:surface-close-out-of-scope: " + json.dumps(offenders) + "\n")
|
|
2621
|
+
return 2
|
|
2622
|
+
validate_surface_adjudication(work, devlyn, state, surface)
|
|
2623
|
+
validate_surface_execution(devlyn, state)
|
|
2624
|
+
sys.stdout.write("ok: phases.surface_close.surface-check\n")
|
|
2625
|
+
return 0
|
|
2626
|
+
restored = rollback_surface_delta(work, devlyn, state)
|
|
2627
|
+
sys.stdout.write("ok: phases.surface_close.surface-rollback " + json.dumps(restored) + "\n")
|
|
2628
|
+
return 0
|
|
2629
|
+
|
|
2630
|
+
if args.event in {"spawn", "transition"}:
|
|
2631
|
+
spawn_phase = args.phase if args.event == "spawn" else args.next_phase
|
|
2632
|
+
spawn_round = args.round if args.event == "spawn" else args.next_round
|
|
2633
|
+
implement = (state.get("phases") or {}).get("implement")
|
|
2634
|
+
fix_reentry = (
|
|
2635
|
+
spawn_phase in VALID_TRIGGERS and spawn_round >= 1
|
|
2636
|
+
and isinstance(implement, dict)
|
|
2637
|
+
and implement.get("round") == spawn_round
|
|
2638
|
+
and implement.get("triggered_by") == spawn_phase
|
|
2639
|
+
)
|
|
2640
|
+
if fix_reentry:
|
|
2641
|
+
enforce_closure_durability_reentry(
|
|
2642
|
+
pathlib.Path.cwd(), devlyn, state, spawn_phase, spawn_round,
|
|
2643
|
+
require_existing=True,
|
|
2644
|
+
)
|
|
2645
|
+
if args.event == "spawn":
|
|
2646
|
+
untracked_before = (
|
|
2647
|
+
None if args.untracked_before_json is None
|
|
2648
|
+
else parse_string_list(args.untracked_before_json, "--untracked-before-json")
|
|
2649
|
+
)
|
|
2650
|
+
do_spawn(
|
|
2651
|
+
state, args.phase, args.round, args.triggered_by, args.pre_sha, args.engine, args.model,
|
|
2652
|
+
input_patch_sha256=args.input_patch_sha256,
|
|
2653
|
+
prompt_sha256=args.prompt_sha256,
|
|
2654
|
+
untracked_before=untracked_before,
|
|
2655
|
+
)
|
|
2656
|
+
else:
|
|
2657
|
+
next_untracked_before = (
|
|
2658
|
+
None if args.next_untracked_before_json is None
|
|
2659
|
+
else parse_string_list(
|
|
2660
|
+
args.next_untracked_before_json, "--next-untracked-before-json"
|
|
2661
|
+
)
|
|
2662
|
+
)
|
|
2663
|
+
state = do_transition(
|
|
2664
|
+
state, args.phase, args.next_phase, args.verdict, args.post_sha,
|
|
2665
|
+
args.findings_file, args.log_file, args.engine, args.model,
|
|
2666
|
+
args.engine_session_log, devlyn, args.next_round,
|
|
2667
|
+
args.next_triggered_by, args.next_pre_sha, args.next_engine,
|
|
2668
|
+
args.next_model,
|
|
2669
|
+
next_input_patch_sha256=args.next_input_patch_sha256,
|
|
2670
|
+
next_prompt_sha256=args.next_prompt_sha256,
|
|
2671
|
+
next_untracked_before=next_untracked_before,
|
|
2672
|
+
)
|
|
2673
|
+
if spawn_phase == "surface_close":
|
|
2674
|
+
validate_surface_inputs(pathlib.Path.cwd(), devlyn, state)
|
|
2675
|
+
validate_surface_prompt(devlyn, state)
|
|
2676
|
+
ensure_surface_clean_baseline(pathlib.Path.cwd(), devlyn, state)
|
|
2677
|
+
write_state(state_path, state)
|
|
2678
|
+
if spawn_phase == "verify":
|
|
461
2679
|
clear_verify_round_artifacts(devlyn)
|
|
462
|
-
|
|
2680
|
+
if args.event == "transition":
|
|
2681
|
+
opened = state["phases"][args.next_phase]
|
|
2682
|
+
completed = (
|
|
2683
|
+
opened["history"][-1]
|
|
2684
|
+
if args.phase == args.next_phase
|
|
2685
|
+
else state["phases"][args.phase]
|
|
2686
|
+
)
|
|
2687
|
+
raw = state_path.read_bytes()
|
|
2688
|
+
sys.stdout.write(json.dumps({
|
|
2689
|
+
"completed_phase": args.phase,
|
|
2690
|
+
"completed_at": completed["completed_at"],
|
|
2691
|
+
"completed_verdict": completed["verdict"],
|
|
2692
|
+
"next_phase": args.next_phase,
|
|
2693
|
+
"next_started_at": opened["started_at"],
|
|
2694
|
+
"next_round": opened["round"],
|
|
2695
|
+
"state_path": str(state_path),
|
|
2696
|
+
"state_sha256": hashlib.sha256(raw).hexdigest(),
|
|
2697
|
+
}, sort_keys=True) + "\n")
|
|
2698
|
+
return 0
|
|
463
2699
|
else:
|
|
464
|
-
do_complete(
|
|
2700
|
+
attestation_error = do_complete(
|
|
2701
|
+
state, args.phase, args.verdict, args.post_sha, args.findings_file,
|
|
2702
|
+
args.log_file, args.engine, args.model, args.engine_session_log, devlyn,
|
|
2703
|
+
)
|
|
465
2704
|
|
|
466
|
-
|
|
2705
|
+
if args.event not in {"spawn", "transition"}:
|
|
2706
|
+
write_state(state_path, state)
|
|
2707
|
+
if args.event == "complete" and attestation_error is not None:
|
|
2708
|
+
sys.stderr.write(attestation_error + "\n")
|
|
2709
|
+
return 1
|
|
467
2710
|
sys.stdout.write(f"ok: phases.{args.phase}.{args.event}\n")
|
|
468
2711
|
return 0
|
|
469
2712
|
|