steps/ per ladder step: gate rule 4, writer, backfill (8); R-653, R-656; decoy suite reads live pins (R-663)
gates / gates (push) Successful in 2s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 07:57:51 +02:00
parent 585a7cba22
commit 5ed599cd5f
16 changed files with 1150 additions and 22 deletions
+55 -1
View File
@@ -465,12 +465,50 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
tight = [n for n, p in per.items() if _tight_pct(p) is not None and _tight_pct(p) > MEMORY_TIGHT]
rec = {"soak_s": round(time.time() - t0, 1), "requested_s": seconds, "requests": hits["n"],
"codes": hits["codes"], "first_kill": first_bad, "containers": per,
"unmeasured": [n for n, p in per.items() if not p["measured"]]}
"unmeasured": [n for n, p in per.items() if not p["measured"]],
"load": load_verdict(hits["n"], hits["codes"])}
marks = ["memory_tight"] if tight and not killed else []
say(f"memory watch: killed={killed} tight={tight} requests={hits['n']} codes={hits['codes']}")
return rec, killed, marks
def load_verdict(requests: int, codes: dict) -> str:
"""R-653: did the watch's load REACH the app? `reached` when at least half its requests got any HTTP
answer (a 401 or a 30x is the app answering); `inconclusive` otherwise. Measured 2026-09-23 night:
ghost's and nextcloud's first ten-minute watches sent 11 797 and 9 427 requests and EVERY one was
`err` — memory measured, app idle — and the harness wrote `proven` over it."""
if requests <= 0:
return "inconclusive"
answered = sum(n for c, n in codes.items() if c != "err")
return "reached" if answered * 2 >= requests else "inconclusive"
def clear_scratch_folders(compose_text: str, env: dict, say) -> list:
"""R-656: before FROM, remove the app's OWN scratch drive folders — every `${HDD_PATH}/…`,
`${USERDATA_PATH}/…` and `${IMPORT_PATH}/…` bind the compose names — so a re-run of the same app does
not start on the last run's files (nextcloud's second run never installed, 2026-09-23 night: `occ
status: installed: false` over the first run's config/). `compose down -v` removes named volumes, not
bind-mounted host folders. NEVER a bare root, never a path outside the scratch roots. Returns what was
removed, and says so either way."""
roots = {k: env.get(k) for k in ("HDD_PATH", "USERDATA_PATH", "IMPORT_PATH") if env.get(k)}
removed = []
for var, rel in re.findall(r"\$\{(HDD_PATH|USERDATA_PATH|IMPORT_PATH)\}(/[^:\s\"']*)", compose_text):
root = roots.get(var)
if not root:
continue
rel = rel.strip("/")
target = os.path.realpath(os.path.join(root, rel))
safe_root = os.path.realpath(root)
if not rel or target == safe_root or not target.startswith(safe_root + os.sep):
say(f"scratch folder NOT cleared (outside or equal to {var}={root}): {rel!r}")
continue
if os.path.exists(target):
shutil.rmtree(target, ignore_errors=False)
removed.append(target)
say(f"scratch drive folders cleared before FROM (R-656): {removed or 'none existed'}")
return removed
MIGRATION_RE = re.compile(
r"migrat|upgrad|schema|alter table|CREATE TABLE|InnoDB: Upgrad|mysql_upgrade|"
r"mariadb-upgrade|Running .* migration|Applying|db:migrate",
@@ -558,6 +596,7 @@ def run_edge(edge_id: str) -> dict:
try:
# --- 1. FROM ---
rec["scratch_cleared"] = clear_scratch_folders(compose_text, env, say)
say(f"{edge_id}: deploying {app} at FROM {e['frm']}")
render(app, e["frm"], workdir, env, e.get("template"))
up = compose(workdir, project, "up", "-d")
@@ -652,6 +691,11 @@ def run_edge(edge_id: str) -> dict:
if killed:
rec["verdict"] = "failed"
say("VERDICT -> failed: the new version was OOM-killed or restarted under light load")
elif mem.get("load") != "reached":
rec["verdict"] = "inconclusive"
rec["abort_detail"] = (f"memory watch: fewer than half of its {mem.get('requests')} requests reached "
f"the app ({mem.get('codes')}) — memory measured on an idle app (R-653)")
say("VERDICT -> inconclusive: " + rec["abort_detail"])
# --- 6. the ABORT ---
say(f"{edge_id}: ABORT — putting the FROM images back")
@@ -778,6 +822,16 @@ def write_ladder(argv) -> int:
if probs:
print(f"REFUSED {app}: the entry would not be well-formed: {probs}")
return 1
# `09` §6.4 part 5: the step being SUPERSEDED keeps its own definition. When the ladder's head is the
# compose as it stands, that compose — the head's images with every fix that flowed since — becomes
# steps/<step_key(head.to)>.yml, the file a box one step behind will pin (check-test-record.py rule 4).
prior, _, _ = ladder.parse(fy_p.read_text())
if prior and prior[-1].get("to") == cur:
sp = tdir / ladder.step_file(cur)
if not sp.exists():
sp.parent.mkdir(parents=True, exist_ok=True)
sp.write_text(comp)
print(f"STEP {app}: the superseded step {cur} keeps its definition at {ladder.step_file(cur)}")
# move the compose, per service, on that service's own image: line
out, svc = [], None
for line in comp.splitlines():