devlyn-cli 3.1.2 → 3.2.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 +6 -6
- package/CLAUDE.md +7 -7
- package/README.md +34 -13
- package/bin/devlyn.js +44 -65
- package/bin/instruction-templates.json +82 -0
- package/bin/instructions.js +187 -0
- package/config/skills/_shared/adapters/claude.md +1 -5
- package/config/skills/_shared/adapters/codex.md +1 -5
- package/config/skills/_shared/adapters/omp.md +0 -4
- package/config/skills/_shared/archive_run.py +5 -1
- package/config/skills/_shared/codex-config.md +3 -14
- package/config/skills/_shared/engine-preflight.md +3 -3
- package/config/skills/_shared/expected.schema.json +2 -2
- package/config/skills/_shared/platform-support.py +4 -1
- package/config/skills/_shared/resolve-bootstrap.py +22 -2
- package/config/skills/_shared/role-config.py +14 -2
- package/config/skills/_shared/spec-verify-check.py +427 -101
- package/config/skills/_shared/state-phase-write.py +497 -49
- package/config/skills/_shared/task-complete.py +46 -5
- package/config/skills/_shared/terminal-claim-check.py +2 -2
- package/config/skills/devlyn:ideate/SKILL.md +1 -1
- package/config/skills/devlyn:ideate/references/from-spec-mode.md +1 -1
- package/config/skills/devlyn:ideate/references/spec-template.md +2 -2
- package/config/skills/devlyn:resolve/SKILL.md +29 -51
- package/config/skills/devlyn:resolve/references/free-form-mode.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +8 -7
- package/config/skills/devlyn:resolve/references/phases/cleanup.md +5 -11
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -0
- package/config/skills/devlyn:resolve/references/phases/plan.md +3 -3
- package/config/skills/devlyn:resolve/references/state-schema.md +10 -9
- package/package.json +2 -1
- package/scripts/lint-skills.sh +14 -4
- package/scripts/test-owner-phases.py +193 -0
|
@@ -39,7 +39,7 @@ import sys
|
|
|
39
39
|
import tempfile
|
|
40
40
|
|
|
41
41
|
VALID_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
|
|
42
|
-
VALID_TRIGGERS = {"build_gate", "verify"}
|
|
42
|
+
VALID_TRIGGERS = {"build_gate", "cleanup", "verify"}
|
|
43
43
|
SPAWN_TRIGGERS = VALID_TRIGGERS | {"plan"}
|
|
44
44
|
PHASE_NAMES = {"plan", "probe_derive", "implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"}
|
|
45
45
|
LEGAL_TRANSITIONS = {
|
|
@@ -48,7 +48,7 @@ LEGAL_TRANSITIONS = {
|
|
|
48
48
|
"implement": {"implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"},
|
|
49
49
|
"surface_close": {"build_gate", "cleanup", "verify", "final_report"},
|
|
50
50
|
"build_gate": {"implement", "cleanup", "verify", "final_report"},
|
|
51
|
-
"cleanup": {"verify", "final_report"},
|
|
51
|
+
"cleanup": {"implement", "verify", "final_report"},
|
|
52
52
|
"verify": {"implement", "final_report"},
|
|
53
53
|
"final_report": set(),
|
|
54
54
|
}
|
|
@@ -308,6 +308,36 @@ def bind_process_evidence(
|
|
|
308
308
|
raise runner.EvidenceError(
|
|
309
309
|
"spec-verify.results.json commands must be an array"
|
|
310
310
|
)
|
|
311
|
+
preflight_failure = "preflight_failure" in results
|
|
312
|
+
if preflight_failure:
|
|
313
|
+
failure = results["preflight_failure"]
|
|
314
|
+
if (
|
|
315
|
+
not isinstance(failure, dict)
|
|
316
|
+
or set(failure) != {"run_id", "phase", "round", "findings"}
|
|
317
|
+
or failure["run_id"] != state.get("run_id")
|
|
318
|
+
or failure["phase"] != phase
|
|
319
|
+
or type(failure["round"]) is not int
|
|
320
|
+
or failure["round"] != runner.phase_round(state, phase)
|
|
321
|
+
):
|
|
322
|
+
raise runner.EvidenceError("BUILD_GATE preflight failure identity is invalid")
|
|
323
|
+
record = failure["findings"]
|
|
324
|
+
if not isinstance(record, dict) or set(record) != {"path", "sha256"}:
|
|
325
|
+
raise runner.EvidenceError("BUILD_GATE preflight failure findings binding is invalid")
|
|
326
|
+
path = runner._checked_file(work, record["path"], "preflight failure findings")
|
|
327
|
+
raw = path.read_bytes()
|
|
328
|
+
if hashlib.sha256(raw).hexdigest() != record["sha256"]:
|
|
329
|
+
raise runner.EvidenceError("BUILD_GATE preflight failure findings digest mismatch")
|
|
330
|
+
findings = [loads_strict_json(line) for line in raw.decode("utf-8").splitlines() if line.strip()]
|
|
331
|
+
if len(findings) != 1 or not isinstance(findings[0], dict) or (
|
|
332
|
+
findings[0].get("phase") != phase
|
|
333
|
+
or findings[0].get("severity") != "CRITICAL"
|
|
334
|
+
or findings[0].get("rule_id") not in {
|
|
335
|
+
"correctness.spec-verify-malformed", "correctness.risk-probe-integrity",
|
|
336
|
+
}
|
|
337
|
+
):
|
|
338
|
+
raise runner.EvidenceError("BUILD_GATE preflight failure requires its CRITICAL finding")
|
|
339
|
+
if verdict not in {"FAIL", "BLOCKED"}:
|
|
340
|
+
raise runner.EvidenceError("BUILD_GATE preflight failure requires FAIL or BLOCKED verdict")
|
|
311
341
|
carrier = results.get("process_evidence")
|
|
312
342
|
if carrier is None:
|
|
313
343
|
if commands:
|
|
@@ -315,43 +345,42 @@ def bind_process_evidence(
|
|
|
315
345
|
"BUILD_GATE commands exist without a process-evidence carrier"
|
|
316
346
|
)
|
|
317
347
|
manifest_path = work / runner.manifest_relative_path(state, phase)
|
|
318
|
-
if
|
|
348
|
+
if os.path.lexists(manifest_path):
|
|
319
349
|
raise runner.EvidenceError(
|
|
320
350
|
"BUILD_GATE manifest exists without a process-evidence carrier"
|
|
321
351
|
)
|
|
322
|
-
if runner.mechanical_evidence_required(work, state):
|
|
352
|
+
if not preflight_failure and runner.mechanical_evidence_required(work, state):
|
|
323
353
|
raise runner.EvidenceError(
|
|
324
354
|
"required BUILD_GATE evidence has no process-evidence carrier"
|
|
325
355
|
)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
)
|
|
356
|
+
else:
|
|
357
|
+
round_ = runner.phase_round(state, phase)
|
|
358
|
+
manifest = carrier.get("manifest") if isinstance(carrier, dict) else None
|
|
359
|
+
if (
|
|
360
|
+
not isinstance(carrier, dict)
|
|
361
|
+
or carrier.get("phase") != phase
|
|
362
|
+
or carrier.get("round") != round_
|
|
363
|
+
or not isinstance(manifest, dict)
|
|
364
|
+
or manifest.get("path") != runner.manifest_relative_path(state, phase)
|
|
365
|
+
):
|
|
366
|
+
raise runner.EvidenceError(
|
|
367
|
+
"BUILD_GATE process-evidence carrier does not match the active run/round"
|
|
368
|
+
)
|
|
369
|
+
outcome = runner.validate_summary_commands(work, commands, carrier)
|
|
370
|
+
if outcome["verdict"] == "BLOCKED" and verdict != "BLOCKED":
|
|
371
|
+
denial = outcome["capability_denials"][0]
|
|
372
|
+
raise runner.EvidenceError(
|
|
373
|
+
"BUILD_GATE capability denial requires BLOCKED verdict: "
|
|
374
|
+
f"{denial['id']}:{denial['operation']}"
|
|
375
|
+
)
|
|
376
|
+
if (
|
|
377
|
+
outcome["verdict"] == "NEEDS_WORK"
|
|
378
|
+
and verdict in {"PASS", "PASS_WITH_ISSUES"}
|
|
379
|
+
):
|
|
380
|
+
raise runner.EvidenceError(
|
|
381
|
+
"BUILD_GATE process evidence mismatch cannot complete as "
|
|
382
|
+
f"{verdict}: {','.join(outcome['failed_ids'])}"
|
|
383
|
+
)
|
|
355
384
|
except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
|
|
356
385
|
raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
|
|
357
386
|
existing = state.get("process_evidence")
|
|
@@ -362,8 +391,11 @@ def bind_process_evidence(
|
|
|
362
391
|
try:
|
|
363
392
|
for prior in existing:
|
|
364
393
|
runner.validate_bound_carrier(work, prior)
|
|
365
|
-
except runner.EvidenceError as exc:
|
|
394
|
+
except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
|
|
366
395
|
raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
|
|
396
|
+
if carrier is None:
|
|
397
|
+
state.setdefault("process_evidence", None)
|
|
398
|
+
return
|
|
367
399
|
if any(
|
|
368
400
|
isinstance(item, dict)
|
|
369
401
|
and item.get("phase") == carrier["phase"]
|
|
@@ -1115,7 +1147,7 @@ def finding_targets_block(block: dict, findings: list[dict]) -> bool:
|
|
|
1115
1147
|
|
|
1116
1148
|
|
|
1117
1149
|
def _read_triggering_findings(devlyn: pathlib.Path, origin_phase: str) -> tuple[list[dict], str]:
|
|
1118
|
-
name = "
|
|
1150
|
+
name = "verify-merged.findings.jsonl" if origin_phase == "verify" else f"{origin_phase}.findings.jsonl"
|
|
1119
1151
|
path = devlyn / name
|
|
1120
1152
|
try:
|
|
1121
1153
|
raw = path.read_bytes()
|
|
@@ -1547,6 +1579,34 @@ def is_plan_dispatch_receipt(entry: dict) -> bool:
|
|
|
1547
1579
|
return all(field in entry for field in PLAN_RECEIPT_FIELDS)
|
|
1548
1580
|
|
|
1549
1581
|
|
|
1582
|
+
OWNER_EXECUTION = {"plan": "orchestrator_context", "build_gate": "orchestrator_commands",
|
|
1583
|
+
"cleanup": "orchestrator_commands"}
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def orchestrator_phase(entry: dict | None, phase: str) -> bool:
|
|
1587
|
+
if not isinstance(entry, dict) or "execution_kind" not in entry:
|
|
1588
|
+
return False
|
|
1589
|
+
if entry["execution_kind"] != OWNER_EXECUTION[phase]:
|
|
1590
|
+
raise SystemExit(f"error: phases.{phase}.execution_kind is invalid")
|
|
1591
|
+
return True
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def validate_owner_identity(devlyn, phase, round_, engine=None, model=None,
|
|
1595
|
+
prompt_sha256=None, engine_session_log=None, entry=None) -> None:
|
|
1596
|
+
if any(value is not None for value in (engine, model, prompt_sha256, engine_session_log)):
|
|
1597
|
+
raise SystemExit(f"error: orchestrator {phase} cannot claim worker identity or session")
|
|
1598
|
+
if entry is not None and (
|
|
1599
|
+
any(entry.get(field) is not None for field in ("engine", "model_requested", "model_effective"))
|
|
1600
|
+
or any(field in entry for field in ("model", "prompt_sha256", "invocation_receipt", "role_argv", "role_evidence"))
|
|
1601
|
+
):
|
|
1602
|
+
raise SystemExit(f"error: orchestrator {phase} contains worker identity")
|
|
1603
|
+
if devlyn is not None:
|
|
1604
|
+
for name in (f"{phase}.prompt.{round_}", f"{phase}.worker-session.{round_}.jsonl",
|
|
1605
|
+
f"{phase}.invocation.{round_}.json", f"{phase}.argv.{round_}.json"):
|
|
1606
|
+
if os.path.lexists(devlyn / name):
|
|
1607
|
+
raise SystemExit(f"error: orchestrator {phase} has current-round worker evidence: {name}")
|
|
1608
|
+
|
|
1609
|
+
|
|
1550
1610
|
def append_phase_history(entry: dict, phase: str) -> None:
|
|
1551
1611
|
if entry.get("started_at") is None:
|
|
1552
1612
|
return
|
|
@@ -1557,6 +1617,12 @@ def append_phase_history(entry: dict, phase: str) -> None:
|
|
|
1557
1617
|
fields = PLAN_RECEIPT_FIELDS + (
|
|
1558
1618
|
("invocation_receipt",) if "invocation_receipt" in entry else ()
|
|
1559
1619
|
)
|
|
1620
|
+
elif phase == "build_gate" or (phase in OWNER_EXECUTION and "execution_kind" in entry):
|
|
1621
|
+
fields = tuple(field for field in (
|
|
1622
|
+
"started_at", "verdict", "completed_at", "duration_ms", "round", "triggered_by",
|
|
1623
|
+
"execution_kind", "engine", "model", "model_requested", "model_effective", "prompt_sha256",
|
|
1624
|
+
"invocation_receipt", "role_argv", "artifacts", "output_sha256", "pre_sha", "post_sha",
|
|
1625
|
+
) if field in entry)
|
|
1560
1626
|
elif phase in WORKER_SESSION_ARTIFACT_PHASES and "invocation_receipt" in entry:
|
|
1561
1627
|
fields = (
|
|
1562
1628
|
"started_at", "verdict", "completed_at", "duration_ms",
|
|
@@ -1622,7 +1688,13 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1622
1688
|
prompt_sha256: str | None = None,
|
|
1623
1689
|
untracked_before: list[str] | None = None,
|
|
1624
1690
|
devlyn: pathlib.Path | None = None) -> None:
|
|
1625
|
-
|
|
1691
|
+
# Omitted worker metadata selects owner PLAN/CLEANUP. Explicit metadata keeps
|
|
1692
|
+
# the historical worker API; an owner span never accepts those claims.
|
|
1693
|
+
owner = phase == "build_gate" or (
|
|
1694
|
+
phase in {"plan", "cleanup"}
|
|
1695
|
+
and all(value is None for value in (engine, model, prompt_sha256))
|
|
1696
|
+
)
|
|
1697
|
+
if not owner and phase in {"implement", "cleanup", "verify"} and "role_resolution" in state:
|
|
1626
1698
|
resolution = role_config_module()["snapshot"](state)
|
|
1627
1699
|
selected = resolution["roles"]["primary_judge" if phase == "verify" else "worker"]
|
|
1628
1700
|
if engine is not None and engine != selected["engine"]:
|
|
@@ -1637,7 +1709,17 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1637
1709
|
phases_value = state.get("phases")
|
|
1638
1710
|
entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
|
|
1639
1711
|
validate_plan_output(state, devlyn, phase)
|
|
1712
|
+
if phase in OWNER_EXECUTION and orchestrator_phase(entry, phase):
|
|
1713
|
+
validate_owner_identity(devlyn, phase, entry.get("round"), entry=entry)
|
|
1714
|
+
if not owner:
|
|
1715
|
+
raise SystemExit("error: owner phase cannot respawn as a worker")
|
|
1716
|
+
if owner:
|
|
1717
|
+
validate_owner_identity(devlyn, phase, round_, engine, model, prompt_sha256)
|
|
1718
|
+
if phase == "cleanup" and pre_sha is None:
|
|
1719
|
+
raise SystemExit("error: owner cleanup requires --pre-sha")
|
|
1640
1720
|
if phase == "plan":
|
|
1721
|
+
if owner and (state.get("phases", {}).get("implement") or {}).get("started_at"):
|
|
1722
|
+
raise SystemExit("BLOCKED:plan-already-in-use: scope cannot change after implementation starts")
|
|
1641
1723
|
history = entry.get("history", []) if isinstance(entry, dict) else []
|
|
1642
1724
|
if not isinstance(history, list):
|
|
1643
1725
|
raise SystemExit("error: phases.plan.history must be an array")
|
|
@@ -1650,11 +1732,11 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1650
1732
|
raise SystemExit(
|
|
1651
1733
|
f"BLOCKED:plan-round-nonmonotonic: expected={dispatch_count} supplied={round_}"
|
|
1652
1734
|
)
|
|
1653
|
-
if not isinstance(engine, str) or not engine:
|
|
1735
|
+
if not owner and (not isinstance(engine, str) or not engine):
|
|
1654
1736
|
raise SystemExit("error: phases.plan spawn requires --engine")
|
|
1655
|
-
if not isinstance(model, str) or not model:
|
|
1737
|
+
if not owner and (not isinstance(model, str) or not model):
|
|
1656
1738
|
raise SystemExit("error: phases.plan spawn requires --model")
|
|
1657
|
-
if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
|
|
1739
|
+
if not owner and (prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256)):
|
|
1658
1740
|
raise SystemExit("error: phases.plan spawn requires --prompt-sha256")
|
|
1659
1741
|
if dispatch_count > 0 and triggered_by is None:
|
|
1660
1742
|
raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
|
|
@@ -1692,6 +1774,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1692
1774
|
)
|
|
1693
1775
|
if (
|
|
1694
1776
|
state.get("version") == "3.0"
|
|
1777
|
+
and not owner
|
|
1695
1778
|
and requested_engine == "codex"
|
|
1696
1779
|
and phase in WORKER_SESSION_ARTIFACT_PHASES
|
|
1697
1780
|
and prompt_sha256 is None
|
|
@@ -1705,6 +1788,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1705
1788
|
)
|
|
1706
1789
|
if (
|
|
1707
1790
|
state.get("version") == "3.0"
|
|
1791
|
+
and not owner
|
|
1708
1792
|
and requested_engine == "codex"
|
|
1709
1793
|
and phase in WORKER_SESSION_ARTIFACT_PHASES
|
|
1710
1794
|
and not requested_model
|
|
@@ -1754,6 +1838,11 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1754
1838
|
entry["pre_sha"] = pre_sha
|
|
1755
1839
|
if prompt_sha256 is not None:
|
|
1756
1840
|
entry["prompt_sha256"] = prompt_sha256
|
|
1841
|
+
if owner:
|
|
1842
|
+
entry["execution_kind"] = OWNER_EXECUTION[phase]
|
|
1843
|
+
entry["engine"] = entry["model_requested"] = entry["model_effective"] = None
|
|
1844
|
+
entry.pop("prompt_sha256", None)
|
|
1845
|
+
entry.pop("role_evidence", None)
|
|
1757
1846
|
if phase == "surface_close":
|
|
1758
1847
|
entry["input_patch_sha256"] = input_patch_sha256
|
|
1759
1848
|
entry["untracked_before"] = untracked_before
|
|
@@ -1801,6 +1890,29 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1801
1890
|
raise SystemExit(
|
|
1802
1891
|
f"error: phases.{phase} already completed — respawn before completing again"
|
|
1803
1892
|
)
|
|
1893
|
+
owner = phase in OWNER_EXECUTION and orchestrator_phase(entry, phase)
|
|
1894
|
+
if owner:
|
|
1895
|
+
validate_owner_identity(
|
|
1896
|
+
devlyn, phase, entry.get("round"), engine, model,
|
|
1897
|
+
engine_session_log=engine_session_log, entry=entry,
|
|
1898
|
+
)
|
|
1899
|
+
if phase == "cleanup" and verdict in {"PASS", "PASS_WITH_ISSUES"}:
|
|
1900
|
+
if work is None or not entry.get("pre_sha") or post_sha != entry["pre_sha"]:
|
|
1901
|
+
raise SystemExit("BLOCKED:owner-cleanup-source-changed: preserve the IMPLEMENT checkpoint")
|
|
1902
|
+
checked = subprocess.run(
|
|
1903
|
+
["git", "diff", "--quiet", entry["pre_sha"], "--"], cwd=work,
|
|
1904
|
+
capture_output=True, check=False,
|
|
1905
|
+
)
|
|
1906
|
+
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=work,
|
|
1907
|
+
capture_output=True, text=True, check=False)
|
|
1908
|
+
if checked.returncode != 0 or head.returncode != 0 or head.stdout.strip() != post_sha:
|
|
1909
|
+
raise SystemExit("BLOCKED:owner-cleanup-source-changed: return code cleanup to IMPLEMENT")
|
|
1910
|
+
checker = runpy.run_path(pathlib.Path(__file__).with_name("spec-verify-check.py"))
|
|
1911
|
+
baseline, baseline_error = checker["load_untracked_baseline"](devlyn)
|
|
1912
|
+
current, current_error = checker["current_untracked_files"](work)
|
|
1913
|
+
if baseline_error or current_error or current - baseline:
|
|
1914
|
+
detail = baseline_error or current_error or repr(sorted(current - baseline))
|
|
1915
|
+
raise SystemExit(f"BLOCKED:owner-cleanup-untracked: {detail}")
|
|
1804
1916
|
if phase != "plan":
|
|
1805
1917
|
validate_plan_output(state, devlyn, phase)
|
|
1806
1918
|
if phase == "final_report":
|
|
@@ -1859,7 +1971,7 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1859
1971
|
entry.setdefault("model_requested", None)
|
|
1860
1972
|
entry.pop("model", None)
|
|
1861
1973
|
|
|
1862
|
-
artifact_phase = WORKER_SESSION_ARTIFACT_PHASES.get(phase)
|
|
1974
|
+
artifact_phase = None if owner else WORKER_SESSION_ARTIFACT_PHASES.get(phase)
|
|
1863
1975
|
retained_session = None
|
|
1864
1976
|
if devlyn is not None and artifact_phase is not None:
|
|
1865
1977
|
candidate = devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
|
|
@@ -1988,6 +2100,11 @@ def do_transition(
|
|
|
1988
2100
|
"""
|
|
1989
2101
|
if next_phase not in LEGAL_TRANSITIONS.get(phase, set()):
|
|
1990
2102
|
raise SystemExit(f"error: illegal phase transition: {phase} -> {next_phase}")
|
|
2103
|
+
if phase == "cleanup" and orchestrator_phase(state.get("phases", {}).get(phase), phase):
|
|
2104
|
+
if next_phase == "verify" and verdict not in {"PASS", "PASS_WITH_ISSUES"}:
|
|
2105
|
+
raise SystemExit("BLOCKED:owner-cleanup-failed: repair before VERIFY")
|
|
2106
|
+
if next_phase == "implement" and (verdict != "FAIL" or next_triggered_by != "cleanup"):
|
|
2107
|
+
raise SystemExit("BLOCKED:owner-cleanup-failed: repair requires FAIL and cleanup trigger")
|
|
1991
2108
|
candidate = copy.deepcopy(state)
|
|
1992
2109
|
attestation_error = do_complete(
|
|
1993
2110
|
candidate, phase, verdict, post_sha, findings_file, log_file,
|
|
@@ -2617,7 +2734,7 @@ def self_test() -> int:
|
|
|
2617
2734
|
build_carrier_1 = write_build_gate_results()
|
|
2618
2735
|
transitioned = build_gate_state_cli(
|
|
2619
2736
|
"transition", "--verdict", "PASS", "--next-phase", "cleanup",
|
|
2620
|
-
"--next-round", "0",
|
|
2737
|
+
"--next-round", "0", "--next-pre-sha", "test-pre-sha",
|
|
2621
2738
|
)
|
|
2622
2739
|
assert transitioned.returncode == 0, transitioned.stderr
|
|
2623
2740
|
build_bound_state = read_state(evidence_state_path)
|
|
@@ -2714,6 +2831,148 @@ def self_test() -> int:
|
|
|
2714
2831
|
assert blocked.returncode == 0, blocked.stderr
|
|
2715
2832
|
print("PASS iter-0112 BUILD_GATE sealed outcome owns the verdict floor")
|
|
2716
2833
|
|
|
2834
|
+
def test_orchestrator_build() -> None:
|
|
2835
|
+
work = (devlyn / "orchestrator-build").resolve()
|
|
2836
|
+
active = work / ".devlyn"
|
|
2837
|
+
active.mkdir(parents=True)
|
|
2838
|
+
state_file = active / "pipeline.state.json"
|
|
2839
|
+
fixture = {"version": "3.0", "run_id": "rs-orchestrator-build",
|
|
2840
|
+
"engine": "codex", "phases": {"build_gate": None}}
|
|
2841
|
+
(active / "spec-verify.results.json").write_text(
|
|
2842
|
+
json.dumps({"commands": [], "process_evidence": None}), encoding="utf-8",
|
|
2843
|
+
)
|
|
2844
|
+
env = {key: value for key, value in os.environ.items()
|
|
2845
|
+
if not key.startswith("DEVLYN_INVOCATION_")}
|
|
2846
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
2847
|
+
|
|
2848
|
+
def cli(phase, *args):
|
|
2849
|
+
return subprocess.run(
|
|
2850
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
2851
|
+
"--devlyn-dir", ".devlyn", "--phase", phase, *args],
|
|
2852
|
+
cwd=work, env=env, capture_output=True, text=True, check=False,
|
|
2853
|
+
)
|
|
2854
|
+
|
|
2855
|
+
def rejected(phase, *args):
|
|
2856
|
+
before = state_file.read_bytes()
|
|
2857
|
+
result = cli(phase, *args)
|
|
2858
|
+
assert result.returncode != 0, result.stdout
|
|
2859
|
+
assert "error:" in result.stderr or "BLOCKED:" in result.stderr, result.stderr
|
|
2860
|
+
assert state_file.read_bytes() == before
|
|
2861
|
+
|
|
2862
|
+
def command_identity(entry):
|
|
2863
|
+
assert entry["execution_kind"] == "orchestrator_commands"
|
|
2864
|
+
assert all(entry[key] is None for key in ("engine", "model_requested", "model_effective"))
|
|
2865
|
+
assert not {"prompt_sha256", "invocation_receipt", "role_argv", "role_evidence"} & entry.keys()
|
|
2866
|
+
|
|
2867
|
+
write_state(state_file, fixture)
|
|
2868
|
+
for args in (("--engine", "claude"), ("--model", "worker-model"),
|
|
2869
|
+
("--prompt-sha256", "0" * 64)):
|
|
2870
|
+
rejected("build_gate", "spawn", "--round", "0", *args)
|
|
2871
|
+
for name in ("build_gate.prompt.0", "build_gate.worker-session.0.jsonl",
|
|
2872
|
+
"build_gate.invocation.0.json", "build_gate.argv.0.json"):
|
|
2873
|
+
path = active / name
|
|
2874
|
+
path.write_text("forged", encoding="utf-8")
|
|
2875
|
+
rejected("build_gate", "spawn", "--round", "0")
|
|
2876
|
+
path.unlink()
|
|
2877
|
+
result = cli("build_gate", "spawn", "--round", "0")
|
|
2878
|
+
assert result.returncode == 0, result.stderr
|
|
2879
|
+
opened = read_state(state_file)
|
|
2880
|
+
command_identity(opened["phases"]["build_gate"])
|
|
2881
|
+
for args in (("--engine", "codex"), ("--model", "worker-model"),
|
|
2882
|
+
("--engine-session-log", str(active / "external.jsonl"))):
|
|
2883
|
+
rejected("build_gate", "complete", "--verdict", "PASS", *args)
|
|
2884
|
+
for field, value in (("engine", "codex"), ("model_effective", "worker-model"),
|
|
2885
|
+
("invocation_receipt", {}), ("prompt_sha256", None)):
|
|
2886
|
+
bad = copy.deepcopy(opened)
|
|
2887
|
+
bad["phases"]["build_gate"][field] = value
|
|
2888
|
+
write_state(state_file, bad)
|
|
2889
|
+
rejected("build_gate", "complete", "--verdict", "PASS")
|
|
2890
|
+
for kind in (None, "worker", {}, False):
|
|
2891
|
+
bad = copy.deepcopy(opened)
|
|
2892
|
+
bad["phases"]["build_gate"]["execution_kind"] = kind
|
|
2893
|
+
write_state(state_file, bad)
|
|
2894
|
+
rejected("build_gate", "complete", "--verdict", "PASS")
|
|
2895
|
+
bad["phases"]["build_gate"]["completed_at"] = now_iso()
|
|
2896
|
+
write_state(state_file, bad)
|
|
2897
|
+
rejected("build_gate", "spawn", "--round", "1")
|
|
2898
|
+
write_state(state_file, opened)
|
|
2899
|
+
proof = active / "build_gate.worker-session.0.jsonl"
|
|
2900
|
+
proof.symlink_to(active / "missing")
|
|
2901
|
+
rejected("build_gate", "complete", "--verdict", "PASS")
|
|
2902
|
+
proof.unlink()
|
|
2903
|
+
result = cli("build_gate", "complete", "--verdict", "PASS")
|
|
2904
|
+
assert result.returncode == 0, result.stderr
|
|
2905
|
+
completed = read_state(state_file)["phases"]["build_gate"]
|
|
2906
|
+
command_identity(completed)
|
|
2907
|
+
assert completed["verdict"] == "PASS" and completed["completed_at"]
|
|
2908
|
+
result = cli("build_gate", "spawn", "--round", "1")
|
|
2909
|
+
assert result.returncode == 0, result.stderr
|
|
2910
|
+
reentered = read_state(state_file)["phases"]["build_gate"]
|
|
2911
|
+
command_identity(reentered)
|
|
2912
|
+
assert reentered["history"][0]["execution_kind"] == "orchestrator_commands"
|
|
2913
|
+
assert reentered["history"][0]["round"] == 0
|
|
2914
|
+
result = cli("build_gate", "transition", "--verdict", "PASS",
|
|
2915
|
+
"--next-phase", "cleanup", "--next-round", "0", "--next-engine", "claude")
|
|
2916
|
+
assert result.returncode == 0, result.stderr
|
|
2917
|
+
|
|
2918
|
+
# Incoming atomic handoff must not inherit the top-level Codex identity.
|
|
2919
|
+
incoming = copy.deepcopy(fixture)
|
|
2920
|
+
incoming["phases"]["implement"] = {
|
|
2921
|
+
"started_at": now_iso(), "completed_at": None, "round": 0, "engine": "claude",
|
|
2922
|
+
}
|
|
2923
|
+
write_state(state_file, incoming)
|
|
2924
|
+
args = ("transition", "--verdict", "PASS", "--next-phase", "build_gate", "--next-round", "0")
|
|
2925
|
+
rejected("implement", *args, "--next-engine", "codex")
|
|
2926
|
+
result = cli("implement", *args)
|
|
2927
|
+
assert result.returncode == 0, result.stderr
|
|
2928
|
+
command_identity(read_state(state_file)["phases"]["build_gate"])
|
|
2929
|
+
|
|
2930
|
+
# Seed a historical span, then use the unchanged real receipt API.
|
|
2931
|
+
prompt = active / "build_gate.prompt.0"
|
|
2932
|
+
session = active / "build_gate.worker-session.0.jsonl"
|
|
2933
|
+
receipt_path = active / "build_gate.invocation.0.json"
|
|
2934
|
+
prompt.write_text("historical build\n", encoding="utf-8")
|
|
2935
|
+
session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
|
|
2936
|
+
model = "gpt-5.6-sol"
|
|
2937
|
+
legacy = copy.deepcopy(fixture)
|
|
2938
|
+
legacy["phases"]["build_gate"] = {
|
|
2939
|
+
"started_at": now_iso(), "completed_at": None, "round": 0,
|
|
2940
|
+
"engine": "codex", "model_requested": model,
|
|
2941
|
+
"prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
|
|
2942
|
+
}
|
|
2943
|
+
receipts = invocation_receipt_module()
|
|
2944
|
+
receipts.start_receipt(
|
|
2945
|
+
work, receipt_path, fixture["run_id"], "build_gate", 0, str(prompt), str(session),
|
|
2946
|
+
["--json", "-C", str(work), "-s", "workspace-write", "-m", model,
|
|
2947
|
+
"-c", "sandbox_workspace_write.network_access=true", "historical build"],
|
|
2948
|
+
)
|
|
2949
|
+
receipts.finish_receipt(work, receipt_path, 0)
|
|
2950
|
+
write_state(state_file, legacy)
|
|
2951
|
+
result = cli("build_gate", "complete", "--verdict", "PASS", "--engine-session-log", str(session))
|
|
2952
|
+
assert result.returncode == 0, result.stderr
|
|
2953
|
+
legacy_done = read_state(state_file)
|
|
2954
|
+
old = legacy_done["phases"]["build_gate"]
|
|
2955
|
+
assert "execution_kind" not in old and old["model_effective"] is None
|
|
2956
|
+
assert old["invocation_receipt"]["sandbox_network_access"] is True
|
|
2957
|
+
result = cli("build_gate", "spawn", "--round", "1")
|
|
2958
|
+
assert result.returncode == 0, result.stderr
|
|
2959
|
+
fresh = read_state(state_file)["phases"]["build_gate"]
|
|
2960
|
+
command_identity(fresh)
|
|
2961
|
+
history = fresh["history"][0]
|
|
2962
|
+
assert "execution_kind" not in history and history["round"] == 0
|
|
2963
|
+
for field in ("engine", "model_requested", "model_effective", "prompt_sha256", "invocation_receipt"):
|
|
2964
|
+
assert history[field] == old[field]
|
|
2965
|
+
write_state(state_file, legacy)
|
|
2966
|
+
session.write_text("altered", encoding="utf-8")
|
|
2967
|
+
rejected("build_gate", "transition", "--verdict", "PASS", "--engine-session-log", str(session),
|
|
2968
|
+
"--next-phase", "cleanup", "--next-round", "0", "--next-engine", "claude")
|
|
2969
|
+
result = cli("build_gate", "complete", "--verdict", "PASS", "--engine-session-log", str(session))
|
|
2970
|
+
assert result.returncode == 1 and "invocation-receipt-invalid" in result.stderr
|
|
2971
|
+
assert read_state(state_file)["phases"]["build_gate"]["verdict"] == "BLOCKED"
|
|
2972
|
+
print("PASS iter-0125 command BUILD CLI identity, transitions/history and historical receipt boundaries")
|
|
2973
|
+
|
|
2974
|
+
test_orchestrator_build()
|
|
2975
|
+
|
|
2717
2976
|
def test_interrupted_build_gate() -> None:
|
|
2718
2977
|
# Iter-0119 R1-R5: interrupted observations bind only to BLOCKED,
|
|
2719
2978
|
# without changing their bytes or bypassing archive/receipt guards.
|
|
@@ -2735,7 +2994,7 @@ def self_test() -> int:
|
|
|
2735
2994
|
"phases": {"build_gate": None, "final_report": None},
|
|
2736
2995
|
"process_evidence": None,
|
|
2737
2996
|
}
|
|
2738
|
-
do_spawn(fixture, "build_gate", 0, None, None,
|
|
2997
|
+
do_spawn(fixture, "build_gate", 0, None, None, None, None)
|
|
2739
2998
|
write_state(state_file, fixture)
|
|
2740
2999
|
open_bytes = state_file.read_bytes()
|
|
2741
3000
|
relative = runner.manifest_relative_path(fixture, "build_gate")
|
|
@@ -2948,6 +3207,8 @@ def self_test() -> int:
|
|
|
2948
3207
|
receipt = active / "build_gate.invocation.0.json"
|
|
2949
3208
|
model = "gpt-5.6-sol"
|
|
2950
3209
|
candidate = copy.deepcopy(fixture)
|
|
3210
|
+
# Seed the historical absent-kind route; a new spawn is commands-only.
|
|
3211
|
+
candidate["phases"]["build_gate"].pop("execution_kind")
|
|
2951
3212
|
candidate["phases"]["build_gate"].update({
|
|
2952
3213
|
"engine": "codex", "model_requested": model,
|
|
2953
3214
|
"prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
|
|
@@ -2975,8 +3236,8 @@ def self_test() -> int:
|
|
|
2975
3236
|
assert receipt.read_bytes() == receipt_bytes
|
|
2976
3237
|
print("PASS iter-0119 nonzero receipt persists BLOCKED before exit1; transition stays atomic")
|
|
2977
3238
|
|
|
2978
|
-
candidate =
|
|
2979
|
-
do_spawn(candidate, "build_gate", 1, None, None,
|
|
3239
|
+
candidate = read_state(state_file)
|
|
3240
|
+
do_spawn(candidate, "build_gate", 1, None, None, None, None)
|
|
2980
3241
|
write_state(state_file, candidate)
|
|
2981
3242
|
next_relative = runner.manifest_relative_path(candidate, "build_gate")
|
|
2982
3243
|
runner.capture_process(
|
|
@@ -2994,8 +3255,190 @@ def self_test() -> int:
|
|
|
2994
3255
|
assert {path: path.read_bytes() for path in observed} == observed
|
|
2995
3256
|
assert set(observed).issubset(archive.dynamic_evidence_artifacts(active, appended))
|
|
2996
3257
|
|
|
3258
|
+
# A post-spec denial refresh must preserve earlier failed checks.
|
|
3259
|
+
refresh_state = copy.deepcopy(fixture)
|
|
3260
|
+
refresh_state["phases"]["build_gate"] = None
|
|
3261
|
+
do_spawn(refresh_state, "build_gate", 2, None, None, None, None, devlyn=active)
|
|
3262
|
+
write_state(state_file, refresh_state)
|
|
3263
|
+
refresh_relative = runner.manifest_relative_path(refresh_state, "build_gate")
|
|
3264
|
+
refresh_manifest = work / refresh_relative
|
|
3265
|
+
for obligation in obligations[:2]:
|
|
3266
|
+
runner.capture_process(work, refresh_manifest, fixture["run_id"], "build_gate", 2, obligation)
|
|
3267
|
+
prior_raw = {path: path.read_bytes() for path in refresh_manifest.parent.iterdir()
|
|
3268
|
+
if path != refresh_manifest}
|
|
3269
|
+
sealed = runner.validate_manifest(
|
|
3270
|
+
work, refresh_relative, fixture["run_id"], "build_gate", 2, require_expectations=False,
|
|
3271
|
+
)
|
|
3272
|
+
summary.write_text(json.dumps({
|
|
3273
|
+
"commands": runner.bound_carrier_summary_commands(work, sealed),
|
|
3274
|
+
"process_evidence": sealed, "findings_count": 1,
|
|
3275
|
+
}), encoding="utf-8")
|
|
3276
|
+
denied = runner.normalize_obligation({
|
|
3277
|
+
"id": "browser-capability", "phase": "build_gate", "cmd": "browser-check",
|
|
3278
|
+
})
|
|
3279
|
+
runner.record_capability_denial(
|
|
3280
|
+
work, refresh_manifest, fixture["run_id"], "build_gate", 2, denied,
|
|
3281
|
+
"loopback", b"authoritative route denial fixture",
|
|
3282
|
+
)
|
|
3283
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
3284
|
+
refreshed = runner.validate_manifest(
|
|
3285
|
+
work, refresh_relative, fixture["run_id"], "build_gate", 2, require_expectations=False,
|
|
3286
|
+
)
|
|
3287
|
+
refreshed_commands = runner.bound_carrier_summary_commands(work, refreshed)
|
|
3288
|
+
runner.validate_summary_commands(work, refreshed_commands, refreshed)
|
|
3289
|
+
results = loads_strict_json(summary.read_text(encoding="utf-8"))
|
|
3290
|
+
results.update(commands=refreshed_commands, process_evidence=refreshed)
|
|
3291
|
+
summary.write_text(json.dumps(results), encoding="utf-8")
|
|
3292
|
+
for verdict in ("PASS", "FAIL"):
|
|
3293
|
+
rejected("complete", "--verdict", verdict)
|
|
3294
|
+
completed = cli("complete", "--verdict", "BLOCKED")
|
|
3295
|
+
assert completed.returncode == 0, completed.stderr
|
|
3296
|
+
assert results["findings_count"] == 1 and len(refreshed_commands) == 3
|
|
3297
|
+
assert refreshed_commands[1]["pass"] is False
|
|
3298
|
+
assert {path: path.read_bytes() for path in prior_raw} == prior_raw
|
|
3299
|
+
assert read_state(state_file)["process_evidence"] == [refreshed]
|
|
3300
|
+
print("PASS iter-0125 post-spec denial refresh retains failed expectations/raw without replay")
|
|
3301
|
+
|
|
2997
3302
|
test_interrupted_build_gate()
|
|
2998
3303
|
|
|
3304
|
+
def test_preflight_build_gate() -> None:
|
|
3305
|
+
work = (devlyn / "preflight-build-gate").resolve()
|
|
3306
|
+
active = work / ".devlyn"
|
|
3307
|
+
active.mkdir(parents=True)
|
|
3308
|
+
state_file = active / "pipeline.state.json"
|
|
3309
|
+
results_file = active / "spec-verify.results.json"
|
|
3310
|
+
checker = pathlib.Path(__file__).with_name("spec-verify-check.py")
|
|
3311
|
+
env = {key: value for key, value in os.environ.items() if key not in {
|
|
3312
|
+
"BENCH_WORKDIR", "SPEC_VERIFY_PHASE", "SPEC_VERIFY_FINDINGS_FILE",
|
|
3313
|
+
"SPEC_VERIFY_FINDING_PREFIX",
|
|
3314
|
+
} and not key.startswith("DEVLYN_INVOCATION_")}
|
|
3315
|
+
runner = process_evidence_module()
|
|
3316
|
+
|
|
3317
|
+
def complete(verdict: str) -> subprocess.CompletedProcess[str]:
|
|
3318
|
+
return subprocess.run(
|
|
3319
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
3320
|
+
"--devlyn-dir", ".devlyn", "--phase", "build_gate", "complete",
|
|
3321
|
+
"--verdict", verdict, "--findings-file", ".devlyn/spec-verify-findings.jsonl"],
|
|
3322
|
+
cwd=work, env=env, capture_output=True, text=True, encoding="utf-8",
|
|
3323
|
+
)
|
|
3324
|
+
|
|
3325
|
+
history = []
|
|
3326
|
+
for round_, scenario in enumerate(("fresh", "language", "stale", "denied")):
|
|
3327
|
+
fixture = {
|
|
3328
|
+
"version": "3.0", "run_id": "rs-preflight-build-gate",
|
|
3329
|
+
"source": {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md"},
|
|
3330
|
+
"phases": {"build_gate": None}, "process_evidence": copy.deepcopy(history) or None,
|
|
3331
|
+
}
|
|
3332
|
+
do_spawn(fixture, "build_gate", round_, None, None, None, None)
|
|
3333
|
+
write_state(state_file, fixture)
|
|
3334
|
+
original_state = state_file.read_bytes()
|
|
3335
|
+
results_file.unlink(missing_ok=True)
|
|
3336
|
+
relative = runner.manifest_relative_path(fixture, "build_gate")
|
|
3337
|
+
manifest = work / relative
|
|
3338
|
+
obligation = {"id": "type-check", "phase": "build_gate",
|
|
3339
|
+
"argv": [sys.executable, "-c", "raise SystemExit(7)"]}
|
|
3340
|
+
if scenario == "language":
|
|
3341
|
+
runner.capture_process(work, manifest, fixture["run_id"], "build_gate", round_, obligation)
|
|
3342
|
+
elif scenario == "denied":
|
|
3343
|
+
runner.record_capability_denial(
|
|
3344
|
+
work, manifest, fixture["run_id"], "build_gate", round_, obligation,
|
|
3345
|
+
"subprocess", b"route denied subprocess",
|
|
3346
|
+
)
|
|
3347
|
+
elif scenario == "stale":
|
|
3348
|
+
results_file.write_bytes(prior_results)
|
|
3349
|
+
observed = {path: path.read_bytes() for path in manifest.parent.iterdir()} if manifest.exists() else {}
|
|
3350
|
+
rejected = subprocess.run(
|
|
3351
|
+
[sys.executable, str(checker), "--include-risk-probes"],
|
|
3352
|
+
cwd=work, env=env, capture_output=True, text=True, encoding="utf-8",
|
|
3353
|
+
)
|
|
3354
|
+
assert rejected.returncode == 1 and "source.criteria_path" in rejected.stderr, rejected.stderr
|
|
3355
|
+
results = loads_strict_json(results_file.read_text(encoding="utf-8"))
|
|
3356
|
+
assert results["preflight_failure"]["round"] == round_
|
|
3357
|
+
prior_results = results_file.read_bytes()
|
|
3358
|
+
finding = active / "spec-verify-findings.jsonl"
|
|
3359
|
+
finding_bytes = finding.read_bytes()
|
|
3360
|
+
for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK"):
|
|
3361
|
+
denied = complete(verdict)
|
|
3362
|
+
assert denied.returncode != 0 and "preflight failure requires" in denied.stderr, denied.stderr
|
|
3363
|
+
assert state_file.read_bytes() == original_state
|
|
3364
|
+
if scenario == "denied":
|
|
3365
|
+
denied = complete("FAIL")
|
|
3366
|
+
assert denied.returncode != 0 and "capability denial requires BLOCKED" in denied.stderr
|
|
3367
|
+
assert state_file.read_bytes() == original_state
|
|
3368
|
+
completed = complete("BLOCKED" if scenario == "denied" else "FAIL")
|
|
3369
|
+
assert completed.returncode == 0, completed.stderr
|
|
3370
|
+
bound = read_state(state_file)
|
|
3371
|
+
assert bound["phases"]["build_gate"]["verdict"] == ("BLOCKED" if scenario == "denied" else "FAIL")
|
|
3372
|
+
assert finding.read_bytes() == finding_bytes and results_file.read_bytes() == prior_results
|
|
3373
|
+
assert {path: path.read_bytes() for path in observed} == observed
|
|
3374
|
+
if observed:
|
|
3375
|
+
assert bound["process_evidence"] == [*history, results["process_evidence"]]
|
|
3376
|
+
else:
|
|
3377
|
+
assert bound["process_evidence"] == (history or None) and results["commands"] == []
|
|
3378
|
+
history = bound["process_evidence"] or []
|
|
3379
|
+
for mutation in ("run", "phase", "round", "bool-round", "digest", "escape", "finding", "null"):
|
|
3380
|
+
state_file.write_bytes(original_state)
|
|
3381
|
+
altered = copy.deepcopy(results)
|
|
3382
|
+
failure = altered["preflight_failure"]
|
|
3383
|
+
if mutation in {"run", "phase", "round", "bool-round"}:
|
|
3384
|
+
field, value = {"run": ("run_id", "rs-other"), "phase": ("phase", "verify"),
|
|
3385
|
+
"round": ("round", round_ + 1), "bool-round": ("round", True)}[mutation]
|
|
3386
|
+
failure[field] = value
|
|
3387
|
+
elif mutation == "null":
|
|
3388
|
+
altered["preflight_failure"] = None
|
|
3389
|
+
elif mutation == "finding":
|
|
3390
|
+
finding.write_bytes(b"{}\n")
|
|
3391
|
+
failure["findings"]["sha256"] = hashlib.sha256(finding.read_bytes()).hexdigest()
|
|
3392
|
+
else:
|
|
3393
|
+
failure["findings"]["path" if mutation == "escape" else "sha256"] = (
|
|
3394
|
+
"../outside.jsonl" if mutation == "escape" else "0" * 64
|
|
3395
|
+
)
|
|
3396
|
+
results_file.write_text(json.dumps(altered), encoding="utf-8")
|
|
3397
|
+
result = complete("FAIL")
|
|
3398
|
+
assert result.returncode != 0 and "BLOCKED:process-evidence-invalid" in result.stderr, (mutation, result.stderr)
|
|
3399
|
+
assert state_file.read_bytes() == original_state
|
|
3400
|
+
finding.write_bytes(finding_bytes)
|
|
3401
|
+
results_file.write_bytes(prior_results)
|
|
3402
|
+
if scenario in {"fresh", "stale"}:
|
|
3403
|
+
state_file.write_bytes(original_state)
|
|
3404
|
+
malformed_prior = read_state(state_file)
|
|
3405
|
+
malformed_prior["process_evidence"] = {}
|
|
3406
|
+
write_state(state_file, malformed_prior)
|
|
3407
|
+
before = state_file.read_bytes()
|
|
3408
|
+
invalid = complete("FAIL")
|
|
3409
|
+
assert invalid.returncode != 0 and "null or an array" in invalid.stderr, invalid.stderr
|
|
3410
|
+
assert state_file.read_bytes() == before
|
|
3411
|
+
state_file.write_bytes(original_state)
|
|
3412
|
+
if scenario == "stale":
|
|
3413
|
+
prior_stream = work / history[0]["streams"][0]["stdout"]["path"]
|
|
3414
|
+
saved = prior_stream.read_bytes()
|
|
3415
|
+
prior_stream.write_bytes(b"altered prior observation")
|
|
3416
|
+
invalid = complete("FAIL")
|
|
3417
|
+
assert invalid.returncode != 0 and "mismatch" in invalid.stderr, invalid.stderr
|
|
3418
|
+
assert state_file.read_bytes() == original_state
|
|
3419
|
+
prior_stream.write_bytes(saved)
|
|
3420
|
+
from unittest.mock import patch
|
|
3421
|
+
read_bytes = pathlib.Path.read_bytes
|
|
3422
|
+
|
|
3423
|
+
def unreadable_prior(path):
|
|
3424
|
+
if path == prior_stream:
|
|
3425
|
+
raise PermissionError("prior stream unreadable")
|
|
3426
|
+
return read_bytes(path)
|
|
3427
|
+
|
|
3428
|
+
fixture = read_state(state_file)
|
|
3429
|
+
unchanged = copy.deepcopy(fixture)
|
|
3430
|
+
with patch.object(pathlib.Path, "read_bytes", unreadable_prior):
|
|
3431
|
+
try:
|
|
3432
|
+
bind_process_evidence(fixture, "build_gate", "FAIL", active, work)
|
|
3433
|
+
except SystemExit as exc:
|
|
3434
|
+
assert str(exc) == "BLOCKED:process-evidence-invalid: prior stream unreadable", exc
|
|
3435
|
+
else:
|
|
3436
|
+
raise AssertionError("unreadable prior evidence completed")
|
|
3437
|
+
assert fixture == unchanged and state_file.read_bytes() == original_state
|
|
3438
|
+
print("PASS preflight rejection: actual checker-to-completion, success floors, identity/digest controls")
|
|
3439
|
+
|
|
3440
|
+
test_preflight_build_gate()
|
|
3441
|
+
|
|
2999
3442
|
# complete() before spawn() must fail loudly, not silently invent data.
|
|
3000
3443
|
write_state(state_path, {"phases": {}})
|
|
3001
3444
|
state = read_state(state_path)
|
|
@@ -3170,7 +3613,7 @@ def self_test() -> int:
|
|
|
3170
3613
|
# resets the live record.
|
|
3171
3614
|
write_state(state_path, {"phases": {}})
|
|
3172
3615
|
state = read_state(state_path)
|
|
3173
|
-
do_spawn(state, "build_gate", 0, None, None,
|
|
3616
|
+
do_spawn(state, "build_gate", 0, None, None, None, None)
|
|
3174
3617
|
write_state(state_path, state)
|
|
3175
3618
|
time.sleep(0.05)
|
|
3176
3619
|
state = read_state(state_path)
|
|
@@ -3338,7 +3781,7 @@ def self_test() -> int:
|
|
|
3338
3781
|
# exact diff window for later mechanical checks.
|
|
3339
3782
|
write_state(state_path, {"phases": {}})
|
|
3340
3783
|
state = read_state(state_path)
|
|
3341
|
-
do_spawn(state, "cleanup", 0, None, "pre-sha",
|
|
3784
|
+
do_spawn(state, "cleanup", 0, None, "pre-sha", "claude", None)
|
|
3342
3785
|
write_state(state_path, state)
|
|
3343
3786
|
state = read_state(state_path)
|
|
3344
3787
|
do_complete(state, "cleanup", "PASS", "post-sha", None, None, None, None)
|
|
@@ -4091,7 +4534,11 @@ def self_test() -> int:
|
|
|
4091
4534
|
)
|
|
4092
4535
|
write_state(state_path, {"phases": {}})
|
|
4093
4536
|
state = read_state(state_path)
|
|
4094
|
-
|
|
4537
|
+
# Historical Claude BUILD evidence keeps the original mismatch guard.
|
|
4538
|
+
state["phases"]["build_gate"] = {
|
|
4539
|
+
"started_at": now_iso(), "completed_at": None, "round": 0,
|
|
4540
|
+
"engine": "claude", "model_requested": "claude-" "opus-5",
|
|
4541
|
+
}
|
|
4095
4542
|
mismatch = do_complete(
|
|
4096
4543
|
state, "build_gate", "PASS", None, None, None, None, None, str(claude_log)
|
|
4097
4544
|
)
|
|
@@ -5104,15 +5551,16 @@ def main() -> int:
|
|
|
5104
5551
|
spawn_phase = args.phase if args.event == "spawn" else args.next_phase
|
|
5105
5552
|
spawn_round = args.round if args.event == "spawn" else args.next_round
|
|
5106
5553
|
implement = (state.get("phases") or {}).get("implement")
|
|
5554
|
+
origin = implement.get("triggered_by") if isinstance(implement, dict) else None
|
|
5107
5555
|
fix_reentry = (
|
|
5108
5556
|
spawn_phase in VALID_TRIGGERS and spawn_round >= 1
|
|
5109
5557
|
and isinstance(implement, dict)
|
|
5110
5558
|
and implement.get("round") == spawn_round
|
|
5111
|
-
and
|
|
5559
|
+
and (origin == spawn_phase or (origin == "cleanup" and spawn_phase == "build_gate"))
|
|
5112
5560
|
)
|
|
5113
5561
|
if fix_reentry:
|
|
5114
5562
|
enforce_closure_durability_reentry(
|
|
5115
|
-
pathlib.Path.cwd(), devlyn, state,
|
|
5563
|
+
pathlib.Path.cwd(), devlyn, state, origin, spawn_round,
|
|
5116
5564
|
require_existing=True,
|
|
5117
5565
|
)
|
|
5118
5566
|
if args.event == "spawn":
|