Files
BFM-decomp/tools/parallel_gate.py
T
Drew T 4f7c3b64a3 docs(phase-33): commit-map + citations resolved to the rewritten history (C4–C7 — the tip commit)
- docs/commit-map.tsv: 4,032 rows (ordinal of the ORIGINAL main -> rewritten hash, author/committer dates, subject);
  1 pruned row of zeros (ordinal 1712, "session archive update"); 0 old hashes asserted; ordinal 1 unchanged by the
  rewrite (byte-identical)
- resolve_tokens: 1,238 commit:NNNN tokens -> shortest-unique new hashes in 98 files (docs, phase-ends, logs, tool
  docstrings, 2 C comments, the A5 evidence logs); residue left as tokens: commit:1712 x4 (the pruned commit),
  commit:orphan-24 x2, commit:orphan-26, commit:orphan-35 (cited commits that exist in no lineage)
- the rewrite (C4): filter-repo 2.47.0 on a bare clone of the C2 tip, 311 s, exactly 1 pruned, main 4,032 -> 4,031;
  the pre-rewrite history is mirrored in the private archive repo and in the local bundle
- the proof (C5): verify_rewrite 4,031 pairs / 0 failures; absent_scan 0 offenders; gate_scan 0 offenders on the clone
- adoption (C6): 100 text files differ at the tip, 0 purge paths, 0 added/deleted; leftover refs dropped; no gc yet
- resolver skips tools/public_rewrite/ (its self-test fixtures are the token grammar, not citations); repo-local
  identity is the GitHub noreply address from here on; CURRENT_PHASE: C4–C7 logged, checkpoint -> NEXT = C8
2026-09-06 23:28:39 -06:00

678 lines
41 KiB
Python

#!/usr/bin/env python3
"""parallel_gate.py — gate MANY binaries CONCURRENTLY in isolated git worktrees, merge only the
passing drafts back into the main tree, and commit ONCE (P31 S65).
WHY THIS EXISTS
---------------
`gate_stage.py` is single-threaded BY HARNESS, not by nature. The build is already per-binary
(`build/<bin>/*.o`, its own `.ld`, its own SHA check), so N binaries could compile at once. What
serializes them is SHARED MUTABLE STATE, all of it in the working tree:
* the splice writes `src/<bin>/*.c` in the one checkout;
* `assert_write_set` measures a GLOBAL `git status`, so a concurrent run's writes read as this
run's blast-radius violation;
* the commit is broad BY NECESSITY (propagation legitimately touches many overlays, and a
narrower glob once DROPPED four R22-verified banks). NOTE, corrected P31 S69: THIS TOOL does
NOT use `git add -u src/` — that is `gate_stage`'s form. Here the orchestrator adds exactly the
ADOPTED paths (`git add -- <adopted>`), which is why the merge-safety check decides what gets
committed. A stale reading of this very line sent the first diagnosis of the untracked-carve-file
bug in the wrong direction; the cause was the baseline comparison, not the add.
Measured cost of that serialization on 2026-08-29: a 109-binary h_norm sweep ran ~1 min/binary on a
32-core box at load 1.4 — about 4% utilisation — while an `xargs -P 4` attempt over the SAME tree
corrupted it (one aborted run's stage edits were swept into a concurrent run's commit, 696 broken
lines into ov_MAIN_012, check-all 212/213).
THE FIX IS ISOLATION, NOT LOCKING. Each worker gets its own `git worktree` (own index, own
`src/`, own `build/`), so the three shared-state problems above simply do not exist. Workers NEVER
commit and never touch the main tree. The orchestrator then applies only the drafts the gate
ACCEPTED, commits once, and runs ONE R22 clean-fleet sweep over the merged result — so the
whole-binary byte gate remains the sole arbiter (G3/P9) and the final state is verified as a whole,
not as N independent claims.
WHAT A WORKER NEEDS (measured, not guessed)
-------------------------------------------
* tracked sources: come free with the worktree (src/, include/, config/, tools/, Makefile)
* `asm/` -> SYMLINK to the main tree. A gate never writes it (only `make extract` does), and
it is 453 MB — copying it per worker would be the whole cost of the exercise.
* `.venv/` -> SYMLINK (gitignored, so absent in a fresh worktree; gate_stage shells `.venv/bin/python`)
* `build/<bin>/{<bin>.ld,undefined_syms_auto.txt,undefined_funcs_auto.txt}` -> COPY (1.9 MB/binary).
These are splat-extract outputs, untracked, and the link step needs them. Copying
them is what lets a worker skip `make extract` entirely.
MERGE SAFETY
------------
Every worktree is created from ONE pinned commit. Before adopting a worker's file the orchestrator
asserts the main tree's copy still matches that pinned version — if anything else changed that
binary meanwhile, the file is REFUSED rather than clobbered (the failure mode this whole tool exists
to prevent). Adopting is per-BINARY and per-FILE, never a blanket add.
parallel_gate.py --plan plan.json [--workers 8] [--commit] [--r22] [--keep]
plan.json: [{"binary": "ov_SC03_099", "drafts": "/abs/path/to/dir"}, ...]
"""
import argparse, functools, glob, json, os, re, shutil, subprocess, sys, time
import work_evidence
from concurrent.futures import ThreadPoolExecutor, as_completed
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PY = os.path.join(REPO, ".venv/bin/python")
WT_ROOT = os.path.join(REPO, ".run/pgate")
# The per-binary generated link inputs. DO NOT hard-code `build/<bin>/<bin>.ld` — that is the
# OVERLAY convention and it is wrong for `main`, whose Makefile variables put the linker script at
# `build/us/SLUS_007.26.ld` and its two undefined_*_auto.txt at the REPO ROOT. Hard-coding it made
# every main draft fail in a worktree and report as an honest gate rejection: measured S68, main
# banked 0 of 3 with `missing_generated: [main.ld, undefined_syms_auto.txt,
# undefined_funcs_auto.txt]` recorded in the results JSON and acted on by nobody — R32's corrected
# form, a loud failure that nobody counts is exactly as invisible as a silent one, and the same
# shape as R43's `sweep_parallel accepted main and banked 0/105`.
# Ask the Makefile, which already declares <b>_LD_SCRIPT / <b>_UNDEF_SYMS / <b>_UNDEF_FUNCS per
# binary (R33: derive from the invariant, do not re-derive it).
GEN_VARS = ("LD_SCRIPT", "UNDEF_SYMS", "UNDEF_FUNCS")
@functools.lru_cache(maxsize=None)
def generated_paths(binary):
"""(repo-relative path, ...) of <binary>'s generated link inputs, per the Makefile itself."""
r = sh(["make", "--no-print-directory", "--eval=__pgate-%: ; @echo \"$($*)\""]
+ ["__pgate-" + v for v in GEN_VARS] + ["BINARY=" + binary])
out = [ln.strip() for ln in r.stdout.splitlines() if ln.strip()]
if r.returncode or len(out) != len(GEN_VARS):
raise SystemExit("[pgate] cannot resolve generated paths for %r from the Makefile "
"(rc=%d, got %r) — refusing to gate a binary whose link inputs are "
"unknown (R43)" % (binary, r.returncode, out))
return tuple(out)
def sh(cmd, cwd=REPO, timeout=None):
return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd, timeout=timeout)
def head_commit():
return sh(["git", "rev-parse", "HEAD"]).stdout.strip()
def make_worktree(idx, pin):
"""One reusable worktree per worker slot, pinned to `pin` so the merge check has a baseline."""
wt = os.path.join(WT_ROOT, "wt%d" % idx)
if os.path.exists(wt):
sh(["git", "worktree", "remove", "--force", wt])
os.makedirs(WT_ROOT, exist_ok=True)
r = sh(["git", "worktree", "add", "--detach", wt, pin])
if r.returncode:
raise SystemExit("[pgate] worktree add failed: %s" % (r.stderr or r.stdout)[-300:])
# read-only, huge, gitignored: asm/ (453 MB of .s), the venv gate_stage shells, expected/.
# A gate never writes them — only `make extract` does — so symlinks are correct and copying
# would dominate the cost.
for link in ("asm", ".venv", "expected"):
tgt, dst = os.path.join(REPO, link), os.path.join(wt, link)
if os.path.exists(tgt) and not os.path.exists(dst):
os.symlink(tgt, dst)
# DIRECTORIES THAT MIX TRACKED AND UNTRACKED CONTENT cannot be symlinked wholesale: the worktree
# already has the tracked half, so `ln -s` would nest the link INSIDE it (measured, twice). Link
# the MISSING ENTRIES instead. extracted/retail holds 3 tracked files in git and the multi-GB
# ROM payload only on disk; the OBJCOPY size/pad step stats that payload.
link_missing(os.path.join(REPO, "extracted/retail"), os.path.join(wt, "extracted/retail"))
# UNTRACKED GENERATED HEADERS. splat writes these into include/ and they are gitignored, so a
# fresh worktree has none of them and `as` dies with "can't open macro.inc for reading" on the
# very first .data.o — which surfaces as every draft "failed" and reads exactly like a wave of
# bad drafts (measured: 4 binaries, 10 drafts, 0 banked, before this line existed). Copy, do not
# symlink: they are small and a worker must never be able to write the main tree's copy.
for f in ("macro.inc", "gte_macros.inc", "labels.inc", "include_asm.h"):
srcf = os.path.join(REPO, "include", f)
if os.path.exists(srcf):
shutil.copy2(srcf, os.path.join(wt, "include", f))
# THE TOOLCHAIN ITSELF. `tools/maspsx` is a git SUBMODULE (a worktree creates the directory and
# leaves it EMPTY) and `tools/bin/**` (cc1) + parts of `tools/psyq` are gitignored binaries. The
# directories therefore EXIST but are short, which is why a naive "is it missing?" check passes
# and the build then dies with "cc1: No such file or directory". Link the CONTENTS, per entry,
# so an existing-but-empty dir is repaired rather than skipped.
for sub in ("bin", "maspsx", "psyq"):
link_missing(os.path.join(REPO, "tools", sub), os.path.join(wt, "tools", sub))
return wt
def link_missing(s_dir, d_dir):
"""Symlink every entry of s_dir that d_dir lacks. Never replaces an existing entry, so a
directory holding BOTH tracked files (present in the worktree) and gitignored ones (present only
in the main tree) is repaired entry-by-entry."""
if not os.path.isdir(s_dir):
return
os.makedirs(d_dir, exist_ok=True)
for e in os.listdir(s_dir):
dst = os.path.join(d_dir, e)
if not os.path.lexists(dst):
os.symlink(os.path.join(s_dir, e), dst)
def stage_generated(wt, binary):
"""Copy splat's untracked per-binary outputs so the worker can LINK without re-extracting.
Paths come from the Makefile (see generated_paths) and are mirrored at the SAME repo-relative
location inside the worktree, so main's root-level undefined_*_auto.txt land at the root and an
overlay's land under build/<bin>/ — without this function needing to know which is which."""
missing = []
for rel in generated_paths(binary):
s = os.path.join(REPO, rel)
d = os.path.join(wt, rel)
if os.path.exists(s):
os.makedirs(os.path.dirname(d) or wt, exist_ok=True)
shutil.copy2(s, d)
else:
missing.append(rel)
# EXTRACTED ASSET OBJECTS. The link line pulls build/assets/<bin>/*.o (splat's binary-data
# objects, e.g. trailing.o). Without them the compile succeeds and the LINK dies with
# "cannot find build/assets/<bin>/trailing.o" — a failure that arrives late and, again, reads
# as a bad draft. 8 KB per binary; copy them.
a_src, a_dst = os.path.join(REPO, "build/assets", binary), os.path.join(wt, "build/assets", binary)
if os.path.isdir(a_src):
os.makedirs(a_dst, exist_ok=True)
for f in os.listdir(a_src):
sp = os.path.join(a_src, f)
if os.path.isfile(sp):
shutil.copy2(sp, os.path.join(a_dst, f))
# THE SIGNATURE REGISTRY (P31 S69, Fable-3). SIBLING: `verify_worktree.provision` step 3c does
# the same job for the OTHER worktree provisioner, fleet-wide rather than per-binary — it hit
# this identical defect independently in S74, five sessions after this fix (2,603/2,603
# functions raised, 0 carve owners, a FALSE corruption verdict). Two provisioners, no shared
# list, so the fix had to be made twice: if you add a derived dependency here, add it there too
# (cookbook §442).
# `jr_isolate_all.jr_inventory` resolves every
# committed `.rodata` carve's owner through `family_remap.reloc_targets`, whose `nins_of`
# reads the gitignored `.run/sig.<binary>.jsonl`. A fresh worktree has no `.run/sig.*`, so
# inside a worker EVERY carve reads UNOWNED, jr_inventory R32-aborts, harvest_verify prints
# `isolate FAILED`, and the draft is booked CARVE-REFUSED — an instrument verdict about the
# worktree, not the function. Measured on ov_SC02_000/func_8017F950 (a RELOC-ONLY twin whose
# body rtu-MATCHes 117/117): dry-run isolation passes in the main tree and aborts in the
# worktree with 30 phantom UNOWNED carves; linking this one file is the difference. Read-only
# input, so a symlink is correct (the registry is regenerated only by the main tree).
sig = os.path.join(REPO, ".run", "sig.%s.jsonl" % binary)
if os.path.exists(sig):
os.makedirs(os.path.join(wt, ".run"), exist_ok=True)
dst = os.path.join(wt, ".run", "sig.%s.jsonl" % binary)
if not os.path.lexists(dst):
os.symlink(sig, dst)
else:
missing.append(".run/sig.%s.jsonl (jtbl isolation cannot resolve carve owners without it)" % binary)
return missing
def stubs_of(wt, binary):
"""{fn} still INCLUDE_ASM in THIS tree — the bank oracle (a bank REMOVES a stub)."""
r = sh([PY, "-c",
"import sys;sys.path.insert(0,'tools');import corpus;"
"print('\\n'.join(sorted(s.symbol for s in corpus.stubs(%r).values())))" % binary],
cwd=wt)
if r.returncode:
# THE REASON TRAVELS WITH THE REFUSAL (P31 S74). This used to return a bare None and the
# caller reported "corpus refused in worktree" — a true statement that names nothing, so
# the actual cause (a committed isolation whose asm/ was never regenerated) took a manual
# re-run in a hand-built worktree to see. corpus refuses with a precise message; print it.
return ("REFUSED", (r.stderr or r.stdout).strip().splitlines()[-3:])
return set(r.stdout.split())
def src_scope(wt, binary):
"""The paths `git status` must be asked about to find this binary's edits.
`src/<binary>/` is true for every OVERLAY and FALSE FOR main, whose TUs sit at `src/*.c`
(src/800.c, src/boot.c, ...). The merge asked `git status --porcelain -- src/main/`, which
matches nothing, so a main worker returned `files: {}` — and parallel_gate then printed
"12 banked across 2 binaries" and committed ONE of them. Measured P31 S71: 11 byte-proven main
banks discarded in a single batch, and silently, because the bank oracle (a stub disappeared)
and the merge scope are two different questions. So take the scope from the binary's OWN stub
rows, which name their TU, and keep the directory prefix for the overlays that have one.
"""
r = sh([PY, "-c",
"import sys;sys.path.insert(0,'tools');import corpus;"
"print('\\n'.join(sorted({s.path for s in corpus.stubs(%r).values()})))" % binary],
cwd=wt)
paths = {p for p in r.stdout.split() if p} if not r.returncode else set()
if os.path.isdir(os.path.join(wt, "src", binary)):
paths.add("src/%s/" % binary)
return sorted(paths) or ["src/%s/" % binary]
_JTBL_RE = re.compile(r"jtbl_[0-9A-Fa-f]{8}")
def _drafts_carry_jtbl(binary, drafts):
"""Does any draft in this job reference a jump table? (same predicate harvest_verify carves on)"""
try:
import corpus
stubs = corpus.stubs(binary)
except Exception:
return False
try:
names = {os.path.basename(f)[:-2] for f in os.listdir(drafts) if f.endswith(".c")}
except OSError:
return False
for st in stubs.values():
if st.symbol in names:
try:
if _JTBL_RE.search(open(os.path.join(REPO, st.asm_path), errors="replace").read()):
return True
except OSError:
continue
return False
# A jtbl carve writes THREE kinds of output, and the merge must carry all three or none:
# 1. src/<bin>/*.c per-binary, adopted like any bank
# 2. config/splat.<bin>.yaml per-binary, adopted whole (baseline-checked)
# 3. config/overlays.mk SHARED — adopt ONLY this binary's block (see ovl_block)
# Carrying 1 without 2+3 is what turned a green worker into 13 red binaries of 213 in P31 S67
# (reverted in a732097c3): the C body referenced a carve the config never described.
def isolate_asm(wt, binary):
"""Give this worker a WRITABLE asm/ for ONE binary so a jtbl carve can re-extract safely.
THE PROBLEM THIS REMOVES (P31 S67). `harvest_verify`'s jtbl carve runs `make extract`, and the
worktree's `asm/` is a SYMLINK to the main tree — so a carving worker would rewrite the MAIN
tree's asm while 11 other workers read it. That is why jtbl drafts were routed to a SERIAL lane,
and why one 16-binary batch took ~1 hour to protect a single jtbl draft.
THE FIX IS CHEAP, and the numbers are why: `asm/` is 448 MB, but ONE binary's subtree is
3.6-5.0 MB. So replace the blanket symlink with a real directory that SYMLINKS every other
binary (read-only, free) and holds a real COPY of just this binary. `make extract BINARY=<b>`
then writes only inside the worktree. ~5 MB per worker; nothing serial remains.
Idempotent: a reused worker slot re-isolates for its new binary.
"""
link = os.path.join(wt, "asm")
real = os.path.join(REPO, "asm")
if os.path.islink(link):
os.unlink(link)
elif os.path.isdir(link):
shutil.rmtree(link, ignore_errors=True)
os.makedirs(link, exist_ok=True)
for entry in os.listdir(real):
src, dst = os.path.join(real, entry), os.path.join(link, entry)
if entry == binary:
shutil.copytree(src, dst, symlinks=True) # the ONE writable copy
elif not os.path.exists(dst):
os.symlink(src, dst) # everything else stays shared
return True
def ovl_block(text, binary):
"""This binary's block of config/overlays.mk, or None.
Blocks are delimited by `# --- <binary> (...) ---` headers and run to the next such header.
Splicing ONE block is what makes a SHARED file safe to merge from a worker: two workers touching
different binaries edit disjoint regions, and a worker can never widen its blast radius to
another binary's carve state.
"""
hdr = re.compile(r"^# --- (\S+) ", re.M)
marks = [(m.start(), m.group(1)) for m in hdr.finditer(text)]
for i, (pos, name) in enumerate(marks):
if name == binary:
end = marks[i + 1][0] if i + 1 < len(marks) else len(text)
return text[pos:end]
return None
def splice_ovl_block(main_text, binary, block):
"""Replace this binary's block in the main overlays.mk. Returns None if the block is absent."""
cur = ovl_block(main_text, binary)
if cur is None:
return None
return main_text.replace(cur, block, 1)
def gate_one(idx, pin, job):
binary, drafts = job["binary"], job["drafts"]
t0 = time.time()
wt = job.get("_wt")
# THE DRAFTS PATH IS RESOLVED AGAINST THE MAIN REPO, NEVER THE WORKTREE (P31 S70).
# gate_stage runs with `cwd=wt`, so a RELATIVE --drafts resolved inside the worktree. `.run/` is
# deliberately not linked into a worktree (see the verdict-layer note below), so every plan
# pointing at the project's own scratch convention (R12: all scratch lives under .run/) landed on
# a path that does not exist there. gate_stage then found 0 drafts, banked 0, and exited rc=0 —
# a clean success reporting a TRUE number about an EMPTY world. Measured: 35 binaries / 57 drafts
# "banked 0" in 1-2s each, while the SAME drafts gated in-tree banked 15/16 and 3/6.
drafts = drafts if os.path.isabs(drafts) else os.path.join(REPO, drafts)
# MAIN IS NOT GATEABLE HERE, AND THE RULE PREDATES THIS TOOL (R43, P31 S71).
# `ox_campaign.gate_main_batch` already states it: "main is gated by ONE CLEAN REBUILD of the
# whole EXE, never incrementally. gate_stage/sweep_parallel build incrementally, and main's
# extract rewrites the linker script, so an incremental main gate returns a FALSE DIFF."
# This tool's worker IS gate_stage, so it inherits that exactly — and the failure is not a
# false diff but a false PASS: S71 ran main through here, got "11 banked", committed it, and
# the R22 clean-fleet verify then came back 212/213. The tree did not compile from clean, and
# once the two declaration conflicts were reconciled it was still not byte-identical. Every one
# of the 11 was then re-gated the honest way (substitute -> extract -> build -> compare SHA1,
# one function at a time) and rejected. A tool must refuse an input it cannot handle rather
# than process it wrongly; use tools/gate_main.py.
if binary == 'main':
return {"binary": binary, "banked": [], "files": {}, "ovl": None,
"secs": round(time.time() - t0, 1), "missing_generated": [], "rc": None,
"error": "REFUSED — main cannot be gated incrementally (its extract rewrites the "
"linker script); gate_stage in a worktree reports a FALSE PASS. "
"Use tools/gate_main.py, which does one clean substitute -> extract -> "
"build -> SHA1 cycle for the whole batch."}
try:
# ...and assert the input actually exists before spending a worktree on it (R32/R43): a job
# whose drafts are unreadable is a DEFECT, not a zero-yield result.
try:
ndrafts = len([f for f in os.listdir(drafts) if f.endswith(".c")])
except OSError as e:
ndrafts, e_ls = 0, e
if not ndrafts:
return {"binary": binary, "banked": [], "files": {}, "ovl": None,
"secs": round(time.time() - t0, 1), "missing_generated": [], "rc": None,
"error": "REFUSED — no .c drafts readable at %s (a 0-draft job would report "
"'banked 0' as if the drafts had failed)" % drafts}
missing = stage_generated(wt, binary)
if missing:
# REFUSE, never gate anyway (R43). A worker missing its linker script or its
# undefined_*_auto.txt cannot LINK, so every draft comes back rejected and the batch
# reads as an honest wave of bad drafts. Measured S68: main banked 0 of 3 this exact
# way, and the `missing_generated` field that recorded it was consumed by nobody.
return {"binary": binary, "banked": [], "files": {}, "ovl": None,
"secs": round(time.time() - t0, 1), "missing_generated": missing, "rc": None,
"error": "REFUSED — generated link inputs absent from the worktree: %s "
"(run `make extract BINARY=%s` in the main tree first)"
% (", ".join(missing), binary)}
# A CARVING JOB NEEDS ITS OWN asm/ (see isolate_asm). Without this the job is unsafe in a
# worktree and had to run serially; with it, jtbl parallelises like everything else.
if _drafts_carry_jtbl(binary, drafts):
isolate_asm(wt, binary)
before = stubs_of(wt, binary)
if isinstance(before, tuple):
return {"binary": binary, "banked": [],
"error": "corpus refused in worktree: " + " | ".join(before[1])}
scope = src_scope(wt, binary) # BEFORE the gate: a bank deletes the stub naming its TU
# STALE VERDICTS FROM AN EARLIER JOB IN THIS REUSED WORKTREE (P31 S71). Worktrees wt0..wtN
# are reused across jobs, and the verdict layer below reads every
# `.run/harvest_failed*.classified.txt` it finds — so a later job inherited an earlier
# binary's rows and reported them under its own name (ov_SC04_011 carrying a verdict whose
# own text names src/ov_SC01_009/...). Clear them so a verdict belongs to the job that made it.
for _stale in glob.glob(os.path.join(wt, ".run", "harvest_failed*.classified.txt")):
try:
os.remove(_stale)
except OSError:
pass
r = sh([PY, "tools/gate_stage.py", "--drafts", drafts, "--binary", binary,
"--no-propagate", "--source-tag", "pgate"], cwd=wt, timeout=3600)
# WALL-CLOCK FLOOR (P31 S70, §402). A worker that reached gate_stage is committed to a full
# per-binary build+check; that cannot finish in a couple of seconds. When the drafts path was
# unreachable inside the worktree this returned "banked 0" in 1-2s with rc=0 and EVERY other
# signal said success — the runtime was the only tell. Non-strict so one odd worker cannot
# abort the batch, but the flag rides in the result and the summary prints it (R55).
blind = not work_evidence.assert_floor(
"pgate/%s" % binary, time.time() - t0, claimed="a full binary build + sha1 check",
strict=False)
after = stubs_of(wt, binary)
banked = sorted(before - after) if after is not None else []
files, ovl, status_raw = {}, None, ""
if banked: # capture the worker's resulting TU text for the merge
# THE CAPTURE IS EVIDENCE, NOT A SIDE EFFECT (S80): a worker banked 1 and captured 0 files,
# the merge adopted 0 / refused 0, and the run exited 0 — the bank silently never reached the
# main tree. Keep the raw status + scope in the result so the next such case is diagnosable.
st = sh(["git", "status", "--porcelain", "--"] + scope, cwd=wt)
status_raw = st.stdout
for rel in status_raw.splitlines():
p = rel[3:].strip()
if p:
files[p] = open(os.path.join(wt, p)).read()
# CARVE STATE. The per-binary splat yaml is adopted whole; overlays.mk is SHARED, so we
# carry only this binary's BLOCK and splice it (never a blanket file adopt — the
# `carve-state-files-never-blanket-add` rule).
yml = "config/splat.%s.yaml" % binary
if os.path.exists(os.path.join(wt, yml)):
wtxt = open(os.path.join(wt, yml)).read()
if wtxt != sh(["git", "show", "%s:%s" % (pin, yml)]).stdout:
files[yml] = wtxt
wov = os.path.join(wt, "config/overlays.mk")
if os.path.exists(wov):
blk = ovl_block(open(wov).read(), binary)
if blk is not None and blk != ovl_block(
sh(["git", "show", "%s:config/overlays.mk" % pin]).stdout, binary):
ovl = blk
# THE FAILURE CLASSES ARE PART OF THE RESULT, NOT DEBRIS IN A TRUNCATED TAIL (P31 S69).
# `tail` is the last 200 chars — the JSON summary line — so `failed by class: PLUMBING=1`,
# printed EARLIER by harvest_verify, never survived. gater_lane's in-tree retry therefore
# could not tell "the worktree was blind" from "cc1 emitted a real diagnostic naming the
# function", and retried all 22 binaries serially for nothing (measured S69: ~20 min).
# THE VERDICT LAYER LIVES IN THE WORKTREE'S OWN .run/ AND DIES WITH IT (P31 S69, R47).
# `harvest_verify` writes `<stem>.classified.txt` — ONE ROW PER FUNCTION with the exact cc1
# diagnostic. `.run/` is not symlinked into a worktree, so those rows were lost and survived
# only because gater_lane re-ran the whole binary IN-TREE afterwards, purely as a side effect.
# Copy them out, and derive the class summary FROM THEM.
#
# THE FIRST ATTEMPT AT THIS PARSED `failed by class:` OUT OF THE WORKER'S STDOUT AND WAS
# INERT: the worker is `gate_stage`, which does not print that line — harvest_verify does,
# one level down. `classes` came back empty for all 17 binaries of the batch and the retry
# gate that consumed it never fired once. A field that is always empty makes its consumer a
# no-op, silently (R54); reading the artifact the tool actually writes cannot drift that way.
verdicts, cls = [], ""
wrun = os.path.join(wt, ".run")
if os.path.isdir(wrun):
for name in sorted(os.listdir(wrun)):
if name.startswith("harvest_failed") and name.endswith(".classified.txt"):
try:
rows = [ln.rstrip("\n") for ln in open(os.path.join(wrun, name)) if ln.strip()]
except OSError:
continue
verdicts.extend(rows)
try:
shutil.copy(os.path.join(wrun, name),
os.path.join(REPO, ".run/gate_lane/%s.pgate.classified.txt" % binary))
except OSError:
pass
# A row is `<fn>\t<CLASS>: <diagnostic>`. The CLASS alone is not enough to decide whether the
# worktree was blind — `CC1-FAIL(no-diagnostic)` is the blind signature, `CC1-FAIL: <file>:<line>:
# <message>` is a real compile error — so keep the whole row and let the consumer judge.
classes = []
for row in verdicts:
part = row.split("\t", 1)[-1]
classes.append(part.split(":", 1)[0].strip())
cls = " ".join(sorted(set(classes)))
return {"binary": binary, "banked": banked, "files": files, "ovl": ovl,
"blind_suspect": blind, "scope": scope, "status_raw": status_raw[:2000],
"secs": round(time.time() - t0, 1),
"missing_generated": missing, "classes": cls, "verdicts": verdicts,
"rc": r.returncode, "tail": (r.stdout or r.stderr)[-200:] if not banked else ""}
except Exception as e:
return {"binary": binary, "banked": [], "error": "%s: %s" % (type(e).__name__, str(e)[:160])}
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--plan", required=True, help="JSON [{binary, drafts}, ...]")
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--commit", action="store_true")
ap.add_argument("--r22", action="store_true", help="clean-fleet verify after the merge (do it)")
ap.add_argument("--keep", action="store_true", help="keep worktrees for inspection")
ap.add_argument("--no-merge", action="store_true",
help="gate in the worktrees and REPORT only; never write the main tree. The "
"dirty-tree precondition applies to the MERGE, not to gating — a worker "
"is isolated by construction — so this mode runs safely alongside another "
"gate/sweep and is how you measure the speedup without waiting for a quiet tree.")
a = ap.parse_args()
if not a.no_merge:
dirty = sh(["git", "status", "--porcelain", "--", "src/", "config/"]).stdout.strip()
if dirty:
sys.exit("REFUSED: src/ or config/ is dirty — commit or inspect first (R42):\n" + dirty[:400])
jobs = json.load(open(a.plan))
jobs = [j for j in jobs if os.path.isdir(j["drafts"]) and
any(f.endswith(".c") for f in os.listdir(j["drafts"]))]
if not jobs:
sys.exit("no jobs with drafts")
pin = head_commit()
nw = max(1, min(a.workers, len(jobs)))
print("[pgate] %d binaries, %d workers, pinned at %s" % (len(jobs), nw, pin[:9]), flush=True)
wts = [make_worktree(i, pin) for i in range(nw)]
results, t0 = [], time.time()
try:
# one worktree per SLOT, handed round-robin: a worker is reused across jobs, never shared
# concurrently (each future owns its slot for its whole run).
slot_q = list(range(nw))
def run(job, slot):
job["_wt"] = wts[slot]
return gate_one(slot, pin, job)
with ThreadPoolExecutor(max_workers=nw) as ex:
futs = {}
for i, job in enumerate(jobs):
futs[ex.submit(run, job, i % nw)] = job["binary"]
for f in as_completed(futs):
r = f.result(); results.append(r)
print("[pgate] %-14s banked %-3d %s%s" % (r["binary"], len(r["banked"]),
r.get("error") or ("%.0fs" % r.get("secs", 0)),
" <-- BLIND SUSPECT: finished below the build floor (§402)"
if r.get("blind_suspect") else ""), flush=True)
finally:
if not a.keep:
for wt in wts:
sh(["git", "worktree", "remove", "--force", wt])
total = sum(len(r["banked"]) for r in results)
print("[pgate] %d banked across %d binaries in %.0fs wall (workers=%d)"
% (total, sum(1 for r in results if r["banked"]), time.time() - t0, nw), flush=True)
# ---- MERGE: adopt only files whose main-tree copy is still the pinned version (never clobber)
adopted, refused, new_files = [], [], []
if a.no_merge:
print("[pgate] --no-merge: main tree untouched; %d file(s) held in .run/pgate_results.json"
% sum(len(r.get("files") or {}) for r in results), flush=True)
json.dump(results, open(os.path.join(REPO, ".run/pgate_results.json"), "w"), indent=1)
return
for r in results:
for p, text in (r.get("files") or {}).items():
# BASELINE CHECK — and a NEW file is not a moved one (P31 S69).
# A jtbl carve SPLITS a TU, so the worker legitimately creates
# `src/<bin>/<bin>_jr_<addr>.c` that exists neither at the pin nor in the main tree.
# The original form compared `git show`'s stdout (EMPTY STRING when the path is not at
# the pin) against `None` (absent from the main tree) — and `None != ""`, so EVERY
# carve-created file was refused as "main tree moved under them" and left UNTRACKED.
# Nothing failed locally (the file is on disk, R22 passes), but the yaml naming its
# subseg IS committed, so a fresh clone got the config without the source. Eight files
# accumulated that way in one session and only surfaced because the dirty-tree guard
# refused a later run. Distinguish "not at the pin" from "empty at the pin" via the
# RETURN CODE, so absent-in-both compares equal and adopts.
shown = sh(["git", "show", "%s:%s" % (pin, p)])
base = shown.stdout if shown.returncode == 0 else None
cur = open(os.path.join(REPO, p)).read() if os.path.exists(os.path.join(REPO, p)) else None
if cur != base:
refused.append(p); continue
if base is None:
new_files.append(p)
open(os.path.join(REPO, p), "w").write(text)
adopted.append(p)
# CARVE STATE, per binary, on the SHARED overlays.mk: splice only this binary's block, and
# only if that block still equals the pinned baseline. Two workers carving different binaries
# therefore edit disjoint regions and can never clobber each other (the same refusal
# discipline as the per-file adopt above, at block granularity).
if r.get("ovl"):
b = r["binary"]
ovp = os.path.join(REPO, "config/overlays.mk")
main_text = open(ovp).read()
pinned_blk = ovl_block(sh(["git", "show", "%s:config/overlays.mk" % pin]).stdout, b)
if ovl_block(main_text, b) != pinned_blk:
refused.append("config/overlays.mk[%s]" % b); continue
spliced = splice_ovl_block(main_text, b, r["ovl"])
if spliced is None:
refused.append("config/overlays.mk[%s: block absent]" % b); continue
open(ovp, "w").write(spliced)
if "config/overlays.mk" not in adopted:
adopted.append("config/overlays.mk")
if new_files:
print("[pgate] %d NEW file(s) created by a carve, now tracked: %s"
% (len(new_files), " ".join(new_files)), flush=True)
print("[pgate] merged %d file(s); REFUSED %d (main tree moved under them): %s"
% (len(adopted), len(refused), " ".join(refused[:5])), flush=True)
# BANKED-BUT-NOT-MERGED IS A FAILURE, NOT A QUIET ZERO (S80; R32/R61a). A worker whose bank oracle
# fired (a stub disappeared) but whose captured files were neither adopted nor refused has left a
# byte-proven bank in a worktree that is about to be deleted. Say so per binary, keep the evidence,
# and exit non-zero — the caller must splice by hand or re-run, never read "exit 0" as banked.
dropped = [r for r in results if r.get("banked")
and not any((p in adopted) or (p in refused) for p in (r.get("files") or {}))]
for r in dropped:
print("!! [pgate] BANKED-BUT-NOT-MERGED %s: banked %s but the worker captured %d file(s) "
"(scope=%s; git status=%r) — the bank is NOT in the main tree"
% (r["binary"], " ".join(r["banked"]), len(r.get("files") or {}),
r.get("scope"), (r.get("status_raw") or "")[:300]), flush=True)
# A CARVE THAT LANDS IN src/ + config/ LEAVES asm/ STALE, AND THE NEXT TOOL TO READ THE CORPUS
# REFUSES ON THAT BINARY (P31 S74, the R22 corollary inside a gate). An isolation writes
# `INCLUDE_ASM(".../<new-subseg>", fn)` into a NEW TU; those .s files do not exist until a
# re-extract, so `corpus.stubs` correctly reports "the tree and the source disagree" — and
# every later gate on that binary then fails with `corpus refused`, which reads as a mystery.
# Measured here: ov_SC03_105 was left in exactly that state by its own successful gate and the
# next job could not run at all. Re-extract the binaries this run carved, and if one is STILL
# unreadable say so loudly rather than leaving a tree no tool can read (R32/R43).
for b in sorted({f.split("/")[1] for f in new_files if f.startswith("src/")}):
chk = [PY, "-c", "import sys;sys.path.insert(0,'tools');import corpus;corpus.stubs(%r)" % b]
if sh(chk, cwd=REPO).returncode:
print("[pgate] %s: carve left asm/ stale -> re-extracting" % b, flush=True)
sh(["make", "extract", "BINARY=" + b], cwd=REPO)
if sh(chk, cwd=REPO).returncode:
print("[pgate] %s: STILL UNREADABLE after re-extract — the tree and the source "
"disagree and no tool can read this binary until it is fixed by hand" % b,
flush=True)
if a.r22 and adopted:
# THE SAME EXCLUSIVITY GUARD AS tools/r22_verify.sh, because the destructive operation is
# HERE too. `make clean` deletes asm/ and build/, and drafting agents READ asm/ — they never
# write src/, so a dirty-tree check does not see them. Measured FOUR times in S68: agents
# reporting "asm/<binary> is MISSING from the tree" mid-draft, one surviving only because it
# found an old snapshot. Putting the guard only on the standalone script left this path —
# the one actually used most — unguarded. A guard belongs where the operation is (R54).
# Liveness from the RECORDED ledger, never from scratch-dir mtimes: a `.run/*wave*` glob
# blew past ARG_MAX and made the heuristic silently PASS on a live lane, and even fixed it
# could not tell a thinking agent from a finished one.
_lf = sh([sys.executable, "tools/lane_inflight.py", "list"])
busy = _lf.stdout.strip() if _lf.returncode else ""
if busy and not os.environ.get("R22_FORCE"):
print("[pgate] R22 SKIPPED — drafting agents are live and read asm/ (a clean would pull "
"it out from under them):\n%s\n[pgate] the merge IS committed; run "
"tools/r22_verify.sh once the lane drains, or set R22_FORCE=1."
% "\n".join(" " + l for l in busy.splitlines()), flush=True)
# RECORD THE DEBT. A skipped fleet check that nobody tracks is the same failure mode as
# a loud error nobody counts (R32): it reads as "verified" at session close. This file
# is the countable form — the session checkpoint must quote it, and it is deleted only
# by an R22 that actually runs green.
with open(os.path.join(REPO, ".run/R22_DEBT"), "a") as fh:
fh.write("%s deferred after commit of %s (%d file(s))\n"
% (time.strftime("%H:%M:%S"), head_commit()[:9], len(adopted)))
a.r22 = False
if a.r22 and adopted:
print("[pgate] R22 clean-fleet verify …", flush=True)
sh(["make", "clean"], timeout=1800)
sh(["make", "extract-all", "JOBS=32"], timeout=7200)
c = sh(["make", "check-all", "JOBS=32"], timeout=7200)
line = [l for l in c.stdout.splitlines() if l.startswith("check-all:")]
print("[pgate] %s" % (line[-1] if line else "check-all produced no summary"), flush=True)
if not line or "0 failed" not in line[-1]:
sys.exit("[pgate] ABORT — fleet NOT green after merge; files left in tree for inspection (R42)")
# a green fleet clears every deferred check — that is what the debt file was waiting for
try:
os.remove(os.path.join(REPO, ".run/R22_DEBT"))
print("[pgate] cleared .run/R22_DEBT (fleet verified green)", flush=True)
except OSError:
pass
if a.commit and adopted:
sh(["git", "add", "--"] + adopted)
msg = ("feat(decomp): parallel gate — %d fns across %d binaries (%d workers)\n\n%s"
% (total, sum(1 for r in results if r["banked"]), nw,
"\n".join(" %-14s %s" % (r["binary"], " ".join(r["banked"]))
for r in results if r["banked"])[:3000]))
sh(["git", "-c", "user.name=Drew T", "-c", "user.email=50529377+Druthulu@users.noreply.github.com",
"commit", "-q", "-m", msg])
print("[pgate] committed %s" % head_commit()[:9], flush=True)
json.dump(results, open(os.path.join(REPO, ".run/pgate_results.json"), "w"), indent=1)
# A per-run copy: the fixed path is overwritten by the next run, which is exactly how the S80
# banked-but-not-merged case lost its evidence before anyone looked.
os.makedirs(os.path.join(REPO, ".run/pgate_runs"), exist_ok=True)
json.dump(results, open(os.path.join(REPO, ".run/pgate_runs/%s.json"
% time.strftime("%Y%m%d-%H%M%S")), "w"), indent=1)
if dropped:
sys.exit(2)
if __name__ == "__main__":
main()