@evident-ai/runner-synchroniser 3.4.1-dev.7802262 → 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.
Files changed (3) hide show
  1. package/README.md +23 -18
  2. package/dist/cli.js +149 -23
  3. 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, starts a fresh backup chain), `30` = fatal; other = usage/tool broken |
63
- | `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 |
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. `packages/runner-image/README.md`'s [Strategy/What/Cost table](../runner-image/README.md#when-the-replica-is-unusable-evident_on_unusable_replica) is the operator-facing view of the same command — this section doesn't restate it.
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". 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.
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 `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.
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 corrupt local DB is discarded and the boot proceeds with a
266
- genuinely fresh one exactly `session-db-classify`'s `31` guarantee, so
267
- `litestream replicate` starting against it afterwards is just as safe.
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 local DB failed and litestream retains no older restore point |
283
- | `exhausted` (`allFailed`) | `33` | every retained restore point was tried and every one failed |
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 (any `exhausted` reason); the boot proceeds with a fresh DB | `candidatesTried` (in prose, not `k=v`) |
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. It is drained once by `evident run` into runner activity and does not alter its exit
338
- code.
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
- `packages/runner-image/entrypoint.sh` and the MicroVM's
343
- `packages/runner-cdk/microvm-image/hooks` (#608), discovered by grep so a third one
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
- `packages/runner-image/README.md` documents these same three from the deployment side —
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 starts a fresh backup chain (#1106)
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, NOT deleted \u2014 see the SESSION-DB-REPLICA-QUARANTINED warning above for where.`;
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
- return 33;
52095
- // booted with a fresh DB; the shell still starts a fresh backup chain
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
- return `SESSION-DB-INTEGRITY-EXHAUSTED: opencode.db failed its integrity check and litestream's restore-point listing could not be read (${tried}); booting with a FRESH opencode.db.`;
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
- return `SESSION-DB-INTEGRITY-EXHAUSTED: opencode.db failed its integrity check and no older restore point is retained (${tried}); booting with a FRESH opencode.db.`;
52142
+ reason = `opencode.db failed its integrity check and no older restore point is retained (${tried})`;
52143
+ break;
52117
52144
  case "allFailed":
52118
- return `SESSION-DB-INTEGRITY-EXHAUSTED: opencode.db failed its integrity check and every retained restore point also failed (${tried}); booting with a FRESH opencode.db.`;
52145
+ reason = `opencode.db failed its integrity check and every retained restore point also failed (${tried})`;
52146
+ break;
52119
52147
  case "budgetExhausted":
52120
- return `SESSION-DB-INTEGRITY-EXHAUSTED: 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; booting with a FRESH opencode.db.`;
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) 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.`
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
- return walkBack(params);
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
- for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
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
- log(
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
- and ask again, ${EXIT_SESSION_DB_UNUSABLE} unusable (booted fresh,
63009
- fresh backup chain), ${EXIT_SESSION_DB_FATAL} fatal
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
- candidate was exhausted (booted fresh)
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.7802262",
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": "packages/runner-synchroniser"
45
+ "directory": "runner/synchroniser"
46
46
  },
47
47
  "homepage": "https://evident.run",
48
48
  "author": "Evident <hello@evident.run>",