@evident-ai/runner-synchroniser 3.4.1-dev.aa71e9a → 3.4.1-dev.b740141
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 +23 -18
- package/dist/cli.js +149 -23
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -59,8 +59,8 @@ build time.
|
|
|
59
59
|
| `sync-once <claude\|opencode>` | — | `0` = ran; non-zero = tool broken |
|
|
60
60
|
| `model-auth-ready` | — | `0` = ready, `10` = not ready; other = tool broken |
|
|
61
61
|
| `self-stop` | — | `0` = stopped, `20` = keep the task; other = tool broken |
|
|
62
|
-
| `session-db-classify <litestream-restore-exit-code> <attempt> [--on-unusable-replica=<prune\|leave\|clear\|crash>] [--recovery-occurred] [--fresh-db-fallback]` | — | `0` = restored/no-replica/disabled, `32` = re-run and ask again, `31` = unusable (booted fresh,
|
|
63
|
-
| `session-db-verify <litestream-config-path>` | — | `0` = healthy/skipped/walked-back, `33` =
|
|
62
|
+
| `session-db-classify <litestream-restore-exit-code> <attempt> [--on-unusable-replica=<prune\|leave\|clear\|crash>] [--recovery-occurred] [--fresh-db-fallback]` | — | `0` = restored/no-replica/disabled, `32` = re-run and ask again, `31` = unusable (booted fresh, replicates into the existing prefix), `30` = fatal; other = usage/tool broken |
|
|
63
|
+
| `session-db-verify <litestream-config-path>` | — | `0` = healthy/skipped/walked-back, `33` = exhausted after verified separation and local disposal, `34` = separation or disposal could not be proven; other = usage/tool broken |
|
|
64
64
|
|
|
65
65
|
`self-stop` scales this agent's own ECS service to `desiredCount=0` on a clean idle exit
|
|
66
66
|
(see Configuration for `CLUSTER`/`SERVICE`/`EVIDENT_SELFSTOP_ROLE_ARN`). It exits `0`
|
|
@@ -75,7 +75,7 @@ saved query.
|
|
|
75
75
|
|
|
76
76
|
### `session-db-classify`
|
|
77
77
|
|
|
78
|
-
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. `
|
|
78
|
+
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. `runner/docker-images/fargate/README.md`'s [Strategy/What/Cost table](../docker-images/fargate/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.
|
|
79
79
|
|
|
80
80
|
#### Activity recovery report
|
|
81
81
|
|
|
@@ -213,7 +213,7 @@ what it is.
|
|
|
213
213
|
#### The four `--on-unusable-replica` strategies
|
|
214
214
|
|
|
215
215
|
- **`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.
|
|
216
|
-
- **`leave`** — zero S3 mutation → `31`. Cost is "prior history lost this boot".
|
|
216
|
+
- **`leave`** — zero S3 mutation → `31`. Cost is "prior history lost this boot". The fresh local DB replicates into the existing prefix; no new chain is started. Upside: the runner still comes online without a crash loop.
|
|
217
217
|
- **`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.
|
|
218
218
|
- **`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.
|
|
219
219
|
|
|
@@ -235,7 +235,7 @@ at boot the newest L0 is normally *absent* (`noL0Present`) or freshly written an
|
|
|
235
235
|
(`targetHealthy`) — `prune`'s real-world reach is narrower than the code alone suggests,
|
|
236
236
|
and it can never repair corruption at a higher compaction level.
|
|
237
237
|
|
|
238
|
-
`EVIDENT_ON_UNUSABLE_REPLICA` is a `
|
|
238
|
+
`EVIDENT_ON_UNUSABLE_REPLICA` is a `runner/docker-images/fargate` (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 [the Fargate image README](../docker-images/fargate/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.
|
|
239
239
|
|
|
240
240
|
See `specs/local-runner.feature`'s "Recovering session history at startup" scenarios for
|
|
241
241
|
the behavioural anchor (31 comes online, 30 does not).
|
|
@@ -262,9 +262,12 @@ nothing had ever run a real `PRAGMA integrity_check`).
|
|
|
262
262
|
each into a scratch path and integrity-checking it there. The first one that passes
|
|
263
263
|
is adopted — renamed over `opencode.db`, replacing it and its stale sidecars.
|
|
264
264
|
3. If nothing retained passes (or there's nothing to try, or the search runs out of
|
|
265
|
-
time/points), the
|
|
266
|
-
|
|
267
|
-
|
|
265
|
+
time/points), the active replica prefix is separated first: its history is moved aside,
|
|
266
|
+
then the prefix is re-listed as empty before the corrupt local DB and sidecars are
|
|
267
|
+
discarded. Only both proofs permit a fresh boot. Otherwise exit `34` stops the boot before
|
|
268
|
+
OpenCode or Litestream starts; the backup history remains readable at its quarantine
|
|
269
|
+
destination or original key, and surviving local files remain in place without a process
|
|
270
|
+
opening or writing them.
|
|
268
271
|
|
|
269
272
|
A walked-back boot keeps replicating into the **same** replica — no S3 mutation, no new
|
|
270
273
|
prefix. litestream re-bases to the replica's high-water mark and continues the txid
|
|
@@ -279,10 +282,8 @@ against S3 at the ~1 GB scale a production replica can reach.)
|
|
|
279
282
|
| `skipped` | `0` | local DB absent or zero bytes — nothing to verify |
|
|
280
283
|
| `healthy` | `0` | the local DB passed its integrity check as-is |
|
|
281
284
|
| `walkedBack` | `0` | the local DB failed, but an older retained restore point passed and was adopted |
|
|
282
|
-
| `exhausted` (`noCandidates`) | `33` | the
|
|
283
|
-
| `exhausted`
|
|
284
|
-
| `exhausted` (`budgetExhausted`) | `33` | the search stopped on `EVIDENT_SESSION_DB_WALKBACK_MAX_POINTS`/`_BUDGET_SECONDS` before trying every retained point |
|
|
285
|
-
| `exhausted` (`enumerationFailed`) | `33` | `litestream ltx` could not be read (bad exit, non-JSON, malformed listing) |
|
|
285
|
+
| `exhausted` (`noCandidates`, `allFailed`, `budgetExhausted`, or `enumerationFailed`) | `33` | the active prefix was proven separated (or persistence is disabled) **and** the corrupt local DB and sidecars were proven discarded |
|
|
286
|
+
| `exhausted` | `34` | either the replica separation or local discard could not be proven; the boot stops before OpenCode and Litestream start |
|
|
286
287
|
|
|
287
288
|
`healthy`/`walkedBack`/`skipped` all share exit `0` deliberately: the shell's behaviour
|
|
288
289
|
afterwards — start `litestream replicate` — is identical either way, so a further split
|
|
@@ -301,7 +302,10 @@ before looking at everything" would report the wrong conclusion to the operator.
|
|
|
301
302
|
| `INFO: SESSION-DB-WALKBACK-ADOPTED` | `adopt` (`session-db-verify.ts`) | A candidate passes and is adopted over `opencode.db` | `txid`, `candidates`, `bytes` |
|
|
302
303
|
| `SESSION-DB-INTEGRITY` | `describeSessionDbVerification` (`diagnostics.ts`), logged once per invocation as the final outcome | The local DB passed its check as-is | `bytes` |
|
|
303
304
|
| `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` |
|
|
304
|
-
| `SESSION-DB-INTEGRITY-EXHAUSTED` | `describeSessionDbVerification` (`diagnostics.ts`), logged once per invocation as the final outcome | Nothing retained passed
|
|
305
|
+
| `SESSION-DB-INTEGRITY-EXHAUSTED` | `describeSessionDbVerification` (`diagnostics.ts`), logged once per invocation as the final outcome | Nothing retained passed; a fresh boot follows only when both separation and local disposal were proven | `candidatesTried` (in prose, not `k=v`) |
|
|
306
|
+
| `SESSION-DB-REPLICA-SEPARATED` | `separateCorruptReplica` (`replica-recovery.ts`) | The active prefix was re-listed empty after separation | replica root and quarantine destination |
|
|
307
|
+
| `SESSION-DB-REPLICA-SEPARATION-UNVERIFIED` | `separateCorruptReplica` (`replica-recovery.ts`) | A separation could not be proven | remaining objects or list error |
|
|
308
|
+
| `SESSION-DB-LOCAL-DISCARD-FAILED` | `verifySessionDb` (`session-db-verify.ts`) | The local DB or a sidecar could not be proven gone | removed and surviving paths |
|
|
305
309
|
|
|
306
310
|
The first four fire only while the walkback loop runs; the last three are always the
|
|
307
311
|
one message this command logs as its own final word on the outcome — `entrypoint.sh`
|
|
@@ -334,13 +338,14 @@ task, and only a `0` — returned solely on a confirmed `desiredCount` of 0 —
|
|
|
334
338
|
`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.
|
|
335
339
|
|
|
336
340
|
`session-db-verify` appends the same best-effort versioned report for a walkback or exhausted
|
|
337
|
-
history.
|
|
338
|
-
|
|
341
|
+
history. A refused boot records `session_db_boot_refused`, but `evident run` has not started to
|
|
342
|
+
drain it, so operators see that refusal in boot logs and the restart loop. The report does not
|
|
343
|
+
alter the command's exit code.
|
|
339
344
|
|
|
340
345
|
`src/shell-contract.json` is the machine-checked source of truth for the command list, and
|
|
341
346
|
`shell-contract.test.ts` holds **every** shell that speaks it to it — Fargate's
|
|
342
|
-
`
|
|
343
|
-
`
|
|
347
|
+
`runner/docker-images/fargate/entrypoint.sh` and the MicroVM's
|
|
348
|
+
`runner/docker-images/microvm/hooks` (#608), discovered by grep so a third one
|
|
344
349
|
cannot go unchecked. Each shell must: call only subcommands the CLI implements (and, for
|
|
345
350
|
`entrypoint.sh`, call all of them); route every call through one `run_synchroniser`; report
|
|
346
351
|
a broken tool; and take each answer code the commands it calls can return **silently and by
|
|
@@ -370,7 +375,7 @@ key, so they never appear in `env`'s output or `litestream.yml`.
|
|
|
370
375
|
| `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. |
|
|
371
376
|
| `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`. |
|
|
372
377
|
|
|
373
|
-
`
|
|
378
|
+
`runner/docker-images/fargate/README.md` documents these same three from the deployment side —
|
|
374
379
|
keep them in sync.
|
|
375
380
|
|
|
376
381
|
Requiring **both** `LITESTREAM_BUCKET` and `LITESTREAM_PREFIX` (never just one) means
|
package/dist/cli.js
CHANGED
|
@@ -52000,7 +52000,7 @@ function sessionDbExitCode(outcome) {
|
|
|
52000
52000
|
// re-run `litestream restore` and ask again
|
|
52001
52001
|
case "unusableReplica":
|
|
52002
52002
|
return 31;
|
|
52003
|
-
// booted with a fresh DB; the shell still
|
|
52003
|
+
// booted with a fresh DB; the shell still replicates into the existing prefix
|
|
52004
52004
|
case "fatal":
|
|
52005
52005
|
return 30;
|
|
52006
52006
|
// shared by a genuine misconfig and a deliberate `crash` choice
|
|
@@ -52063,7 +52063,7 @@ function describeSessionDbClassification(outcome, config) {
|
|
|
52063
52063
|
case "recovered": {
|
|
52064
52064
|
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(", ")})`;
|
|
52065
52065
|
if (outcome.strategy === "quarantine") {
|
|
52066
|
-
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
|
|
52066
|
+
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 and remains readable at the quarantine destination \u2014 see the SESSION-DB-REPLICA-QUARANTINED warning above for where.`;
|
|
52067
52067
|
}
|
|
52068
52068
|
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." : "");
|
|
52069
52069
|
}
|
|
@@ -52084,6 +52084,12 @@ function assertNeverSessionDbVerifySkippedLocalDb(value) {
|
|
|
52084
52084
|
function assertNeverSessionDbVerifyExhaustedReason(value) {
|
|
52085
52085
|
throw new Error(`Unhandled session-DB verify exhausted reason: ${JSON.stringify(value)}`);
|
|
52086
52086
|
}
|
|
52087
|
+
function assertNeverSessionDbVerifySeparation(value) {
|
|
52088
|
+
throw new Error(`Unhandled session-DB verify separation: ${JSON.stringify(value)}`);
|
|
52089
|
+
}
|
|
52090
|
+
function assertNeverSessionDbVerifyLocalDb(value) {
|
|
52091
|
+
throw new Error(`Unhandled session-DB verify local-DB disposal: ${JSON.stringify(value)}`);
|
|
52092
|
+
}
|
|
52087
52093
|
function sessionDbVerifyExitCode(outcome) {
|
|
52088
52094
|
switch (outcome.kind) {
|
|
52089
52095
|
case "skipped":
|
|
@@ -52091,8 +52097,26 @@ function sessionDbVerifyExitCode(outcome) {
|
|
|
52091
52097
|
case "walkedBack":
|
|
52092
52098
|
return 0;
|
|
52093
52099
|
case "exhausted":
|
|
52094
|
-
|
|
52095
|
-
|
|
52100
|
+
switch (outcome.separation.kind) {
|
|
52101
|
+
case "notConfigured":
|
|
52102
|
+
case "alreadyEmpty":
|
|
52103
|
+
case "quarantined":
|
|
52104
|
+
break;
|
|
52105
|
+
case "incomplete":
|
|
52106
|
+
case "unreachable":
|
|
52107
|
+
return 34;
|
|
52108
|
+
default:
|
|
52109
|
+
return assertNeverSessionDbVerifySeparation(outcome.separation);
|
|
52110
|
+
}
|
|
52111
|
+
switch (outcome.localDb.kind) {
|
|
52112
|
+
case "discarded":
|
|
52113
|
+
return 33;
|
|
52114
|
+
case "retained":
|
|
52115
|
+
case "discardFailed":
|
|
52116
|
+
return 34;
|
|
52117
|
+
default:
|
|
52118
|
+
return assertNeverSessionDbVerifyLocalDb(outcome.localDb);
|
|
52119
|
+
}
|
|
52096
52120
|
default:
|
|
52097
52121
|
return assertNeverSessionDbVerify(outcome);
|
|
52098
52122
|
}
|
|
@@ -52109,18 +52133,56 @@ function describeSessionDbVerifySkipped(localDb) {
|
|
|
52109
52133
|
}
|
|
52110
52134
|
function describeSessionDbVerifyExhausted(outcome) {
|
|
52111
52135
|
const tried = `tried ${outcome.candidatesTried} restore point${outcome.candidatesTried === 1 ? "" : "s"}`;
|
|
52136
|
+
let reason;
|
|
52112
52137
|
switch (outcome.reason) {
|
|
52113
52138
|
case "enumerationFailed":
|
|
52114
|
-
|
|
52139
|
+
reason = `opencode.db failed its integrity check and litestream's restore-point listing could not be read (${tried})`;
|
|
52140
|
+
break;
|
|
52115
52141
|
case "noCandidates":
|
|
52116
|
-
|
|
52142
|
+
reason = `opencode.db failed its integrity check and no older restore point is retained (${tried})`;
|
|
52143
|
+
break;
|
|
52117
52144
|
case "allFailed":
|
|
52118
|
-
|
|
52145
|
+
reason = `opencode.db failed its integrity check and every retained restore point also failed (${tried})`;
|
|
52146
|
+
break;
|
|
52119
52147
|
case "budgetExhausted":
|
|
52120
|
-
|
|
52148
|
+
reason = `opencode.db failed its integrity check; the walkback search budget ran out before every retained restore point was tried (${tried}) \u2014 widen it with EVIDENT_SESSION_DB_WALKBACK_MAX_POINTS/EVIDENT_SESSION_DB_WALKBACK_BUDGET_SECONDS if there was more to try`;
|
|
52149
|
+
break;
|
|
52121
52150
|
default:
|
|
52122
52151
|
return assertNeverSessionDbVerifyExhaustedReason(outcome.reason);
|
|
52123
52152
|
}
|
|
52153
|
+
let separation;
|
|
52154
|
+
switch (outcome.separation.kind) {
|
|
52155
|
+
case "quarantined":
|
|
52156
|
+
separation = ` The active replica prefix was verified empty; moved history remains readable at ${outcome.separation.destination}.`;
|
|
52157
|
+
break;
|
|
52158
|
+
case "alreadyEmpty":
|
|
52159
|
+
separation = " The active replica prefix was already empty.";
|
|
52160
|
+
break;
|
|
52161
|
+
case "notConfigured":
|
|
52162
|
+
separation = " Persistence is disabled, so there is no replica to separate.";
|
|
52163
|
+
break;
|
|
52164
|
+
case "incomplete":
|
|
52165
|
+
case "unreachable":
|
|
52166
|
+
separation = ` Boot is stopping because replica separation could not be proven (${outcome.separation.detail}); backup history remains readable at its original key or quarantine destination.`;
|
|
52167
|
+
break;
|
|
52168
|
+
default:
|
|
52169
|
+
return assertNeverSessionDbVerifySeparation(outcome.separation);
|
|
52170
|
+
}
|
|
52171
|
+
let localDb;
|
|
52172
|
+
switch (outcome.localDb.kind) {
|
|
52173
|
+
case "discarded":
|
|
52174
|
+
localDb = " booting with a FRESH opencode.db.";
|
|
52175
|
+
break;
|
|
52176
|
+
case "retained":
|
|
52177
|
+
localDb = " The corrupt local database and sidecars were left untouched for investigation.";
|
|
52178
|
+
break;
|
|
52179
|
+
case "discardFailed":
|
|
52180
|
+
localDb = ` Boot is stopping because local disposal could not be proven; removed=${outcome.localDb.removed.join(", ") || "(none)"} survived=${outcome.localDb.survived.join(", ") || "(none)"}. See SESSION-DB-LOCAL-DISCARD-FAILED.`;
|
|
52181
|
+
break;
|
|
52182
|
+
default:
|
|
52183
|
+
return assertNeverSessionDbVerifyLocalDb(outcome.localDb);
|
|
52184
|
+
}
|
|
52185
|
+
return `SESSION-DB-INTEGRITY-EXHAUSTED: ${reason}.${separation}${localDb}`;
|
|
52124
52186
|
}
|
|
52125
52187
|
function describeSessionDbVerification(outcome) {
|
|
52126
52188
|
switch (outcome.kind) {
|
|
@@ -52162,8 +52224,9 @@ var nodeFileOps = {
|
|
|
52162
52224
|
try {
|
|
52163
52225
|
const stats = await stat(path);
|
|
52164
52226
|
return { size: stats.size };
|
|
52165
|
-
} catch {
|
|
52166
|
-
return null;
|
|
52227
|
+
} catch (error) {
|
|
52228
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
|
52229
|
+
throw error;
|
|
52167
52230
|
}
|
|
52168
52231
|
},
|
|
52169
52232
|
readFile: readFile5,
|
|
@@ -62435,8 +62498,40 @@ async function quarantineReplica(store, prefix, objects, log, stamp = (/* @__PUR
|
|
|
62435
62498
|
}
|
|
62436
62499
|
function logQuarantined(destination, moved, failed, log) {
|
|
62437
62500
|
log(
|
|
62438
|
-
`WARNING: SESSION-DB-REPLICA-QUARANTINED: moved ${moved.length} unusable replica object(s) aside to ${destination} (${failed.length} could not be moved)
|
|
62501
|
+
`WARNING: SESSION-DB-REPLICA-QUARANTINED: moved ${moved.length} unusable replica object(s) aside to ${destination} (${failed.length} could not be moved). Moved history remains readable at that destination; failed moves remain at their original keys.` + (failed.length > 0 ? " The active replica prefix is not clear." : "") + `See SESSION-DB-REPLICA-SIZE above for how much was set aside.`
|
|
62502
|
+
);
|
|
62503
|
+
}
|
|
62504
|
+
async function separateCorruptReplica(store, prefix, log, stamp) {
|
|
62505
|
+
const probe = await probeReplica(store, prefix, log);
|
|
62506
|
+
if (!probe.ok) return { kind: "unreachable", detail: probe.detail };
|
|
62507
|
+
const root12 = replicaDbPrefix(prefix);
|
|
62508
|
+
if (probe.objects.length === 0) {
|
|
62509
|
+
log(
|
|
62510
|
+
`INFO: SESSION-DB-REPLICA-SEPARATED: ${root12} was already empty; this boot starts a fresh backup chain.`
|
|
62511
|
+
);
|
|
62512
|
+
return { kind: "alreadyEmpty" };
|
|
62513
|
+
}
|
|
62514
|
+
const result = await quarantineReplica(store, prefix, probe.objects, log, stamp);
|
|
62515
|
+
try {
|
|
62516
|
+
const remaining = await store.list(root12);
|
|
62517
|
+
if (remaining.length > 0) {
|
|
62518
|
+
const detail = `${remaining.length} object(s) remain`;
|
|
62519
|
+
log(
|
|
62520
|
+
`WARNING: SESSION-DB-REPLICA-SEPARATION-UNVERIFIED: ${root12} still has ${detail}; moved=${result.moved.length} failed=${result.failed.length}.`
|
|
62521
|
+
);
|
|
62522
|
+
return { kind: "incomplete", detail };
|
|
62523
|
+
}
|
|
62524
|
+
} catch (error) {
|
|
62525
|
+
const detail = describeError(error);
|
|
62526
|
+
log(
|
|
62527
|
+
`WARNING: SESSION-DB-REPLICA-SEPARATION-UNVERIFIED: could not re-list ${root12}: ${detail}; moved=${result.moved.length} failed=${result.failed.length}.`
|
|
62528
|
+
);
|
|
62529
|
+
return { kind: "incomplete", detail };
|
|
62530
|
+
}
|
|
62531
|
+
log(
|
|
62532
|
+
`INFO: SESSION-DB-REPLICA-SEPARATED: ${root12} was verified empty by re-listing after moving ${result.moved.length} object(s) to ${result.destination}; this boot starts a fresh backup chain.`
|
|
62439
62533
|
);
|
|
62534
|
+
return { kind: "quarantined", destination: result.destination, moved: result.moved };
|
|
62440
62535
|
}
|
|
62441
62536
|
async function clearReplica(store, prefix, keys, log) {
|
|
62442
62537
|
const deleted = [];
|
|
@@ -62648,6 +62743,15 @@ function recordVerifyOutcome(outcome, at2) {
|
|
|
62648
62743
|
verified_restore_point: outcome.txid,
|
|
62649
62744
|
restore_points_tried: outcome.candidatesTried
|
|
62650
62745
|
};
|
|
62746
|
+
if (sessionDbVerifyExitCode(outcome) === 34)
|
|
62747
|
+
return {
|
|
62748
|
+
...record,
|
|
62749
|
+
outcome: "session_db_boot_refused",
|
|
62750
|
+
severity: "error",
|
|
62751
|
+
reason: outcome.localDb.kind === "discardFailed" ? "local_discard_failed" : "replica_separation_unproven",
|
|
62752
|
+
restore_points_tried: outcome.candidatesTried,
|
|
62753
|
+
quarantine_destination: outcome.separation.kind === "quarantined" ? outcome.separation.destination : null
|
|
62754
|
+
};
|
|
62651
62755
|
return {
|
|
62652
62756
|
...record,
|
|
62653
62757
|
outcome: "fresh_session_db",
|
|
@@ -62730,7 +62834,11 @@ async function verifySessionDb(params) {
|
|
|
62730
62834
|
log(
|
|
62731
62835
|
`WARNING: SESSION-DB-INTEGRITY-FAILED: opencode.db at ${dbPath} failed its integrity check: ${result.detail}`
|
|
62732
62836
|
);
|
|
62733
|
-
|
|
62837
|
+
const decision = await walkBack(params);
|
|
62838
|
+
if (decision.kind !== "exhausted") return decision;
|
|
62839
|
+
const separation = params.store === null || config.prefix === null ? { kind: "notConfigured" } : await separateCorruptReplica(params.store, config.prefix, log);
|
|
62840
|
+
const localDb = separation.kind === "notConfigured" || separation.kind === "alreadyEmpty" || separation.kind === "quarantined" ? await discardCorruptDb(fileOps, dbPath, log) : { kind: "retained", reason: "separationUnproven" };
|
|
62841
|
+
return { ...decision, separation, localDb };
|
|
62734
62842
|
}
|
|
62735
62843
|
async function clearScratch(fileOps, scratch) {
|
|
62736
62844
|
await fileOps.remove(scratch);
|
|
@@ -62738,15 +62846,32 @@ async function clearScratch(fileOps, scratch) {
|
|
|
62738
62846
|
await fileOps.remove(`${scratch}-shm`);
|
|
62739
62847
|
}
|
|
62740
62848
|
async function discardCorruptDb(fileOps, dbPath, log) {
|
|
62741
|
-
|
|
62849
|
+
const paths = [dbPath, `${dbPath}-wal`, `${dbPath}-shm`];
|
|
62850
|
+
const errors = [];
|
|
62851
|
+
for (const path of paths) {
|
|
62742
62852
|
try {
|
|
62743
62853
|
await fileOps.remove(path);
|
|
62744
62854
|
} catch (error) {
|
|
62745
|
-
|
|
62746
|
-
`WARNING: could not remove corrupt local session-DB debris at ${path}: ${describeError(error)}`
|
|
62747
|
-
);
|
|
62855
|
+
errors.push(`${path}: ${describeError(error)}`);
|
|
62748
62856
|
}
|
|
62749
62857
|
}
|
|
62858
|
+
const survived = [];
|
|
62859
|
+
const removed = [];
|
|
62860
|
+
for (const path of paths) {
|
|
62861
|
+
try {
|
|
62862
|
+
if (await fileOps.stat(path) === null) removed.push(path);
|
|
62863
|
+
else survived.push(path);
|
|
62864
|
+
} catch (error) {
|
|
62865
|
+
survived.push(path);
|
|
62866
|
+
errors.push(`${path}: could not prove removal: ${describeError(error)}`);
|
|
62867
|
+
}
|
|
62868
|
+
}
|
|
62869
|
+
if (errors.length === 0 && survived.length === 0) return { kind: "discarded" };
|
|
62870
|
+
const detail = errors.length === 0 ? "paths remained after removal" : errors.join("; ");
|
|
62871
|
+
log(
|
|
62872
|
+
`WARNING: SESSION-DB-LOCAL-DISCARD-FAILED: removed=${removed.join(", ") || "(none)"} survived=${survived.join(", ") || "(none)"} errors=${detail}; boot is stopping so no process opens or writes what remains.`
|
|
62873
|
+
);
|
|
62874
|
+
return { kind: "discardFailed", removed, survived, detail };
|
|
62750
62875
|
}
|
|
62751
62876
|
async function adopt(fileOps, scratch, dbPath, log, txid, candidatesTried, bytes) {
|
|
62752
62877
|
await fileOps.remove(`${dbPath}-wal`);
|
|
@@ -62768,12 +62893,10 @@ async function walkBack(params) {
|
|
|
62768
62893
|
log(
|
|
62769
62894
|
`WARNING: SESSION-DB-WALKBACK-ENUMERATION-FAILED: ${listing.stdout === null ? `litestream ltx exited non-zero: ${listing.detail}` : "litestream ltx succeeded but its listing could not be parsed"}`
|
|
62770
62895
|
);
|
|
62771
|
-
await discardCorruptDb(fileOps, dbPath, log);
|
|
62772
62896
|
return { kind: "exhausted", reason: "enumerationFailed", candidatesTried: 0 };
|
|
62773
62897
|
}
|
|
62774
62898
|
const candidates = walkbackCandidates(points);
|
|
62775
62899
|
if (candidates.length === 0) {
|
|
62776
|
-
await discardCorruptDb(fileOps, dbPath, log);
|
|
62777
62900
|
return { kind: "exhausted", reason: "noCandidates", candidatesTried: 0 };
|
|
62778
62901
|
}
|
|
62779
62902
|
const deadline = now() + budgetSeconds * 1e3;
|
|
@@ -62804,7 +62927,6 @@ async function walkBack(params) {
|
|
|
62804
62927
|
return { kind: "walkedBack", txid, bytes, candidatesTried };
|
|
62805
62928
|
}
|
|
62806
62929
|
await clearScratch(fileOps, scratch);
|
|
62807
|
-
await discardCorruptDb(fileOps, dbPath, log);
|
|
62808
62930
|
return {
|
|
62809
62931
|
kind: "exhausted",
|
|
62810
62932
|
reason: index < candidates.length ? "budgetExhausted" : "allFailed",
|
|
@@ -62989,6 +63111,7 @@ var EXIT_SESSION_DB_FATAL = 30;
|
|
|
62989
63111
|
var EXIT_SESSION_DB_UNUSABLE = 31;
|
|
62990
63112
|
var EXIT_SESSION_DB_RETRY = 32;
|
|
62991
63113
|
var EXIT_SESSION_DB_UNVERIFIABLE = 33;
|
|
63114
|
+
var EXIT_SESSION_DB_UNSEPARATED = 34;
|
|
62992
63115
|
var STATE_SUFFIX = ".synchash";
|
|
62993
63116
|
var COMMANDS = shell_contract_default.commands;
|
|
62994
63117
|
var USAGE = `Usage: runner-synchroniser <command> [args]
|
|
@@ -63005,13 +63128,14 @@ var USAGE = `Usage: runner-synchroniser <command> [args]
|
|
|
63005
63128
|
[--fresh-db-fallback]
|
|
63006
63129
|
classify a just-run \`litestream restore\` of opencode.db;
|
|
63007
63130
|
exit 0 restored/no-replica/disabled, ${EXIT_SESSION_DB_RETRY} re-run
|
|
63008
|
-
|
|
63009
|
-
|
|
63131
|
+
and ask again, ${EXIT_SESSION_DB_UNUSABLE} unusable (booted fresh,
|
|
63132
|
+
replicates into the existing prefix), ${EXIT_SESSION_DB_FATAL} fatal
|
|
63010
63133
|
session-db-verify <litestream-config-path>
|
|
63011
63134
|
verify opencode.db's integrity at boot, walking back through
|
|
63012
63135
|
litestream's retained restore points if it's corrupt; exit 0
|
|
63013
63136
|
healthy/skipped/walked-back, ${EXIT_SESSION_DB_UNVERIFIABLE} every
|
|
63014
|
-
|
|
63137
|
+
candidate was exhausted (booted fresh), ${EXIT_SESSION_DB_UNSEPARATED}
|
|
63138
|
+
separation/local disposal could not be proven
|
|
63015
63139
|
`;
|
|
63016
63140
|
function shellQuote(value) {
|
|
63017
63141
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
@@ -63170,7 +63294,8 @@ async function commandSessionDbVerify(litestreamConfigPath, config, deps) {
|
|
|
63170
63294
|
config.opencodeDbPath
|
|
63171
63295
|
);
|
|
63172
63296
|
const outcome = await verifySessionDb({
|
|
63173
|
-
config: { opencodeDbPath: config.opencodeDbPath },
|
|
63297
|
+
config: { opencodeDbPath: config.opencodeDbPath, prefix: config.prefix },
|
|
63298
|
+
store: (deps.objectStoreFor ?? s3ObjectStoreFor)(config),
|
|
63174
63299
|
fileOps,
|
|
63175
63300
|
sqlite: (deps.sqliteIntegrityFor ?? nodeSqliteIntegrityFor)(),
|
|
63176
63301
|
litestream,
|
|
@@ -63279,6 +63404,7 @@ export {
|
|
|
63279
63404
|
EXIT_NOT_READY,
|
|
63280
63405
|
EXIT_SESSION_DB_FATAL,
|
|
63281
63406
|
EXIT_SESSION_DB_RETRY,
|
|
63407
|
+
EXIT_SESSION_DB_UNSEPARATED,
|
|
63282
63408
|
EXIT_SESSION_DB_UNUSABLE,
|
|
63283
63409
|
EXIT_SESSION_DB_UNVERIFIABLE,
|
|
63284
63410
|
EXIT_USAGE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evident-ai/runner-synchroniser",
|
|
3
|
-
"version": "3.4.1-dev.
|
|
3
|
+
"version": "3.4.1-dev.b740141",
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"tsup": "^8.3.5",
|
|
27
27
|
"typescript": "^5.7.2",
|
|
28
28
|
"vitest": "^3.1.1",
|
|
29
|
-
"runner-image": "workspace:*"
|
|
29
|
+
"runner-fargate-image": "workspace:*"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
32
|
"node": ">=22.13.0"
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|
|
44
44
|
"url": "https://github.com/sroze/evident.git",
|
|
45
|
-
"directory": "
|
|
45
|
+
"directory": "runner/synchroniser"
|
|
46
46
|
},
|
|
47
47
|
"homepage": "https://evident.run",
|
|
48
48
|
"author": "Evident <hello@evident.run>",
|