Leandro von Werra Agent Manager commited on
Commit
1dfeefd
·
unverified ·
1 Parent(s): 5136183

Make agent state durable without live bucket writes (#25)

Browse files

* Checkpoint agent state off the bucket mount

* Fix state checkpoints and resumed input delivery

---------

Co-authored-by: Agent Manager <agents@agent-manager.local>

Dockerfile CHANGED
@@ -17,7 +17,7 @@ FROM node:22-bookworm AS runtime
17
  # agents and humans reach for (jq/htop/sqlite3/editors/media, fonts so headless
18
  # Chromium screenshots don't render tofu).
19
  RUN apt-get update && apt-get install -y --no-install-recommends \
20
- tmux git git-lfs ca-certificates curl python3 make g++ ripgrep bubblewrap rsync \
21
  jq htop lsof tree ncdu sqlite3 vim nano zip unzip file procps less \
22
  ffmpeg imagemagick fonts-liberation fonts-noto-color-emoji \
23
  && rm -rf /var/lib/apt/lists/* \
 
17
  # agents and humans reach for (jq/htop/sqlite3/editors/media, fonts so headless
18
  # Chromium screenshots don't render tofu).
19
  RUN apt-get update && apt-get install -y --no-install-recommends \
20
+ tmux git git-lfs ca-certificates curl python3 make g++ ripgrep bubblewrap rsync util-linux \
21
  jq htop lsof tree ncdu sqlite3 vim nano zip unzip file procps less \
22
  ffmpeg imagemagick fonts-liberation fonts-noto-color-emoji \
23
  && rm -rf /var/lib/apt/lists/* \
README.md CHANGED
@@ -109,8 +109,11 @@ api.restart_space(space_id)
109
 
110
  Everything durable lives under `/data`: `sessions.json`, `groups.json`,
111
  `workspaces/<path>/` (agent working dirs + shared `skills/`), and each
112
- CLI's config/credentials/history (`HOME=/data/home`, plus `CLAUDE_CONFIG_DIR`
113
- and `CODEX_HOME` under `/data/state`).
 
 
 
114
 
115
  ## Architecture
116
 
 
109
 
110
  Everything durable lives under `/data`: `sessions.json`, `groups.json`,
111
  `workspaces/<path>/` (agent working dirs + shared `skills/`), and each
112
+ CLI's closed state checkpoints under `/data/state`. Active harness state lives
113
+ on local POSIX storage and is restored/checkpointed by
114
+ [`scripts/agent-state.sh`](scripts/agent-state.sh); SQLite harnesses use online
115
+ database backups rather than copying live WAL files. See
116
+ [`docs/agent-state-checkpoints.md`](docs/agent-state-checkpoints.md).
117
 
118
  ## Architecture
119
 
docs/agent-state-checkpoints.md ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent state checkpoints
2
+
3
+ ## Problem
4
+
5
+ The Space's `/data` volume is an hf-mount FUSE view over mutable object
6
+ storage, not a POSIX disk. Its default streaming writer buffers an open file in
7
+ memory and uploads it on close. That is a bad match for Codex, which holds one
8
+ rollout open for an entire resumed session: an unclean process/container stop
9
+ can discard every append since the previous open epoch closed.
10
+
11
+ The old layout also copied opencode and Hermes SQLite databases, WALs, and SHMs
12
+ with `rsync`. Those files can be observed at different transaction boundaries;
13
+ having all three files is not proof that the copy is a consistent database.
14
+
15
+ ## Invariants
16
+
17
+ 1. An agent only mutates ordinary files on `$AM_LOCAL`.
18
+ 2. The bucket only receives writes from a short-lived checkpoint operation.
19
+ 3. A published SQLite checkpoint comes from SQLite's online backup API and
20
+ passes `PRAGMA quick_check` before upload.
21
+ 4. The previous durable checkpoint remains authoritative until its replacement
22
+ has been completely written and closed.
23
+ 5. A hot/dev restart never restores an older bucket copy over newer local
24
+ state (`rsync --update`).
25
+ 6. A normal shutdown stops the server and performs a final checkpoint. An
26
+ ungraceful stop loses at most the checkpoint interval (15 seconds by
27
+ default), not the lifetime of an open transcript.
28
+
29
+ ## Layout
30
+
31
+ | Harness | Live state | Durable checkpoint | Adapter |
32
+ | --- | --- | --- | --- |
33
+ | Codex | `$AM_LOCAL/codex-home` | `/data/state/codex` | file tree; local SQLite cache excluded |
34
+ | Claude Code | `$AM_LOCAL/agent-state/claude` | `/data/state/claude` | file tree |
35
+ | Gemini CLI | `$AM_LOCAL/agent-state/gemini-home/.gemini` | `/data/state/gemini` | file tree |
36
+ | OpenClaw | `$AM_LOCAL/oc-home/.openclaw` | `/data/state/openclaw-backup` | file tree |
37
+ | opencode | `$AM_LOCAL/opencode-share` | `/data/state/opencode` | file tree plus online `opencode.db` backup |
38
+ | Hermes | `$AM_LOCAL/hermes` | `/data/state/hermes` | file tree plus online `state.db` backup |
39
+
40
+ Remote agents already use a good object-store pattern: one closed, immutable
41
+ Markdown file per message. Shell sessions have no model transcript; workspace
42
+ files remain durable but live process state and terminal scrollback are outside
43
+ this checkpoint mechanism.
44
+
45
+ ## Lifecycle
46
+
47
+ `scripts/agent-state.sh restore` runs after legacy-path migration and before the
48
+ server starts. For file trees, durable files fill an empty local tree but do
49
+ not overwrite newer local files left by an in-container dev restart. For
50
+ SQLite harnesses, a valid existing local database is authoritative on a hot
51
+ restart; otherwise a verified checkpoint database is preferred. The previous
52
+ raw DB/WAL layout is accepted only as a one-release migration fallback. The
53
+ server refuses to start if restore is incomplete, avoiding a new lineage from
54
+ being written over partially restored state.
55
+
56
+ While the server is running, one supervisor loop calls
57
+ `scripts/agent-state.sh checkpoint` every
58
+ `$AGENT_STATE_CHECKPOINT_SECONDS` (default 15). A local `flock` prevents a
59
+ timer checkpoint from overlapping the shutdown checkpoint.
60
+
61
+ Ordinary trees use rsync's temporary-destination/rename behavior. The FUSE
62
+ writer therefore closes the replacement before it becomes the canonical
63
+ object. A local per-adapter timestamp selects changed paths and feeds those
64
+ paths to `rsync --files-from`; periodic checkpoints walk only the fast local
65
+ tree and never enumerate the remote bucket tree. Files modified while a
66
+ checkpoint is running are deliberately selected again on the next pass.
67
+ When their database or WAL changed, opencode and Hermes run `.backup` to a
68
+ local staging DB, validate that DB, then publish the closed result under
69
+ `checkpoints/`; idle databases do not generate bucket writes.
70
+
71
+ The first version does not propagate deletions from live trees. A stale durable
72
+ file is safer than deleting history based on a transient or partially restored
73
+ local view, but it can reappear after a fresh-container restore. Retention and
74
+ garbage collection should be a separate, explicit operation with tombstones or
75
+ a verified manifest rather than `rsync --delete` in the hot checkpoint loop.
76
+
77
+ PID 1 remains a small shell supervisor instead of `exec`-ing Node. On
78
+ `SIGTERM`, `SIGINT`, or `SIGHUP`, it forwards the signal to the server, waits
79
+ up to one second for the held PTYs to stop and flush their local files, and
80
+ takes a final checkpoint. The final checkpoint waits for any in-flight timer
81
+ checkpoint's lock before reading the quiet state.
82
+
83
+ ## Migration and rollback
84
+
85
+ - Codex's old `$CODEX_HOME/sessions -> /data/state/codex/sessions` symlink is
86
+ unlinked only at the known local path. Its durable target is never removed.
87
+ - Existing Gemini state under `/data/home/.gemini` is copied into the new
88
+ durable checkpoint and retained as a rollback copy.
89
+ - Existing real opencode/Hermes directories are copied to the durable store and
90
+ renamed to `*.pre-agent-state`; if that rollback name already exists, a
91
+ timestamped name is used. They are not deleted by this proposal.
92
+ - Existing Codex SQLite remnants stay quarantined. They are caches; rollouts,
93
+ history, auth, and config are the restored source of truth.
94
+
95
+ Before the first production deployment, any currently open Codex rollout must
96
+ be copied to a new closed migration object and verified through the bucket API.
97
+ Restarting first would repeat the loss mode this change is intended to fix.
98
+
99
+ Rollback is configuration-only: point each harness back at its retained
100
+ durable/legacy path. No migration step deletes the previous state.
101
+
102
+ ## Guarantees and remaining work
103
+
104
+ This proposal bounds ungraceful loss to the checkpoint interval. It does not
105
+ claim synchronous per-token durability. If that is required later, JSONL
106
+ checkpoints can be replaced by immutable complete-line segments without
107
+ changing the live layout or SQLite adapters.
108
+
109
+ Gemini state becomes durable here, but Agent Manager still needs a separate
110
+ conversation-identity change before it can safely resume an exact Gemini
111
+ session in a folder shared by multiple Gemini panes. Using `--resume latest`
112
+ without pinning would risk cross-session pickup, so this branch deliberately
113
+ does not enable that shortcut.
114
+
115
+ A future harness-independent input journal should write one immutable object
116
+ per submitted prompt before delivery. That would preserve the operator's input
117
+ even if a CLI fails before recording it, while transcript checkpoints remain
118
+ the source for assistant/tool events.
119
+
120
+ ## Verification
121
+
122
+ `server/state-checkpoint.test.mjs` exercises the failure boundaries without
123
+ touching `/data`:
124
+
125
+ - restores each file-backed harness to local storage;
126
+ - preserves newer local state across a hot restart;
127
+ - checkpoints a Codex rollout while another process holds its FD open;
128
+ - kills that writer and reconstructs the rollout from the checkpoint;
129
+ - snapshots committed opencode data while a WAL-mode writer remains alive;
130
+ - validates opencode and Hermes checkpoint databases;
131
+ - proves a corrupt live DB cannot replace the previous durable snapshot; and
132
+ - restores SQLite without stale WAL/SHM companions.
docs/session-sharing.md CHANGED
@@ -74,11 +74,12 @@ The **file vs database** split drives most of the design:
74
  - Codex is the same shape via `codexSessionId` + `codexRollout` (`runner.js:319-321`), and
75
  as of `1dfb753` **opencode too**, via `opencodeSessionId`. Three of the five harnesses now
76
  carry a per-session conversation pin that import can simply set.
77
- - opencode's and Hermes' SQLite live on **local disk via symlink** (also `1dfb753`), with a
78
- durable copy synced to the bucket every 60s because a synchronous read of a FUSE-backed
79
- sqlite could stall the event loop and freeze the whole server. Extraction still goes
80
- through `opencodeDbPath()`, so §6 is unaffected, but **never** read these DBs
81
- synchronously on a request path.
 
82
 
83
  ## 3. Verified Hub behaviour
84
 
 
74
  - Codex is the same shape via `codexSessionId` + `codexRollout` (`runner.js:319-321`), and
75
  as of `1dfb753` **opencode too**, via `opencodeSessionId`. Three of the five harnesses now
76
  carry a per-session conversation pin that import can simply set.
77
+ - opencode's and Hermes' SQLite live on **local disk via symlink**. Durable
78
+ checkpoints are made through SQLite's online backup API and validated before
79
+ upload; raw DB/WAL/SHM copies are not transactionally safe. This also keeps a
80
+ synchronous FUSE read from stalling the event loop and freezing the whole
81
+ server. Extraction still goes through `opencodeDbPath()`, so §6 is
82
+ unaffected, but **never** read these DBs synchronously on a request path.
83
 
84
  ## 3. Verified Hub behaviour
85
 
docs/trace-panel-spec.md CHANGED
@@ -168,15 +168,17 @@ A session is a *query*, not a file.
168
  `type: 'text' | 'tool' | 'step-finish'`, plus `text`, `tool`, `state.input`, `state.output`
169
  - **Never copy or ship this database**: `account.access_token`, `account.refresh_token` and a
170
  `credential` table live in it. Select one conversation.
171
- - Live data is on **local disk via a symlink** with a durable copy synced to the bucket
172
- (commit `1dfb753`), precisely because a synchronous read of a FUSE-backed sqlite froze the
173
- whole server. Open **read-only**, and never on a hot path.
 
 
174
 
175
  ### Hermes — **SQLite**, `~/.hermes/state.db`
176
  - `sessions(id, cwd, title, started_at, input_tokens, output_tokens, cache_read_tokens)`
177
  - `messages(id, session_id, role, content, timestamp, tool_name, token_count, active)` —
178
  flat `content`; a row with `tool_name` set is a tool interaction. `timestamp` is **seconds**
179
- (multiply by 1000). Same FUSE/symlink note as opencode.
180
  - Hermes has **no per-session pin** in Agent Manager, so it is attributed by `cwd`.
181
  - Alternative worth knowing: `hermes sessions export --format trace` emits Claude-Code JSONL
182
  specifically for the HF viewer, and `--redact` exists. We chose direct SQLite reads for one
 
168
  `type: 'text' | 'tool' | 'step-finish'`, plus `text`, `tool`, `state.input`, `state.output`
169
  - **Never copy or ship this database**: `account.access_token`, `account.refresh_token` and a
170
  `credential` table live in it. Select one conversation.
171
+ - Live data is on **local disk via a symlink**. Durable state is a verified
172
+ SQLite online-backup checkpoint; copying a live DB/WAL/SHM set with `rsync`
173
+ is not transactionally safe. This layout also prevents a synchronous read of
174
+ FUSE-backed SQLite from freezing the whole server. Open **read-only**, and
175
+ never on a hot path.
176
 
177
  ### Hermes — **SQLite**, `~/.hermes/state.db`
178
  - `sessions(id, cwd, title, started_at, input_tokens, output_tokens, cache_read_tokens)`
179
  - `messages(id, session_id, role, content, timestamp, tool_name, token_count, active)` —
180
  flat `content`; a row with `tool_name` set is a tool interaction. `timestamp` is **seconds**
181
+ (multiply by 1000). Same local-live/online-checkpoint note as opencode.
182
  - Hermes has **no per-session pin** in Agent Manager, so it is attributed by `cwd`.
183
  - Alternative worth knowing: `hermes sessions export --format trace` emits Claude-Code JSONL
184
  specifically for the HF viewer, and `--redact` exists. We chose direct SQLite reads for one
entrypoint.sh CHANGED
@@ -10,51 +10,12 @@ if ! mkdir -p "$DATA_DIR/workspaces" 2>/dev/null; then
10
  fi
11
  export DATA_DIR
12
 
13
- # Empty directories do NOT persist on the bucket there is no backing object
14
- # key, so a dir created empty is gone after a restart, and anything pointing at
15
- # it (a symlink, a config path) breaks. `keepdir` occupies the path with a real
16
- # file so it survives. Use it for every bucket-backed dir created empty.
17
- # Seen in the wild: $CODEX_DURABLE/sessions vanished, leaving
18
- # $CODEX_HOME/sessions dangling -> codex "thread-store internal error:
19
- # File exists (os error 17)" and every transcript lost.
20
- keepdir() {
21
- for d in "$@"; do
22
- mkdir -p "$d" 2>/dev/null || continue
23
- [ -e "$d/.keep" ] || echo "keep marker: empty dirs are not persisted on the /data bucket" > "$d/.keep" 2>/dev/null || true
24
- done
25
- }
26
-
27
- # Occupy a config path with a real file when nothing lives there. Two bugs need
28
- # this. (1) A tool that writes atomically (temp + rename) can leave the temp
29
- # behind and never land the target. (2) The bucket tree API then matches
30
- # `<path>` against the leftover `<path>.<uuid>.tmp` by RAW STRING PREFIX, and
31
- # hf-mount reads a non-empty listing as proof the path is a DIRECTORY — so the
32
- # missing file materializes as a phantom dir and readers die with EISDIR.
33
- # A real file short-circuits it: the HEAD succeeds, so the listing fallback that
34
- # synthesizes the directory never runs. See docs/fuse-phantom-directories.md.
35
- occupy_file() {
36
- path="$1"; default="$2"
37
- mkdir -p "$(dirname "$path")" 2>/dev/null || return 0
38
- [ -d "$path" ] && rm -rf "$path" 2>/dev/null
39
- [ -e "$path" ] || printf '%s\n' "$default" > "$path" 2>/dev/null || true
40
- }
41
-
42
- # Put HOME on the durable bucket so EVERY agent's logins/config persist across
43
- # restarts (gemini ~/.gemini, etc.). Agents whose SQLite state can't live on the
44
- # FUSE bucket (codex, openclaw, opencode, hermes) are relocated to local disk
45
- # below, each with its own durable copy on the bucket.
46
  export HOME="$DATA_DIR/home"
47
  mkdir -p "$HOME"
48
- # Gemini writes its project registry atomically and the rename can fail on the
49
- # bucket, leaving projects.json.<uuid>.tmp orphans and no projects.json — after
50
- # which the prefix bug above turns projects.json into a directory and the CLI
51
- # dies with `EISDIR: illegal operation on a directory, read`. Keep a real file
52
- # there. Same class as the opencode.json guard in server/src/runner.js.
53
- occupy_file "$HOME/.gemini/projects.json" '{"projects": {}}'
54
- # Claude keeps its established dir (so existing logins keep working). Codex's
55
- # home moves to local disk below (its SQLite databases corrupt on the bucket).
56
- export CLAUDE_CONFIG_DIR="$DATA_DIR/state/claude"
57
- mkdir -p "$CLAUDE_CONFIG_DIR"
58
 
59
  # NOTE: every var exported below must be listed in NON_SECRET (server/src/
60
  # index.js) — this script runs after the build-time env snapshot, so anything
@@ -64,120 +25,118 @@ mkdir -p "$CLAUDE_CONFIG_DIR"
64
  # /data bucket — object storage is slow for many-small-files and can't mmap or
65
  # lock well, so running libraries from it is painful. These reinstall on demand.
66
  export AM_LOCAL="/home/node/local"
67
- if ! mkdir -p "$AM_LOCAL/bin" 2>/dev/null; then AM_LOCAL="$DATA_DIR/.local-cache"; mkdir -p "$AM_LOCAL/bin"; fi
 
 
 
 
 
68
  export UV_CACHE_DIR="$AM_LOCAL/uv-cache"
69
 
70
- # Liveness handle for the state-sync loops below: this script ends with
71
- # `exec node …`, so $$ IS the app's pid. Each loop checks it and gives up once
72
- # its own app instance is gone, because a dev-mode app restart re-runs this
73
- # script inside the SAME container: the old loops survive (reparented to PID 1)
74
- # and the new run adds three more. Measured after three restart attempts: ten
75
- # loops alive, all waking every 60s to rsync the same bucket paths over each
76
- # other. Not exported nothing downstream needs it (see NON_SECRET note above).
77
- AM_MAIN_PID=$$
78
-
79
- # Codex keeps a growing family of SQLite databases (logs_2, goals_1, memories_1
80
- # plus mmap'd -shm siblings) that corrupt on the FUSE bucket: SQLite needs real
81
- # locking/mmap. Same cure as OpenClaw — codex's HOME lives on LOCAL disk. The
82
- # heavyweight append-only rollouts stay on the bucket via one symlink (plain
83
- # files never corrupted there, and pinned rollout paths keep working); the
84
- # small durable state (auth, config, history) restores at boot and syncs back
85
- # every 60s; the SQLite caches are purely local, rebuilt from rollouts when
86
- # the disk resets.
87
- CODEX_DURABLE="$DATA_DIR/state/codex"
88
  export CODEX_HOME="$AM_LOCAL/codex-home"
89
- mkdir -p "$CODEX_HOME"
90
- # keepdir, not mkdir: `sessions` is the symlink target below, and an empty dir
91
- # on the bucket disappears — which is exactly how codex lost its thread store.
92
- keepdir "$CODEX_DURABLE/sessions" "$CODEX_DURABLE/db-backups"
93
- # quarantine sqlite remnants on the bucket (incl. the earlier symlink attempt)
94
- keepdir "$CODEX_DURABLE/db-backups/am-quarantine"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  for f in "$CODEX_DURABLE"/logs_2.sqlite* "$CODEX_DURABLE"/goals_1.sqlite* "$CODEX_DURABLE"/memories_1.sqlite*; do
96
  [ -e "$f" ] || [ -L "$f" ] && mv "$f" "$CODEX_DURABLE/db-backups/am-quarantine/" 2>/dev/null || true
97
  done
98
- rsync -a --exclude 'sessions' --exclude '*.sqlite*' --exclude 'db-backups' \
99
- --exclude 'cache' --exclude '.tmp' --exclude 'mcp-oauth-locks' \
100
- "$CODEX_DURABLE/" "$CODEX_HOME/" 2>/dev/null || true
101
- ln -sfn "$CODEX_DURABLE/sessions" "$CODEX_HOME/sessions"
102
- ( while :; do
103
- sleep 60
104
- kill -0 "$AM_MAIN_PID" 2>/dev/null || exit
105
- rsync -a --exclude 'sessions' --exclude '*.sqlite*' --exclude 'db-backups' \
106
- --exclude 'cache' --exclude '.tmp' --exclude 'mcp-oauth-locks' \
107
- "$CODEX_HOME/" "$CODEX_DURABLE/" 2>/dev/null || true
108
- done ) &
109
- export PIP_CACHE_DIR="$AM_LOCAL/pip-cache"
110
- export PYTHONPYCACHEPREFIX="$AM_LOCAL/pycache"
111
- export PYTHONUSERBASE="$AM_LOCAL/py" # pip install --user → local, fast
112
- export NPM_CONFIG_PREFIX="$AM_LOCAL/npm" # npm install -g → local, no root needed
113
- export PATH="$AM_LOCAL/py/bin:$AM_LOCAL/npm/bin:$AM_LOCAL/bin:$HOME/.local/bin:$PATH"
114
 
115
- # OpenClaw: its session engine fingerprints file metadata at nanosecond
116
- # precision and false-positives on the FUSE bucket ("session file changed while
117
- # embedded prompt lock was released"). Its state therefore lives on LOCAL disk,
118
- # with a durable copy on the bucket: restored on boot, synced back every 60s.
119
- # Worst case on an unclean stop: the last minute of chat history.
120
- # OpenClaw can't run its state on the FUSE bucket (its session fence
121
- # false-positives on unstable metadata) and it REJECTS symlinked paths (the
122
- # workspace boundary check). No symlinks, no env overrides — OpenClaw simply
123
- # gets its OWN HOME on local disk: a real, ordinary install from its point of
124
- # view. Durable copy on the bucket: restored on boot, synced back every 60s.
125
- # Worst case on an unclean stop: the last minute of claw state.
126
- export OPENCLAW_HOME="$AM_LOCAL/oc-home" # runner launches openclaw with HOME=$OPENCLAW_HOME
127
- export OPENCLAW_STATE_DIR="$OPENCLAW_HOME/.openclaw" # where the server finds its config/traces
128
- OC_BACKUP="$DATA_DIR/state/openclaw-backup"
129
- mkdir -p "$OPENCLAW_STATE_DIR" "$OC_BACKUP"
130
- # heal from the earlier symlink experiment
131
  [ -L "$HOME/.openclaw" ] && rm "$HOME/.openclaw"
132
- # seed local state: backup (freshest) first, then legacy dirs fill gaps (--update: never clobber newer)
133
- [ -n "$(ls -A "$OC_BACKUP" 2>/dev/null)" ] && rsync -a "$OC_BACKUP/" "$OPENCLAW_STATE_DIR/" 2>/dev/null
134
  for legacy in "$HOME/.openclaw.pre-symlink" "$HOME/.openclaw"; do
135
  if [ -d "$legacy" ] && [ ! -L "$legacy" ]; then
136
- rsync -a --update "$legacy/" "$OPENCLAW_STATE_DIR/" 2>/dev/null || true
 
 
 
137
  fi
138
  done
139
- # small comforts in the private HOME (harmless if missing)
140
- cp "$HOME/.gitconfig" "$OPENCLAW_HOME/.gitconfig" 2>/dev/null || true
141
- ( while :; do
142
- sleep 60
143
- kill -0 "$AM_MAIN_PID" 2>/dev/null || exit
144
- rsync -a --delete "$OPENCLAW_STATE_DIR/" "$OC_BACKUP/" 2>/dev/null || true
145
- done ) &
146
-
147
- # opencode + hermes keep their conversation history in SQLite (opencode at
148
- # ~/.local/share/opencode, hermes at ~/.hermes). SQLite on the FUSE bucket
149
- # corrupts, and worse: a SYNCHRONOUS read can STALL on FUSE and wedge the
150
- # server's event loop — the Overview reads these dbs on every poll, so one
151
- # stalled read takes the whole Space down. The live data therefore lives on
152
- # LOCAL disk, exposed at the well-known path via a symlink, with a durable copy
153
- # on the bucket: restored on boot, synced back every 60s. Unlike codex these
154
- # dbs ARE the source of truth (no rollout files to rebuild from), so the
155
- # sync-back INCLUDES the sqlite. Worst case on an unclean stop: the last minute
156
- # of chat history.
157
- OC_LIVE="$AM_LOCAL/opencode-share"; OC_DURABLE="$DATA_DIR/state/opencode"; OC_LINK="$HOME/.local/share/opencode"
158
- HERMES_LIVE="$AM_LOCAL/hermes"; HERMES_DURABLE="$DATA_DIR/state/hermes"; HERMES_LINK="$HOME/.hermes"
159
- mkdir -p "$OC_LIVE" "$HERMES_LIVE" "$(dirname "$OC_LINK")"
160
- keepdir "$OC_DURABLE" "$HERMES_DURABLE"
161
- # One-time migration: existing history is a REAL dir at the well-known path on
162
- # the bucket. Fold it into the durable store BEFORE the path becomes a symlink,
163
- # so no conversation is stranded on the bucket or lost.
164
- for pair in "$OC_LINK|$OC_DURABLE" "$HERMES_LINK|$HERMES_DURABLE"; do
165
  lnk="${pair%%|*}"; dur="${pair##*|}"
166
  if [ -e "$lnk" ] && [ ! -L "$lnk" ]; then
167
- rsync -a "$lnk/" "$dur/" 2>/dev/null || true
168
- rm -rf "$lnk" 2>/dev/null || true
 
 
 
 
 
 
 
 
 
 
169
  fi
170
  done
171
- rsync -a "$OC_DURABLE/" "$OC_LIVE/" 2>/dev/null || true # restore durable → live (local disk is wiped each boot)
172
- rsync -a "$HERMES_DURABLE/" "$HERMES_LIVE/" 2>/dev/null || true
173
- ln -sfn "$OC_LIVE" "$OC_LINK"
174
  ln -sfn "$HERMES_LIVE" "$HERMES_LINK"
175
- ( while :; do
176
- sleep 60
177
- kill -0 "$AM_MAIN_PID" 2>/dev/null || exit
178
- rsync -a "$OC_LIVE/" "$OC_DURABLE/" 2>/dev/null || true
179
- rsync -a "$HERMES_LIVE/" "$HERMES_DURABLE/" 2>/dev/null || true
180
- done ) &
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  # Durable, user-editable setup script. Runs on EVERY start (keep it idempotent);
183
  # seed a template on first boot.
@@ -221,4 +180,45 @@ else
221
  fi
222
  echo "[install.sh finished $(date -u) exit=$INSTALL_CODE]" >> "$DATA_DIR/install.log"
223
 
224
- exec node /app/server/src/index.js
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  fi
11
  export DATA_DIR
12
 
13
+ # HOME remains the durable place for ordinary shell/user configuration. Agent
14
+ # harness state is relocated to local disk below and checkpointed explicitly:
15
+ # actively-mutated files and SQLite databases must not use the FUSE bucket as
16
+ # their live filesystem.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  export HOME="$DATA_DIR/home"
18
  mkdir -p "$HOME"
 
 
 
 
 
 
 
 
 
 
19
 
20
  # NOTE: every var exported below must be listed in NON_SECRET (server/src/
21
  # index.js) — this script runs after the build-time env snapshot, so anything
 
25
  # /data bucket — object storage is slow for many-small-files and can't mmap or
26
  # lock well, so running libraries from it is painful. These reinstall on demand.
27
  export AM_LOCAL="/home/node/local"
28
+ if ! mkdir -p "$AM_LOCAL/bin" 2>/dev/null; then
29
+ # Never fall back onto the bucket: agent state needs POSIX close/locking
30
+ # semantics even when the preferred local prefix is unavailable.
31
+ AM_LOCAL="/tmp/agent-manager-local"
32
+ mkdir -p "$AM_LOCAL/bin"
33
+ fi
34
  export UV_CACHE_DIR="$AM_LOCAL/uv-cache"
35
 
36
+ # One state model for every harness:
37
+ # * live files are ordinary local POSIX files under $AM_LOCAL;
38
+ # * the bucket contains closed checkpoints only;
39
+ # * SQLite checkpoints use the online backup API rather than racing copies
40
+ # of a DB, WAL, and SHM.
41
+ #
42
+ # hf-mount's streaming writer buffers a long-lived append until close. Codex
43
+ # keeps its rollout open for the life of a session, so the old sessions symlink
44
+ # could lose the whole open epoch on a restart.
45
+ export CODEX_DURABLE="$DATA_DIR/state/codex"
 
 
 
 
 
 
 
 
46
  export CODEX_HOME="$AM_LOCAL/codex-home"
47
+ export CLAUDE_DURABLE="$DATA_DIR/state/claude"
48
+ export CLAUDE_CONFIG_DIR="$AM_LOCAL/agent-state/claude"
49
+ export GEMINI_DURABLE="$DATA_DIR/state/gemini"
50
+ export GEMINI_CLI_HOME="$AM_LOCAL/agent-state/gemini-home"
51
+ export GEMINI_LIVE="$GEMINI_CLI_HOME/.gemini"
52
+ export OPENCLAW_HOME="$AM_LOCAL/oc-home"
53
+ export OPENCLAW_STATE_DIR="$OPENCLAW_HOME/.openclaw"
54
+ export OPENCLAW_DURABLE="$DATA_DIR/state/openclaw-backup"
55
+ export OPENCODE_LIVE="$AM_LOCAL/opencode-share"
56
+ export OPENCODE_DURABLE="$DATA_DIR/state/opencode"
57
+ export HERMES_LIVE="$AM_LOCAL/hermes"
58
+ export HERMES_DURABLE="$DATA_DIR/state/hermes"
59
+
60
+ mkdir -p "$CODEX_HOME" "$CODEX_DURABLE/sessions" "$CODEX_DURABLE/db-backups" \
61
+ "$CLAUDE_CONFIG_DIR" "$CLAUDE_DURABLE" "$GEMINI_LIVE" "$GEMINI_DURABLE" \
62
+ "$OPENCLAW_STATE_DIR" "$OPENCLAW_DURABLE" "$OPENCODE_LIVE" \
63
+ "$OPENCODE_DURABLE" "$HERMES_LIVE" "$HERMES_DURABLE"
64
+
65
+ # Heal the old Codex layout on hot/dev restarts. Only unlink the known local
66
+ # sessions symlink; never recursively remove its durable target.
67
+ [ -L "$CODEX_HOME/sessions" ] && rm "$CODEX_HOME/sessions"
68
+ mkdir -p "$CODEX_HOME/sessions"
69
+
70
+ # Quarantine SQLite remnants from the old Codex bucket layout. Codex SQLite is
71
+ # disposable cache state; transcripts and user history are checkpointed.
72
+ mkdir -p "$CODEX_DURABLE/db-backups/am-quarantine"
73
  for f in "$CODEX_DURABLE"/logs_2.sqlite* "$CODEX_DURABLE"/goals_1.sqlite* "$CODEX_DURABLE"/memories_1.sqlite*; do
74
  [ -e "$f" ] || [ -L "$f" ] && mv "$f" "$CODEX_DURABLE/db-backups/am-quarantine/" 2>/dev/null || true
75
  done
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
+ # Preserve existing Gemini state while moving it out of durable HOME. The
78
+ # legacy directory remains in place as a rollback copy.
79
+ if [ -d "$HOME/.gemini" ] && [ ! -L "$HOME/.gemini" ]; then
80
+ if ! rsync -a --update "$HOME/.gemini/" "$GEMINI_DURABLE/"; then
81
+ echo "ERROR: could not migrate Gemini state; refusing to start with an empty live home" >&2
82
+ exit 1
83
+ fi
84
+ fi
85
+
86
+ # OpenClaw rejects a symlinked HOME/state path; migrate any state from the
87
+ # earlier layouts into its durable checkpoint before restore.
 
 
 
 
 
88
  [ -L "$HOME/.openclaw" ] && rm "$HOME/.openclaw"
 
 
89
  for legacy in "$HOME/.openclaw.pre-symlink" "$HOME/.openclaw"; do
90
  if [ -d "$legacy" ] && [ ! -L "$legacy" ]; then
91
+ if ! rsync -a --update "$legacy/" "$OPENCLAW_DURABLE/"; then
92
+ echo "ERROR: could not migrate OpenClaw state from $legacy" >&2
93
+ exit 1
94
+ fi
95
  fi
96
  done
97
+
98
+ # opencode and Hermes expect their state at paths under HOME. The live targets
99
+ # are local; legacy real directories are retained as rollback copies instead of
100
+ # being deleted during migration.
101
+ OPENCODE_LINK="$HOME/.local/share/opencode"
102
+ HERMES_LINK="$HOME/.hermes"
103
+ mkdir -p "$(dirname "$OPENCODE_LINK")"
104
+ for pair in "$OPENCODE_LINK|$OPENCODE_DURABLE" "$HERMES_LINK|$HERMES_DURABLE"; do
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  lnk="${pair%%|*}"; dur="${pair##*|}"
106
  if [ -e "$lnk" ] && [ ! -L "$lnk" ]; then
107
+ if ! rsync -a --update "$lnk/" "$dur/"; then
108
+ echo "ERROR: could not migrate agent state from $lnk" >&2
109
+ exit 1
110
+ fi
111
+ backup="$lnk.pre-agent-state"
112
+ if [ -e "$backup" ] || [ -L "$backup" ]; then
113
+ backup="$backup.$(date -u +%Y%m%dT%H%M%SZ).$$"
114
+ fi
115
+ if ! mv "$lnk" "$backup"; then
116
+ echo "ERROR: could not retain rollback copy at $backup" >&2
117
+ exit 1
118
+ fi
119
  fi
120
  done
121
+ ln -sfn "$OPENCODE_LIVE" "$OPENCODE_LINK"
 
 
122
  ln -sfn "$HERMES_LIVE" "$HERMES_LINK"
123
+
124
+ # Restore after all one-time migrations have populated the durable side. On a
125
+ # hot/dev restart, --update preserves newer local state.
126
+ AGENT_STATE_SCRIPT="${AGENT_STATE_SCRIPT:-/app/scripts/agent-state.sh}"
127
+ export AGENT_STATE_SCRIPT
128
+ if ! sh "$AGENT_STATE_SCRIPT" restore; then
129
+ echo "ERROR: agent-state restore was incomplete; refusing to start agents against partial state" >&2
130
+ exit 1
131
+ fi
132
+
133
+ # Small comforts in OpenClaw's private HOME (harmless if missing).
134
+ cp "$HOME/.gitconfig" "$OPENCLAW_HOME/.gitconfig" 2>/dev/null || true
135
+ export PIP_CACHE_DIR="$AM_LOCAL/pip-cache"
136
+ export PYTHONPYCACHEPREFIX="$AM_LOCAL/pycache"
137
+ export PYTHONUSERBASE="$AM_LOCAL/py" # pip install --user → local, fast
138
+ export NPM_CONFIG_PREFIX="$AM_LOCAL/npm" # npm install -g → local, no root needed
139
+ export PATH="$AM_LOCAL/py/bin:$AM_LOCAL/npm/bin:$AM_LOCAL/bin:$HOME/.local/bin:$PATH"
140
 
141
  # Durable, user-editable setup script. Runs on EVERY start (keep it idempotent);
142
  # seed a template on first boot.
 
180
  fi
181
  echo "[install.sh finished $(date -u) exit=$INSTALL_CODE]" >> "$DATA_DIR/install.log"
182
 
183
+ # Keep PID 1 as a tiny supervisor so normal Space/dev restarts receive a final
184
+ # state checkpoint. The timer bounds loss on an ungraceful stop; the child is
185
+ # stopped before the final checkpoint so SQLite and transcript state is quiet.
186
+ AGENT_STATE_CHECKPOINT_SECONDS="${AGENT_STATE_CHECKPOINT_SECONDS:-15}"
187
+ node /app/server/src/index.js &
188
+ APP_PID=$!
189
+
190
+ checkpoint_loop() {
191
+ while kill -0 "$APP_PID" 2>/dev/null; do
192
+ sleep "$AGENT_STATE_CHECKPOINT_SECONDS"
193
+ kill -0 "$APP_PID" 2>/dev/null || break
194
+ sh "$AGENT_STATE_SCRIPT" checkpoint \
195
+ || echo "WARN: periodic agent-state checkpoint failed"
196
+ done
197
+ }
198
+ checkpoint_loop &
199
+ CHECKPOINT_PID=$!
200
+
201
+ finish() {
202
+ code="${1:-0}"
203
+ kill "$CHECKPOINT_PID" 2>/dev/null || true
204
+ wait "$CHECKPOINT_PID" 2>/dev/null || true
205
+ # A timer checkpoint may still be finishing after its loop shell is stopped.
206
+ # checkpoint-final waits for that lock, then captures the quiet post-Node
207
+ # state instead of silently treating a busy lock as success.
208
+ sh "$AGENT_STATE_SCRIPT" checkpoint-final \
209
+ || echo "WARN: final agent-state checkpoint failed"
210
+ exit "$code"
211
+ }
212
+
213
+ shutdown() {
214
+ trap - TERM INT HUP
215
+ kill -TERM "$APP_PID" 2>/dev/null || true
216
+ wait "$APP_PID" 2>/dev/null
217
+ code=$?
218
+ finish "$code"
219
+ }
220
+ trap shutdown TERM INT HUP
221
+
222
+ wait "$APP_PID"
223
+ APP_CODE=$?
224
+ finish "$APP_CODE"
scripts/agent-state.sh ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # Durable state bridge for the agent harnesses.
3
+ #
4
+ # Active state always lives on the container's POSIX filesystem. The mounted
5
+ # bucket only receives short-lived, closed writes made by rsync (ordinary file
6
+ # trees) or SQLite's online backup API (live databases). This is intentional:
7
+ # hf-mount's streaming writer holds an open file in memory until close, while
8
+ # copying a database and its WAL independently can produce a torn backup.
9
+
10
+ set -u
11
+
12
+ MODE="${1:-}"
13
+ case "$MODE" in
14
+ restore|checkpoint|checkpoint-final) ;;
15
+ *) echo "usage: $0 restore|checkpoint|checkpoint-final" >&2; exit 2 ;;
16
+ esac
17
+
18
+ : "${DATA_DIR:?DATA_DIR is required}"
19
+ : "${AM_LOCAL:?AM_LOCAL is required}"
20
+
21
+ CODEX_HOME="${CODEX_HOME:-$AM_LOCAL/codex-home}"
22
+ CODEX_DURABLE="${CODEX_DURABLE:-$DATA_DIR/state/codex}"
23
+ CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AM_LOCAL/agent-state/claude}"
24
+ CLAUDE_DURABLE="${CLAUDE_DURABLE:-$DATA_DIR/state/claude}"
25
+ GEMINI_CLI_HOME="${GEMINI_CLI_HOME:-$AM_LOCAL/agent-state/gemini-home}"
26
+ GEMINI_LIVE="${GEMINI_LIVE:-$GEMINI_CLI_HOME/.gemini}"
27
+ GEMINI_DURABLE="${GEMINI_DURABLE:-$DATA_DIR/state/gemini}"
28
+ OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-$AM_LOCAL/oc-home/.openclaw}"
29
+ OPENCLAW_DURABLE="${OPENCLAW_DURABLE:-$DATA_DIR/state/openclaw-backup}"
30
+ OPENCODE_LIVE="${OPENCODE_LIVE:-$AM_LOCAL/opencode-share}"
31
+ OPENCODE_DURABLE="${OPENCODE_DURABLE:-$DATA_DIR/state/opencode}"
32
+ HERMES_LIVE="${HERMES_LIVE:-$AM_LOCAL/hermes}"
33
+ HERMES_DURABLE="${HERMES_DURABLE:-$DATA_DIR/state/hermes}"
34
+
35
+ LOCK="$AM_LOCAL/agent-state-checkpoint.lock"
36
+ mkdir -p "$AM_LOCAL"
37
+ exec 9>"$LOCK"
38
+ if [ "$MODE" = restore ] || [ "$MODE" = checkpoint-final ]; then
39
+ # A dev-mode restart may overlap the previous app's final checkpoint. Never
40
+ # restore an older durable view while that checkpoint is still being made.
41
+ flock 9
42
+ else
43
+ # Timer and shutdown checkpoints can race; the one already holding the lock
44
+ # is sufficient, and a later timer/shutdown will catch subsequent writes.
45
+ flock -n 9 || exit 0
46
+ fi
47
+
48
+ # `find -newer` is a strict comparison. A file written in the same filesystem
49
+ # timestamp tick as a checkpoint marker would otherwise be skipped forever.
50
+ # Keep a small overlap at every successful boundary; an occasional repeat copy
51
+ # is harmless, while a missed transcript/config is not.
52
+ mark_checkpoint_floor() {
53
+ touch -d '2 seconds ago' "$1" 2>/dev/null || touch "$1"
54
+ }
55
+
56
+ restore_tree() {
57
+ key="$1" durable="$2" live="$3"; shift 3
58
+ mkdir -p "$durable" "$live"
59
+ stamp_dir="$AM_LOCAL/agent-state-stamps"
60
+ stamp="$stamp_dir/$key"
61
+ had_local=false
62
+ [ -n "$(find "$live" -type f -print -quit 2>/dev/null)" ] && had_local=true
63
+ # --update matters for hot/dev restarts: local disk survives those and can be
64
+ # newer than the last completed bucket checkpoint. A fresh container starts
65
+ # with an empty destination, so the same command performs a full restore.
66
+ if rsync -a --update "$@" "$durable/" "$live/"; then
67
+ mkdir -p "$stamp_dir"
68
+ if [ ! -e "$stamp" ]; then
69
+ if [ "$had_local" = true ]; then
70
+ # First deployment over an already-populated local tree: force one
71
+ # checkpoint so newer local files skipped by --update become durable.
72
+ touch -t 197001010000 "$stamp"
73
+ else
74
+ # Fresh container: every local byte came from this durable restore.
75
+ mark_checkpoint_floor "$stamp"
76
+ fi
77
+ fi
78
+ else
79
+ return 1
80
+ fi
81
+ }
82
+
83
+ checkpoint_tree() {
84
+ key="$1" live="$2" durable="$3"; shift 3
85
+ [ -d "$live" ] || return 0
86
+ stamp_dir="$AM_LOCAL/agent-state-stamps"
87
+ mkdir -p "$durable" "$stamp_dir"
88
+ stamp="$stamp_dir/$key"
89
+ if [ ! -e "$stamp" ]; then
90
+ touch -t 197001010000 "$stamp"
91
+ fi
92
+
93
+ # Mark the START of this checkpoint. Any file changed during/after its copy
94
+ # is newer than `next` and will therefore be selected again next time.
95
+ next="$stamp.next.$$"
96
+ list="$stamp.files.$$"
97
+ mark_checkpoint_floor "$next"
98
+ (cd "$live" && find . -type f -newer "$stamp" -print0) > "$list"
99
+
100
+ if [ -s "$list" ]; then
101
+ # --files-from means rsync walks only changed LOCAL paths. It does not scan
102
+ # the remote tree every 15 seconds — a critical property on the bucket
103
+ # mount. Destination temporaries close before rename, retaining the prior
104
+ # object if this process dies during transfer.
105
+ if ! rsync -a -r --from0 --files-from="$list" --delay-updates \
106
+ "$@" "$live/" "$durable/"; then
107
+ rm -f "$next" "$list"
108
+ return 1
109
+ fi
110
+ fi
111
+ mv "$next" "$stamp"
112
+ rm -f "$list"
113
+ }
114
+
115
+ restore_sqlite_tree() {
116
+ durable="$1" live="$2" db_name="$3" checkpoint_name="$4"
117
+ mkdir -p "$durable" "$live"
118
+
119
+ if ! restore_tree "$checkpoint_name-files" "$durable" "$live" \
120
+ --exclude 'checkpoints' --exclude '*.db' --exclude '*.db-*' \
121
+ --exclude '*.sqlite*' --exclude '*-wal' --exclude '*-shm'; then
122
+ return 1
123
+ fi
124
+
125
+ checkpoint="$durable/checkpoints/$checkpoint_name"
126
+ target="$live/$db_name"
127
+ if [ -s "$target" ] && [ "$(sqlite3 "$target" 'PRAGMA quick_check;' 2>/dev/null)" = ok ]; then
128
+ # Local disk survives in-container/dev restarts. Its database can be newer
129
+ # than the last checkpoint (including committed rows still in its WAL), so
130
+ # a valid live database is always the restore authority on a hot restart.
131
+ return 0
132
+ fi
133
+
134
+ # Retain an invalid local set for diagnosis, then recover from the last
135
+ # known-good checkpoint. These are explicit ephemeral paths, never bucket
136
+ # objects.
137
+ had_invalid=false
138
+ if [ -e "$target" ] || [ -e "$target-wal" ] || [ -e "$target-shm" ]; then
139
+ had_invalid=true
140
+ invalid_dir="$AM_LOCAL/agent-state-invalid/$checkpoint_name.$(date -u +%Y%m%dT%H%M%SZ).$$"
141
+ mkdir -p "$invalid_dir"
142
+ [ -e "$target" ] && mv "$target" "$invalid_dir/$db_name"
143
+ [ -e "$target-wal" ] && mv "$target-wal" "$invalid_dir/$db_name-wal"
144
+ [ -e "$target-shm" ] && mv "$target-shm" "$invalid_dir/$db_name-shm"
145
+ fi
146
+
147
+ if [ -s "$checkpoint" ]; then
148
+ # WAL/SHM are ephemeral coordination files, never part of a restored
149
+ # checkpoint. Removing these explicit local paths cannot touch bucket data.
150
+ rm -f "$target-wal" "$target-shm"
151
+ if ! rsync -a "$checkpoint" "$target"; then return 1; fi
152
+ elif [ -s "$durable/$db_name" ]; then
153
+ # One-release compatibility path for state written by the old raw-rsync
154
+ # mechanism. Copy the legacy DB and any WAL so SQLite can recover it; the
155
+ # first successful checkpoint replaces this path as the restore authority.
156
+ if ! rsync -a "$durable/$db_name" "$target"; then return 1; fi
157
+ if [ -f "$durable/$db_name-wal" ] && ! rsync -a "$durable/$db_name-wal" "$target-wal"; then return 1; fi
158
+ if [ -f "$durable/$db_name-shm" ] && ! rsync -a "$durable/$db_name-shm" "$target-shm"; then return 1; fi
159
+ fi
160
+
161
+ if [ "$had_invalid" = true ] && [ ! -s "$target" ]; then
162
+ echo "agent-state: invalid local SQLite state has no durable recovery for $target" >&2
163
+ return 1
164
+ fi
165
+ if [ -s "$target" ] && [ "$(sqlite3 "$target" 'PRAGMA quick_check;' 2>/dev/null)" != ok ]; then
166
+ echo "agent-state: restored SQLite state is invalid for $target" >&2
167
+ return 1
168
+ fi
169
+ }
170
+
171
+ checkpoint_sqlite_tree() {
172
+ live="$1" durable="$2" db_name="$3" checkpoint_name="$4"
173
+ source="$live/$db_name"
174
+
175
+ # A harness can write ordinary state before it creates its database (Hermes
176
+ # setup files are a real example). Always publish that file tree first. The
177
+ # database backup is optional until the database itself exists.
178
+ if ! checkpoint_tree "$checkpoint_name-files" "$live" "$durable" \
179
+ --exclude 'checkpoints' --exclude '*.db' --exclude '*.db-*' \
180
+ --exclude '*.sqlite*' --exclude '*-wal' --exclude '*-shm'; then
181
+ return 1
182
+ fi
183
+ [ -s "$source" ] || return 0
184
+
185
+ sqlite_stamp_dir="$AM_LOCAL/agent-state-stamps"
186
+ sqlite_stamp="$sqlite_stamp_dir/$checkpoint_name-sqlite"
187
+ mkdir -p "$sqlite_stamp_dir"
188
+ # Timestamp-only detection has an equal-tick hole, while deliberately
189
+ # overlapping the marker would rewrite an idle database on rapid successive
190
+ # checkpoints. Record the exact local DB/WAL metadata observed BEFORE the
191
+ # backup instead. A concurrent commit changes the next signature and is
192
+ # therefore picked up by the following checkpoint.
193
+ sqlite_next="$sqlite_stamp.next.$$"
194
+ {
195
+ stat -c 'db|%s|%y|%z' "$source"
196
+ if [ -e "$source-wal" ]; then
197
+ stat -c 'wal|%s|%y|%z' "$source-wal"
198
+ else
199
+ echo 'wal|absent'
200
+ fi
201
+ } > "$sqlite_next"
202
+ if cmp -s "$sqlite_next" "$sqlite_stamp"; then
203
+ rm -f "$sqlite_next"
204
+ return 0
205
+ fi
206
+
207
+ stage_dir="$AM_LOCAL/agent-state-snapshots"
208
+ mkdir -p "$stage_dir" "$durable/checkpoints"
209
+ staged="$stage_dir/$checkpoint_name.$$.tmp"
210
+ rm -f "$staged"
211
+ escaped=$(printf '%s' "$staged" | sed "s/'/''/g")
212
+
213
+ # .backup observes the main DB and WAL through one consistent SQLite read
214
+ # transaction. The staged file is ordinary local storage and is closed before
215
+ # rsync hands it to the bucket.
216
+ if ! sqlite3 "$source" ".timeout 5000" ".backup '$escaped'"; then
217
+ rm -f "$staged" "$sqlite_next"
218
+ return 1
219
+ fi
220
+ if [ "$(sqlite3 "$staged" 'PRAGMA quick_check;' 2>/dev/null)" != ok ]; then
221
+ echo "agent-state: refusing invalid SQLite checkpoint for $source" >&2
222
+ rm -f "$staged" "$sqlite_next"
223
+ return 1
224
+ fi
225
+ if ! rsync -a --delay-updates "$staged" "$durable/checkpoints/$checkpoint_name"; then
226
+ rm -f "$staged" "$sqlite_next"
227
+ return 1
228
+ fi
229
+ rm -f "$staged"
230
+ mv "$sqlite_next" "$sqlite_stamp"
231
+ }
232
+
233
+ failures=0
234
+
235
+ if [ "$MODE" = restore ]; then
236
+ restore_tree codex "$CODEX_DURABLE" "$CODEX_HOME" \
237
+ --exclude '*.sqlite*' --exclude '*.db' --exclude '*.db-*' \
238
+ --exclude '*-wal' --exclude '*-shm' --exclude 'db-backups' \
239
+ --exclude 'cache' --exclude '.tmp' --exclude 'mcp-oauth-locks' || failures=$((failures + 1))
240
+ restore_tree claude "$CLAUDE_DURABLE" "$CLAUDE_CONFIG_DIR" || failures=$((failures + 1))
241
+ restore_tree gemini "$GEMINI_DURABLE" "$GEMINI_LIVE" || failures=$((failures + 1))
242
+ restore_tree openclaw "$OPENCLAW_DURABLE" "$OPENCLAW_STATE_DIR" || failures=$((failures + 1))
243
+ restore_sqlite_tree "$OPENCODE_DURABLE" "$OPENCODE_LIVE" opencode.db opencode.db || failures=$((failures + 1))
244
+ restore_sqlite_tree "$HERMES_DURABLE" "$HERMES_LIVE" state.db state.db || failures=$((failures + 1))
245
+ else
246
+ checkpoint_tree codex "$CODEX_HOME" "$CODEX_DURABLE" \
247
+ --exclude '*.sqlite*' --exclude '*.db' --exclude '*.db-*' \
248
+ --exclude '*-wal' --exclude '*-shm' --exclude 'db-backups' \
249
+ --exclude 'cache' --exclude '.tmp' --exclude 'mcp-oauth-locks' || failures=$((failures + 1))
250
+ checkpoint_tree claude "$CLAUDE_CONFIG_DIR" "$CLAUDE_DURABLE" || failures=$((failures + 1))
251
+ checkpoint_tree gemini "$GEMINI_LIVE" "$GEMINI_DURABLE" || failures=$((failures + 1))
252
+ checkpoint_tree openclaw "$OPENCLAW_STATE_DIR" "$OPENCLAW_DURABLE" || failures=$((failures + 1))
253
+ checkpoint_sqlite_tree "$OPENCODE_LIVE" "$OPENCODE_DURABLE" opencode.db opencode.db || failures=$((failures + 1))
254
+ checkpoint_sqlite_tree "$HERMES_LIVE" "$HERMES_DURABLE" state.db state.db || failures=$((failures + 1))
255
+ fi
256
+
257
+ [ "$failures" -eq 0 ] || {
258
+ echo "agent-state: $MODE completed with $failures failed adapter(s)" >&2
259
+ exit 1
260
+ }
server/migration.test.mjs CHANGED
@@ -8,6 +8,41 @@ import path from 'node:path';
8
  import { WebSocket } from 'ws';
9
 
10
  const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-migration-'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  const PORT = 7893;
13
  const CTRL = '\x00\x00AM:';
@@ -28,7 +63,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
28
  const { SPACE_ID, AM_DISTRIBUTE_SKILLS, ...BASE_ENV } = process.env;
29
 
30
  const srv = spawn('node', ['src/index.js'], {
31
- env: { ...BASE_ENV, PORT: String(PORT), DATA_DIR, AM_BASHRC: '/nonexistent', AM_ALLOW_MISSING_ORIGIN: '1' },
 
 
 
32
  stdio: ['ignore', 'pipe', 'pipe'],
33
  });
34
  let bootLog = '';
@@ -197,6 +235,44 @@ try {
197
  check('stopped session reports stopped', after && after.state === 'stopped', after && after.state);
198
 
199
  await fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  } catch (err) {
201
  check('no exceptions', false, String(err && err.message ? err.message : err));
202
  console.log('--- server log tail ---\n' + bootLog.slice(-1200));
 
8
  import { WebSocket } from 'ws';
9
 
10
  const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-migration-'));
11
+ const BASHRC = path.join(DATA_DIR, 'test.bashrc');
12
+ const BASH_ENV = path.join(DATA_DIR, 'test.bash-env');
13
+ const BIN_DIR = path.join(DATA_DIR, 'bin');
14
+ fs.mkdirSync(BIN_DIR);
15
+ // runner launches each CLI through `bash -lc`, whose login profile replaces
16
+ // PATH. BASH_ENV runs afterwards and keeps this fixture ahead of the real CLI.
17
+ fs.writeFileSync(BASH_ENV, `export PATH="${BIN_DIR}:$PATH"\n`);
18
+ const FAKE_OPENCODE = path.join(BIN_DIR, 'opencode');
19
+ fs.writeFileSync(FAKE_OPENCODE, `#!/usr/bin/env bash
20
+ printf 'OPENCODE-SCREEN-READY\\r\\n'
21
+ saved=$(stty -g)
22
+ stty -echo -icanon min 0 time 1
23
+ end=$((SECONDS + 8))
24
+ while [ "$SECONDS" -lt "$end" ]; do
25
+ IFS= read -r -n 4096 -t 1 _ || true
26
+ done
27
+ stty "$saved"
28
+ printf 'OPENCODE-INPUT-READY\\r\\n'
29
+ while IFS= read -r line; do
30
+ printf 'OPENCODE-EXECUTED:%s\\r\\n' "$line"
31
+ done
32
+ `);
33
+ fs.chmodSync(FAKE_OPENCODE, 0o755);
34
+ fs.writeFileSync(BASHRC, `
35
+ if [ "$AM_NAME" = "input-readiness" ]; then
36
+ # Model a history-heavy TUI that drains startup keystrokes. The old fixed
37
+ # 3.5s delay delivered during this loop and lost the operator's prompt.
38
+ for i in 1 2 3 4 5; do
39
+ printf 'BOOT-FRAME-%s\\n' "$i"
40
+ IFS= read -r -t 1 _ || true
41
+ done
42
+ printf 'INPUT-READY\\n'
43
+ fi
44
+ PS1='test$ '
45
+ `);
46
 
47
  const PORT = 7893;
48
  const CTRL = '\x00\x00AM:';
 
63
  const { SPACE_ID, AM_DISTRIBUTE_SKILLS, ...BASE_ENV } = process.env;
64
 
65
  const srv = spawn('node', ['src/index.js'], {
66
+ env: {
67
+ ...BASE_ENV, PATH: `${BIN_DIR}:${process.env.PATH}`, BASH_ENV,
68
+ PORT: String(PORT), DATA_DIR, AM_BASHRC: BASHRC, AM_ALLOW_MISSING_ORIGIN: '1',
69
+ },
70
  stdio: ['ignore', 'pipe', 'pipe'],
71
  });
72
  let bootLog = '';
 
235
  check('stopped session reports stopped', after && after.state === 'stopped', after && after.state);
236
 
237
  await fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
238
+
239
+ // --- waking a stopped pane waits for the TUI, not a fixed delay ----------
240
+ const delayed = await (await fetch(`${base}/api/sessions`, {
241
+ method: 'POST', headers: { 'content-type': 'application/json' },
242
+ body: JSON.stringify({ cli: 'shell', name: 'input-readiness', path: '.' }),
243
+ })).json();
244
+ const delayedId = delayed.id;
245
+ const delivered = await fetch(`${base}/api/sessions/${delayedId}/input`, {
246
+ method: 'POST', headers: { 'content-type': 'application/json' },
247
+ body: JSON.stringify({ text: "printf '%s-executed\\n' \"$AM_NAME\"" }),
248
+ });
249
+ await sleep(700);
250
+ const delayedTail = await (await fetch(`${base}/api/agents/${delayedId}/tail?lines=120`)).json();
251
+ check('stopped-session input waits through a draining startup TUI',
252
+ delivered.ok && (delayedTail.text || '').includes('INPUT-READY')
253
+ && (delayedTail.text || '').includes('input-readiness-executed'));
254
+ await fetch(`${base}/api/sessions/${delayedId}`, { method: 'DELETE' }).catch(() => {});
255
+
256
+ // OpenCode paints its stable welcome screen before its keyboard handler is
257
+ // always ready. The first typed attempt is silently drained here; delivery
258
+ // must wait for the composer to echo the real prompt before pressing Enter.
259
+ const openCode = await (await fetch(`${base}/api/sessions`, {
260
+ method: 'POST', headers: { 'content-type': 'application/json' },
261
+ body: JSON.stringify({ cli: 'opencode', name: 'opencode-input-readiness', path: '.' }),
262
+ })).json();
263
+ const openCodeInput = 'opencode-delivery-survived';
264
+ const openCodeDelivered = await fetch(`${base}/api/sessions/${openCode.id}/input`, {
265
+ method: 'POST', headers: { 'content-type': 'application/json' },
266
+ body: JSON.stringify({ text: openCodeInput }),
267
+ });
268
+ await sleep(700);
269
+ const openCodeTail = await (await fetch(`${base}/api/agents/${openCode.id}/tail?lines=120`)).json();
270
+ const executed = (openCodeTail.text || '').match(new RegExp(`OPENCODE-EXECUTED:${openCodeInput}`, 'g')) || [];
271
+ check('OpenCode delivery waits for the real input handler after stable paint',
272
+ openCodeDelivered.ok
273
+ && executed.length === 1,
274
+ `${openCodeDelivered.status} ${JSON.stringify(openCodeTail.text || '')}`);
275
+ await fetch(`${base}/api/sessions/${openCode.id}`, { method: 'DELETE' }).catch(() => {});
276
  } catch (err) {
277
  check('no exceptions', false, String(err && err.message ? err.message : err));
278
  console.log('--- server log tail ---\n' + bootLog.slice(-1200));
server/package.json CHANGED
@@ -14,7 +14,7 @@
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
  "test:ui": "node terminal-ui.test.mjs",
17
- "test": "node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs"
18
  },
19
  "engines": {
20
  "node": ">=20.19"
 
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
  "test:ui": "node terminal-ui.test.mjs",
17
+ "test": "node state-checkpoint.test.mjs && node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs"
18
  },
19
  "engines": {
20
  "node": ">=20.19"
server/src/config.js CHANGED
@@ -123,7 +123,7 @@ function isConfigured(id) {
123
  || fileOk(path.join(env.CODEX_HOME || path.join(home, '.codex'), 'auth.json'));
124
  case 'gemini':
125
  return hasEnv('GEMINI_API_KEY', 'GOOGLE_API_KEY')
126
- || fileOk(path.join(home, '.gemini', 'oauth_creds.json'));
127
  case 'opencode': {
128
  const xdgConfig = env.XDG_CONFIG_HOME || path.join(home, '.config');
129
  return hasEnv('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY')
 
123
  || fileOk(path.join(env.CODEX_HOME || path.join(home, '.codex'), 'auth.json'));
124
  case 'gemini':
125
  return hasEnv('GEMINI_API_KEY', 'GOOGLE_API_KEY')
126
+ || fileOk(path.join(env.GEMINI_CLI_HOME || home, '.gemini', 'oauth_creds.json'));
127
  case 'opencode': {
128
  const xdgConfig = env.XDG_CONFIG_HOME || path.join(home, '.config');
129
  return hasEnv('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY')
server/src/index.js CHANGED
@@ -19,7 +19,8 @@ import * as demo from './demo.js';
19
  import * as hidden from './hidden.js';
20
  import {
21
  attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning,
22
- capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook, installOpencodeRepinPlugin,
 
23
  } from './runner.js';
24
 
25
  // Control frames ride the terminal socket behind a leading NUL pair, which real
@@ -158,8 +159,14 @@ await Promise.race([
158
  // running instead of exiting.
159
  // No tmux to outlive us any more: kill the PTYs we hold on the way out so a
160
  // restart can't leave orphaned agents writing into the workspace.
 
161
  for (const sig of ['SIGTERM', 'SIGINT']) {
162
- process.on(sig, () => { try { stopAll(); } catch {} process.exit(0); });
 
 
 
 
 
163
  }
164
 
165
  process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
@@ -314,8 +321,10 @@ async function deliver(session, text, from) {
314
  return false;
315
  }
316
  const started = ensureRunning(session);
317
- if (started) await sleep(3500); // let the CLI boot before the keystrokes land
318
- await sendInput(session.id, text);
 
 
319
  return started;
320
  }
321
 
@@ -783,9 +792,14 @@ const BUILD_ENV_KEYS = (() => {
783
  // after the build-time snapshot, so its vars would otherwise be misdetected as
784
  // injected secrets. Keep this list in sync with entrypoint.sh.
785
  const NON_SECRET = new Set([
786
- 'HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'NPM_CONFIG_PREFIX', 'PWD', 'OLDPWD', 'SHLVL', '_', 'HOSTNAME',
 
 
 
 
 
787
  'ACCELERATOR', 'COMMIT_SHA', 'CPU_CORES', 'HF_DATASETS_TRUST_REMOTE_CODE', 'IMAGE_SHA', 'MEMORY', 'OMP_NUM_THREADS',
788
- 'UV_CACHE_DIR', 'PIP_CACHE_DIR', 'PYTHONPYCACHEPREFIX', 'PYTHONUSERBASE', 'OPENCLAW_STATE_DIR', 'OPENCLAW_HOME',
789
  ]);
790
  const NON_SECRET_PREFIX = ['SPACE_', 'KUBERNETES_', 'NVIDIA_', 'CUDA_', 'NV_', 'AM_'];
791
  function injectedEnvKeys() {
@@ -1385,10 +1399,14 @@ function skillTargetDirs() {
1385
  const home = process.env.HOME || os.homedir();
1386
  const claudeCfg = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
1387
  const dirs = [
1388
- path.join(home, '.agents', 'skills'), // Codex, Gemini, opencode
1389
  path.join(claudeCfg, 'skills'), // Claude Code
1390
  path.join(home, '.hermes', 'skills'), // Hermes
1391
  ];
 
 
 
 
1392
  // OpenClaw runs with its own HOME (see entrypoint.sh) and reads managed
1393
  // skills from ~/.agents/skills resolved against THAT home. Recreated on
1394
  // every boot, so it needs no backup coverage.
 
19
  import * as hidden from './hidden.js';
20
  import {
21
  attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning,
22
+ waitForInputReady, capturePane, ghosttyReady, ghosttyError,
23
+ installClaudeRepinHook, installOpencodeRepinPlugin,
24
  } from './runner.js';
25
 
26
  // Control frames ride the terminal socket behind a leading NUL pair, which real
 
159
  // running instead of exiting.
160
  // No tmux to outlive us any more: kill the PTYs we hold on the way out so a
161
  // restart can't leave orphaned agents writing into the workspace.
162
+ let shutdownStarted = false;
163
  for (const sig of ['SIGTERM', 'SIGINT']) {
164
+ process.on(sig, async () => {
165
+ if (shutdownStarted) return;
166
+ shutdownStarted = true;
167
+ try { await stopAll(); } catch {}
168
+ process.exit(0);
169
+ });
170
  }
171
 
172
  process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
 
321
  return false;
322
  }
323
  const started = ensureRunning(session);
324
+ if (started && !await waitForInputReady(session.id)) {
325
+ throw new Error('session did not become ready for input within 30 seconds — prompt was not sent');
326
+ }
327
+ await sendInput(session.id, text, { confirmEcho: started && session.cli === 'opencode' });
328
  return started;
329
  }
330
 
 
792
  // after the build-time snapshot, so its vars would otherwise be misdetected as
793
  // injected secrets. Keep this list in sync with entrypoint.sh.
794
  const NON_SECRET = new Set([
795
+ 'HOME', 'CLAUDE_CONFIG_DIR', 'CLAUDE_DURABLE', 'CODEX_HOME', 'CODEX_DURABLE',
796
+ 'GEMINI_CLI_HOME', 'GEMINI_LIVE', 'GEMINI_DURABLE',
797
+ 'OPENCLAW_STATE_DIR', 'OPENCLAW_HOME', 'OPENCLAW_DURABLE',
798
+ 'OPENCODE_LIVE', 'OPENCODE_DURABLE', 'HERMES_LIVE', 'HERMES_DURABLE',
799
+ 'AGENT_STATE_SCRIPT', 'AGENT_STATE_CHECKPOINT_SECONDS', 'DATA_DIR',
800
+ 'NPM_CONFIG_PREFIX', 'PWD', 'OLDPWD', 'SHLVL', '_', 'HOSTNAME',
801
  'ACCELERATOR', 'COMMIT_SHA', 'CPU_CORES', 'HF_DATASETS_TRUST_REMOTE_CODE', 'IMAGE_SHA', 'MEMORY', 'OMP_NUM_THREADS',
802
+ 'UV_CACHE_DIR', 'PIP_CACHE_DIR', 'PYTHONPYCACHEPREFIX', 'PYTHONUSERBASE',
803
  ]);
804
  const NON_SECRET_PREFIX = ['SPACE_', 'KUBERNETES_', 'NVIDIA_', 'CUDA_', 'NV_', 'AM_'];
805
  function injectedEnvKeys() {
 
1399
  const home = process.env.HOME || os.homedir();
1400
  const claudeCfg = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
1401
  const dirs = [
1402
+ path.join(home, '.agents', 'skills'), // Codex and opencode
1403
  path.join(claudeCfg, 'skills'), // Claude Code
1404
  path.join(home, '.hermes', 'skills'), // Hermes
1405
  ];
1406
+ // GEMINI_CLI_HOME deliberately changes the home Gemini resolves its global
1407
+ // .agents directory against; fan skills into that local/checkpointed home as
1408
+ // well as the ordinary durable HOME.
1409
+ if (process.env.GEMINI_CLI_HOME) dirs.push(path.join(process.env.GEMINI_CLI_HOME, '.agents', 'skills'));
1410
  // OpenClaw runs with its own HOME (see entrypoint.sh) and reads managed
1411
  // skills from ~/.agents/skills resolved against THAT home. Recreated on
1412
  // every boot, so it needs no backup coverage.
server/src/runner.js CHANGED
@@ -63,6 +63,13 @@ const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
63
  const BUSY_SECS = 4;
64
  // Re-rendering the grid to text on every chunk during a burst is wasteful.
65
  const SAMPLE_THROTTLE_MS = 250;
 
 
 
 
 
 
 
66
  // Despite the Node wrapper's `scrollbackLimit` name, Ghostty's native option is
67
  // a byte budget. Passing a line count such as 20,000 retains only a small native
68
  // allocation (about 700 ordinary rows). Keep the unit explicit at our boundary.
@@ -1835,6 +1842,8 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1835
  console.error('[runner] history restore', error && error.message);
1836
  }
1837
  }
 
 
1838
  const host = {
1839
  id: session.id,
1840
  runId,
@@ -1849,6 +1858,8 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1849
  .filter((line) => line.text && line.ansi).map((line) => [line.text, line.ansi])),
1850
  terminalModes: createTerminalModeTracker(),
1851
  startupHistory: captureResize ? persistedHistory : null,
 
 
1852
  historyCheckpoint: null,
1853
  traceHistoryPage: null,
1854
  traceHistoryTimer: null,
@@ -1857,6 +1868,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1857
  gridTimer: null,
1858
  startedAt: Date.now(),
1859
  lastOutputAt: Date.now(),
 
1860
  screenChangedAt: Date.now(),
1861
  bells: 0,
1862
  };
@@ -1882,6 +1894,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1882
 
1883
  term.onData((chunk) => {
1884
  host.lastOutputAt = Date.now();
 
1885
  host.terminalModes.feed(chunk);
1886
  if (host.traceHistoryTimer) {
1887
  clearTimeout(host.traceHistoryTimer);
@@ -1941,6 +1954,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1941
  try { host.vt.dispose(); } catch {}
1942
  for (const sub of host.subs) sub.onExit();
1943
  host.subs.clear();
 
1944
  });
1945
 
1946
  hosts.set(session.id, host);
@@ -2028,7 +2042,7 @@ export function attach(session, cols, rows) {
2028
  }
2029
 
2030
  /** Type a line into the session's terminal (works with no browser attached). */
2031
- export async function sendInput(id, text) {
2032
  const host = hosts.get(id);
2033
  if (!host) throw new Error('session is not running');
2034
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
@@ -2037,11 +2051,67 @@ export async function sendInput(id, text) {
2037
  // The Enter must arrive as its OWN keypress: TUIs (codex) detect rapid input
2038
  // bursts as a paste, and a CR inside the burst becomes a newline in the
2039
  // composer instead of a submit. A short gap breaks the burst.
2040
- host.pty.write(payload);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2041
  await new Promise((r) => setTimeout(r, 300));
2042
  host.pty.write('\r');
2043
  }
2044
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2045
  /**
2046
  * The session's rendered screen plus `lines` of scrollback above it — what a
2047
  * human would see in the pane. Used by the agent API so one agent can watch
@@ -2075,8 +2145,19 @@ export function stop(id) {
2075
  * Kill every session. Without tmux nothing outlives this process, so a clean
2076
  * shutdown should not leave orphaned PTYs behind holding the workspace.
2077
  */
2078
- export function stopAll() {
2079
- for (const host of hosts.values()) {
 
2080
  try { host.pty.kill(); } catch {}
2081
  }
 
 
 
 
 
 
 
 
 
 
2082
  }
 
63
  const BUSY_SECS = 4;
64
  // Re-rendering the grid to text on every chunk during a burst is wasteful.
65
  const SAMPLE_THROTTLE_MS = 250;
66
+ // A prompt sent while a resumed TUI is still replaying its screen can be
67
+ // discarded, or accept the text but swallow the Enter. Wait for the canonical
68
+ // startup repaint and a genuinely quiet screen instead of guessing a boot
69
+ // duration in the API layer.
70
+ const INPUT_READY_QUIET_MS = Number(process.env.AM_INPUT_READY_QUIET_MS || BUSY_SECS * 1000);
71
+ const INPUT_READY_TIMEOUT_MS = Number(process.env.AM_INPUT_READY_TIMEOUT_MS || 30000);
72
+ const INPUT_ECHO_TIMEOUT_MS = Number(process.env.AM_INPUT_ECHO_TIMEOUT_MS || 20000);
73
  // Despite the Node wrapper's `scrollbackLimit` name, Ghostty's native option is
74
  // a byte budget. Passing a line count such as 20,000 retains only a small native
75
  // allocation (about 700 ordinary rows). Keep the unit explicit at our boundary.
 
1842
  console.error('[runner] history restore', error && error.message);
1843
  }
1844
  }
1845
+ let resolveExit;
1846
+ const exitPromise = new Promise((resolve) => { resolveExit = resolve; });
1847
  const host = {
1848
  id: session.id,
1849
  runId,
 
1858
  .filter((line) => line.text && line.ansi).map((line) => [line.text, line.ansi])),
1859
  terminalModes: createTerminalModeTracker(),
1860
  startupHistory: captureResize ? persistedHistory : null,
1861
+ exitPromise,
1862
+ resolveExit,
1863
  historyCheckpoint: null,
1864
  traceHistoryPage: null,
1865
  traceHistoryTimer: null,
 
1868
  gridTimer: null,
1869
  startedAt: Date.now(),
1870
  lastOutputAt: Date.now(),
1871
+ outputSeq: 0,
1872
  screenChangedAt: Date.now(),
1873
  bells: 0,
1874
  };
 
1894
 
1895
  term.onData((chunk) => {
1896
  host.lastOutputAt = Date.now();
1897
+ host.outputSeq++;
1898
  host.terminalModes.feed(chunk);
1899
  if (host.traceHistoryTimer) {
1900
  clearTimeout(host.traceHistoryTimer);
 
1954
  try { host.vt.dispose(); } catch {}
1955
  for (const sub of host.subs) sub.onExit();
1956
  host.subs.clear();
1957
+ host.resolveExit();
1958
  });
1959
 
1960
  hosts.set(session.id, host);
 
2042
  }
2043
 
2044
  /** Type a line into the session's terminal (works with no browser attached). */
2045
+ export async function sendInput(id, text, { confirmEcho = false } = {}) {
2046
  const host = hosts.get(id);
2047
  if (!host) throw new Error('session is not running');
2048
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
 
2051
  // The Enter must arrive as its OWN keypress: TUIs (codex) detect rapid input
2052
  // bursts as a paste, and a CR inside the burst becomes a newline in the
2053
  // composer instead of a submit. A short gap breaks the burst.
2054
+ if (confirmEcho) {
2055
+ // OpenCode can finish its first stable paint several seconds before its
2056
+ // input handler is installed. Probe with the real composer text, but do
2057
+ // not press Enter until the TUI has painted that text back. Retrying only
2058
+ // the unsubmitted composer is safe; Ctrl+U clears a late/partial attempt.
2059
+ const expected = text.replace(/\s+/g, ' ').trim();
2060
+ const probe = expected.slice(-Math.min(48, expected.length));
2061
+ const deadline = Date.now() + INPUT_ECHO_TIMEOUT_MS;
2062
+ let attempt = 0;
2063
+ let echoed = false;
2064
+ while (Date.now() < deadline && !echoed) {
2065
+ if (hosts.get(id) !== host) throw new Error('session stopped while waiting for input acknowledgement');
2066
+ if (attempt++) {
2067
+ host.pty.write('\x15'); // clear any attempt accepted too late to paint
2068
+ await new Promise((r) => setTimeout(r, 100));
2069
+ }
2070
+ const beforeOutput = host.outputSeq;
2071
+ let beforeScreen = '';
2072
+ try { beforeScreen = host.vt.getVisibleText(); } catch {}
2073
+ host.pty.write(payload);
2074
+ const attemptDeadline = Math.min(deadline, Date.now() + 1200);
2075
+ while (Date.now() < attemptDeadline) {
2076
+ if (hosts.get(id) !== host) throw new Error('session stopped while waiting for input acknowledgement');
2077
+ let screen = '';
2078
+ try { screen = host.vt.getVisibleText(); } catch {}
2079
+ echoed = host.outputSeq > beforeOutput && screen !== beforeScreen
2080
+ && screen.replace(/\s+/g, ' ').includes(probe);
2081
+ if (echoed) break;
2082
+ await new Promise((r) => setTimeout(r, 50));
2083
+ }
2084
+ }
2085
+ if (!echoed) throw new Error('session did not acknowledge the input before the timeout — prompt was not submitted');
2086
+ } else {
2087
+ host.pty.write(payload);
2088
+ }
2089
  await new Promise((r) => setTimeout(r, 300));
2090
  host.pty.write('\r');
2091
  }
2092
 
2093
+ /**
2094
+ * Wait until a newly-started pane can safely receive its first input.
2095
+ *
2096
+ * Resumed primary-screen TUIs may paint a welcome frame, pause, then replay a
2097
+ * large conversation. `resizeCapture` spans that whole transaction when a
2098
+ * durable terminal seed exists. The quiet-window check covers fresh panes and
2099
+ * older sessions without a terminal seed. On timeout callers get an explicit
2100
+ * failure instead of silently losing the operator's prompt.
2101
+ */
2102
+ export async function waitForInputReady(id, timeoutMs = INPUT_READY_TIMEOUT_MS) {
2103
+ const deadline = Date.now() + Math.max(0, timeoutMs);
2104
+ while (Date.now() < deadline) {
2105
+ const host = hosts.get(id);
2106
+ if (!host) throw new Error('session stopped while waiting for input readiness');
2107
+ const lastActivity = Math.max(host.startedAt || 0, host.lastOutputAt || 0, host.screenChangedAt || 0);
2108
+ if (!host.startupHistory && !host.resizeCapture
2109
+ && Date.now() - lastActivity >= INPUT_READY_QUIET_MS) return true;
2110
+ await new Promise((resolve) => setTimeout(resolve, 100));
2111
+ }
2112
+ return false;
2113
+ }
2114
+
2115
  /**
2116
  * The session's rendered screen plus `lines` of scrollback above it — what a
2117
  * human would see in the pane. Used by the agent API so one agent can watch
 
2145
  * Kill every session. Without tmux nothing outlives this process, so a clean
2146
  * shutdown should not leave orphaned PTYs behind holding the workspace.
2147
  */
2148
+ export async function stopAll(timeoutMs = 1000) {
2149
+ const active = [...hosts.values()];
2150
+ for (const host of active) {
2151
  try { host.pty.kill(); } catch {}
2152
  }
2153
+ if (!active.length) return;
2154
+
2155
+ // Give each CLI a bounded chance to handle SIGHUP, close its transcript, and
2156
+ // flush final local state before PID 1 takes the shutdown checkpoint.
2157
+ let timer;
2158
+ await Promise.race([
2159
+ Promise.allSettled(active.map((host) => host.exitPromise)),
2160
+ new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
2161
+ ]);
2162
+ if (timer) clearTimeout(timer);
2163
  }
server/state-checkpoint.test.mjs ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Crash/recovery checks for scripts/agent-state.sh. Everything runs on local
2
+ // temporary directories; no mounted bucket or real agent state is touched.
3
+ import { spawn, execFileSync } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'am-agent-state-'));
11
+ const data = path.join(root, 'durable');
12
+ const local = path.join(root, 'local');
13
+ const script = path.resolve(here, '../scripts/agent-state.sh');
14
+
15
+ const env = {
16
+ ...process.env,
17
+ DATA_DIR: data,
18
+ AM_LOCAL: local,
19
+ CODEX_HOME: path.join(local, 'codex'),
20
+ CODEX_DURABLE: path.join(data, 'state/codex'),
21
+ CLAUDE_CONFIG_DIR: path.join(local, 'claude'),
22
+ CLAUDE_DURABLE: path.join(data, 'state/claude'),
23
+ GEMINI_CLI_HOME: path.join(local, 'gemini-home'),
24
+ GEMINI_LIVE: path.join(local, 'gemini-home/.gemini'),
25
+ GEMINI_DURABLE: path.join(data, 'state/gemini'),
26
+ OPENCLAW_STATE_DIR: path.join(local, 'openclaw'),
27
+ OPENCLAW_DURABLE: path.join(data, 'state/openclaw'),
28
+ OPENCODE_LIVE: path.join(local, 'opencode'),
29
+ OPENCODE_DURABLE: path.join(data, 'state/opencode'),
30
+ HERMES_LIVE: path.join(local, 'hermes'),
31
+ HERMES_DURABLE: path.join(data, 'state/hermes'),
32
+ };
33
+
34
+ let failures = 0;
35
+ const check = (name, ok, detail = '') => {
36
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
37
+ if (!ok) failures++;
38
+ };
39
+ const put = (file, body) => {
40
+ fs.mkdirSync(path.dirname(file), { recursive: true });
41
+ fs.writeFileSync(file, body);
42
+ };
43
+ const run = (mode, options = {}) => execFileSync('sh', [script, mode], {
44
+ env, encoding: 'utf8', stdio: options.stdio || 'pipe',
45
+ });
46
+ const sql = (db, statement) => execFileSync('sqlite3', [db, statement], { encoding: 'utf8' }).trim();
47
+ const waitFor = (child, marker) => new Promise((resolve, reject) => {
48
+ let out = '';
49
+ const timer = setTimeout(() => reject(new Error(`timed out waiting for ${marker}: ${out}`)), 5000);
50
+ child.stdout.on('data', (chunk) => {
51
+ out += chunk;
52
+ if (out.includes(marker)) { clearTimeout(timer); resolve(); }
53
+ });
54
+ child.on('exit', (code) => { clearTimeout(timer); reject(new Error(`sqlite writer exited ${code}: ${out}`)); });
55
+ });
56
+
57
+ try {
58
+ execFileSync('sh', ['-n', script]);
59
+ check('checkpoint helper has valid POSIX shell syntax', true);
60
+
61
+ const codexRel = 'sessions/2026/08/04/rollout-test.jsonl';
62
+ const claudeRel = 'projects/work/session.jsonl';
63
+ const geminiRel = 'tmp/work/chats/session-test.jsonl';
64
+ const clawRel = 'agents/main/sessions/session.jsonl';
65
+ put(path.join(env.CODEX_DURABLE, codexRel), '{"turn":1}\n');
66
+ put(path.join(env.CLAUDE_DURABLE, claudeRel), '{"turn":1}\n');
67
+ put(path.join(env.GEMINI_DURABLE, geminiRel), '{"turn":1}\n');
68
+ put(path.join(env.OPENCLAW_DURABLE, clawRel), '{"turn":1}\n');
69
+
70
+ run('restore');
71
+ check('Codex rollout restores to a real local directory',
72
+ fs.readFileSync(path.join(env.CODEX_HOME, codexRel), 'utf8') === '{"turn":1}\n'
73
+ && !fs.lstatSync(path.join(env.CODEX_HOME, 'sessions')).isSymbolicLink());
74
+ check('Claude transcript restores locally',
75
+ fs.readFileSync(path.join(env.CLAUDE_CONFIG_DIR, claudeRel), 'utf8') === '{"turn":1}\n');
76
+ check('Gemini transcript restores under GEMINI_CLI_HOME',
77
+ fs.readFileSync(path.join(env.GEMINI_LIVE, geminiRel), 'utf8') === '{"turn":1}\n');
78
+ check('OpenClaw transcript restores locally',
79
+ fs.readFileSync(path.join(env.OPENCLAW_STATE_DIR, clawRel), 'utf8') === '{"turn":1}\n');
80
+
81
+ // Re-running entrypoint in the same container must not replace newer live
82
+ // state with an older bucket checkpoint.
83
+ const liveClaude = path.join(env.CLAUDE_CONFIG_DIR, claudeRel);
84
+ const durableClaude = path.join(env.CLAUDE_DURABLE, claudeRel);
85
+ put(durableClaude, '{"turn":"stale"}\n');
86
+ put(liveClaude, '{"turn":"new-local"}\n');
87
+ const now = Date.now() / 1000;
88
+ fs.utimesSync(durableClaude, now - 60, now - 60);
89
+ fs.utimesSync(liveClaude, now, now);
90
+ run('restore');
91
+ check('hot restart preserves newer local state',
92
+ fs.readFileSync(liveClaude, 'utf8') === '{"turn":"new-local"}\n');
93
+
94
+ // A first deployment can inherit a populated local tree but have no local
95
+ // timestamp yet. Restore must force that tree through one checkpoint instead
96
+ // of assuming every byte came from the durable side.
97
+ fs.rmSync(path.join(local, 'agent-state-stamps/claude'));
98
+ run('restore');
99
+ run('checkpoint');
100
+ check('first checkpoint captures pre-existing newer local state',
101
+ fs.readFileSync(durableClaude, 'utf8') === '{"turn":"new-local"}\n');
102
+
103
+ // SQLite-backed harnesses also have ordinary files (setup/auth/config)
104
+ // before their database is first created. Those files must not disappear
105
+ // merely because there is no database to snapshot yet.
106
+ const openPreDb = path.join(env.OPENCODE_LIVE, 'setup.json');
107
+ const hermesPreDb = path.join(env.HERMES_LIVE, 'config.yaml');
108
+ put(openPreDb, '{"configured":true}\n');
109
+ put(hermesPreDb, 'configured: true\n');
110
+ run('checkpoint');
111
+ check('opencode ordinary files checkpoint before opencode.db exists',
112
+ fs.readFileSync(path.join(env.OPENCODE_DURABLE, 'setup.json'), 'utf8') === '{"configured":true}\n');
113
+ check('Hermes ordinary files checkpoint before state.db exists',
114
+ fs.readFileSync(path.join(env.HERMES_DURABLE, 'config.yaml'), 'utf8') === 'configured: true\n');
115
+
116
+ // Reproduce the Codex access pattern: append while holding the canonical
117
+ // rollout FD open. Because the canonical file is now local, a different
118
+ // process can checkpoint the visible bytes without waiting for close.
119
+ const liveRollout = path.join(env.CODEX_HOME, codexRel);
120
+ const held = spawn(process.execPath, ['-e', `
121
+ const fs = require('fs');
122
+ const fd = fs.openSync(process.argv[1], 'a');
123
+ fs.writeSync(fd, '{"turn":2}\\n');
124
+ process.stdout.write('ready\\n');
125
+ setInterval(() => {}, 1000);
126
+ `, liveRollout], { stdio: ['ignore', 'pipe', 'pipe'] });
127
+ await waitFor(held, 'ready');
128
+ run('checkpoint');
129
+ check('open Codex rollout bytes reach a closed durable checkpoint',
130
+ fs.readFileSync(path.join(env.CODEX_DURABLE, codexRel), 'utf8').endsWith('{"turn":2}\n'));
131
+ held.kill('SIGKILL');
132
+
133
+ fs.rmSync(env.CODEX_HOME, { recursive: true, force: true });
134
+ run('restore');
135
+ check('rollout survives writer crash and fresh-local restore',
136
+ fs.readFileSync(path.join(env.CODEX_HOME, codexRel), 'utf8').endsWith('{"turn":2}\n'));
137
+
138
+ // Keep an opencode writer alive in WAL mode. A raw copy can observe the DB
139
+ // and WAL at different instants; SQLite .backup must still yield one valid
140
+ // database containing the committed row.
141
+ fs.mkdirSync(env.OPENCODE_LIVE, { recursive: true });
142
+ const openDb = path.join(env.OPENCODE_LIVE, 'opencode.db');
143
+ const writer = spawn('sqlite3', [openDb], { stdio: ['pipe', 'pipe', 'pipe'] });
144
+ writer.stdin.write('PRAGMA journal_mode=WAL;\n');
145
+ writer.stdin.write('PRAGMA wal_autocheckpoint=0;\n');
146
+ writer.stdin.write('CREATE TABLE message(id INTEGER PRIMARY KEY, body TEXT);\n');
147
+ writer.stdin.write("INSERT INTO message(body) VALUES ('survives');\n");
148
+ writer.stdin.write('.print writer-ready\n');
149
+ await waitFor(writer, 'writer-ready');
150
+ run('checkpoint');
151
+
152
+ const openCheckpoint = path.join(env.OPENCODE_DURABLE, 'checkpoints/opencode.db');
153
+ check('opencode online backup is structurally valid', sql(openCheckpoint, 'PRAGMA quick_check;') === 'ok');
154
+ check('opencode online backup includes committed WAL content',
155
+ sql(openCheckpoint, 'SELECT body FROM message;') === 'survives');
156
+ check('opencode checkpoint publishes no WAL/SHM companions',
157
+ !fs.existsSync(`${openCheckpoint}-wal`) && !fs.existsSync(`${openCheckpoint}-shm`));
158
+ const idleMarker = new Date('2000-01-01T00:00:00.000Z');
159
+ fs.utimesSync(openCheckpoint, idleMarker, idleMarker);
160
+ run('checkpoint');
161
+ check('idle SQLite database does not generate another durable write',
162
+ fs.statSync(openCheckpoint).mtimeMs === idleMarker.getTime());
163
+ const writerExited = new Promise((resolve) => writer.once('exit', resolve));
164
+ writer.stdin.end();
165
+ await writerExited;
166
+
167
+ sql(openDb, "INSERT INTO message(body) VALUES ('newer-local');");
168
+ run('restore');
169
+ check('hot restart does not replace a newer valid local SQLite database',
170
+ sql(openDb, 'SELECT count(*) FROM message;') === '2');
171
+
172
+ fs.mkdirSync(env.HERMES_LIVE, { recursive: true });
173
+ const hermesDb = path.join(env.HERMES_LIVE, 'state.db');
174
+ sql(hermesDb, 'CREATE TABLE messages(body TEXT); INSERT INTO messages VALUES (\'hello\');');
175
+ run('checkpoint');
176
+ const hermesCheckpoint = path.join(env.HERMES_DURABLE, 'checkpoints/state.db');
177
+ check('Hermes uses the same verified SQLite checkpoint adapter',
178
+ sql(hermesCheckpoint, 'SELECT body FROM messages;') === 'hello');
179
+
180
+ // A bad live DB must not replace the last known-good durable checkpoint.
181
+ const openRowsBeforeCorruption = sql(openCheckpoint, 'SELECT body FROM message ORDER BY id;');
182
+ fs.writeFileSync(openDb, 'not a database');
183
+ let badFailed = false;
184
+ try { run('checkpoint'); } catch { badFailed = true; }
185
+ check('invalid live SQLite makes the checkpoint fail closed', badFailed);
186
+ check('failed SQLite checkpoint leaves prior durable snapshot intact',
187
+ sql(openCheckpoint, 'SELECT body FROM message ORDER BY id;') === openRowsBeforeCorruption);
188
+
189
+ fs.rmSync(env.OPENCODE_LIVE, { recursive: true, force: true });
190
+ run('restore');
191
+ check('SQLite snapshot restores without stale WAL state',
192
+ sql(path.join(env.OPENCODE_LIVE, 'opencode.db'), 'SELECT body FROM message ORDER BY id;') === openRowsBeforeCorruption
193
+ && !fs.existsSync(path.join(env.OPENCODE_LIVE, 'opencode.db-wal')));
194
+ } catch (error) {
195
+ check('no unexpected exception', false, error?.stack || String(error));
196
+ } finally {
197
+ fs.rmSync(root, { recursive: true, force: true });
198
+ }
199
+
200
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall agent-state checks passed');
201
+ process.exit(failures ? 1 : 0);