@evident-ai/runner-synchroniser 0.1.1-dev.96bc2ed → 0.1.1-dev.9d0282a
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.
- package/README.md +101 -10
- package/dist/cli.js +154 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +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,
|
|
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
66
|
|
|
67
67
|
`self-stop` scales this agent's own ECS service to `desiredCount=0` on a clean idle exit
|
|
68
68
|
(see Configuration for `CLUSTER`/`SERVICE`/`EVIDENT_SELFSTOP_ROLE_ARN`). It exits `0`
|
|
@@ -115,8 +115,11 @@ 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
|
|
119
|
-
| `unusableReplica(
|
|
118
|
+
| `unusableReplica(targetHealthy)` | `31` | attempt 2, `prune`; the newest L0 isn't provably corrupt AND the escalation declined (unreadable, or nothing parses) |
|
|
119
|
+
| `unusableReplica(noL0Present)` | `31` | attempt 2, `prune`; the prefix is **empty**, so there is nothing to prune and nothing to quarantine |
|
|
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, or the quarantine escalation moved nothing |
|
|
122
|
+
| `recovered(quarantine)` | `32` | attempt 2, `prune` found nothing at L0 but the damage is deeper: the replica was moved aside |
|
|
120
123
|
| `fatal(misconfig)` | `30` | no object store while persistence is enabled, or the probe failed on attempt 2 |
|
|
121
124
|
| `fatal(deliberate)` | `30` | attempt 2, `crash` |
|
|
122
125
|
|
|
@@ -124,16 +127,103 @@ Implementation-facing reference for the 7th command: it decides what a just-run
|
|
|
124
127
|
|
|
125
128
|
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
129
|
|
|
130
|
+
#### Boot size signals
|
|
131
|
+
|
|
132
|
+
Two markers report the numbers a boot already computes but used to throw away (#1113):
|
|
133
|
+
|
|
134
|
+
| Marker | Emitted from | When it fires | Fields |
|
|
135
|
+
| --- | --- | --- | --- |
|
|
136
|
+
| `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` |
|
|
137
|
+
| `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` |
|
|
138
|
+
|
|
139
|
+
**Format convention:** `<MARKER>: <prose> <k>=<v> … bytes=<n>`, with the machine-readable
|
|
140
|
+
fields appended at the end of the line and `bytes` always last:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
SESSION-DB-SIZE: <prose> state=<restored|empty|absent> bytes=<n>
|
|
144
|
+
SESSION-DB-REPLICA-SIZE: <prose> objects=<m> bytes=<n>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Numbers go last because a CloudWatch Logs Insights `parse` capture needs a **literal
|
|
148
|
+
delimiter after every capture except the final one**, which runs to end of line — free
|
|
149
|
+
prose after a capture would make the pattern ambiguous. `diagnostics.test.ts` and
|
|
150
|
+
`replica-recovery.test.ts` each pin a line matching `/ (state=\w+ )?bytes=\d+$/`, so an
|
|
151
|
+
edit that appends prose after the number breaks a test rather than silently breaking an
|
|
152
|
+
operator's saved query.
|
|
153
|
+
|
|
154
|
+
**Logs Insights queries** (copy-pasteable, and the reason the format above is fixed):
|
|
155
|
+
|
|
156
|
+
Session-DB size distribution across the fleet:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
fields @timestamp, @message
|
|
160
|
+
| parse @message "SESSION-DB-SIZE: * state=* bytes=*" as dbProse, dbState, dbBytes
|
|
161
|
+
| filter ispresent(dbBytes)
|
|
162
|
+
| stats count(*) as boots,
|
|
163
|
+
avg(dbBytes) as avgBytes,
|
|
164
|
+
pct(dbBytes, 50) as p50Bytes,
|
|
165
|
+
pct(dbBytes, 95) as p95Bytes,
|
|
166
|
+
max(dbBytes) as maxBytes
|
|
167
|
+
by bin(1d)
|
|
168
|
+
| sort @timestamp desc
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Growth curve, and how many boots restored nothing:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
fields @timestamp, @message
|
|
175
|
+
| parse @message "SESSION-DB-SIZE: * state=* bytes=*" as dbProse, dbState, dbBytes
|
|
176
|
+
| filter ispresent(dbState)
|
|
177
|
+
| stats count(*) as boots, max(dbBytes) as maxBytes by dbState, bin(1d)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Replica size + object count (ECS, failed-first-restore boots only — see the caveat below):
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
fields @timestamp, @message
|
|
184
|
+
| parse @message "SESSION-DB-REPLICA-SIZE: * objects=* bytes=*" as replicaProse, replicaObjects, replicaBytes
|
|
185
|
+
| filter ispresent(replicaBytes)
|
|
186
|
+
| stats max(replicaBytes) as maxReplicaBytes, max(replicaObjects) as maxObjects by bin(1d)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Caveat: the two markers have very different fleet coverage.** `SESSION-DB-SIZE` fires
|
|
190
|
+
on **every healthy boot on both runtimes** and is the fleet-wide growth-curve signal.
|
|
191
|
+
`SESSION-DB-REPLICA-SIZE` fires **only** on ECS's attempt 2 — i.e. only when a first
|
|
192
|
+
restore already failed — and essentially never on the MicroVM, whose hook hard-codes the
|
|
193
|
+
classify attempt to `1` (`common.sh:352`), which never reaches the probe. Chart
|
|
194
|
+
`SESSION-DB-SIZE` for growth; treat `SESSION-DB-REPLICA-SIZE` as a diagnostic for
|
|
195
|
+
failed-restore boots only, or an empty series will look like a broken signal instead of
|
|
196
|
+
what it is.
|
|
197
|
+
|
|
127
198
|
#### The four `--on-unusable-replica` strategies
|
|
128
199
|
|
|
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`;
|
|
130
|
-
- **`leave`** — zero S3 mutation → `31`. Cost is
|
|
200
|
+
- **`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.
|
|
201
|
+
- **`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
202
|
- **`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
203
|
- **`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
204
|
|
|
205
|
+
#### The measured real-world key layout (litestream 0.5.13)
|
|
206
|
+
|
|
207
|
+
`parseLtxKey` (`src/replica-keys.ts`) expects
|
|
208
|
+
`<prefix>/opencode.db/<level:04d>/<minTxid>-<maxTxid>.ltx` — **no `ltx/` path segment**,
|
|
209
|
+
despite litestream.io's generic docs prose suggesting otherwise (that mismatch was
|
|
210
|
+
#1081: every real key failed to parse, silently disabling `prune` in production).
|
|
211
|
+
Verbatim key sampled live from the production replica:
|
|
212
|
+
`agents/e5d866ac-b699-4940-b369-633b25f8b601/opencode.db/0000/000000000001a978-000000000001a978.ltx`.
|
|
213
|
+
Levels observed on that replica: `0000`/`0001`/`0002`/`0003`/`0009`. The zero-padding is
|
|
214
|
+
an observation, not a contract — `parseLtxKey` accepts an unpadded level directory too.
|
|
215
|
+
|
|
216
|
+
**L0 objects are short-lived.** litestream's own startup log measured a level-0
|
|
217
|
+
retention of **5 minutes** (`starting L0 retention monitor interval=15s retention=5m0s`;
|
|
218
|
+
compaction runs L1=30s, L2=5m, L3=1h, L9=24h). litestream expires L0 objects itself, so
|
|
219
|
+
at boot the newest L0 is normally *absent* (`noL0Present`) or freshly written and sound
|
|
220
|
+
(`targetHealthy`) — `prune`'s real-world reach is narrower than the code alone suggests,
|
|
221
|
+
and it can never repair corruption at a higher compaction level.
|
|
222
|
+
|
|
134
223
|
`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
224
|
|
|
136
|
-
See `specs/local-runner.feature
|
|
225
|
+
See `specs/local-runner.feature`'s "Recovering session history at startup" scenarios for
|
|
226
|
+
the behavioural anchor (31 comes online, 30 does not).
|
|
137
227
|
|
|
138
228
|
### Why the contract is asymmetric
|
|
139
229
|
|
|
@@ -248,10 +338,11 @@ running that version — the runner installs this package from a floating npm ta
|
|
|
248
338
|
|
|
249
339
|
`restore`/`sync` and `session-db-classify` talk to a four-method interface: `get(key)`
|
|
250
340
|
returning `null` when the object is absent, `put(key, body)`, `list(prefix)` and
|
|
251
|
-
`delete(key)` for session-DB recovery. `list`
|
|
252
|
-
`[]`
|
|
253
|
-
|
|
254
|
-
`
|
|
341
|
+
`delete(key)` for session-DB recovery. `list(prefix)` returns `{ key, size }` entries
|
|
342
|
+
(`ObjectSummary[]`) — every object under `prefix`, size in bytes. It throws on any error
|
|
343
|
+
and never maps one to `[]` (an empty array means "reached the store, nothing there"); the
|
|
344
|
+
adapter owns pagination, and `delete` is idempotent. S3 vocabulary (`@aws-sdk/client-s3`,
|
|
345
|
+
the typed `NoSuchKey`/`NotFound` errors) is confined to the single adapter in
|
|
255
346
|
`src/s3-object-store.ts`, so the storage backend can be swapped without touching the
|
|
256
347
|
restore/sync/classify logic.
|
|
257
348
|
|
package/dist/cli.js
CHANGED
|
@@ -39615,6 +39615,9 @@ function assertNeverUnusableReplicaReason(value) {
|
|
|
39615
39615
|
function assertNeverFatalCause(value) {
|
|
39616
39616
|
throw new Error(`Unhandled fatal cause: ${JSON.stringify(value)}`);
|
|
39617
39617
|
}
|
|
39618
|
+
function assertNeverNoReplicaLocalDb(value) {
|
|
39619
|
+
throw new Error(`Unhandled noReplica local-DB state: ${JSON.stringify(value)}`);
|
|
39620
|
+
}
|
|
39618
39621
|
function sessionDbExitCode(outcome) {
|
|
39619
39622
|
switch (outcome.kind) {
|
|
39620
39623
|
case "disabled":
|
|
@@ -39627,7 +39630,7 @@ function sessionDbExitCode(outcome) {
|
|
|
39627
39630
|
// re-run `litestream restore` and ask again
|
|
39628
39631
|
case "unusableReplica":
|
|
39629
39632
|
return 31;
|
|
39630
|
-
// booted with a fresh DB; the shell
|
|
39633
|
+
// booted with a fresh DB; the shell still starts a fresh backup chain (#1106)
|
|
39631
39634
|
case "fatal":
|
|
39632
39635
|
return 30;
|
|
39633
39636
|
// shared by a genuine misconfig and a deliberate `crash` choice
|
|
@@ -39643,7 +39646,9 @@ function describeUnusableReplica(reason) {
|
|
|
39643
39646
|
case "attemptsExhausted":
|
|
39644
39647
|
return `${base} (recovery did not make the replica usable after retrying; giving up.)`;
|
|
39645
39648
|
case "layoutMismatch":
|
|
39646
|
-
return `${base} (
|
|
39649
|
+
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.)`;
|
|
39650
|
+
case "noL0Present":
|
|
39651
|
+
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
39652
|
case "recoveryFailed":
|
|
39648
39653
|
return `${base} (the recovery delete itself failed; see the warning above for the S3 error.)`;
|
|
39649
39654
|
case "targetHealthy":
|
|
@@ -39665,18 +39670,31 @@ function describeSessionDbFatal(outcome, config) {
|
|
|
39665
39670
|
return assertNeverFatalCause(outcome.cause);
|
|
39666
39671
|
}
|
|
39667
39672
|
}
|
|
39673
|
+
function describeNoReplica(localDb) {
|
|
39674
|
+
switch (localDb) {
|
|
39675
|
+
case "absent":
|
|
39676
|
+
return "No opencode.db replica found in the object store; opencode will create a fresh session DB.";
|
|
39677
|
+
case "empty":
|
|
39678
|
+
return "litestream restore succeeded but left a zero-byte opencode.db; opencode will create a fresh session DB.";
|
|
39679
|
+
default:
|
|
39680
|
+
return assertNeverNoReplicaLocalDb(localDb);
|
|
39681
|
+
}
|
|
39682
|
+
}
|
|
39668
39683
|
function describeSessionDbClassification(outcome, config) {
|
|
39669
39684
|
switch (outcome.kind) {
|
|
39670
39685
|
case "disabled":
|
|
39671
39686
|
return "No opencode.db restore attempted; credential persistence is disabled (LITESTREAM_BUCKET/LITESTREAM_PREFIX unset).";
|
|
39672
39687
|
case "restored":
|
|
39673
|
-
return
|
|
39688
|
+
return `SESSION-DB-SIZE: opencode.db restored from the object store. state=restored bytes=${outcome.bytes}`;
|
|
39674
39689
|
case "noReplica":
|
|
39675
|
-
return
|
|
39690
|
+
return `SESSION-DB-SIZE: ${describeNoReplica(outcome.localDb)} state=${outcome.localDb} bytes=0`;
|
|
39676
39691
|
case "retryTransient":
|
|
39677
39692
|
return "opencode.db replica restore failed (transient); retrying.";
|
|
39678
39693
|
case "recovered": {
|
|
39679
39694
|
const objects = outcome.deleted.length === 0 ? "no objects (see the warnings above for what failed)" : `${outcome.deleted.length} object${outcome.deleted.length === 1 ? "" : "s"} (${outcome.deleted.join(", ")})`;
|
|
39695
|
+
if (outcome.strategy === "quarantine") {
|
|
39696
|
+
return `Escalated opencode.db replica recovery to quarantine: --on-unusable-replica=prune found nothing at level 0 to delete, so the corruption is deeper than prune can reach. Moved ${objects} aside; retrying the restore. Prior session history is set aside, NOT deleted \u2014 see the SESSION-DB-REPLICA-QUARANTINED warning above for where.`;
|
|
39697
|
+
}
|
|
39680
39698
|
return `Attempted opencode.db replica recovery via --on-unusable-replica=${outcome.strategy}, deleting ${objects}; retrying the restore.` + (outcome.strategy === "clear" ? " Prior session history is lost." : "");
|
|
39681
39699
|
}
|
|
39682
39700
|
case "unusableReplica":
|
|
@@ -39803,7 +39821,7 @@ function replicaDbPrefix(prefix) {
|
|
|
39803
39821
|
var LTX_FILENAME = /^([0-9a-fA-F]+)-([0-9a-fA-F]+)\.ltx$/;
|
|
39804
39822
|
function parseLtxKey(prefix, key) {
|
|
39805
39823
|
if (key.split("/").includes("..")) return null;
|
|
39806
|
-
const root12 =
|
|
39824
|
+
const root12 = replicaDbPrefix(prefix);
|
|
39807
39825
|
if (!key.startsWith(root12)) return null;
|
|
39808
39826
|
const [levelPart, filename, ...rest] = key.slice(root12.length).split("/");
|
|
39809
39827
|
if (rest.length > 0 || filename === void 0 || !/^\d+$/.test(levelPart)) return null;
|
|
@@ -39826,6 +39844,9 @@ function newestL0Key(prefix, keys) {
|
|
|
39826
39844
|
}
|
|
39827
39845
|
return newest?.key ?? null;
|
|
39828
39846
|
}
|
|
39847
|
+
function hasParsableLtxKey(prefix, keys) {
|
|
39848
|
+
return keys.some((key) => parseLtxKey(prefix, key) !== null);
|
|
39849
|
+
}
|
|
39829
39850
|
function isDeletableReplicaKey(prefix, key) {
|
|
39830
39851
|
if (!key.startsWith(replicaDbPrefix(prefix))) return false;
|
|
39831
39852
|
return !key.split("/").includes("..");
|
|
@@ -43392,6 +43413,12 @@ var _ep011 = {
|
|
|
43392
43413
|
Bucket: { type: "contextParams", name: "Bucket" },
|
|
43393
43414
|
Key: { type: "contextParams", name: "Key" }
|
|
43394
43415
|
};
|
|
43416
|
+
var _ep13 = {
|
|
43417
|
+
DisableS3ExpressSessionAuth: { type: "staticContextParams", value: true },
|
|
43418
|
+
Bucket: { type: "contextParams", name: "Bucket" },
|
|
43419
|
+
Key: { type: "contextParams", name: "Key" },
|
|
43420
|
+
CopySource: { type: "contextParams", name: "CopySource" }
|
|
43421
|
+
};
|
|
43395
43422
|
var _ep4 = {
|
|
43396
43423
|
DisableS3ExpressSessionAuth: { type: "staticContextParams", value: true },
|
|
43397
43424
|
Bucket: { type: "contextParams", name: "Bucket" }
|
|
@@ -43403,6 +43430,10 @@ var _ep8 = {
|
|
|
43403
43430
|
var _mw011 = (Command3, cs, config, o7) => [
|
|
43404
43431
|
getThrow200ExceptionsPlugin(config)
|
|
43405
43432
|
];
|
|
43433
|
+
var _mw1 = (Command3, cs, config, o7) => [
|
|
43434
|
+
getThrow200ExceptionsPlugin(config),
|
|
43435
|
+
getSsecPlugin(config)
|
|
43436
|
+
];
|
|
43406
43437
|
var _mw7 = (Command3, cs, config, o7) => [
|
|
43407
43438
|
getFlexibleChecksumsPlugin(config, {
|
|
43408
43439
|
requestChecksumRequired: false,
|
|
@@ -49676,6 +49707,11 @@ var S3Client = class extends Client {
|
|
|
49676
49707
|
}
|
|
49677
49708
|
};
|
|
49678
49709
|
|
|
49710
|
+
// ../../node_modules/.pnpm/@aws-sdk+client-s3@3.1095.0/node_modules/@aws-sdk/client-s3/dist-es/commands/CopyObjectCommand.js
|
|
49711
|
+
init_esm_shims();
|
|
49712
|
+
var CopyObjectCommand = class extends command11(_ep13, _mw1, "CopyObject", CopyObject$) {
|
|
49713
|
+
};
|
|
49714
|
+
|
|
49679
49715
|
// ../../node_modules/.pnpm/@aws-sdk+client-s3@3.1095.0/node_modules/@aws-sdk/client-s3/dist-es/commands/DeleteObjectCommand.js
|
|
49680
49716
|
init_esm_shims();
|
|
49681
49717
|
var DeleteObjectCommand = class extends command11(_ep011, _mw011, "DeleteObject", DeleteObject$) {
|
|
@@ -49726,7 +49762,7 @@ var S3ObjectStore = class {
|
|
|
49726
49762
|
await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body }));
|
|
49727
49763
|
}
|
|
49728
49764
|
async list(prefix) {
|
|
49729
|
-
const
|
|
49765
|
+
const objects = [];
|
|
49730
49766
|
let continuationToken;
|
|
49731
49767
|
do {
|
|
49732
49768
|
const response = await this.client.send(
|
|
@@ -49737,11 +49773,11 @@ var S3ObjectStore = class {
|
|
|
49737
49773
|
})
|
|
49738
49774
|
);
|
|
49739
49775
|
for (const object of response.Contents ?? []) {
|
|
49740
|
-
if (object.Key)
|
|
49776
|
+
if (object.Key) objects.push({ key: object.Key, size: object.Size ?? 0 });
|
|
49741
49777
|
}
|
|
49742
49778
|
continuationToken = response.NextContinuationToken;
|
|
49743
49779
|
} while (continuationToken);
|
|
49744
|
-
return
|
|
49780
|
+
return objects;
|
|
49745
49781
|
}
|
|
49746
49782
|
async delete(key) {
|
|
49747
49783
|
try {
|
|
@@ -49751,6 +49787,30 @@ var S3ObjectStore = class {
|
|
|
49751
49787
|
throw error;
|
|
49752
49788
|
}
|
|
49753
49789
|
}
|
|
49790
|
+
/**
|
|
49791
|
+
* `CopyObject` — S3 copies the bytes server-side, so a 545 MB snapshot never
|
|
49792
|
+
* enters this process (see `ObjectStore.copy`).
|
|
49793
|
+
*
|
|
49794
|
+
* `CopySource` is `<bucket>/<key>` and S3 expects it URL-encoded. `encodeURI`
|
|
49795
|
+
* (not `encodeURIComponent`) is the right one here: it leaves the `/`
|
|
49796
|
+
* separators intact, which S3 needs, while still escaping anything exotic.
|
|
49797
|
+
* Every key this is called with has already passed `isDeletableReplicaKey`,
|
|
49798
|
+
* so it is `<prefix>/opencode.db/…` with no `..` segment and no `%` to
|
|
49799
|
+
* double-encode.
|
|
49800
|
+
*
|
|
49801
|
+
* Deliberately does NOT swallow a not-found the way `delete` does: the only
|
|
49802
|
+
* caller deletes the source immediately afterwards, so a copy that quietly
|
|
49803
|
+
* did nothing would destroy the object it was meant to preserve.
|
|
49804
|
+
*/
|
|
49805
|
+
async copy(fromKey, toKey) {
|
|
49806
|
+
await this.client.send(
|
|
49807
|
+
new CopyObjectCommand({
|
|
49808
|
+
Bucket: this.bucket,
|
|
49809
|
+
CopySource: encodeURI(`${this.bucket}/${fromKey}`),
|
|
49810
|
+
Key: toKey
|
|
49811
|
+
})
|
|
49812
|
+
);
|
|
49813
|
+
}
|
|
49754
49814
|
};
|
|
49755
49815
|
|
|
49756
49816
|
// src/session-db.ts
|
|
@@ -49772,20 +49832,38 @@ function isStructurallySoundLtxObject(bytes) {
|
|
|
49772
49832
|
async function probeReplica(store, prefix, log) {
|
|
49773
49833
|
const root12 = replicaDbPrefix(prefix);
|
|
49774
49834
|
try {
|
|
49775
|
-
const
|
|
49776
|
-
|
|
49835
|
+
const objects = await store.list(root12);
|
|
49836
|
+
const objectCount = objects.length;
|
|
49837
|
+
const totalBytes = objects.reduce((sum, object) => sum + object.size, 0);
|
|
49838
|
+
logReplicaSize(root12, objectCount, totalBytes, log);
|
|
49839
|
+
return { ok: true, keys: objects.map((object) => object.key) };
|
|
49777
49840
|
} catch (error) {
|
|
49778
49841
|
const detail = describeError(error);
|
|
49779
49842
|
log(`WARNING: could not list the replica prefix ${root12}: ${detail}`);
|
|
49780
49843
|
return { ok: false, detail };
|
|
49781
49844
|
}
|
|
49782
49845
|
}
|
|
49846
|
+
function logReplicaSize(root12, objectCount, totalBytes, log) {
|
|
49847
|
+
log(
|
|
49848
|
+
`INFO: SESSION-DB-REPLICA-SIZE: the opencode.db replica under ${root12} objects=${objectCount} bytes=${totalBytes}`
|
|
49849
|
+
);
|
|
49850
|
+
}
|
|
49851
|
+
function logLayoutMismatch(prefix, keys, log) {
|
|
49852
|
+
log(
|
|
49853
|
+
`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.`
|
|
49854
|
+
);
|
|
49855
|
+
}
|
|
49783
49856
|
async function pruneNewestL0(store, prefix, keys, log) {
|
|
49784
49857
|
const target = newestL0Key(prefix, keys);
|
|
49785
|
-
if (target === null
|
|
49786
|
-
if (
|
|
49787
|
-
|
|
49858
|
+
if (target === null) {
|
|
49859
|
+
if (keys.length > 0 && !hasParsableLtxKey(prefix, keys)) {
|
|
49860
|
+
logLayoutMismatch(prefix, keys, log);
|
|
49861
|
+
return { deleted: null, reason: "layoutMismatch" };
|
|
49788
49862
|
}
|
|
49863
|
+
return { deleted: null, reason: "noL0Present" };
|
|
49864
|
+
}
|
|
49865
|
+
if (!isDeletableReplicaKey(prefix, target)) {
|
|
49866
|
+
log(`WARNING: refusing to prune ${target}: failed the replica-key guard.`);
|
|
49789
49867
|
return { deleted: null, reason: "layoutMismatch" };
|
|
49790
49868
|
}
|
|
49791
49869
|
let bytes;
|
|
@@ -49795,7 +49873,7 @@ async function pruneNewestL0(store, prefix, keys, log) {
|
|
|
49795
49873
|
log(
|
|
49796
49874
|
`WARNING: could not re-read ${target} to check whether it is actually corrupt before pruning it: ${describeError(error)}. Declining to prune without positive evidence.`
|
|
49797
49875
|
);
|
|
49798
|
-
return { deleted: null, reason: "
|
|
49876
|
+
return { deleted: null, reason: "unreadable" };
|
|
49799
49877
|
}
|
|
49800
49878
|
if (bytes === null || isStructurallySoundLtxObject(bytes)) {
|
|
49801
49879
|
log(
|
|
@@ -49812,6 +49890,48 @@ async function pruneNewestL0(store, prefix, keys, log) {
|
|
|
49812
49890
|
}
|
|
49813
49891
|
return { deleted: target };
|
|
49814
49892
|
}
|
|
49893
|
+
function quarantineRoot(prefix, stamp) {
|
|
49894
|
+
return `${prefix}/quarantine/opencode.db/${stamp}/`;
|
|
49895
|
+
}
|
|
49896
|
+
async function quarantineReplica(store, prefix, keys, log, stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")) {
|
|
49897
|
+
const destination = quarantineRoot(prefix, stamp);
|
|
49898
|
+
const root12 = replicaDbPrefix(prefix);
|
|
49899
|
+
const moved = [];
|
|
49900
|
+
const failed = [];
|
|
49901
|
+
for (const key of keys) {
|
|
49902
|
+
if (!isDeletableReplicaKey(prefix, key)) {
|
|
49903
|
+
log(`WARNING: refusing to quarantine ${key}: outside the replica prefix.`);
|
|
49904
|
+
continue;
|
|
49905
|
+
}
|
|
49906
|
+
const target = `${destination}${key.slice(root12.length)}`;
|
|
49907
|
+
try {
|
|
49908
|
+
await store.copy(key, target);
|
|
49909
|
+
} catch (error) {
|
|
49910
|
+
log(
|
|
49911
|
+
`WARNING: could not copy ${key} to ${target}: ${describeError(error)}. Leaving the original in place \u2014 never deleting an object the quarantine did not preserve.`
|
|
49912
|
+
);
|
|
49913
|
+
failed.push(key);
|
|
49914
|
+
continue;
|
|
49915
|
+
}
|
|
49916
|
+
try {
|
|
49917
|
+
await store.delete(key);
|
|
49918
|
+
} catch (error) {
|
|
49919
|
+
log(
|
|
49920
|
+
`WARNING: quarantined ${key} to ${target} but could not delete the original: ${describeError(error)}`
|
|
49921
|
+
);
|
|
49922
|
+
failed.push(key);
|
|
49923
|
+
continue;
|
|
49924
|
+
}
|
|
49925
|
+
moved.push(key);
|
|
49926
|
+
}
|
|
49927
|
+
logQuarantined(destination, moved, failed, log);
|
|
49928
|
+
return { destination, moved, failed };
|
|
49929
|
+
}
|
|
49930
|
+
function logQuarantined(destination, moved, failed, log) {
|
|
49931
|
+
log(
|
|
49932
|
+
`WARNING: SESSION-DB-REPLICA-QUARANTINED: moved ${moved.length} unusable replica object(s) aside to ${destination} (${failed.length} could not be moved) so this boot can start a fresh backup chain. The bytes are NOT deleted \u2014 copy them back from there to investigate. See SESSION-DB-REPLICA-SIZE above for how much was set aside.`
|
|
49933
|
+
);
|
|
49934
|
+
}
|
|
49815
49935
|
async function clearReplica(store, prefix, keys, log) {
|
|
49816
49936
|
const deleted = [];
|
|
49817
49937
|
const failed = [];
|
|
@@ -49850,7 +49970,7 @@ async function decideSessionDbRestore(params) {
|
|
|
49850
49970
|
const prefix = config.prefix;
|
|
49851
49971
|
if (exitCode === 0) {
|
|
49852
49972
|
const stat2 = await fileOps.stat(config.opencodeDbPath);
|
|
49853
|
-
return stat2 !== null && stat2.size > 0 ? { kind: "restored" } : { kind: "noReplica" };
|
|
49973
|
+
return stat2 !== null && stat2.size > 0 ? { kind: "restored", bytes: stat2.size } : { kind: "noReplica", localDb: stat2 === null ? "absent" : "empty" };
|
|
49854
49974
|
}
|
|
49855
49975
|
if (store === null) {
|
|
49856
49976
|
return {
|
|
@@ -49886,8 +50006,20 @@ async function decideSessionDbRestore(params) {
|
|
|
49886
50006
|
case "prune": {
|
|
49887
50007
|
const result = await pruneNewestL0(store, prefix, keys, log);
|
|
49888
50008
|
if (result.deleted === null) {
|
|
49889
|
-
|
|
49890
|
-
|
|
50009
|
+
if (shouldEscalateToQuarantine(result.reason, prefix, keys)) {
|
|
50010
|
+
const quarantined = await quarantineReplica(store, prefix, keys, log);
|
|
50011
|
+
if (quarantined.moved.length === 0) {
|
|
50012
|
+
return { kind: "unusableReplica", reason: "recoveryFailed" };
|
|
50013
|
+
}
|
|
50014
|
+
return { kind: "recovered", strategy: "quarantine", deleted: quarantined.moved };
|
|
50015
|
+
}
|
|
50016
|
+
if (result.reason === "deleteFailed") {
|
|
50017
|
+
return { kind: "unusableReplica", reason: "recoveryFailed" };
|
|
50018
|
+
}
|
|
50019
|
+
if (result.reason === "unreadable") {
|
|
50020
|
+
return { kind: "unusableReplica", reason: "targetHealthy" };
|
|
50021
|
+
}
|
|
50022
|
+
return { kind: "unusableReplica", reason: result.reason };
|
|
49891
50023
|
}
|
|
49892
50024
|
return { kind: "recovered", strategy: "prune", deleted: [result.deleted] };
|
|
49893
50025
|
}
|
|
@@ -49899,6 +50031,10 @@ async function decideSessionDbRestore(params) {
|
|
|
49899
50031
|
return assertNeverStrategy(strategy);
|
|
49900
50032
|
}
|
|
49901
50033
|
}
|
|
50034
|
+
function shouldEscalateToQuarantine(reason, prefix, keys) {
|
|
50035
|
+
if (reason !== "targetHealthy" && reason !== "noL0Present") return false;
|
|
50036
|
+
return hasParsableLtxKey(prefix, keys);
|
|
50037
|
+
}
|
|
49902
50038
|
async function discardLocalDebris(fileOps, dbPath, log) {
|
|
49903
50039
|
for (const path2 of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
49904
50040
|
try {
|
|
@@ -50064,7 +50200,7 @@ var USAGE = `Usage: runner-synchroniser <command> [args]
|
|
|
50064
50200
|
classify a just-run \`litestream restore\` of opencode.db;
|
|
50065
50201
|
exit 0 restored/no-replica/disabled, ${EXIT_SESSION_DB_RETRY} re-run
|
|
50066
50202
|
and ask again, ${EXIT_SESSION_DB_UNUSABLE} unusable (booted fresh,
|
|
50067
|
-
|
|
50203
|
+
fresh backup chain), ${EXIT_SESSION_DB_FATAL} fatal
|
|
50068
50204
|
`;
|
|
50069
50205
|
function shellQuote(value) {
|
|
50070
50206
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evident-ai/runner-synchroniser",
|
|
3
|
-
"version": "0.1.1-dev.
|
|
3
|
+
"version": "0.1.1-dev.9d0282a",
|
|
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",
|