@evident-ai/runner-synchroniser 0.1.1-dev.d8865c7 → 3.1.1-dev.297b927

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 +174 -12
  2. package/dist/cli.js +687 -211
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -23,7 +23,7 @@ bundle directly (`node cli.js <command>`).
23
23
  ## Install
24
24
 
25
25
  The package is published to npm as `@evident-ai/runner-synchroniser`, exposing one
26
- `runner-synchroniser` binary and requiring Node >= 22:
26
+ `runner-synchroniser` binary and requiring Node >= 22.13.0:
27
27
 
28
28
  ```bash
29
29
  npx @evident-ai/runner-synchroniser@dev env
@@ -62,7 +62,8 @@ 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
+ | `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, starts a fresh backup chain), `30` = fatal; other = usage/tool broken |
66
+ | `session-db-verify <litestream-config-path>` | — | `0` = healthy/skipped/walked-back, `33` = every retained restore point was exhausted (booted fresh, starts a fresh backup chain); other = usage/tool broken |
66
67
 
67
68
  `self-stop` scales this agent's own ECS service to `desiredCount=0` on a clean idle exit
68
69
  (see Configuration for `CLUSTER`/`SERVICE`/`EVIDENT_SELFSTOP_ROLE_ARN`). It exits `0`
@@ -115,8 +116,11 @@ Implementation-facing reference for the 7th command: it decides what a just-run
115
116
  | `recovered` | `32` | attempt 2, a successful `prune` (1 key) or any `clear` (including a partial one) |
116
117
  | `unusableReplica(strategy)` | `31` | attempt 2, `leave` |
117
118
  | `unusableReplica(attemptsExhausted)` | `31` | attempt ≥ 3, before the probe |
118
- | `unusableReplica(targetHealthy\|layoutMismatch)` | `31` | attempt 2, `prune` declined to delete anything |
119
- | `unusableReplica(recoveryFailed)` | `31` | attempt 2, `prune`'s delete itself threw |
119
+ | `unusableReplica(targetHealthy)` | `31` | attempt 2, `prune`; the newest L0 isn't provably corrupt AND the escalation declined (unreadable, or nothing parses) |
120
+ | `unusableReplica(noL0Present)` | `31` | attempt 2, `prune`; the prefix is **empty**, so there is nothing to prune and nothing to quarantine |
121
+ | `unusableReplica(layoutMismatch)` | `31` | attempt 2, `prune`; objects exist under the prefix but **none** parse as an LTX key at all — **a bug**, see below |
122
+ | `unusableReplica(recoveryFailed)` | `31` | attempt 2, `prune`'s delete itself threw, or the quarantine escalation moved nothing |
123
+ | `recovered(quarantine)` | `32` | attempt 2, `prune` found nothing at L0 but the damage is deeper: the replica was moved aside |
120
124
  | `fatal(misconfig)` | `30` | no object store while persistence is enabled, or the probe failed on attempt 2 |
121
125
  | `fatal(deliberate)` | `30` | attempt 2, `crash` |
122
126
 
@@ -124,16 +128,171 @@ Implementation-facing reference for the 7th command: it decides what a just-run
124
128
 
125
129
  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.
126
130
 
131
+ #### Boot size signals
132
+
133
+ Two markers report the numbers a boot already computes but used to throw away (#1113):
134
+
135
+ | Marker | Emitted from | When it fires | Fields |
136
+ | --- | --- | --- | --- |
137
+ | `SESSION-DB-SIZE` | `describeSessionDbClassification` (`diagnostics.ts`) | Once per `session-db-classify` invocation that reaches `restored` or `noReplica` — i.e. every boot where the restore exit code was `0`. `disabled` and every non-zero-exit outcome (`retryTransient`, `recovered`, `unusableReplica`, `fatal`) never `stat` the local DB, so they emit no size. | `state` (`restored`\|`empty`\|`absent`), `bytes` |
138
+ | `SESSION-DB-REPLICA-SIZE` | `probeReplica` (`replica-recovery.ts`), its `ok` branch | Only on attempt 2's probe, i.e. only after a **failed** first restore — see the caveat below. | `objects`, `bytes` |
139
+
140
+ **Format convention:** `<MARKER>: <prose> <k>=<v> … bytes=<n>`, with the machine-readable
141
+ fields appended at the end of the line and `bytes` always last:
142
+
143
+ ```
144
+ SESSION-DB-SIZE: <prose> state=<restored|empty|absent> bytes=<n>
145
+ SESSION-DB-REPLICA-SIZE: <prose> objects=<m> bytes=<n>
146
+ ```
147
+
148
+ Numbers go last because a CloudWatch Logs Insights `parse` capture needs a **literal
149
+ delimiter after every capture except the final one**, which runs to end of line — free
150
+ prose after a capture would make the pattern ambiguous. `diagnostics.test.ts` and
151
+ `replica-recovery.test.ts` each pin a line matching `/ (state=\w+ )?bytes=\d+$/`, so an
152
+ edit that appends prose after the number breaks a test rather than silently breaking an
153
+ operator's saved query.
154
+
155
+ **Logs Insights queries** (copy-pasteable, and the reason the format above is fixed):
156
+
157
+ Session-DB size distribution across the fleet:
158
+
159
+ ```
160
+ fields @timestamp, @message
161
+ | parse @message "SESSION-DB-SIZE: * state=* bytes=*" as dbProse, dbState, dbBytes
162
+ | filter ispresent(dbBytes)
163
+ | stats count(*) as boots,
164
+ avg(dbBytes) as avgBytes,
165
+ pct(dbBytes, 50) as p50Bytes,
166
+ pct(dbBytes, 95) as p95Bytes,
167
+ max(dbBytes) as maxBytes
168
+ by bin(1d)
169
+ | sort @timestamp desc
170
+ ```
171
+
172
+ Growth curve, and how many boots restored nothing:
173
+
174
+ ```
175
+ fields @timestamp, @message
176
+ | parse @message "SESSION-DB-SIZE: * state=* bytes=*" as dbProse, dbState, dbBytes
177
+ | filter ispresent(dbState)
178
+ | stats count(*) as boots, max(dbBytes) as maxBytes by dbState, bin(1d)
179
+ ```
180
+
181
+ Replica size + object count (ECS, failed-first-restore boots only — see the caveat below):
182
+
183
+ ```
184
+ fields @timestamp, @message
185
+ | parse @message "SESSION-DB-REPLICA-SIZE: * objects=* bytes=*" as replicaProse, replicaObjects, replicaBytes
186
+ | filter ispresent(replicaBytes)
187
+ | stats max(replicaBytes) as maxReplicaBytes, max(replicaObjects) as maxObjects by bin(1d)
188
+ ```
189
+
190
+ **Caveat: the two markers have very different fleet coverage.** `SESSION-DB-SIZE` fires
191
+ on **every healthy boot on both runtimes** and is the fleet-wide growth-curve signal.
192
+ `SESSION-DB-REPLICA-SIZE` fires **only** on ECS's attempt 2 — i.e. only when a first
193
+ restore already failed — and essentially never on the MicroVM, whose hook hard-codes the
194
+ classify attempt to `1` (`common.sh:352`), which never reaches the probe. Chart
195
+ `SESSION-DB-SIZE` for growth; treat `SESSION-DB-REPLICA-SIZE` as a diagnostic for
196
+ failed-restore boots only, or an empty series will look like a broken signal instead of
197
+ what it is.
198
+
127
199
  #### The four `--on-unusable-replica` strategies
128
200
 
129
- - **`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`/`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.
130
- - **`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.
201
+ - **`prune` (default)** — ≤1 guarded delete iff the newest L0's own bytes fail the LTX header check (`length >= 100` and magic `"LTX1"`) → `recovered`/`32`; a failed delete (`recoveryFailed`) or a declined escalation → `31`. **Header-only** check: a valid header with deeper corruption is judged sound and left alone by the *delete* and since #1106 that is no longer the end of the road. When prune finds nothing to delete (`targetHealthy`/`noL0Present`) **and** at least one key parses as an LTX object, it **escalates to quarantine**: every object under `<prefix>/opencode.db/` is moved to `<prefix>/quarantine/opencode.db/<timestamp>/` → `recovered(quarantine)`/`32`. It does **not** escalate on `layoutMismatch` (we don't understand the layout), on an unreadable object (absent evidence), or on an empty prefix. Never prunes twice.
202
+ - **`leave`** — zero S3 mutation → `31`. Cost is "prior history lost this boot". Since #1106 it is **not** also ephemeral: the boot still starts a fresh backup chain, because the classifier guarantees the local DB is fresh rather than half-restored. Upside: replica left byte-intact for forensics, no crash loop.
131
203
  - **`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.
132
204
  - **`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.
133
205
 
134
- `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.
206
+ #### The measured real-world key layout (litestream 0.5.13)
207
+
208
+ `parseLtxKey` (`src/replica-keys.ts`) expects
209
+ `<prefix>/opencode.db/<level:04d>/<minTxid>-<maxTxid>.ltx` — **no `ltx/` path segment**,
210
+ despite litestream.io's generic docs prose suggesting otherwise (that mismatch was
211
+ #1081: every real key failed to parse, silently disabling `prune` in production).
212
+ Verbatim key sampled live from the production replica:
213
+ `agents/e5d866ac-b699-4940-b369-633b25f8b601/opencode.db/0000/000000000001a978-000000000001a978.ltx`.
214
+ Levels observed on that replica: `0000`/`0001`/`0002`/`0003`/`0009`. The zero-padding is
215
+ an observation, not a contract — `parseLtxKey` accepts an unpadded level directory too.
216
+
217
+ **L0 objects are short-lived.** litestream's own startup log measured a level-0
218
+ retention of **5 minutes** (`starting L0 retention monitor interval=15s retention=5m0s`;
219
+ compaction runs L1=30s, L2=5m, L3=1h, L9=24h). litestream expires L0 objects itself, so
220
+ at boot the newest L0 is normally *absent* (`noL0Present`) or freshly written and sound
221
+ (`targetHealthy`) — `prune`'s real-world reach is narrower than the code alone suggests,
222
+ and it can never repair corruption at a higher compaction level.
223
+
224
+ `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|33` 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.
225
+
226
+ See `specs/local-runner.feature`'s "Recovering session history at startup" scenarios for
227
+ the behavioural anchor (31 comes online, 30 does not).
228
+
229
+ ### `session-db-verify`
230
+
231
+ The 8th command: it runs *after* the restore loop above has settled and *before*
232
+ `opencode` opens the DB. The restore loop only proves litestream could write a file;
233
+ this command is what proves that file is actually intact (#1345 — a leaf-page-corrupt
234
+ but openable `opencode.db` was re-restored unchanged every boot, forever, because
235
+ nothing had ever run a real `PRAGMA integrity_check`).
236
+
237
+ #### Positional
238
+
239
+ | Positional | Meaning |
240
+ | --- | --- |
241
+ | `<litestream-config-path>` | Path to the `litestream.yml` this CLI itself generated (`litestream-config`); used to enumerate and restore from retained points. |
242
+
243
+ #### What it does
244
+
245
+ 1. `PRAGMA integrity_check` the local `opencode.db`. Healthy → done, `0`.
246
+ 2. On failure, enumerate litestream's retained restore points (`litestream ltx`) and
247
+ walk them back from newest to oldest (excluding the one that just failed), restoring
248
+ each into a scratch path and integrity-checking it there. The first one that passes
249
+ is adopted — renamed over `opencode.db`, replacing it and its stale sidecars.
250
+ 3. If nothing retained passes (or there's nothing to try, or the search runs out of
251
+ time/points), the corrupt local DB is discarded and the boot proceeds with a
252
+ genuinely fresh one — exactly `session-db-classify`'s `31` guarantee, so
253
+ `litestream replicate` starting against it afterwards is just as safe.
254
+
255
+ A walked-back boot keeps replicating into the **same** replica — no S3 mutation, no new
256
+ prefix. litestream re-bases to the replica's high-water mark and continues the txid
257
+ chain from there; the pre-walkback lineage stays independently restorable at its old
258
+ txids. (Measured end-to-end against a `file` replica with a tiny DB; not measured
259
+ against S3 at the ~1 GB scale a production replica can reach.)
260
+
261
+ #### Outcome → exit code
135
262
 
136
- See `specs/local-runner.feature:49-70` for the behavioural anchor (31 comes online, 30 does not).
263
+ | Outcome | Code | When |
264
+ | --- | --- | --- |
265
+ | `skipped` | `0` | local DB absent or zero bytes — nothing to verify |
266
+ | `healthy` | `0` | the local DB passed its integrity check as-is |
267
+ | `walkedBack` | `0` | the local DB failed, but an older retained restore point passed and was adopted |
268
+ | `exhausted` (`noCandidates`) | `33` | the local DB failed and litestream retains no older restore point |
269
+ | `exhausted` (`allFailed`) | `33` | every retained restore point was tried and every one failed |
270
+ | `exhausted` (`budgetExhausted`) | `33` | the search stopped on `EVIDENT_SESSION_DB_WALKBACK_MAX_POINTS`/`_BUDGET_SECONDS` before trying every retained point |
271
+ | `exhausted` (`enumerationFailed`) | `33` | `litestream ltx` could not be read (bad exit, non-JSON, malformed listing) |
272
+
273
+ `healthy`/`walkedBack`/`skipped` all share exit `0` deliberately: the shell's behaviour
274
+ afterwards — start `litestream replicate` — is identical either way, so a further split
275
+ would be a distinction with no consumer. `noCandidates`/`allFailed` are kept distinct
276
+ from `budgetExhausted` for the same reason `unusableReplica`'s reasons are kept distinct
277
+ above: conflating "we looked and nothing was good" with "we ran out of time/points
278
+ before looking at everything" would report the wrong conclusion to the operator.
279
+
280
+ #### Boot markers
281
+
282
+ | Marker | Emitted from | When it fires | Fields |
283
+ | --- | --- | --- | --- |
284
+ | `WARNING: SESSION-DB-INTEGRITY-FAILED` | `verifySessionDb` (`session-db-verify.ts`) | The local DB fails its check, before the walkback starts | — (prose carries the PRAGMA's own detail) |
285
+ | `WARNING: SESSION-DB-WALKBACK-ENUMERATION-FAILED` | the walkback loop (`session-db-verify.ts`) | `litestream ltx` exited non-zero (prose carries its stderr — missing binary, bad `-config`, S3 error) or answered with a listing that could not be parsed | — (prose carries litestream's own stderr) |
286
+ | `WARNING: SESSION-DB-WALKBACK-CANDIDATE-FAILED` | the walkback loop (`session-db-verify.ts`) | A candidate restore point fails to restore, or restores but also fails its own check | `txid` |
287
+ | `INFO: SESSION-DB-WALKBACK-ADOPTED` | `adopt` (`session-db-verify.ts`) | A candidate passes and is adopted over `opencode.db` | `txid`, `candidates`, `bytes` |
288
+ | `SESSION-DB-INTEGRITY` | `describeSessionDbVerification` (`diagnostics.ts`), logged once per invocation as the final outcome | The local DB passed its check as-is | `bytes` |
289
+ | `SESSION-DB-INTEGRITY-WALKBACK` | `describeSessionDbVerification` (`diagnostics.ts`), logged once per invocation as the final outcome | Same event as `SESSION-DB-WALKBACK-ADOPTED` above, restated as the command's outcome | `txid`, `candidates`, `bytes` |
290
+ | `SESSION-DB-INTEGRITY-EXHAUSTED` | `describeSessionDbVerification` (`diagnostics.ts`), logged once per invocation as the final outcome | Nothing retained passed (any `exhausted` reason); the boot proceeds with a fresh DB | `candidatesTried` (in prose, not `k=v`) |
291
+
292
+ The first four fire only while the walkback loop runs; the last three are always the
293
+ one message this command logs as its own final word on the outcome — `entrypoint.sh`
294
+ reuses `SESSION-DB-INTEGRITY-EXHAUSTED` verbatim when it logs its own `33` handling, the
295
+ same way it already reuses `SESSION-DB-REPLICA-UNUSABLE` for `session-db-classify`'s `31`.
137
296
 
138
297
  ### Why the contract is asymmetric
139
298
 
@@ -186,6 +345,8 @@ key, so they never appear in `env`'s output or `litestream.yml`.
186
345
  | `LITESTREAM_BUCKET` / `LITESTREAM_PREFIX` | Object-store bucket and key prefix. | Persistence off (needs **both**): `restore`/`sync-once` no-op, `env` warns. |
187
346
  | `AWS_REGION` | Region for the AWS clients (S3, and the ECS/STS calls `self-stop` makes). | Left to the SDK's own resolution. |
188
347
  | `CREDS_SYNC_INTERVAL` | Seconds between sync ticks, reported by `env` for the caller's loop. | Falls back to `60`; also on unparseable or non-positive values. |
348
+ | `EVIDENT_SESSION_DB_WALKBACK_MAX_POINTS` | Distinct restore points `session-db-verify`'s walkback will try before giving up. | Falls back to `10`; also on unparseable or non-positive values. |
349
+ | `EVIDENT_SESSION_DB_WALKBACK_BUDGET_SECONDS` | Wall-clock budget, in seconds, for the whole walkback loop. | Falls back to `180`; also on unparseable or non-positive values. |
189
350
  | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | Presence alone counts as configured model auth (see `model-auth-ready`). | No API-key fallback; `model-auth-ready` then depends solely on the credential files. |
190
351
  | `CLUSTER` † / `SERVICE` † | ECS cluster/service `self-stop` scales to `desiredCount=0`. | Warns "cannot self-stop" and exits `20` (keep the task). Ignored by every other command. |
191
352
  | `EVIDENT_SELFSTOP_ROLE_ARN` † | Role `self-stop` assumes for its ECS calls. | Optional: falls back to the task role's own credentials. A failed/incomplete assume-role → `20`. |
@@ -248,10 +409,11 @@ running that version — the runner installs this package from a floating npm ta
248
409
 
249
410
  `restore`/`sync` and `session-db-classify` talk to a four-method interface: `get(key)`
250
411
  returning `null` when the object is absent, `put(key, body)`, `list(prefix)` and
251
- `delete(key)` for session-DB recovery. `list` throws on any error and never maps one to
252
- `[]` (an empty array means "reached the store, nothing there"); the adapter owns
253
- pagination, and `delete` is idempotent. S3 vocabulary (`@aws-sdk/client-s3`, the typed
254
- `NoSuchKey`/`NotFound` errors) is confined to the single adapter in
412
+ `delete(key)` for session-DB recovery. `list(prefix)` returns `{ key, size }` entries
413
+ (`ObjectSummary[]`) every object under `prefix`, size in bytes. It throws on any error
414
+ and never maps one to `[]` (an empty array means "reached the store, nothing there"); the
415
+ adapter owns pagination, and `delete` is idempotent. S3 vocabulary (`@aws-sdk/client-s3`,
416
+ the typed `NoSuchKey`/`NotFound` errors) is confined to the single adapter in
255
417
  `src/s3-object-store.ts`, so the storage backend can be swapped without touching the
256
418
  restore/sync/classify logic.
257
419