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