@evident-ai/runner-cdk 3.4.1-dev.3adba63 → 3.4.1-dev.59c7df3

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.
@@ -24,6 +24,8 @@ CONTEXT_FILE="/dev/shm/evident-run-context"
24
24
  TUNNEL_PID_FILE="/dev/shm/evident-tunnel.pid"
25
25
  OPENCODE_PID_FILE="/dev/shm/evident-opencode.pid"
26
26
  LITESTREAM_PID_FILE="/dev/shm/evident-litestream.pid"
27
+ CREDS_SYNC_PID_FILE="/dev/shm/evident-creds-sync.pid"
28
+ CREDS_SYNC_LAST_ERROR_FILE="/dev/shm/evident-creds-sync.last-error"
27
29
 
28
30
  # Where the runner's credential store lives inside the durable-state bucket.
29
31
  # The BUCKET is the same for every VM from an image version, so the stack bakes
@@ -37,57 +39,33 @@ STATE_PREFIX_FILE="/dev/shm/evident-state-prefix"
37
39
  # The generated litestream.yml (#812). tmpfs for the same two reasons as the
38
40
  # files above: it must never reach the block device (Q6, `docs`), and it
39
41
  # survives suspend/resume, which is what lets `/resume` start litestream again
40
- # with no regeneration cost. Regenerated only when absent or empty
41
- # (`ensure_litestream_config`, below).
42
+ # with no regeneration cost. The CLI regenerates it only when absent or empty.
42
43
  LITESTREAM_CONFIG_FILE="/dev/shm/evident-litestream.yml"
43
44
 
44
- # Set when a `session-db-classify` answer of 31 (replica unusable), or a
45
- # restore this hook gave up on for its own reasons (a truncated `timeout`, a
46
- # broken tool), means this boot must NOT start `litestream replicate` — doing
47
- # so would let a partial/fresh local DB overwrite a replica this boot never
48
- # proved is safe to write over. Cleared at the top of every `restore_session_db`
49
- # call (i.e. every `/run`) before any other decision, so a marker left by an
50
- # earlier boot cannot silently disable replication for the VM's whole life.
45
+ # Set by the CLI when session-DB restore or verification could not prove this
46
+ # boot safe to replicate. The teardown hooks read it before flushing, and
47
+ # `/terminate` removes it with the rest of the per-VM state.
51
48
  SESSION_DB_NO_REPLICATE_MARKER="/dev/shm/evident-session-db-no-replicate"
52
49
 
53
- log() { echo "[hook:$(basename "$0")] $*"; }
54
- warn() { echo "[hook:$(basename "$0")] $*" >&2; }
55
- error() { echo "[hook:$(basename "$0")] ERROR: $*" >&2; }
50
+ hook_name() { printf '%s' "${0##*/}"; }
51
+ log() { echo "[hook:$(hook_name)] $*"; }
52
+ warn() { echo "[hook:$(hook_name)] $*" >&2; }
53
+ error() { echo "[hook:$(hook_name)] ERROR: $*" >&2; }
56
54
 
57
55
  # Returns the CLI's own exit code. Domain outcomes (nothing persisted yet, a
58
56
  # corrupt object) are LOGGED and exit 0, the predicates answer "no" with 10, and
59
57
  # `session-db-classify`'s three typed answers are 30 (fatal)/31 (replica
60
- # unusable)/32 (retry) see `runner/synchroniser/src/cli.ts`'s own
58
+ # unusable)/32 (retry), extended by `session-db-verify`'s 33 (integrity
59
+ # exhausted, replica separated and local disposed) / 34 (could not prove
60
+ # separation or disposal) — see `runner/synchroniser/src/cli.ts`'s own
61
61
  # comment for what each means, not restated here. Any OTHER non-zero status
62
- # means the tool itself broke, which is the only case worth an ERROR here
63
- # EXCEPT 124/137 (a `timeout` deadline/SIGKILL) when the caller asked for one:
64
- # that is an intentional bound firing, not a broken tool, so the caller
65
- # classifies it instead (restore_credentials, #930). A trailing
66
- # `--evident-deadline=N` (stripped below before forwarding to the CLI, and
67
- # never produced by anything but timed_synchroniser) is how a caller opts in;
68
- # every other caller — sync_credentials, restore_session_db's `env` /
69
- # `litestream-config` / `session-db-classify`, and this file's own contract
70
- # test — passes none, so 124/137 there still means a genuine external SIGKILL
71
- # (e.g. an OOM kill) and must keep producing the ERROR below.
62
+ # means the tool itself broke, which is the only case worth an ERROR here.
72
63
  run_synchroniser() {
73
- local -a call_args=("$@")
74
- local deadline=""
75
- local last=$(( ${#call_args[@]} - 1 ))
76
- if [ "${last}" -ge 0 ] && [[ "${call_args[last]}" == --evident-deadline=* ]]; then
77
- deadline="${call_args[last]#--evident-deadline=}"
78
- call_args=("${call_args[@]:0:${last}}")
79
- fi
80
-
81
64
  local rc=0
82
- local -a launcher=()
83
- [ -n "${deadline}" ] && launcher=(timeout -k "${CREDENTIAL_RESTORE_KILL_GRACE_SECONDS}" "${deadline}")
84
- "${launcher[@]}" "${SYNCHRONISER}" "${call_args[@]}" || rc=$?
65
+ "${SYNCHRONISER}" "$@" || rc=$?
85
66
  case "${rc}" in
86
- 0 | 10 | 30 | 31 | 32) ;;
87
- 124 | 137)
88
- [ -n "${deadline}" ] || error "synchroniser '${call_args[*]}' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw"
89
- ;;
90
- *) error "synchroniser '${call_args[*]}' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw" ;;
67
+ 0 | 10 | 30 | 31 | 32 | 33 | 34) ;;
68
+ *) error "synchroniser '$*' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw" ;;
91
69
  esac
92
70
  return "${rc}"
93
71
  }
@@ -110,8 +88,8 @@ run_synchroniser() {
110
88
  # Never fails its caller: a timing line is diagnostics, and hardening a
111
89
  # currently-working path is worse than the gap it closes (#931). The `date`
112
90
  # fallback covers a pre-5.0 bash; a 0 means "could not read the clock", which
113
- # timed_synchroniser turns into `elapsed_ms=unknown` rather than aborting /run
114
- # under `set -e` — and rather than a 0 that would read as a fast healthy call.
91
+ # `log_elapsed_since` turns into `elapsed_ms=unknown` rather than aborting a
92
+ # hook under `set -e` — and rather than a 0 that would read as a fast healthy call.
115
93
  now_ms() {
116
94
  local now="${EPOCHREALTIME:-}"
117
95
  if [ -n "${now}" ]; then
@@ -122,16 +100,10 @@ now_ms() {
122
100
  date +%s%3N 2>/dev/null || echo 0
123
101
  }
124
102
 
125
- # Reports what one step COST, in a deliberately machine-greppable line (`op=`,
126
- # `elapsed_ms=`, `rc=`) so "how long does this actually take in the fleet?" is a
127
- # log query rather than another spike. `at_s` is where the step landed on the
128
- # hook's own SECONDS clock, which is what the pre-opencode budget in
129
- # hook-scripts.test.ts is derived against.
130
- #
131
- # Callable directly, not only through timed_synchroniser below, because the
132
- # steps that dominate the pre-opencode window CANNOT be wrapped: their stdout is
133
- # captured (`x="$(run_synchroniser env)"`, later `eval`'d), so a `log` line
134
- # emitted inside the substitution would be evaluated as configuration.
103
+ # Reports what one pre-warm COST, in a deliberately machine-greppable line
104
+ # (`op=`, `elapsed_ms=`, `rc=`) so "how long does this actually take in the
105
+ # fleet?" is a log query rather than another spike. `at_s` is where the step
106
+ # landed on the hook's own SECONDS clock.
135
107
  #
136
108
  # `unknown`, never a number, when either end failed to read the clock (now_ms's
137
109
  # 0). Subtracting them would report `elapsed_ms=0` — indistinguishable from a
@@ -150,26 +122,6 @@ log_elapsed_since() {
150
122
  log "SYNCHRONISER-TIMING op=${op} elapsed_ms=${elapsed_ms} rc=${rc} at_s=${SECONDS}"
151
123
  }
152
124
 
153
- # Runs ONE synchroniser call and reports what it COST, without changing what it
154
- # MEANS: the call's own status is returned untouched, so every caller's
155
- # `|| return 1` / `|| status=$?` keeps working exactly as before.
156
- #
157
- # `deadline` is an explicit parameter, forwarded to run_synchroniser as a
158
- # trailing `--evident-deadline=N` (never a global or dynamically-scoped
159
- # variable, #930) — every current caller (restore_credentials) always has a
160
- # positive remaining-budget value by the time it calls this, having already
161
- # decided to skip the call entirely otherwise.
162
- timed_synchroniser() {
163
- local op="$1" deadline="$2"
164
- shift 2
165
-
166
- local started_ms rc=0
167
- started_ms="$(now_ms)"
168
- run_synchroniser "$@" "--evident-deadline=${deadline}" || rc=$?
169
- log_elapsed_since "${op}" "${started_ms}" "${rc}"
170
- return "${rc}"
171
- }
172
-
173
125
  # Exports what `runner-synchroniser` resolves its object-store location from
174
126
  # (runner/synchroniser/src/config.ts). It treats either being empty as
175
127
  # "persistence disabled" and then reports every restore as a WARNING it still
@@ -191,271 +143,32 @@ load_state_config() {
191
143
 
192
144
  # Mirrors runner-synchroniser's own persistenceEnabled predicate (config.ts):
193
145
  # both non-empty, already checked above, so by this point PERSISTENCE_BUCKET
194
- # is just LITESTREAM_BUCKET. start_litestream and flush_session_db (#812
146
+ # is just LITESTREAM_BUCKET. The CLI's replicator and flush_session_db (#812
195
147
  # WI-3/WI-4) read ONLY this var, never LITESTREAM_BUCKET/LITESTREAM_PREFIX
196
148
  # directly, so every entry point agrees on one predicate regardless of which
197
- # earlier step in THIS hook process set it: restore_session_db's
198
- # `eval "$(run_synchroniser env)"` on /run, or this function on /resume,
199
- # /suspend and /terminate, none of which ever call the synchroniser before
200
- # needing the answer.
149
+ # earlier step in THIS hook process set it; /resume, /suspend and /terminate
150
+ # all use this same function before any teardown decision.
201
151
  export PERSISTENCE_BUCKET="${LITESTREAM_BUCKET}"
202
152
  }
203
153
 
204
- # /run starts no LLM task it restores state, starts opencode and dials the
205
- # tunnel, none of which needs a model credential. Which credential a turn needs
206
- # depends on the model that turn's message asks for, so that question has no
207
- # single boot-time answer; it is answered per message, not here. What IS still
208
- # fatal is credential persistence being unavailable at all (an unset bucket/
209
- # prefix, or a broken synchroniser bundle) — that would silently drop refreshed
210
- # tokens on every suspend, so those two restores stay `|| return 1`.
211
- # `model-auth-ready` therefore stays a boot-time diagnostic: its only output is
212
- # a log line, since the runtime forwards no hook stderr to the doorbell caller.
213
-
214
- # How long restore_credentials' shared step budget is, covering the runner-secret
215
- # fetch plus its three synchroniser calls (restore claude, restore opencode,
216
- # model-auth-ready) as one window rather than a timeout apiece — a fixed per-call cap at the same
217
- # total would false-fire on the ordinary case of one slow call (a slow S3 GET),
218
- # which is precisely the boot this exists to keep healthy. From #930's 3-boot
219
- # sample: the step measured ~3-5s total (~1.2s per synchroniser call, three
220
- # node cold starts of a 1.8 MB bundle) — a WEAK estimate this file's own
221
- # SYNCHRONISER-TIMING lines are what will sharpen for real. Too tight and a
222
- # routine slow call boots this VM with no model credentials until an operator
223
- # reconnects it; too loose and a hung call burns more of the hook's own
224
- # SIGTERM budget before the VM is destroyed mid-boot anyway. The env override
225
- # is for tests only, so they need not burn wall clock.
226
- CREDENTIAL_RESTORE_DEADLINE_SECONDS="${EVIDENT_CREDENTIAL_RESTORE_DEADLINE_SECONDS:-8}"
227
- # The SIGKILL backstop `timeout -k` applies after its own SIGTERM, exactly like
228
- # SESSION_DB_RESTORE_KILL_GRACE_SECONDS above (a bare `timeout` only SIGTERMs,
229
- # and a call that ignored it would be unbounded again). Hard ceiling on the
230
- # step: DEADLINE + this = 10s, once — not per call.
231
- CREDENTIAL_RESTORE_KILL_GRACE_SECONDS=2
232
- GITHUB_PROBE_DEADLINE_SECONDS="${EVIDENT_GITHUB_PROBE_DEADLINE_SECONDS:-10}"
233
-
234
- # What is left of the shared step budget, in whole seconds, `step_started_s`
235
- # seconds after it began. `SECONDS` (a bash builtin with no failure mode,
236
- # unlike `now_ms`) truncates, so a step that began at true time `s0` can read
237
- # less elapsed time than actually passed — the `- 1` restores the invariant
238
- # that no call is granted more than DEADLINE + GRACE from step start.
239
- remaining_credential_budget() {
240
- local step_started_s="$1"
241
- echo $(( CREDENTIAL_RESTORE_DEADLINE_SECONDS - (SECONDS - step_started_s) - 1 ))
242
- }
243
-
244
- # Runs one of the step's bounded calls: skips it with a named warn if the
245
- # shared budget is already exhausted, treats a timeout (124/137) as the
246
- # non-fatal warn-and-continue D1 approved, and keeps every other non-zero
247
- # fatal exactly as restore_credentials always has (`|| return 1` aborts /run
248
- # before opencode and the tunnel start).
249
- bounded_restore_call() {
250
- local op="$1" step_started_s="$2"
251
- shift 2
252
-
253
- local remaining
254
- remaining="$(remaining_credential_budget "${step_started_s}")"
255
- if [ "${remaining}" -lt 1 ]; then
256
- warn "CREDENTIAL-RESTORE-SKIPPED: ${op} skipped; the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore budget is exhausted"
257
- return 0
258
- fi
259
-
260
- local rc=0
261
- timed_synchroniser "${op}" "${remaining}" "$@" || rc=$?
262
- case "${rc}" in
263
- 0) ;;
264
- 124 | 137)
265
- warn "CREDENTIAL-RESTORE-TIMEOUT: ${op} did not finish within its ${remaining}s share of the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore deadline; continuing without it"
266
- ;;
267
- *) return 1 ;;
268
- esac
269
- return 0
270
- }
271
-
272
- fetch_runner_secret() {
273
- local step_started_s="$1" remaining rc=0 payload value stderr_file started_ms populated=0 missing=0
274
- if [ -z "${RUNNER_SECRET_ARN:-}" ]; then
275
- log "runner secret is not configured; continuing without GitHub and MCP credentials"
276
- return 0
277
- fi
278
- remaining="$(remaining_credential_budget "${step_started_s}")"
279
- if [ "${remaining}" -lt 1 ]; then
280
- warn "CREDENTIAL-RESTORE-SKIPPED: runner-secret skipped; the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore budget is exhausted"
281
- return 0
282
- fi
283
- if ! stderr_file="$(mktemp /dev/shm/runner-secret-stderr.XXXXXX)"; then
284
- warn "RUNNER-SECRET-STDERR-UNAVAILABLE: could not allocate diagnostic storage; continuing without runner credentials"
285
- return 0
286
- fi
287
- started_ms="$(now_ms)"
288
- payload="$(timeout -k "${CREDENTIAL_RESTORE_KILL_GRACE_SECONDS}" "${remaining}" aws secretsmanager get-secret-value --secret-id "${RUNNER_SECRET_ARN}" --query SecretString --output text 2>"${stderr_file}")" || rc=$?
289
- log_elapsed_since runner-secret-fetch "${started_ms}" "${rc}"
290
- if [ "${rc}" -eq 124 ] || [ "${rc}" -eq 137 ]; then
291
- warn "CREDENTIAL-RESTORE-TIMEOUT: runner-secret did not finish within its ${remaining}s share of the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore deadline; continuing without it"
292
- rm -f "${stderr_file}"
293
- return 0
294
- fi
295
- if [ "${rc}" -ne 0 ]; then
296
- warn "RUNNER-SECRET-UNREADABLE: $(<"${stderr_file}")"
297
- rm -f "${stderr_file}"
298
- return 0
299
- fi
300
- rm -f "${stderr_file}"
301
- if ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"${payload}"; then
302
- warn "RUNNER-SECRET-UNPARSEABLE: secret value is not a JSON object"
303
- return 0
304
- fi
305
- local -a populated_keys=()
306
- for key in GH_TOKEN BRAVE_API_KEY CLOUDFLARE_API_TOKEN NEON_API_KEY; do
307
- value="$(jq -r --arg k "${key}" '.[$k] // empty' <<<"${payload}")"
308
- if [ -n "${value}" ]; then
309
- export "${key}=${value}"
310
- populated=$((populated + 1))
311
- populated_keys+=("${key}")
312
- else
313
- missing=$((missing + 1))
314
- fi
315
- done
316
- if [ "${populated}" -eq 0 ]; then
317
- warn "RUNNER-SECRET-UNPOPULATED: populate the runner secret as documented in infrastructure/evident-microvm/README.md"
318
- else
319
- log "RUNNER-SECRET-OK: populated ${populated_keys[*]}; ${missing} allow-listed keys empty or absent"
320
- fi
321
- return 0
322
- }
323
-
324
- restore_credentials() {
325
- local step_started_ms step_started_s
326
- step_started_ms="$(now_ms)"
327
- step_started_s="${SECONDS}"
328
-
329
- load_state_config || return 1
330
- # This shares the existing bounded window so /run's worst-case duration does not grow.
331
- fetch_runner_secret "${step_started_s}"
332
- bounded_restore_call restore-claude "${step_started_s}" restore claude || return 1
333
- bounded_restore_call restore-opencode "${step_started_s}" restore opencode || return 1
334
-
335
- # `restore` exits 0 whether it restored, found nothing, or discarded a corrupt
336
- # object, so the synchroniser's own predicate is the only reliable answer to
337
- # "can this VM authenticate a model right now". Its status is captured, not
338
- # used as a condition, so it can never become this function's return status —
339
- # `run` calls this bare (no `||`) under `set -euo pipefail`, and any
340
- # non-zero return here would abort the hook before opencode and the tunnel
341
- # ever start.
342
- local auth_status=0 auth_remaining
343
- auth_remaining="$(remaining_credential_budget "${step_started_s}")"
344
- if [ "${auth_remaining}" -lt 1 ]; then
345
- warn "CREDENTIAL-RESTORE-SKIPPED: model-auth-ready skipped; the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore budget is exhausted"
346
- else
347
- timed_synchroniser model-auth-ready "${auth_remaining}" model-auth-ready || auth_status=$?
348
- case "${auth_status}" in
349
- 0) ;; # some model auth is configured; the two restores above already logged what they found
350
- 10)
351
- warn "no model credentials under s3://${LITESTREAM_BUCKET}/${LITESTREAM_PREFIX}/ \
352
- (neither claude/credentials.json nor opencode/auth.json yielded valid JSON) and neither \
353
- ANTHROPIC_API_KEY nor OPENAI_API_KEY is set. This VM boots and connects; a turn that needs \
354
- a model provider fails until one is connected. See 'Seeding a credential store' in \
355
- infrastructure/evident-microvm/README.md."
356
- ;;
357
- 124 | 137)
358
- warn "CREDENTIAL-RESTORE-TIMEOUT: model-auth-ready did not finish within its ${auth_remaining}s share of the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore deadline; continuing without it"
359
- ;;
360
- *)
361
- # run_synchroniser has already `error`ed the tool-broke line for a
362
- # broken tool; any other unrecognised code lands here too. Do not
363
- # claim there are no credentials — absent evidence is not contrary
364
- # evidence (development-workflow.mdc).
365
- warn "could not determine whether this VM has model credentials"
366
- ;;
367
- esac
368
- fi
369
- # The step total, in the SAME greppable shape as the per-call lines above, so
370
- # one query answers both "what does credential restore cost?" and "which of
371
- # its four operations cost it". Bounded now by CREDENTIAL_RESTORE_DEADLINE_SECONDS
372
- # + CREDENTIAL_RESTORE_KILL_GRACE_SECONDS as one shared window (see the
373
- # comment above that constant) rather than per call. `rc=0` is deliberate
374
- # here, not an absence of failure modes: a timed-out call already warned by
375
- # name above and is non-fatal by design (D1); every failure that DOES abort
376
- # this step has already returned 1 above.
377
- log_elapsed_since credential-restore-step "${step_started_ms}" 0
378
- return 0
379
- }
380
-
381
- apply_runner_opencode_config() {
382
- if [ -z "${RUNNER_OPENCODE_CONFIG:-}" ]; then
383
- log "runner OpenCode config is not configured; using the baked project config"
384
- return 0
385
- fi
386
- local source="${RUNNER_OPENCODE_CONFIG}" target="opencode.json"
387
- [[ "${source}" = /* ]] || source="${WORKSPACE}/${source}"
388
- [ -f "${WORKSPACE}/opencode.jsonc" ] && target="opencode.jsonc"
389
- if [ ! -f "${source}" ]; then
390
- error "RUNNER-OPENCODE-CONFIG-MISSING: ${source} is not a file; headless turns will wedge on the first external-directory permission prompt (#563)"
391
- return 0
392
- fi
393
- cp "${source}" "${WORKSPACE}/${target}"
394
- git -C "${WORKSPACE}" update-index --skip-worktree "${target}" 2>/dev/null \
395
- || warn "could not mark ${target} skip-worktree; it may show as a local change"
396
- log "Applied runner OpenCode config ${source} to ${WORKSPACE}/${target}"
397
- }
398
-
399
- configure_github_access() {
400
- if [ -z "${GH_TOKEN:-}" ]; then
401
- warn "GITHUB-CREDENTIALS-MISSING: GH_TOKEN is unavailable; see RUNNER-SECRET-* above"
402
- return 0
403
- fi
404
- export GIT_CONFIG_GLOBAL=/tmp/gitconfig
405
- if ! : >"${GIT_CONFIG_GLOBAL}" ||
406
- ! git config --global user.name "${GIT_USER_NAME:-evident-bot}" ||
407
- ! git config --global user.email "${GIT_USER_EMAIL:-evident-bot@users.noreply.github.com}" ||
408
- ! git config --global init.defaultBranch main ||
409
- ! printf '%s\n' '#!/usr/bin/env bash' '[ "$1" = get ] || exit 0' 'echo username=x-access-token' 'echo "password=${GH_TOKEN}"' >/tmp/git-credential-helper.sh ||
410
- ! chmod 0700 /tmp/git-credential-helper.sh ||
411
- ! git config --global credential."https://github.com".helper /tmp/git-credential-helper.sh; then
412
- warn "GITHUB-SETUP-FAILED: could not configure local git credentials; continuing without GitHub access"
413
- return 0
414
- fi
415
- (
416
- local output rc=0 login repo_url repo
417
- output="$(timeout -k 1 "${GITHUB_PROBE_DEADLINE_SECONDS}" gh api user --jq .login 2>&1)" || rc=$?
418
- if [ "${rc}" -eq 124 ] || [ "${rc}" -eq 137 ]; then warn "GITHUB-PROBE-TIMEOUT: auth probe exceeded ${GITHUB_PROBE_DEADLINE_SECONDS}s"; return; fi
419
- if [ "${rc}" -ne 0 ]; then warn "GITHUB-AUTH-REJECTED: ${output}"; return; fi
420
- log "GITHUB-AUTH-OK: ${output}"
421
- repo_url="$(git -C "${WORKSPACE}" remote get-url origin 2>/dev/null || true)"
422
- repo="$(printf '%s' "${repo_url}" | sed -E 's#(https://github.com/|git@github.com:)##; s#\.git$##')"
423
- [ -n "${repo}" ] || return
424
- output="$(timeout -k 1 "${GITHUB_PROBE_DEADLINE_SECONDS}" gh api "repos/${repo}" --jq .full_name 2>&1)" || rc=$?
425
- if [ "${rc}" -eq 124 ] || [ "${rc}" -eq 137 ]; then warn "GITHUB-PROBE-TIMEOUT: repository probe for ${repo} exceeded ${GITHUB_PROBE_DEADLINE_SECONDS}s"; return; fi
426
- [ "${rc}" -eq 0 ] || warn "GITHUB-REPO-INACCESSIBLE: ${repo}: ${output}"
427
- ) &
428
- }
429
-
430
- # Best-effort by design: /suspend must still drop the tunnel and /terminate must
431
- # still clean up, so a flush that cannot happen is loud but never fatal.
154
+ # Best-effort by design: the teardown hooks must still complete even when a
155
+ # credential flush cannot happen.
432
156
  sync_credentials() {
433
157
  load_state_config || return 0
434
158
  run_synchroniser sync-once claude || true
435
159
  run_synchroniser sync-once opencode || true
436
160
  }
437
161
 
438
- # --- Session DB restore (#812 WI-2) ----------------------------------------
439
- #
440
- # Restores opencode.db from S3 before opencode opens it — the read half only.
441
- # Writes NOTHING to S3 itself; replication back to S3 is started separately by
442
- # `start_litestream` (below) from the `run`/`resume` hooks. `ensure_litestream_config`/
443
- # `restore_session_db` are the MicroVM side of the same contract
444
- # runner/docker-images/fargate/entrypoint.sh's inlined restore already speaks, going
445
- # through the SAME runner-synchroniser CLI.
162
+ # --- boot pre-warms ----------------------------------------------------------
446
163
 
447
164
  # Reads the ~30 MB litestream binary into the page cache, in the background, so
448
165
  # the FIRST exec of it does not pay that read on the critical path.
449
166
  #
450
- # Why it is on the critical path at all: restore_session_db must finish before
451
- # start_opencode, and HOOK_SIGTERM_SECONDS is a POINT on the hook's SECONDS
452
- # clock, so a second spent here is a second taken from the rest of /run — and a
453
- # /run that overruns is SIGTERMed mid-boot and its VM destroyed. (Until #1172
454
- # the binding point was the tighter in-hook opencode readiness deadline; that
455
- # deadline is gone, the hook's own SIGTERM is not.)
456
- # Boot measurements put ~6.6-7.3s between the credential restore and the
457
- # litestream version line, of which only ~0.2s is accounted for by the two node
458
- # calls in between — the remainder is INFERRED to be this read, never measured.
167
+ # The pre-warm overlaps the CLI's node startup and auth round trip, so the first
168
+ # litestream operation does not also pay the binary's cold page-cache read.
169
+ # Boot measurements put ~6.6-7.3s before the litestream version line, of which
170
+ # only ~0.2s is accounted for by the two node calls in between — the remainder
171
+ # is INFERRED to be this read, never measured.
459
172
  # The `litestream-prewarm` and `litestream-version` timings are what settle it
460
173
  # on the next boot.
461
174
  #
@@ -482,246 +195,40 @@ prewarm_litestream() {
482
195
  log "pre-warming ${binary} in the background"
483
196
  }
484
197
 
485
- # Generates ${LITESTREAM_CONFIG_FILE} from runner-synchroniser's own renderer
486
- # the SAME config module the credential restore/sync already goes through,
487
- # so there is no second copy of the bucket/prefix/path logic to drift
488
- # (litestream-config.ts's own header makes the same point). Only when absent
489
- # or empty, so `/resume` (WI-4) restarting litestream after a suspend/resume
490
- # snapshot — /dev/shm intact — pays nothing to regenerate it.
491
- #
492
- # Returns 1 on failure — fatal to the CALLER, never to the hook: every caller
493
- # (restore_session_db here, start_litestream in WI-3) must still let opencode
494
- # and the tunnel start regardless.
495
- ensure_litestream_config() {
496
- if [ -s "${LITESTREAM_CONFIG_FILE}" ]; then
497
- return 0
498
- fi
499
-
500
- # Timed OUTSIDE the substitution, not with timed_synchroniser: this call's
501
- # stdout IS the config, so a timing line emitted inside `$(...)` would land in
502
- # litestream.yml.
503
- local rendered config_started_ms config_rc=0
504
- config_started_ms="$(now_ms)"
505
- rendered="$(run_synchroniser litestream-config)" || config_rc=$?
506
- log_elapsed_since litestream-config "${config_started_ms}" "${config_rc}"
507
- if [ "${config_rc}" -ne 0 ]; then
508
- error "could not generate ${LITESTREAM_CONFIG_FILE}: runner-synchroniser litestream-config failed (see the error above)"
509
- return 1
510
- fi
511
- printf '%s\n' "${rendered}" >"${LITESTREAM_CONFIG_FILE}" || {
512
- error "could not write ${LITESTREAM_CONFIG_FILE}"
513
- return 1
514
- }
515
-
516
- # Q7's diagnostic, for whoever can read this VM's CloudWatch log: which way
517
- # litestream resolved its AWS region, and whether it can even see the
518
- # names it would need to (never the VALUES of any OTHER AWS_* variable —
519
- # AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN may be present here).
520
- local region_empty="yes"
521
- if printf '%s\n' "${rendered}" | grep -Eq '^ *region: *[^[:space:]]'; then
522
- region_empty="no"
523
- fi
524
- # Hoisted out of the log line below so it can be timed: this is the FIRST exec
525
- # of the litestream binary on the boot, and the pre-warm /run fires (see
526
- # prewarm_litestream) is aimed squarely at what this number measures.
527
- local version_started_ms litestream_version
528
- version_started_ms="$(now_ms)"
529
- litestream_version="$(litestream version 2>/dev/null || echo 'unknown')"
530
- log_elapsed_since litestream-version "${version_started_ms}" 0
531
- log "litestream ${litestream_version}; AWS_REGION=${AWS_REGION:-<unset>} AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-<unset>}; rendered litestream.yml region empty: ${region_empty}"
532
- }
533
-
534
- # Writes the marker AND names the reason, in one call, so the marker can never
535
- # appear silently. Every give-up path in restore_session_db calls this EXCEPT
536
- # the classifier's 31 (#1106 — see that branch), and no success path ever does.
537
- # It is what a later `/resume`/`start_litestream` (WI-3) reads to skip
538
- # replicating this boot: a local DB that is only PARTIALLY restored must never
539
- # be allowed to overwrite a replica this boot never proved safe to write over.
540
- # A merely FRESH local DB is not that danger — litestream continues the txid
541
- # chain — which is precisely why 31 no longer belongs here.
542
- mark_no_replicate() {
543
- : >"${SESSION_DB_NO_REPLICATE_MARKER}"
544
- warn "SESSION-DB-NO-REPLICATE: $1"
545
- }
546
-
547
- # Removes local session-DB debris a give-up path leaves behind. Called on
548
- # EVERY give-up path and NO success path: skipping it on a give-up is a real
549
- # bug (a truncated file left in place makes opencode fail to open a malformed
550
- # database, which fails /run outright — worse than the history loss this
551
- # discards); calling it on a success path would delete a good restore. One
552
- # `rm -f` per path, not a single command, so a failure removing one does not
553
- # skip the other two.
554
- discard_session_db_debris() {
555
- rm -f "${OPENCODE_DB_PATH}" "${OPENCODE_DB_PATH}-wal" "${OPENCODE_DB_PATH}-shm"
556
- }
557
-
558
- # How long restore_session_db waits for ONE `litestream restore` before giving
559
- # up. From M4 (see the plan's §2): ~50 MB/s extrapolated from the x86_64 ECS
560
- # container's own live replica, so 7s covers roughly 300-350 MB — an
561
- # EXTRAPOLATION, not a MicroVM measurement (different arch, network path and
562
- # credential source); the boot line ensure_litestream_config logs, plus the
563
- # ${SECONDS} stamps in /run, are how it gets measured here for real.
198
+ # Reads the aws CLI v2 install tree into the page cache, in the background, so
199
+ # the CLI's first runner-secret fetch does not pay first-touch I/O. The read
200
+ # overlaps `evident run`'s node startup and auth round trip rather than a hook
201
+ # deadline.
564
202
  #
565
- # 7 rather than the 8 it was: hook-scripts.test.ts's pre-opencode ladder held an
566
- # allowance of 4s for the rest of this step, which live boots measured at
567
- # 6.7-7.4s, so the ladder passed while real boots blew the deadline. Correcting
568
- # that allowance is what takes this second — the remedy the ladder's own failure
569
- # message prescribes, and the one that leaves the SIGTERM worst case untouched.
570
- # The cost is real and unmeasured on this platform: ~50 MB less restorable
571
- # replica before a truncation that silently loses history. A fresh
572
- # state_prefix starts at zero, so this is generous for a long time, but
573
- # RoutingStrategy is 'per_user' only (routing-strategy.ts) and every prefix is
574
- # deterministic and permanent per (pool, routing key) — so a long-lived
575
- # runner's opencode.db grows monotonically across VM generations, exactly
576
- # like the ~1 GB ECS one M4 measured, and will eventually hit this ceiling.
577
- # Past it the restore is truncated (SESSION-DB-RESTORE-TRUNCATED, below) and
578
- # this boot skips replication — silent, permanent history loss for that
579
- # runner unless somebody reads the log. Not solved here (WI-0 Task 0.2).
580
- # The env override is for tests only, so they need not burn 7s of wall clock.
581
- SESSION_DB_RESTORE_DEADLINE_SECONDS="${EVIDENT_SESSION_DB_RESTORE_DEADLINE_SECONDS:-7}"
582
- # The SIGKILL backstop `timeout -k` applies after its own SIGTERM: `timeout`
583
- # alone only SIGTERMs, and a litestream that ignored it would be unbounded
584
- # again. Hard ceiling on this step: DEADLINE + this = 9s.
585
- SESSION_DB_RESTORE_KILL_GRACE_SECONDS=2
586
-
587
- # The one bounded restore attempt, run before start_opencode (opencode opens
588
- # the DB the moment it starts, so this is the only place it can happen).
589
- # Every branch returns 0 — /run calls this bare under `set -euo pipefail`,
590
- # exactly like restore_credentials, and nothing about the session DB may ever
591
- # fail /run (Q3): a /run that exits 1 fails the whole lifecycle transition
592
- # and the user gets NO runner at all, which is worse than losing history.
593
- restore_session_db() {
594
- # Cleanup, not a decision, and unconditionally first: /dev/shm survives
595
- # suspend/resume and a /run retry, so a marker left by an earlier boot must
596
- # never silently disable replication for the rest of this VM's life.
597
- rm -f "${SESSION_DB_NO_REPLICATE_MARKER}"
598
-
599
- # Config resolved ONCE here, in the synchroniser (config.ts owns
600
- # OPENCODE_DB_PATH; no second copy of that path here), exactly as
601
- # entrypoint.sh's own line does. Unlike ECS's `|| die`, a failure here must
602
- # not abort the hook — `x="$(cmd)"` under `set -e` WOULD abort the whole
603
- # shell on a failing substitution, which is exactly why it is caught
604
- # explicitly rather than left to `set -e`.
605
- # Timed OUTSIDE the substitution for the same reason ensure_litestream_config
606
- # is: this call's stdout is `eval`'d, so a timing line emitted inside `$(...)`
607
- # would be evaluated as configuration.
608
- local synchroniser_env env_started_ms env_rc=0
609
- env_started_ms="$(now_ms)"
610
- synchroniser_env="$(run_synchroniser env)" || env_rc=$?
611
- log_elapsed_since session-db-env "${env_started_ms}" "${env_rc}"
612
- if [ "${env_rc}" -ne 0 ]; then
613
- mark_no_replicate "could not resolve the runner-synchroniser configuration (see the ERROR above)"
614
- return 0
615
- fi
616
- # Guarded for the SAME reason as the substitution above, which is easy to miss:
617
- # `eval` returns the status of what it ran, so a malformed line from a skewed
618
- # bundle would abort /run right here under `set -e` — before start_opencode,
619
- # so the user gets NO runner at all. entrypoint.sh:110 leaves the identical
620
- # line bare because on ECS the blast radius is a crash-loop-and-replace; here
621
- # it is the whole lifecycle transition, so Q3 makes it a give-up instead.
622
- eval "${synchroniser_env}" || {
623
- mark_no_replicate "the runner-synchroniser configuration could not be evaluated; the installed bundle likely does not match this hook"
624
- return 0
625
- }
626
-
627
- # #931's exact lesson, one shell over (runner/docker-images/fargate/entrypoint.sh):
628
- # the `|| { ... }` above only catches a non-zero EXIT — an `env` that exits
629
- # 0 with an INCOMPLETE contract (a runner-synchroniser version/build skew)
630
- # would otherwise abort right here under `set -u` the moment
631
- # `${OPENCODE_DB_PATH}` is dereferenced below. entrypoint.sh may `die` on
632
- # this (ECS just crash-loops and replaces the task); this hook cannot —
633
- # Q3 says nothing about the session DB may ever fail /run — so a missing
634
- # OPENCODE_DB_PATH is a give-up, not a silent default (a guessed path would
635
- # be actively wrong, not merely absent).
636
- if [ -z "${OPENCODE_DB_PATH+x}" ]; then
637
- mark_no_replicate "run_synchroniser env did not define OPENCODE_DB_PATH; the installed runner-synchroniser build likely does not match this hook"
638
- return 0
639
- fi
640
-
641
- # #770's arm, checked FIRST of the decisions and with its OWN message —
642
- # never the SESSION-DB-REPLICA-UNUSABLE one below (#764's finding: an
643
- # operator grepping that marker must not hit the disabled case).
644
- # PERSISTENCE_BUCKET is empty exactly when LITESTREAM_BUCKET or
645
- # LITESTREAM_PREFIX is unset (config.ts's persistenceEnabled) — the
646
- # IDENTICAL predicate ECS's own litestream-launch gate uses
647
- # (entrypoint.sh), so the two images agree on what "disabled" means.
648
- if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
649
- warn "SESSION-DB-PERSISTENCE-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; opencode starts with a fresh session DB and nothing is replicated."
650
- return 0
651
- fi
652
-
653
- if ! ensure_litestream_config; then
654
- mark_no_replicate "could not generate ${LITESTREAM_CONFIG_FILE} (see the error above)"
203
+ # A single `cat` of the `aws` entrypoint (litestream's pattern above) is NOT
204
+ # enough here: v2 ships as a real Python distribution (~7,500 files under the
205
+ # resolved binary's own directory), and a cold invocation demand-pages a
206
+ # scattered set of them (botocore's endpoints.json/partitions.json, service
207
+ # model JSON, shared libs) one boot measured #1997's fetch at 7,243ms cold
208
+ # vs ~400ms warm, an ~18x gap the litestream read-ahead trick alone cannot
209
+ # close. `find -exec cat` walks that whole tree instead of one file.
210
+ prewarm_aws_cli() {
211
+ local binary tree
212
+ binary="$(command -v aws 2>/dev/null || true)"
213
+ if [ -z "${binary}" ]; then
214
+ warn "aws CLI is not on PATH; skipping the boot pre-warm"
655
215
  return 0
656
216
  fi
217
+ tree="$(dirname "$(readlink -f "${binary}")")"
657
218
 
658
- local restore_rc=0 restore_started_ms
659
- restore_started_ms="$(now_ms)"
660
- timeout -k "${SESSION_DB_RESTORE_KILL_GRACE_SECONDS}" "${SESSION_DB_RESTORE_DEADLINE_SECONDS}" \
661
- litestream restore -config "${LITESTREAM_CONFIG_FILE}" \
662
- -if-db-not-exists -if-replica-exists "${OPENCODE_DB_PATH}" || restore_rc=$?
663
- log_elapsed_since session-db-restore "${restore_started_ms}" "${restore_rc}"
664
-
665
- # `timeout`'s OWN codes, handled BEFORE classifying: 124/137 are a
666
- # TRUNCATED restore, not a corrupt replica — handing them to
667
- # session-db-classify would mislabel a slow/large replica as one. 125-127
668
- # mean `timeout` (or litestream itself) is broken, not the replica.
669
- case "${restore_rc}" in
670
- 124 | 137)
671
- discard_session_db_debris
672
- mark_no_replicate "SESSION-DB-RESTORE-TRUNCATED: litestream restore did not finish within the ${SESSION_DB_RESTORE_DEADLINE_SECONDS}s deadline (+${SESSION_DB_RESTORE_KILL_GRACE_SECONDS}s kill grace), ${SECONDS}s into the hook; opencode starts with a fresh session DB and nothing is replicated this boot"
673
- return 0
674
- ;;
675
- 125 | 126 | 127)
676
- error "litestream restore could not even run (timeout exited ${restore_rc})"
677
- discard_session_db_debris
678
- mark_no_replicate "restore tool is broken (timeout exited ${restore_rc}); opencode starts with a fresh session DB and nothing is replicated this boot"
679
- return 0
680
- ;;
681
- esac
682
-
683
- local classify_rc=0
684
- run_synchroniser session-db-classify "${restore_rc}" 1 --on-unusable-replica=leave --fresh-db-fallback || classify_rc=$?
685
- case "${classify_rc}" in
686
- 0) ;; # restored, or no replica yet — the CLI already logged which
687
- 31)
688
- # The classifier already discarded the local debris (session-db.ts) — and
689
- # that is exactly why this is the ONE give-up path that does NOT
690
- # mark_no_replicate (#1106). Every other give-up here may be sitting on a
691
- # half-restored DB, so the marker stays; a 31 is structurally guaranteed
692
- # to be a FRESH one, and replicating it starts a new backup chain instead
693
- # of leaving this boot with a zero-width backup window. Same reasoning,
694
- # and same one-line change, as runner/docker-images/fargate/entrypoint.sh's `31)`.
695
- warn "SESSION-DB-REPLICA-UNUSABLE: booting with a fresh opencode.db and replicating into the existing prefix this boot (see the WARNING above)"
696
- ;;
697
- 32)
698
- # The CLI's own contract for 32 is "re-run litestream restore and ask
699
- # again", but this hook has budget for exactly ONE attempt (Q4) — name
700
- # the deviation so nobody reads this as a bug.
701
- discard_session_db_debris
702
- mark_no_replicate "session-db-classify asked for another restore attempt (32), but this hook has budget for only one; treating it as a give-up rather than retrying"
703
- ;;
704
- 30)
705
- # The CLI already logged its own FATAL line above; Q3 still says boot
706
- # fresh rather than fail /run.
707
- discard_session_db_debris
708
- mark_no_replicate "session-db-classify returned fatal (30); see the FATAL message above"
709
- ;;
710
- *)
711
- # run_synchroniser already logged the "tool broke" ERROR for this.
712
- discard_session_db_debris
713
- mark_no_replicate "session-db-classify exited ${classify_rc}, which is none of its documented answers"
714
- ;;
715
- esac
716
-
717
- return 0
219
+ (
220
+ local started_ms rc=0
221
+ started_ms="$(now_ms)"
222
+ find "${tree}" -type f -exec cat {} + >/dev/null 2>&1 || rc=$?
223
+ log_elapsed_since aws-cli-prewarm "${started_ms}" "${rc}"
224
+ ) &
225
+ log "pre-warming ${tree} in the background"
718
226
  }
719
- # --- Session DB restore (end) -----------------------------------------------
720
227
 
721
228
  # `kill -0` answers "does this pid exist", which is not the question any caller
722
229
  # here is asking. A process that has exited but has not been reaped — a zombie —
723
230
  # still exists, so `kill -0` reports a corpse as ALIVE. That condition is the
724
- # normal case for everything these hooks start: `start_tunnel`/`start_opencode`
231
+ # normal case for everything these hooks start: `start_tunnel` and its CLI child
725
232
  # background a process that outlives the hook, the hook shell must return so AWS
726
233
  # gets its 200, and PID 1 in this image is a bare node hook server with no init
727
234
  # (Dockerfile) — so nothing ever wait()s for the orphan. It stayed a zombie for
@@ -810,6 +317,7 @@ regenerate_machine_id() {
810
317
  tunnel_is_running() { is_running "${TUNNEL_PID_FILE}"; }
811
318
  opencode_is_running() { is_running "${OPENCODE_PID_FILE}"; }
812
319
  litestream_is_running() { is_running "${LITESTREAM_PID_FILE}"; }
320
+ creds_sync_is_running() { is_running "${CREDS_SYNC_PID_FILE}"; }
813
321
 
814
322
  # `jq -e` alone is not enough: its exit status reflects the LAST OUTPUT VALUE,
815
323
  # and an interpolation of a missing field is still a non-empty string, so a
@@ -823,24 +331,6 @@ payload_is_complete() {
823
331
  ' >/dev/null 2>&1
824
332
  }
825
333
 
826
- # `setsid` so opencode outlives this hook: the script must return so AWS gets its
827
- # 200, while opencode keeps serving. The guard makes /run idempotent — a retry
828
- # after a killed hook must not put a second instance on the same port.
829
- start_opencode() {
830
- if opencode_is_running; then
831
- warn "opencode already running (pid $(cat "${OPENCODE_PID_FILE}")); reusing it"
832
- return 0
833
- fi
834
-
835
- setsid opencode serve \
836
- --hostname 127.0.0.1 \
837
- --port "${OPENCODE_PORT}" \
838
- --print-logs &
839
-
840
- echo $! >"${OPENCODE_PID_FILE}"
841
- log "opencode starting (pid $(cat "${OPENCODE_PID_FILE}"))"
842
- }
843
-
844
334
  stop_opencode() {
845
335
  if ! opencode_is_running; then
846
336
  # Clear the file here too, for the same reason stop_tunnel does: opencode
@@ -905,69 +395,11 @@ stop_opencode_and_wait() {
905
395
  log "opencode stopped ${outcome}"
906
396
  }
907
397
 
908
- # --- litestream replicate (#812 WI-3) ---------------------------------------
909
- #
910
- # The write half of session-DB persistence. restore_session_db (above) is the
911
- # read half only and never starts this. Q8 chose one backgrounded, unsupervised
912
- # `litestream replicate` over a periodic flush or a `replicate -exec` wrapper
913
- # around opencode: SIGTERM is `replicate`'s own final-sync trigger, so
914
- # stop_litestream (below) doubles as the checked flush /suspend and /terminate
915
- # need, and there is no supervisor in this image to hand the process to (no
916
- # init, no `wait -n` shell that could stay alive after the hook returns).
917
-
918
398
  # The graceful wait before the SIGKILL backstop in stop_litestream, below.
919
399
  # litestream's own sync is normally sub-second, so 10s is generous headroom —
920
400
  # chosen the same way TUNNEL_STOP_WAIT_SECONDS was, not a measured worst case.
921
401
  LITESTREAM_STOP_WAIT_SECONDS="${EVIDENT_LITESTREAM_STOP_WAIT_SECONDS:-10}"
922
402
 
923
- # Guards, in this exact order and no other — reordering any of them re-arms the
924
- # invariant it exists to protect:
925
- #
926
- # 1. persistence disabled (#770) — bucket AND prefix both non-empty — is its
927
- # OWN arm with its OWN message, checked FIRST, and must never log
928
- # SESSION-DB-REPLICA-UNUSABLE: an operator grepping that marker must not
929
- # hit the disabled case (#764).
930
- # 2. the no-replicate marker (restore_session_db above) — set when this boot's
931
- # local DB may be only PARTIALLY restored, so starting replicate here would
932
- # let it overwrite a replica this boot never proved safe to write over.
933
- # A classifier 31 no longer sets it (#1106): that DB is guaranteed fresh,
934
- # and skipping replicate for it was what made the history loss unbounded.
935
- # 3. no usable config — ensure_litestream_config (restore_session_db, above)
936
- # already logged why it is missing; this is not a place to retry it.
937
- # 4. already running — idempotence, mirroring start_tunnel's own guard.
938
- #
939
- # Only past all four does it actually spawn, backgrounded with `setsid` for the
940
- # same reason start_opencode/start_tunnel are: the hook must return so AWS gets
941
- # its 200, while replicate keeps running. litestream's OWN stderr is left to
942
- # reach CloudWatch — never redirected — because there is no supervisor in this
943
- # image, so that is the only channel that can say why a replicator died.
944
- start_litestream() {
945
- if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
946
- warn "SESSION-DB-PERSISTENCE-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; nothing is replicated."
947
- return 0
948
- fi
949
-
950
- if [ -e "${SESSION_DB_NO_REPLICATE_MARKER}" ]; then
951
- log "skipping litestream replicate: this boot's session DB was not proven safe to replicate (see the SESSION-DB-* warning above)"
952
- return 0
953
- fi
954
-
955
- if [ ! -s "${LITESTREAM_CONFIG_FILE}" ]; then
956
- error "no usable ${LITESTREAM_CONFIG_FILE}; not starting litestream replicate"
957
- return 0
958
- fi
959
-
960
- if litestream_is_running; then
961
- warn "litestream already running (pid $(cat "${LITESTREAM_PID_FILE}")); reusing it"
962
- return 0
963
- fi
964
-
965
- setsid litestream replicate -config "${LITESTREAM_CONFIG_FILE}" &
966
-
967
- echo $! >"${LITESTREAM_PID_FILE}"
968
- log "litestream replicate starting (pid $(cat "${LITESTREAM_PID_FILE}"))"
969
- }
970
-
971
403
  # The graceful stop: mirrors stop_tunnel exactly, including PROC_DEAD_REASON's
972
404
  # one deciding read AFTER the poll loop (never inside it, which polls 10x/s and
973
405
  # would bury the log) — gone / zombie / SIGKILLed are the three outcomes an
@@ -1014,7 +446,7 @@ stop_litestream() {
1014
446
  # stop_tunnel's own comment argues against an argument a future caller can get
1015
447
  # wrong. litestream_is_running is the same O(1) check stop_litestream's own
1016
448
  # no-op branch uses, not a poll — /run's cleanup trap fires this before
1017
- # start_litestream is ever reached whenever an earlier step failed, and an
449
+ # the replicator start is ever reached whenever an earlier step failed, and an
1018
450
  # operator reading that log must not be told a stop signal went to a process
1019
451
  # that never started.
1020
452
  kill_litestream() {
@@ -1030,6 +462,149 @@ kill_litestream() {
1030
462
  }
1031
463
  # --- litestream replicate (end) ----------------------------------------------
1032
464
 
465
+ # --- credential sync loop (#1868 WI-3, ECS parity) ---------------------------
466
+ #
467
+ # sync_credentials (above) covers the three boundary flushes /run's restore,
468
+ # /suspend and /terminate already call. What it does NOT cover is a VM that
469
+ # runs for a long time between those boundaries: a provider re-authenticated
470
+ # through the proxied UI hours into a run would sit unflushed until the next
471
+ # suspend/terminate, and a VM that dies without one (a crash, an OOM kill)
472
+ # loses everything since boot. runner/docker-images/fargate/entrypoint.sh's
473
+ # own sync_credentials_loop is the ECS side of the identical gap; this is the
474
+ # same fix, backgrounded like the other long-lived services so it
475
+ # outlives this hook process, `( … ) &` rather than `setsid`: a plain
476
+ # backgrounded subshell is reparented to init and keeps running once its
477
+ # parent hook script exits (verified: PPID=1, still alive, with no controlling
478
+ # terminal in this image to send it a stray SIGHUP), and it inherits every
479
+ # function this file defines, so it can call run_synchroniser directly with no
480
+ # re-exec.
481
+
482
+ # Bounded confirmation window `stop_credential_sync` polls after signalling the
483
+ # loop. The loop's current child is one fast `run_synchroniser sync-once` call,
484
+ # so this stays a short backstop rather than a graceful drain.
485
+ CREDS_SYNC_STOP_WAIT_SECONDS=2
486
+
487
+ # Best-effort per tick, exactly like sync_credentials above: a failed tick
488
+ # must never end the loop, or a single transient S3 error would silently
489
+ # disable sync for the rest of the VM's life.
490
+ start_credential_sync() {
491
+ if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
492
+ warn "CREDS-SYNC-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; no interval credential sync this boot."
493
+ return 0
494
+ fi
495
+
496
+ if creds_sync_is_running; then
497
+ warn "credential sync loop already running (pid $(cat "${CREDS_SYNC_PID_FILE}")); reusing it"
498
+ return 0
499
+ fi
500
+
501
+ # This is the same environment input runner-synchroniser validates. The
502
+ # variable is normally absent, so that ordinary case uses the documented
503
+ # default without a warning; a present invalid value is named and rejected.
504
+ local interval="${CREDS_SYNC_INTERVAL:-60}"
505
+ if [ -n "${CREDS_SYNC_INTERVAL+x}" ] && [[ ! "${CREDS_SYNC_INTERVAL}" =~ ^[1-9][0-9]*$ ]]; then
506
+ warn "CREDS-SYNC-INTERVAL-INVALID: CREDS_SYNC_INTERVAL='${CREDS_SYNC_INTERVAL}' is not a positive integer; using 60s"
507
+ interval=60
508
+ fi
509
+
510
+ rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
511
+
512
+ (
513
+ # Releases the fds this subshell inherited from the hook process before
514
+ # settling in for the VM's whole remaining life: nothing here writes to
515
+ # them (every synchroniser call already redirects its own), so there is
516
+ # no reason to keep holding the hook's original stdout/stderr open. A
517
+ # long-lived process that instead inherited a pipe's write end (a test
518
+ # harness reading the hook's own output, for one) would keep that pipe
519
+ # from ever reporting EOF — testing-guide.mdc's own lesson, and the same
520
+ # reason the long-lived services never inherit stdio either. That
521
+ # redirect also means `warn`/`log`/`error` calls in here go nowhere, so a
522
+ # failed sync-once is instead recorded to CREDS_SYNC_LAST_ERROR_FILE and
523
+ # surfaced by stop_credential_sync, which DOES have live stdio.
524
+ exec >/dev/null 2>&1 </dev/null
525
+
526
+ # A TERM this subshell receives (from stop_credential_sync, below) only
527
+ # kills THIS wrapper by default — its currently-running child (`sleep`,
528
+ # or a `run_synchroniser sync-once` call) is a separate process that
529
+ # would otherwise be orphaned and keep running, free to upload STALE
530
+ # credentials to S3 after the boundary flush that /suspend and
531
+ # /terminate perform immediately following the stop. Tracking the
532
+ # current child explicitly and forwarding the signal closes that race.
533
+ creds_sync_child_pid=""
534
+ trap 'trap - TERM; [ -n "${creds_sync_child_pid}" ] && kill -TERM "${creds_sync_child_pid}" 2>/dev/null; exit 0' TERM
535
+
536
+ while true; do
537
+ sleep "${interval}" &
538
+ creds_sync_child_pid=$!
539
+ wait "${creds_sync_child_pid}" 2>/dev/null
540
+ creds_sync_child_pid=""
541
+
542
+ run_synchroniser sync-once claude &
543
+ creds_sync_child_pid=$!
544
+ wait "${creds_sync_child_pid}" 2>/dev/null || echo "claude" >"${CREDS_SYNC_LAST_ERROR_FILE}"
545
+ creds_sync_child_pid=""
546
+
547
+ run_synchroniser sync-once opencode &
548
+ creds_sync_child_pid=$!
549
+ wait "${creds_sync_child_pid}" 2>/dev/null || echo "opencode" >"${CREDS_SYNC_LAST_ERROR_FILE}"
550
+ creds_sync_child_pid=""
551
+ done
552
+ ) &
553
+
554
+ echo $! >"${CREDS_SYNC_PID_FILE}"
555
+ log "CREDS-SYNC-STARTED: pid=$! interval=${interval}s"
556
+ }
557
+
558
+ # Signals the loop, then confirms (bounded — see CREDS_SYNC_STOP_WAIT_SECONDS)
559
+ # that it and its current child are actually gone before returning: /suspend
560
+ # and /terminate start their own boundary flush immediately after this call,
561
+ # and an orphaned in-flight sync-once surviving past that point can overwrite
562
+ # fresher credentials with stale ones. The TERM trap inside the loop (above)
563
+ # forwards the signal to its current child almost instantly — this poll is a
564
+ # defensive confirmation, not the primary mechanism, so it stays short; a
565
+ # SIGKILL backstop covers a child that ignores TERM entirely.
566
+ #
567
+ # The DIED branch is a liveness report, not a no-op: every recovery/no-op path
568
+ # must say what it found (development-workflow.mdc) — a stopped-before-called
569
+ # loop and a died-on-its-own loop are different facts an operator needs told
570
+ # apart, not the same "nothing to stop" line.
571
+ stop_credential_sync() {
572
+ if [ ! -s "${CREDS_SYNC_PID_FILE}" ]; then
573
+ log "CREDS-SYNC-NOT-RUNNING: no credential sync loop to stop"
574
+ return 0
575
+ fi
576
+
577
+ local pid
578
+ pid="$(cat "${CREDS_SYNC_PID_FILE}")"
579
+ if ! process_is_alive "${pid}"; then
580
+ rm -f "${CREDS_SYNC_PID_FILE}"
581
+ warn "CREDS-SYNC-DIED: credential sync loop (pid=${pid}) had already exited before this stop"
582
+ return 0
583
+ fi
584
+
585
+ kill -TERM "${pid}" 2>/dev/null || true
586
+ rm -f "${CREDS_SYNC_PID_FILE}"
587
+
588
+ local waited_ms=0
589
+ while process_is_alive "${pid}" && [ "${waited_ms}" -lt $((CREDS_SYNC_STOP_WAIT_SECONDS * 1000)) ]; do
590
+ sleep 0.1
591
+ waited_ms=$((waited_ms + 100))
592
+ done
593
+
594
+ if process_is_alive "${pid}"; then
595
+ kill -KILL "${pid}" 2>/dev/null || true
596
+ warn "CREDS-SYNC-STOP-TIMEOUT: pid=${pid} still alive after ${CREDS_SYNC_STOP_WAIT_SECONDS}s; sent SIGKILL"
597
+ fi
598
+
599
+ if [ -s "${CREDS_SYNC_LAST_ERROR_FILE}" ]; then
600
+ warn "CREDS-SYNC-HAD-FAILURES: sync-once failed at least once for: $(tr '\n' ' ' <"${CREDS_SYNC_LAST_ERROR_FILE}")"
601
+ rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
602
+ fi
603
+
604
+ log "CREDS-SYNC-STOPPED: pid=${pid}"
605
+ }
606
+ # --- credential sync loop (end) -----------------------------------------------
607
+
1033
608
  # --- flush_session_db (#812 WI-4) -------------------------------------------
1034
609
  #
1035
610
  # The checked, synchronous flush /suspend and /terminate need before they
@@ -1040,19 +615,16 @@ kill_litestream() {
1040
615
  # Ceiling for the one-shot `litestream replicate -once` flush below. litestream's
1041
616
  # own sync is normally sub-second (M5, the plan's grounding); 10s is generous
1042
617
  # headroom, chosen the same way LITESTREAM_STOP_WAIT_SECONDS was above — not a
1043
- # measured worst case. The SIGKILL backstop below reuses
1044
- # SESSION_DB_RESTORE_KILL_GRACE_SECONDS rather than its own literal: it is the
1045
- # identical concept (bare `timeout` only SIGTERMs, and a litestream that
1046
- # ignored it would be unbounded again), and there is no reason for the restore
1047
- # and flush paths to ever drift on how long a SIGTERM gets to land.
618
+ # measured worst case. The SIGKILL backstop gives the flush its own two-second
619
+ # grace after `timeout` sends SIGTERM.
1048
620
  SESSION_DB_FLUSH_DEADLINE_SECONDS="${EVIDENT_SESSION_DB_FLUSH_DEADLINE_SECONDS:-10}"
621
+ SESSION_DB_FLUSH_KILL_GRACE_SECONDS=2
1049
622
 
1050
- # Guards mirror start_litestream's first three exactly (disabled -> marker ->
623
+ # Guards mirror the CLI replicator's first three exactly (disabled -> marker ->
1051
624
  # config) — a flush must never attempt work those guards would have refused
1052
625
  # to start in the first place. PERSISTENCE_BUCKET is the SAME variable
1053
- # start_litestream reads, so both agree regardless of which earlier step in
1054
- # THIS process set it (load_state_config, above, or restore_session_db's
1055
- # eval, on /run).
626
+ # the CLI reads, so both agree regardless of which earlier step in
627
+ # THIS process set it (load_state_config above).
1056
628
  #
1057
629
  # Then: stop the daemon FIRST — its SIGTERM IS litestream's own final-sync
1058
630
  # trigger — and only once it is confirmed stopped does the synchronous -once
@@ -1092,7 +664,7 @@ flush_session_db() {
1092
664
  # Only reached with the daemon confirmed stopped, or never running — never
1093
665
  # concurrently with it (see above).
1094
666
  local flush_rc=0
1095
- timeout -k "${SESSION_DB_RESTORE_KILL_GRACE_SECONDS}" "${SESSION_DB_FLUSH_DEADLINE_SECONDS}" \
667
+ timeout -k "${SESSION_DB_FLUSH_KILL_GRACE_SECONDS}" "${SESSION_DB_FLUSH_DEADLINE_SECONDS}" \
1096
668
  litestream replicate -once -config "${LITESTREAM_CONFIG_FILE}" || flush_rc=$?
1097
669
 
1098
670
  if [ "${flush_rc}" -eq 0 ]; then
@@ -1147,6 +719,26 @@ fi
1147
719
  # its 200, while the tunnel keeps serving.
1148
720
  start_tunnel() {
1149
721
  local runner_key="$1" api_url="$2" tunnel_url="$3"
722
+ local restore_runner_credentials="${4:-false}" restore_history="${5:-false}"
723
+ local -a credential_flags=()
724
+ local -a session_db_flags=()
725
+ local -a opencode_config_flags=()
726
+
727
+ # The explicit fourth argument is set only by /run; /resume uses the default
728
+ # so a resumed VM never restores credentials over stores it already has.
729
+ if [ "${restore_runner_credentials}" = true ]; then
730
+ credential_flags=(--restore-runner-credentials)
731
+ fi
732
+
733
+ # Only a fresh /run asks the CLI to restore session history. /resume keeps
734
+ # the database from the snapshot and must not restore over it.
735
+ if [ "${restore_history}" = true ]; then
736
+ session_db_flags=(--restore-session-db)
737
+ fi
738
+
739
+ if [ -n "${RUNNER_OPENCODE_CONFIG:-}" ]; then
740
+ opencode_config_flags=(--opencode-config-overlay "${RUNNER_OPENCODE_CONFIG}")
741
+ fi
1150
742
 
1151
743
  if tunnel_is_running; then
1152
744
  warn "tunnel already running (pid $(cat "${TUNNEL_PID_FILE}")); not starting a second one"
@@ -1169,6 +761,13 @@ start_tunnel() {
1169
761
  --port "${OPENCODE_PORT}" \
1170
762
  --endpoint "${api_url}" \
1171
763
  --tunnel "${tunnel_url}" \
764
+ --opencode-pid-file "${OPENCODE_PID_FILE}" \
765
+ --litestream-config "${LITESTREAM_CONFIG_FILE}" \
766
+ --litestream-pid-file "${LITESTREAM_PID_FILE}" \
767
+ --session-db-no-replicate-marker "${SESSION_DB_NO_REPLICATE_MARKER}" \
768
+ "${credential_flags[@]}" \
769
+ "${session_db_flags[@]}" \
770
+ "${opencode_config_flags[@]}" \
1172
771
  --idle-timeout "${IDLE_TIMEOUT_SECONDS}" \
1173
772
  --enable-file-sync-to "${HOME}/.claude" &
1174
773
 
@@ -1301,10 +900,9 @@ check_runner_key() {
1301
900
  local runner_key="$1" api_url="$2"
1302
901
 
1303
902
  # status.ts's own exit-code contract (its header comment) means this exits
1304
- # non-zero on EVERY branch except `ok` guarded exactly like
1305
- # ensure_litestream_config's `run_synchroniser litestream-config` call,
1306
- # above, so that expected non-zero does not abort this function under the
1307
- # caller's `set -e` before the case below ever runs. Only stdout is
903
+ # non-zero on EVERY branch except `ok`; guard the capture so an expected
904
+ # non-zero does not abort this function under the caller's `set -e` before
905
+ # the case below ever runs. Only stdout is
1308
906
  # captured: status.ts's own contract is one parseable JSON line and nothing
1309
907
  # else there, and — like litestream's stderr elsewhere in this file — its
1310
908
  # stderr is left to reach CloudWatch directly rather than being folded in,