Spaces:
Running
Running
Leandro von Werra Agent Manager commited on
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 +1 -1
- README.md +5 -2
- docs/agent-state-checkpoints.md +132 -0
- docs/session-sharing.md +6 -5
- docs/trace-panel-spec.md +6 -4
- entrypoint.sh +141 -141
- scripts/agent-state.sh +260 -0
- server/migration.test.mjs +77 -1
- server/package.json +1 -1
- server/src/config.js +1 -1
- server/src/index.js +25 -7
- server/src/runner.js +85 -4
- server/state-checkpoint.test.mjs +201 -0
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
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
| 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**
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
| 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**
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
#
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
export UV_CACHE_DIR="$AM_LOCAL/uv-cache"
|
| 69 |
|
| 70 |
-
#
|
| 71 |
-
#
|
| 72 |
-
#
|
| 73 |
-
#
|
| 74 |
-
#
|
| 75 |
-
#
|
| 76 |
-
#
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 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 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 116 |
-
#
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
#
|
| 125 |
-
#
|
| 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/" "$
|
|
|
|
|
|
|
|
|
|
| 137 |
fi
|
| 138 |
done
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 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/"
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
fi
|
| 170 |
done
|
| 171 |
-
|
| 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 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: {
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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, () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 318 |
-
|
|
|
|
|
|
|
| 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', '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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);
|