@evident-ai/runner-synchroniser 0.1.1-dev.9639f77 → 0.1.1-dev.a63323f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +105 -5
  2. package/dist/cli.js +27 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -62,6 +62,7 @@ version at all.
62
62
  | `sync-once <claude\|opencode>` | — | `0` = ran; non-zero = tool broken |
63
63
  | `model-auth-ready` | — | `0` = ready, `10` = not ready; other = tool broken |
64
64
  | `self-stop` | — | `0` = stopped, `20` = keep the task; other = tool broken |
65
+ | `session-db-classify <litestream-restore-exit-code> <attempt> [--on-unusable-replica=<prune\|leave\|clear\|crash>]` | — | `0` = restored/no-replica/disabled, `32` = re-run and ask again, `31` = unusable (booted fresh, skip replicate), `30` = fatal; other = usage/tool broken |
65
66
 
66
67
  `self-stop` scales this agent's own ECS service to `desiredCount=0` on a clean idle exit
67
68
  (see Configuration for `CLUSTER`/`SERVICE`/`EVIDENT_SELFSTOP_ROLE_ARN`). It exits `0`
@@ -74,6 +75,87 @@ broader: `src/logger.ts` marks it verbatim because operator greps and CloudWatch
74
75
  match on that exact string, so widening what it labels is cheaper than breaking every
75
76
  saved query.
76
77
 
78
+ ### `session-db-classify`
79
+
80
+ Implementation-facing reference for the 7th command: it decides what a just-run `litestream restore` of `opencode.db` means and, on attempt 2 only, may run a recovery strategy against S3. `packages/runner-image/README.md`'s [Strategy/What/Cost table](../runner-image/README.md#when-the-replica-is-unusable-evident_on_unusable_replica) is the operator-facing view of the same command — this section doesn't restate it.
81
+
82
+ #### Positionals
83
+
84
+ | Positional | Meaning |
85
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
86
+ | `<litestream-restore-exit-code>` | Status of the `litestream restore` the shell just ran. `0` → stat the local DB; any non-zero → the recovery ladder below. The value itself is never inspected (a Go panic `2` is treated exactly like a `1`). |
87
+ | `<attempt>` | 1-based (`/^\d+$/`, `>= 1`) — the sole mutation gate. |
88
+
89
+ #### The attempt ladder
90
+
91
+ | Attempt | Behaviour |
92
+ | ------- | ---------------------------------------------------------------- |
93
+ | 1 | Plain retry — **zero S3 calls, not even the probe**. |
94
+ | 2 | The **only** attempt that may probe or mutate S3. |
95
+ | ≥ 3 | Gives up **before** the probe. |
96
+
97
+ `entrypoint.sh` caps its retry loop at a hard-coded `3` as a real second bound: the classifier is stateless per invocation, so nothing except that loop stops attempt 2 from being asked twice.
98
+
99
+ #### `--on-unusable-replica` flag syntax
100
+
101
+ - Only the `=`-joined form (`--on-unusable-replica=leave`); the space-separated form is rejected.
102
+ - Position-free.
103
+ - Repeating the flag is rejected, even with the same value both times.
104
+ - Any other `--` token is rejected.
105
+ - Every rejection exits `2` (`EXIT_USAGE`) with the usage message — **never** a silent fall-back to the destructive default.
106
+
107
+ #### Outcome → exit code
108
+
109
+ | Outcome | Code | When |
110
+ | ----------------------------------------------- | ---- | -------------------------------------------------------------------------------- |
111
+ | `disabled` | `0` | bucket or prefix unset — short-circuits **before** the exit code is examined |
112
+ | `restored` | `0` | restore exit `0` and the local DB is non-empty |
113
+ | `noReplica` | `0` | restore exit `0` but the DB is absent or zero-byte |
114
+ | `retryTransient` | `32` | restore failed, attempt 1 — no probe, no S3 call at all |
115
+ | `recovered` | `32` | attempt 2, a successful `prune` (1 key) or any `clear` (including a partial one) |
116
+ | `unusableReplica(strategy)` | `31` | attempt 2, `leave` |
117
+ | `unusableReplica(attemptsExhausted)` | `31` | attempt ≥ 3, before the probe |
118
+ | `unusableReplica(targetHealthy)` | `31` | attempt 2, `prune`; the newest L0 isn't provably corrupt, so it declined to delete it |
119
+ | `unusableReplica(noL0Present)` | `31` | attempt 2, `prune`; no level-0 object was present to prune — **normal**, see below |
120
+ | `unusableReplica(layoutMismatch)` | `31` | attempt 2, `prune`; objects exist under the prefix but **none** parse as an LTX key at all — **a bug**, see below |
121
+ | `unusableReplica(recoveryFailed)` | `31` | attempt 2, `prune`'s delete itself threw |
122
+ | `fatal(misconfig)` | `30` | no object store while persistence is enabled, or the probe failed on attempt 2 |
123
+ | `fatal(deliberate)` | `30` | attempt 2, `crash` |
124
+
125
+ `31` also guarantees local debris (`opencode.db`, `-wal`, `-shm`) is discarded, one `try` per path, at the classifier's single return point. Note `32` isn't purely "transient" — `recovered` shares it with `retryTransient` even though a `recovered` outcome already mutated S3.
126
+
127
+ The probe is a `list`, not a `get`: S3 answers a wrong bucket name with `NoSuchBucket`, also a 404, so a `get`-based probe would read a misconfigured bucket as healthy and unlock recovery against it.
128
+
129
+ #### The four `--on-unusable-replica` strategies
130
+
131
+ - **`prune` (default)** — ≤1 guarded delete iff the newest L0's own bytes fail the LTX header check (`length >= 100` and magic `"LTX1"`) → `recovered`/`32`; declined (`targetHealthy`/`noL0Present`/`layoutMismatch`) or a failed delete (`recoveryFailed`) → `31`. **Header-only** check: a valid header with deeper corruption is judged sound and left alone — the real trade-off, and **not** "can destroy a healthy L0 object when an older one is corrupt" (false of merged `main`: the delete is unreachable unless the object's own bytes fail the check). `targetHealthy` also covers an unreadable object (absent evidence must not unlock a delete). Never escalates to `clear`, never prunes twice.
132
+ - **`leave`** — zero S3 mutation → `31`. Cost is not just "prior history lost": `entrypoint.sh` also skips `litestream replicate` for the **whole boot**, so this boot's history is ephemeral too. Upside: replica left byte-intact for forensics, no crash loop.
133
+ - **`clear`** — deletes every key under `<prefix>/opencode.db/` passing `isDeletableReplicaKey`; that guard, not the `list()` prefix, is the boundary (IAM grants `s3:DeleteObject*` bucket-wide). One `try` per key. A **partial** clear still reports `recovered`/`32`. Cost: all saved history, unconditionally.
134
+ - **`crash`** — first in the switch, no S3 mutation even considered → `fatal(deliberate)`/`30` → `entrypoint.sh` `die`s → task replaced → **crash loop** until an operator intervenes.
135
+
136
+ #### The measured real-world key layout (litestream 0.5.13)
137
+
138
+ `parseLtxKey` (`src/replica-keys.ts`) expects
139
+ `<prefix>/opencode.db/<level:04d>/<minTxid>-<maxTxid>.ltx` — **no `ltx/` path segment**,
140
+ despite litestream.io's generic docs prose suggesting otherwise (that mismatch was
141
+ #1081: every real key failed to parse, silently disabling `prune` in production).
142
+ Verbatim key sampled live from the production replica:
143
+ `agents/e5d866ac-b699-4940-b369-633b25f8b601/opencode.db/0000/000000000001a978-000000000001a978.ltx`.
144
+ Levels observed on that replica: `0000`/`0001`/`0002`/`0003`/`0009`. The zero-padding is
145
+ an observation, not a contract — `parseLtxKey` accepts an unpadded level directory too.
146
+
147
+ **L0 objects are short-lived.** litestream's own startup log measured a level-0
148
+ retention of **5 minutes** (`starting L0 retention monitor interval=15s retention=5m0s`;
149
+ compaction runs L1=30s, L2=5m, L3=1h, L9=24h). litestream expires L0 objects itself, so
150
+ at boot the newest L0 is normally *absent* (`noL0Present`) or freshly written and sound
151
+ (`targetHealthy`) — `prune`'s real-world reach is narrower than the code alone suggests,
152
+ and it can never repair corruption at a higher compaction level.
153
+
154
+ `EVIDENT_ON_UNUSABLE_REPLICA` is a `packages/runner-image` (entrypoint) variable translated into the flag above at boot — this CLI never reads it, so it has no row in this README's Configuration table below; see [runner-image's README](../runner-image/README.md#when-the-replica-is-unusable-evident_on_unusable_replica). An unrecognised value (including wrong casing, e.g. `Prune`) exits `2`, outside `entrypoint.sh`'s `0|10|20|30|31|32` allow-list — a typo **crash-loops the task on attempt 1** rather than falling back to `prune`, and also logs the misleading "credential persistence is DEGRADED" ERROR.
155
+
156
+ See `specs/local-runner.feature`'s "Recovering session history at startup" scenarios for
157
+ the behavioural anchor (31 comes online, 30 does not).
158
+
77
159
  ### Why the contract is asymmetric
78
160
 
79
161
  `restore` and `sync-once` **never** report a domain outcome through the exit code. A
@@ -97,6 +179,8 @@ that it has no auth. `self-stop` decides whether a task may go away, so a broken
97
179
  must never kill one: `entrypoint.sh` reads any non-zero exactly like `20` and keeps the
98
180
  task, and only a `0` — returned solely on a confirmed `desiredCount` of 0 — lets it stop.
99
181
 
182
+ `session-db-classify` is neither a domain outcome nor a predicate — it's a **third category**: a typed classification with four actionable answers (`0` ran, `32` retry, `31` unusable, `30` fatal), numbered above the two predicates' codes so they can't collide with a future one (`cli.ts:42-47`; see `diagnostics.ts`'s `sessionDbExitCode` for the single outcome → code mapping). The fail-safe direction inverts here: the two predicates above treat an unexpected code as the *safe* answer, but `entrypoint.sh` treats an unexpected code from `session-db-classify` as **fatal** (it `die`s) — correctly, because a boot that can't classify its own replica must not guess about deleting S3 objects.
183
+
100
184
  `src/shell-contract.json` is the machine-checked source of truth for the command list, and
101
185
  `shell-contract.test.ts` holds **every** shell that speaks it to it — Fargate's
102
186
  `packages/runner-image/entrypoint.sh` and the MicroVM's
@@ -142,10 +226,23 @@ The paths and keys that follow from the bucket/prefix:
142
226
  | OpenCode DB | `$HOME/.local/share/opencode/opencode.db` | `<prefix>/opencode.db` (litestream) |
143
227
 
144
228
  The session database is replicated by litestream itself; this package only generates its
145
- config. `env` emits `CREDS_BUCKET`, `CLAUDE_CREDS`, `OPENCODE_DB_PATH` and
229
+ config. `env` emits `PERSISTENCE_BUCKET`, `CLAUDE_CREDS`, `OPENCODE_DB_PATH` and
146
230
  `CREDS_SYNC_INTERVAL`, each single-quoted so a value containing shell metacharacters is
147
231
  inert when `eval`'d.
148
232
 
233
+ `entrypoint.sh` asserts, right after it `eval`s this output, that each key is *defined*
234
+ (not merely non-empty) and splits the four into two groups: a key is **required** — the
235
+ shell dies naming it — if it has no safe degradation; it is **tolerated** — the shell logs
236
+ a WARNING naming it and carries on — if it does. `OPENCODE_DB_PATH` and
237
+ `CREDS_SYNC_INTERVAL` are required: both are dereferenced for real work (deleting/creating
238
+ the session DB, sizing the sync-loop sleep), so a guessed value would be actively wrong,
239
+ not merely absent. `PERSISTENCE_BUCKET` and `CLAUDE_CREDS` are tolerated: an empty
240
+ `PERSISTENCE_BUCKET` is already the designed "persistence disabled" state, and
241
+ `CLAUDE_CREDS` is dereferenced only in operator-facing message text. If you change
242
+ `renderEnv` to make a required key conditional, the entrypoint will brick every container
243
+ running that version — the runner installs this package from a floating npm tag (`dev` /
244
+ `latest`), so the shell and the CLI can genuinely be different versions (#779, #821).
245
+
149
246
  ## Behaviour worth knowing
150
247
 
151
248
  - **A non-empty local file that is not valid JSON is protected**, never overwritten and
@@ -170,11 +267,14 @@ inert when `eval`'d.
170
267
 
171
268
  ## The `ObjectStore` port
172
269
 
173
- `restore` and `sync` talk to a two-method interface `get(key)` returning `null` when
174
- the object is absent, and `put(key, body)`. S3 vocabulary (`@aws-sdk/client-s3`, the
175
- typed `NoSuchKey`/`NotFound` errors) is confined to the single adapter in
270
+ `restore`/`sync` and `session-db-classify` talk to a four-method interface: `get(key)`
271
+ returning `null` when the object is absent, `put(key, body)`, `list(prefix)` and
272
+ `delete(key)` for session-DB recovery. `list` throws on any error and never maps one to
273
+ `[]` (an empty array means "reached the store, nothing there"); the adapter owns
274
+ pagination, and `delete` is idempotent. S3 vocabulary (`@aws-sdk/client-s3`, the typed
275
+ `NoSuchKey`/`NotFound` errors) is confined to the single adapter in
176
276
  `src/s3-object-store.ts`, so the storage backend can be swapped without touching the
177
- restore/sync logic.
277
+ restore/sync/classify logic.
178
278
 
179
279
  The S3 adapter is the only one that exists. It uses no static credentials — auth is
180
280
  whatever the ambient AWS credential chain resolves (on the Evident runner, the ECS task
package/dist/cli.js CHANGED
@@ -39643,7 +39643,9 @@ function describeUnusableReplica(reason) {
39643
39643
  case "attemptsExhausted":
39644
39644
  return `${base} (recovery did not make the replica usable after retrying; giving up.)`;
39645
39645
  case "layoutMismatch":
39646
- return `${base} (no level-0 LTX object was found under the replica prefix, so --on-unusable-replica=prune had nothing matching its expected layout to delete.)`;
39646
+ return `${base} (objects exist under the replica prefix but NONE of them match the LTX key layout --on-unusable-replica=prune expects \u2014 see the SESSION-DB-REPLICA-LAYOUT-MISMATCH warning above for a sample key. This means our key parsing is wrong, or litestream's on-disk layout changed.)`;
39647
+ case "noL0Present":
39648
+ return `${base} (--on-unusable-replica=prune found no level-0 object to prune. This is expected: litestream expires level-0 objects itself after a few minutes, so at boot the newest one is normally absent. prune only ever targets level 0, so it cannot repair corruption at a higher compaction level.)`;
39647
39649
  case "recoveryFailed":
39648
39650
  return `${base} (the recovery delete itself failed; see the warning above for the S3 error.)`;
39649
39651
  case "targetHealthy":
@@ -39803,7 +39805,7 @@ function replicaDbPrefix(prefix) {
39803
39805
  var LTX_FILENAME = /^([0-9a-fA-F]+)-([0-9a-fA-F]+)\.ltx$/;
39804
39806
  function parseLtxKey(prefix, key) {
39805
39807
  if (key.split("/").includes("..")) return null;
39806
- const root12 = `${replicaDbPrefix(prefix)}ltx/`;
39808
+ const root12 = replicaDbPrefix(prefix);
39807
39809
  if (!key.startsWith(root12)) return null;
39808
39810
  const [levelPart, filename, ...rest] = key.slice(root12.length).split("/");
39809
39811
  if (rest.length > 0 || filename === void 0 || !/^\d+$/.test(levelPart)) return null;
@@ -39826,6 +39828,9 @@ function newestL0Key(prefix, keys) {
39826
39828
  }
39827
39829
  return newest?.key ?? null;
39828
39830
  }
39831
+ function hasParsableLtxKey(prefix, keys) {
39832
+ return keys.some((key) => parseLtxKey(prefix, key) !== null);
39833
+ }
39829
39834
  function isDeletableReplicaKey(prefix, key) {
39830
39835
  if (!key.startsWith(replicaDbPrefix(prefix))) return false;
39831
39836
  return !key.split("/").includes("..");
@@ -49780,12 +49785,22 @@ async function probeReplica(store, prefix, log) {
49780
49785
  return { ok: false, detail };
49781
49786
  }
49782
49787
  }
49788
+ function logLayoutMismatch(prefix, keys, log) {
49789
+ log(
49790
+ `WARNING: SESSION-DB-REPLICA-LAYOUT-MISMATCH: listed ${keys.length} object(s) under the replica prefix ${replicaDbPrefix(prefix)} but none of them match the expected LTX key layout (<prefix>/opencode.db/<level>/<minTxid>-<maxTxid>.ltx). Sample key: ${keys[0]}. --on-unusable-replica=prune cannot select anything to delete until this is fixed.`
49791
+ );
49792
+ }
49783
49793
  async function pruneNewestL0(store, prefix, keys, log) {
49784
49794
  const target = newestL0Key(prefix, keys);
49785
- if (target === null || !isDeletableReplicaKey(prefix, target)) {
49786
- if (target !== null) {
49787
- log(`WARNING: refusing to prune ${target}: failed the replica-key guard.`);
49795
+ if (target === null) {
49796
+ if (keys.length > 0 && !hasParsableLtxKey(prefix, keys)) {
49797
+ logLayoutMismatch(prefix, keys, log);
49798
+ return { deleted: null, reason: "layoutMismatch" };
49788
49799
  }
49800
+ return { deleted: null, reason: "noL0Present" };
49801
+ }
49802
+ if (!isDeletableReplicaKey(prefix, target)) {
49803
+ log(`WARNING: refusing to prune ${target}: failed the replica-key guard.`);
49789
49804
  return { deleted: null, reason: "layoutMismatch" };
49790
49805
  }
49791
49806
  let bytes;
@@ -50104,9 +50119,13 @@ function parseSessionDbClassifyArgs(args) {
50104
50119
  }
50105
50120
  function renderEnv(config) {
50106
50121
  return [
50107
- // Empty when persistence is disabled entrypoint.sh gates the sync loop
50108
- // on this being non-empty, exactly as it did when it computed it itself.
50109
- `CREDS_BUCKET=${shellQuote(config.bucket ?? "")}`,
50122
+ // The bucket NAME, kept (not reduced to a boolean) so an operator can
50123
+ // read it; empty exactly when persistence is disabled (both
50124
+ // LITESTREAM_BUCKET and LITESTREAM_PREFIX must be set — see config.ts's
50125
+ // persistenceEnabled). entrypoint.sh gates BOTH the credential-sync
50126
+ // loop and whether it starts litestream replicate on this being
50127
+ // non-empty.
50128
+ `PERSISTENCE_BUCKET=${shellQuote(config.bucket ?? "")}`,
50110
50129
  `CLAUDE_CREDS=${shellQuote(config.claude.path)}`,
50111
50130
  `OPENCODE_DB_PATH=${shellQuote(config.opencodeDbPath)}`,
50112
50131
  `CREDS_SYNC_INTERVAL=${shellQuote(String(config.syncIntervalSeconds))}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evident-ai/runner-synchroniser",
3
- "version": "0.1.1-dev.9639f77",
3
+ "version": "0.1.1-dev.a63323f",
4
4
  "description": "Restores and syncs the Evident runner's OpenCode credential stores (and litestream config) to an object store, so a runner survives task replacement with almost no state loss.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",