Leandro von Werra commited on
Commit
615a703
·
1 Parent(s): 1dfeefd

Copy open Codex rollouts to closed objects before a deploy

Browse files

A restart loses whatever the FUSE writer has not flushed. Snapshot each open
rollout out through the bucket API, verify it by readback, and reconcile on the
way up.

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