Files
BFM-decomp/tools/recover_drafts.py
T
Drew T c0e64f48b6 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.
2026-08-11 21:12:44 -06:00

171 lines
7.3 KiB
Python

#!/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())