mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-10-06 01:04:13 -04:00
feat(phase-30 S48): tools/recover_drafts.py — the transcript IS the backup
Today a 28-agent wave lost 21 adversarially-verified drafts to a shared output directory, and I wrote them off before Drew asked whether the workflow results could just be analysed. They were all recoverable, for zero agent tokens. Encodes the method that worked 21/21, including the two shortcuts that do NOT: - taking each Write's content recovers only single-write drafts (8/21 — agents refine); - taking an Edit's new_string as a file yields a FRAGMENT, not a file. So it replays the mutation history per (agent, file_path), snapshots after every mutation, emits newest-first, and also scans Bash heredocs (the 21st draft never used Write/Edit at all). --gate runs match_one newest-first and keeps the first MATCH. Self-test on wf_d804f25a-f6f: 3/3 including the heredoc case.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recover agent-written draft files from a workflow run's TRANSCRIPTS. (P30 S48)
|
||||
|
||||
WHY THIS EXISTS. A 28-agent wave wrote its drafts into one shared directory, and later agents
|
||||
deleted earlier agents' files while tidying. 21 adversarially-VERIFIED cracks looked destroyed and
|
||||
were written off; a 5M-token re-crack wave was launched before Drew asked the right question —
|
||||
"can you just analyse the workflow results to get those back?"
|
||||
|
||||
They were all recoverable. Every agent tool call is recorded in the run transcript WITH ITS CONTENT,
|
||||
so the transcript is the backup for anything an agent produced. Recovery is deterministic and costs
|
||||
zero agent tokens.
|
||||
|
||||
THE METHOD, and why the two obvious shortcuts fail:
|
||||
* Taking each `Write` record's content recovers only the drafts that were written once — agents
|
||||
REFINE. (Measured: 8 of 21.)
|
||||
* Taking each `Edit`'s `new_string` as a file is worse: it is a FRAGMENT, not a file.
|
||||
So: replay the mutation history per (agent, file_path) — `Write` sets the content, `Edit` applies
|
||||
old->new to the current content — and snapshot after EVERY mutation. The wanted file state is one
|
||||
of those snapshots, usually but not always the last (an agent may end on a failed experiment).
|
||||
(Measured: 20 of 21.)
|
||||
* The last file never used Write/Edit at all — it was written by a shell heredoc. So also scan
|
||||
`Bash` commands for `<<'EOF' … EOF` bodies. (Measured: 21 of 21.)
|
||||
|
||||
Snapshots are emitted newest-first so a caller can gate them in order and stop at the first MATCH;
|
||||
`--gate` does exactly that when given the per-function asm subdir map.
|
||||
|
||||
tools/recover_drafts.py <run_dir> --names func_A,func_B --out .run/recover
|
||||
tools/recover_drafts.py <run_dir> --names-json lost.json --out .run/recover \
|
||||
--gate targets.json # [{"name":..., "sub":...}] -> runs match_one, writes recovered.json
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HEREDOC_RE = re.compile(r"<<\s*'?(\w+)'?\s*\n(.*?)\n\1", re.S)
|
||||
|
||||
|
||||
def _tool_blocks(obj, out):
|
||||
"""Every {name, input} tool-use block anywhere in a transcript record."""
|
||||
if isinstance(obj, dict):
|
||||
if isinstance(obj.get("input"), dict) and obj.get("name"):
|
||||
out.append((obj["name"], obj["input"]))
|
||||
for v in obj.values():
|
||||
_tool_blocks(v, out)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
_tool_blocks(v, out)
|
||||
|
||||
|
||||
def snapshots(run_dir, names):
|
||||
"""{fn: [content, ...]} newest-first, deduped, from every agent transcript in `run_dir`."""
|
||||
want = set(names)
|
||||
per_fn = {n: [] for n in want}
|
||||
for path in sorted(glob.glob(os.path.join(run_dir, "agent-*.jsonl"))):
|
||||
state = {} # file_path -> current content, PER AGENT
|
||||
for line in open(path, errors="ignore"):
|
||||
if not any(t in line for t in ('"Write"', '"Edit"', '"Bash"')):
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
ops = []
|
||||
_tool_blocks(rec, ops)
|
||||
for name, inp in ops:
|
||||
if name == "Bash": # heredoc-written files
|
||||
cmd = inp.get("command", "") or ""
|
||||
for _tag, body in HEREDOC_RE.findall(cmd):
|
||||
for fn in want:
|
||||
if fn in body and "{" in body:
|
||||
per_fn[fn].append(body)
|
||||
continue
|
||||
fp = inp.get("file_path", "") or ""
|
||||
if not fp.endswith(".c"):
|
||||
continue
|
||||
fn = os.path.basename(fp)[:-2]
|
||||
if fn not in want:
|
||||
continue
|
||||
if name == "Write":
|
||||
state[fp] = inp.get("content", "")
|
||||
else:
|
||||
cur = state.get(fp)
|
||||
if cur is None:
|
||||
continue # an Edit to a file this agent never wrote
|
||||
old, new = inp.get("old_string", ""), inp.get("new_string", "")
|
||||
if not old or old not in cur:
|
||||
continue # stale/failed edit — keep the prior state
|
||||
state[fp] = cur.replace(old, new) if inp.get("replace_all") else cur.replace(old, new, 1)
|
||||
per_fn[fn].append(state[fp])
|
||||
out = {}
|
||||
for fn, versions in per_fn.items():
|
||||
seen, uniq = set(), []
|
||||
for c in reversed(versions): # newest first
|
||||
h = hash(c)
|
||||
if h in seen:
|
||||
continue
|
||||
seen.add(h)
|
||||
uniq.append(c)
|
||||
if uniq:
|
||||
out[fn] = uniq
|
||||
return out
|
||||
|
||||
|
||||
def gate(fn, path, sub):
|
||||
r = subprocess.run(f"python3 tools/match_one.py {fn} --c {path} --asm-subdir {sub}",
|
||||
shell=True, cwd=REPO, capture_output=True, text=True, timeout=600)
|
||||
first = (r.stdout or "").strip().split("\n")[0]
|
||||
return first.startswith("MATCH"), first[:80]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("run_dir", help="the workflow run's transcript dir (…/subagents/workflows/wf_…)")
|
||||
ap.add_argument("--names", help="comma-separated func_XXXXXXXX to recover")
|
||||
ap.add_argument("--names-json", help="JSON list of {'fn':…} or bare names")
|
||||
ap.add_argument("--out", default=".run/recover")
|
||||
ap.add_argument("--gate", metavar="TARGETS_JSON",
|
||||
help="[{name, sub}] — gate snapshots newest-first, keep the first MATCH")
|
||||
a = ap.parse_args()
|
||||
|
||||
names = set()
|
||||
if a.names:
|
||||
names |= {x.strip() for x in a.names.split(",") if x.strip()}
|
||||
if a.names_json:
|
||||
for r in json.load(open(a.names_json)):
|
||||
names.add(r["fn"] if isinstance(r, dict) else r)
|
||||
if not names:
|
||||
ap.error("give --names or --names-json")
|
||||
|
||||
snaps = snapshots(a.run_dir, names)
|
||||
outdir = os.path.join(REPO, a.out)
|
||||
for fn, versions in snaps.items():
|
||||
d = os.path.join(outdir, fn)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
for i, c in enumerate(versions):
|
||||
open(os.path.join(d, f"v{i:02d}.c"), "w").write(c)
|
||||
missing = sorted(names - set(snaps))
|
||||
print(f"recover_drafts: {len(snaps)}/{len(names)} functions have snapshots "
|
||||
f"({sum(len(v) for v in snaps.values())} versions) -> {a.out}"
|
||||
+ (f"; NO RECORD: {missing}" if missing else ""))
|
||||
|
||||
if not a.gate:
|
||||
return 0
|
||||
subs = {t["name"]: t["sub"] for t in json.load(open(a.gate))}
|
||||
hits = {}
|
||||
for fn, versions in sorted(snaps.items()):
|
||||
sub = subs.get(fn)
|
||||
if not sub:
|
||||
print(f" {fn}: no asm subdir given — skipped"); continue
|
||||
for i, _c in enumerate(versions):
|
||||
p = os.path.join(a.out, fn, f"v{i:02d}.c")
|
||||
ok, line = gate(fn, p, sub)
|
||||
if ok:
|
||||
hits[fn] = p
|
||||
print(f" MATCH {fn} <- v{i:02d}.c")
|
||||
break
|
||||
else:
|
||||
print(f" no version matches: {fn} ({len(versions)} tried)")
|
||||
json.dump(hits, open(os.path.join(REPO, a.out, "recovered.json"), "w"), indent=1)
|
||||
print(f"recover_drafts: {len(hits)}/{len(snaps)} gated MATCH -> {a.out}/recovered.json")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user