Spaces:
Running
Running
File size: 13,054 Bytes
615a703 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | #!/usr/bin/env bash
# Get every open Codex rollout onto the bucket as a CLOSED object, before a
# deploy replaces the container.
#
# scripts/migrate-open-rollouts.sh # snapshot, upload, verify
# scripts/migrate-open-rollouts.sh --dry-run # report, touch nothing
# scripts/migrate-open-rollouts.sh --restore # boot side: reconcile
#
# WHY THIS EXISTS
#
# Codex appends to $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl and
# holds that descriptor open for the whole life of a resumed session. That path
# is a symlink onto the /data bucket (entrypoint.sh), which is FUSE over object
# storage, and the mount's streaming writer can hold the entire open epoch in
# memory until close. Reads inside this container see the buffer, so the file
# looks complete from here. The OBJECT does not have it.
#
# Measured on prod, 2026-08-13, three live Codex sessions:
#
# rollout-2026-08-05T14-33-11-β¦ local 9 671 486 B object 767 B
# rollout-2026-08-07T15-30-46-β¦ local 8 051 317 B object 767 B
# rollout-2026-08-13T14-09-11-β¦ local 971 492 B object absent
#
# 767 bytes is the session header written at open. Everything after it exists
# only in this container. Kill the container and ~18 MB of conversation across
# three agents is gone β which is exactly the loss mode PR #25 fixes, and
# exactly what deploying that fix would trigger one last time on the way in.
#
# So: copy the writer's own view out through the bucket API (HTTP, not the
# mount), verify it landed, and only then let the deploy proceed.
#
# WHAT IT DOES NOT DO
#
# It does not stop, signal, or touch the Codex processes. It reads. A rollout is
# append-only JSONL, so a snapshot taken while the writer runs is a valid prefix
# of the session β we trim a partial trailing line and record how many bytes
# that cost. Losing the last half-written line is not the failure mode anyone is
# worried about here.
#
# It does not write the canonical path. If it did, the dying writer could flush
# its 767-byte view back over a full copy β the clobber this is meant to
# prevent. Copies go to a separate migration prefix, and --restore reconciles
# them on the way up, once no writer holds the file.
set -euo pipefail
MODE=snapshot
DRY=0
for a in "$@"; do
case "$a" in
--restore) MODE=restore ;;
--dry-run) DRY=1 ;;
-h|--help) sed -n '2,6p' "$0"; exit 0 ;;
*) echo "unknown argument: $a" >&2; exit 2 ;;
esac
done
[ -n "${HF_TOKEN:-}" ] || { echo "HF_TOKEN is not set" >&2; exit 1; }
DATA_DIR="${DATA_DIR:-/data}"
CODEX_DURABLE="${CODEX_DURABLE:-$DATA_DIR/state/codex}"
# The dev deploy script names a Space's bucket "<space>-data"; prod follows the
# same rule (lvwerra/agent-manager -> lvwerra/agent-manager-data). Override with
# AM_BUCKET when running against something else.
BUCKET="${AM_BUCKET:-${SPACE_ID:?SPACE_ID unset and AM_BUCKET not given}-data}"
# Staging is local POSIX disk, never the bucket: the whole point is to hold a
# closed byte-exact copy somewhere the FUSE writer has no opinion about.
STAGE="${AM_LOCAL:-/tmp}/rollout-migration"
export CODEX_DURABLE BUCKET STAGE MODE DRY
python3 - <<'PY'
import hashlib, json, os, re, shutil, sys, time
from pathlib import Path
from huggingface_hub import (
download_bucket_files,
list_bucket_tree,
sync_bucket,
)
TOKEN = os.environ["HF_TOKEN"]
BUCKET = os.environ["BUCKET"]
DURABLE = Path(os.environ["CODEX_DURABLE"])
STAGE = Path(os.environ["STAGE"])
DRY = os.environ["DRY"] == "1"
RESTORE = os.environ["MODE"] == "restore"
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
PREFIX = "state/codex/migration" # remote home for closed copies
ROLLOUT = re.compile(r"/sessions/.*/rollout-[^/]+\.jsonl$")
def remote_of(p: Path) -> str:
"""Bucket key for a path under the mount: /data/state/x -> state/x."""
return str(p.relative_to(DATA_DIR))
def object_size(key: str):
"""Size of one bucket object, or None if there is no object.
Deliberately NOT get_bucket_file_metadata(): on these xet-backed objects its
.size is wrong. Measured 2026-08-13 against known-length uploads β a
9 796 319 B rollout reported 767, a 2 329 B manifest reported 694 β while
list_bucket_tree reported both correctly and download_bucket_files returned
byte-exact content. Do not "simplify" this back to a metadata call: every
number this script prints, and its entire safe/unsafe verdict, depends on
the size being real.
"""
parent, _, name = key.rpartition("/")
for f in list_bucket_tree(BUCKET, parent, recursive=False, token=TOKEN):
if Path(f.path).name == name:
return getattr(f, "size", None)
return None
def open_rollouts():
"""Every rollout held open for WRITING, with the writer's own file position.
/proc is the only honest source here. An agent's rollout is identified by
the descriptor a live Codex process holds, not by anything on disk: mtime
on the mount is stale (it tracks the last object commit, not the last
append), so a find -newermt sweep misses precisely the files at risk.
"""
out = {}
for pid_dir in Path("/proc").iterdir():
if not pid_dir.name.isdigit():
continue
fd_dir = pid_dir / "fd"
try:
fds = list(fd_dir.iterdir())
except OSError:
continue # not ours, or exited mid-scan
for fd in fds:
try:
target = os.readlink(fd)
except OSError:
continue
if not ROLLOUT.search(target):
continue
try:
info = (pid_dir / "fdinfo" / fd.name).read_text()
except OSError:
continue
flags = pos = 0
for line in info.splitlines():
if line.startswith("flags:"):
flags = int(line.split()[1], 8)
elif line.startswith("pos:"):
pos = int(line.split()[1])
# O_RDONLY is 0 in the low two bits. Readers (the manager parsing a
# trace, a repin hook) are not at risk and must not be snapshotted
# at their seek position.
if flags & 0o3 == 0:
continue
prev = out.get(target)
if prev is None or pos > prev["pos"]:
out[target] = {"pos": pos, "pid": int(pid_dir.name)}
return out
def trim_to_last_record(raw: bytes):
"""Drop a half-written trailing line. Returns (kept, dropped, bad_lines)."""
cut = raw.rfind(b"\n")
kept, dropped = (raw[: cut + 1], len(raw) - cut - 1) if cut >= 0 else (b"", len(raw))
bad = 0
for line in kept.splitlines():
if not line.strip():
continue
try:
json.loads(line)
except Exception:
bad += 1
return kept, dropped, bad
def snapshot():
live = open_rollouts()
if not live:
print("no Codex rollout is open for writing β nothing to migrate")
return 0
run_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
run_dir = STAGE / run_id
entries, concerns = [], 0
print(f"==> {len(live)} open rollout(s); bucket {BUCKET}")
for path, fd in sorted(live.items()):
p = Path(path)
raw = p.read_bytes() # the writer's view, buffer included
kept, dropped, bad = trim_to_last_record(raw)
digest = hashlib.sha256(kept).hexdigest()
remote_size = object_size(remote_of(p))
at_risk = len(kept) - (remote_size or 0)
staged = run_dir / remote_of(p)
if not DRY:
staged.parent.mkdir(parents=True, exist_ok=True)
staged.write_bytes(kept)
entries.append({
"path": str(p),
"remote": remote_of(p),
"pid": fd["pid"],
"writer_pos": fd["pos"],
"bytes": len(kept),
"sha256": digest,
"partial_tail_dropped": dropped,
"invalid_lines": bad,
"object_bytes_before": remote_size,
"bytes_at_risk": at_risk,
})
if bad:
concerns += 1
flag = f" !! {bad} unparseable line(s)" if bad else ""
print(f" {p.name}")
print(f" local {len(kept):>10} object {str(remote_size):>10}"
f" at risk {at_risk:>10} dropped {dropped}{flag}")
if DRY:
print("==> --dry-run: nothing staged, nothing uploaded")
return 0
manifest = {
"run_id": run_id,
"created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"bucket": BUCKET,
"space": os.environ.get("SPACE_ID"),
"entries": entries,
}
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
dest = f"hf://buckets/{BUCKET}/{PREFIX}/{run_id}"
print(f"==> uploading {run_dir} -> {dest}")
sync_bucket(source=str(run_dir), dest=dest, token=TOKEN, quiet=True)
# Verify by reading the objects back, not by trusting the writer. A copy
# nobody checked is the same bet we are trying to get off.
print("==> verifying")
for e in entries:
key = f"{PREFIX}/{run_id}/{e['remote']}"
size = object_size(key)
if size is None:
print(f" !! {e['remote']}: no object after upload")
concerns += 1
continue
if size != e["bytes"]:
print(f" !! {e['remote']}: object {size} B, staged {e['bytes']} B")
concerns += 1
continue
check = run_dir / "verify" / e["remote"]
check.parent.mkdir(parents=True, exist_ok=True)
download_bucket_files(BUCKET, [(key, str(check))], token=TOKEN)
got = hashlib.sha256(check.read_bytes()).hexdigest()
if got != e["sha256"]:
print(f" !! {e['remote']}: sha256 mismatch on readback")
concerns += 1
else:
print(f" ok {Path(e['remote']).name} {e['bytes']} B {got[:12]}")
check.unlink(missing_ok=True)
shutil.rmtree(run_dir / "verify", ignore_errors=True)
print(f"==> run {run_id} at hf://buckets/{BUCKET}/{PREFIX}/{run_id}")
if concerns:
print(f"!! {concerns} problem(s) β do NOT restart until these are understood",
file=sys.stderr)
return 1
print("==> every open rollout is a verified closed object; safe to deploy")
return 0
def restore():
"""Boot side. Put back anything the dying writer failed to flush.
Runs before agents start, when no descriptor is held. A migrated copy is
only ever restored when it is strictly longer than what is on the canonical
path, so a container that shut down cleanly β and therefore flushed a
complete rollout β is left completely alone.
"""
runs = sorted(
{Path(f.path).parts[3] for f in list_bucket_tree(BUCKET, PREFIX, recursive=True, token=TOKEN)
if Path(f.path).name == "manifest.json"}
)
if not runs:
print("no migration runs on the bucket β nothing to restore")
return 0
run_id = runs[-1]
local_manifest = STAGE / f"manifest-{run_id}.json"
local_manifest.parent.mkdir(parents=True, exist_ok=True)
download_bucket_files(BUCKET, [(f"{PREFIX}/{run_id}/manifest.json", str(local_manifest))],
token=TOKEN)
manifest = json.loads(local_manifest.read_text())
print(f"==> newest migration run {run_id}, {len(manifest['entries'])} rollout(s)")
restored = failed = 0
for e in manifest["entries"]:
target = Path(e["path"])
have = target.stat().st_size if target.exists() else 0
if have >= e["bytes"]:
print(f" skip {target.name}: on disk {have} B >= migrated {e['bytes']} B")
continue
if DRY:
print(f" would restore {target.name}: {have} B -> {e['bytes']} B")
restored += 1
continue
target.parent.mkdir(parents=True, exist_ok=True)
tmp = STAGE / "restore" / e["remote"]
tmp.parent.mkdir(parents=True, exist_ok=True)
download_bucket_files(BUCKET, [(f"{PREFIX}/{run_id}/{e['remote']}", str(tmp))],
token=TOKEN)
if hashlib.sha256(tmp.read_bytes()).hexdigest() != e["sha256"]:
print(f" !! {target.name}: migrated copy fails its own checksum; left alone")
failed += 1
continue
# Stage locally, then one close()d copy onto the mount. Never stream a
# download straight through FUSE β that is the write pattern this whole
# script exists to work around.
shutil.copyfile(tmp, target)
tmp.unlink(missing_ok=True)
print(f" restored {target.name}: {have} B -> {e['bytes']} B")
restored += 1
shutil.rmtree(STAGE / "restore", ignore_errors=True)
print(f"==> {restored} restored, {failed} failed")
return 1 if failed else 0
sys.exit(restore() if RESTORE else snapshot())
PY
|