@evident-ai/runner-synchroniser 0.1.1-dev.96bc2ed → 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 +24 -3
  2. package/dist/cli.js +20 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -115,7 +115,9 @@ Implementation-facing reference for the 7th command: it decides what a just-run
115
115
  | `recovered` | `32` | attempt 2, a successful `prune` (1 key) or any `clear` (including a partial one) |
116
116
  | `unusableReplica(strategy)` | `31` | attempt 2, `leave` |
117
117
  | `unusableReplica(attemptsExhausted)` | `31` | attempt ≥ 3, before the probe |
118
- | `unusableReplica(targetHealthy\|layoutMismatch)` | `31` | attempt 2, `prune` declined to delete anything |
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 |
119
121
  | `unusableReplica(recoveryFailed)` | `31` | attempt 2, `prune`'s delete itself threw |
120
122
  | `fatal(misconfig)` | `30` | no object store while persistence is enabled, or the probe failed on attempt 2 |
121
123
  | `fatal(deliberate)` | `30` | attempt 2, `crash` |
@@ -126,14 +128,33 @@ The probe is a `list`, not a `get`: S3 answers a wrong bucket name with `NoSuchB
126
128
 
127
129
  #### The four `--on-unusable-replica` strategies
128
130
 
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.
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.
130
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.
131
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.
132
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.
133
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
+
134
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.
135
155
 
136
- See `specs/local-runner.feature:49-70` for the behavioural anchor (31 comes online, 30 does not).
156
+ See `specs/local-runner.feature`'s "Recovering session history at startup" scenarios for
157
+ the behavioural anchor (31 comes online, 30 does not).
137
158
 
138
159
  ### Why the contract is asymmetric
139
160
 
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evident-ai/runner-synchroniser",
3
- "version": "0.1.1-dev.96bc2ed",
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",