@evident-ai/runner-cdk 0.1.1-dev.da70cd4 → 3.4.1-dev.1856549

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 (32) hide show
  1. package/README.md +76 -19
  2. package/dist/controller-lambda/handler.js +50523 -0
  3. package/dist/evident-scale-to-zero-construct.js +4 -5
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.js +14 -1
  6. package/dist/microvm/constants.d.ts +7 -0
  7. package/dist/microvm/constants.js +34 -0
  8. package/dist/microvm/construct.d.ts +87 -0
  9. package/dist/microvm/construct.js +253 -0
  10. package/dist/microvm/controller/doorbell.d.ts +73 -0
  11. package/dist/microvm/controller/doorbell.js +107 -0
  12. package/dist/microvm/controller/handle-doorbell.d.ts +27 -0
  13. package/dist/microvm/controller/handle-doorbell.js +480 -0
  14. package/dist/microvm/controller/microvm-client.d.ts +75 -0
  15. package/dist/microvm/controller/microvm-client.js +7 -0
  16. package/dist/microvm/controller/shape-catalogue.d.ts +64 -0
  17. package/dist/microvm/controller/shape-catalogue.js +108 -0
  18. package/dist/microvm/controller/throttle-retry.d.ts +11 -0
  19. package/dist/microvm/controller/throttle-retry.js +27 -0
  20. package/dist/microvm/image/stage-context.d.ts +33 -0
  21. package/dist/microvm/image/stage-context.js +148 -0
  22. package/dist/microvm/shapes.d.ts +72 -0
  23. package/dist/microvm/shapes.js +93 -0
  24. package/dist/microvm-image-context/Dockerfile +227 -0
  25. package/dist/microvm-image-context/hook-server.js +286 -0
  26. package/dist/microvm-image-context/hooks/common.sh +1418 -0
  27. package/dist/microvm-image-context/hooks/resume +79 -0
  28. package/dist/microvm-image-context/hooks/run +117 -0
  29. package/dist/microvm-image-context/hooks/suspend +19 -0
  30. package/dist/microvm-image-context/hooks/terminate +34 -0
  31. package/dist/waker/construct.js +1 -1
  32. package/package.json +15 -7
@@ -0,0 +1,1418 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced by every hook script. Nothing here runs at image build time.
3
+ #
4
+ # These hooks are the SECOND shell speaking the runner-synchroniser CLI contract
5
+ # (runner/docker-images/fargate/entrypoint.sh is the first), so both are held to it by
6
+ # runner/synchroniser/src/shell-contract.test.ts — which derives what
7
+ # `run_synchroniser` below must handle from the subcommands this shell calls.
8
+
9
+ # Installed from npm by the image (docker/Dockerfile's ARG
10
+ # RUNNER_SYNCHRONISER_VERSION) and resolved off PATH here. The Dockerfile's
11
+ # required-binary assertion is the loud failure if it is missing.
12
+ SYNCHRONISER="runner-synchroniser"
13
+ OPENCODE_PORT="${OPENCODE_PORT:-4096}"
14
+
15
+ # A hook script keeps no memory between invocations, but /resume must re-dial
16
+ # with the runner key /run was given, so /run leaves it here. Deliberate, on two
17
+ # axes: tmpfs means it never reaches the block device, and the SHARED image
18
+ # snapshot is taken at build time — long before /run — so the key cannot enter
19
+ # the artefact every VM boots from. And at 0600 owned by uid 10001 it is
20
+ # readable by nobody who could not already read it out of the tunnel process's
21
+ # /proc/<pid>/environ, which runs as that same uid. /terminate removes it.
22
+ # shellcheck disable=SC2034 # read by the scripts that source this file
23
+ CONTEXT_FILE="/dev/shm/evident-run-context"
24
+ TUNNEL_PID_FILE="/dev/shm/evident-tunnel.pid"
25
+ OPENCODE_PID_FILE="/dev/shm/evident-opencode.pid"
26
+ LITESTREAM_PID_FILE="/dev/shm/evident-litestream.pid"
27
+
28
+ # Where the runner's credential store lives inside the durable-state bucket.
29
+ # The BUCKET is the same for every VM from an image version, so the stack bakes
30
+ # it into the image environment; the PREFIX selects one runner's store, so it is
31
+ # per-VM and can only arrive in the /run payload. /run leaves it here for the
32
+ # hooks that flush back to it. A file of its own rather than a line of
33
+ # CONTEXT_FILE: it is not a secret, so /suspend never has to read the runner key
34
+ # to find out where to write.
35
+ STATE_PREFIX_FILE="/dev/shm/evident-state-prefix"
36
+
37
+ # The generated litestream.yml (#812). tmpfs for the same two reasons as the
38
+ # files above: it must never reach the block device (Q6, `docs`), and it
39
+ # 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
+ LITESTREAM_CONFIG_FILE="/dev/shm/evident-litestream.yml"
43
+
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.
51
+ SESSION_DB_NO_REPLICATE_MARKER="/dev/shm/evident-session-db-no-replicate"
52
+
53
+ hook_name() { printf '%s' "${0##*/}"; }
54
+ log() { echo "[hook:$(hook_name)] $*"; }
55
+ warn() { echo "[hook:$(hook_name)] $*" >&2; }
56
+ error() { echo "[hook:$(hook_name)] ERROR: $*" >&2; }
57
+
58
+ session_db_recovery_report_path() {
59
+ local path="${EVIDENT_SESSION_DB_RECOVERY_REPORT:-}"
60
+ path="${path#"${path%%[![:space:]]*}"}"
61
+ path="${path%"${path##*[![:space:]]}"}"
62
+ if [ -n "${path}" ]; then
63
+ printf '%s\n' "${path}"
64
+ return 0
65
+ fi
66
+ local home="${HOME:-}"
67
+ home="${home#"${home%%[![:space:]]*}"}"
68
+ home="${home%"${home##*[![:space:]]}"}"
69
+ [ -n "${home}" ] || return 1
70
+ printf '%s\n' "${home}/.local/state/evident/session-db-recovery.jsonl"
71
+ }
72
+
73
+ report_session_db_giveup() {
74
+ local reason="$1" exit_code="${2:-null}" outcome path at
75
+ case "${reason}" in
76
+ synchroniser_config_unresolved|synchroniser_config_unevaluable|synchroniser_config_incomplete|litestream_config_unavailable|classification_fatal) outcome="restore_misconfigured" ;;
77
+ restore_deadline_exceeded|restore_tool_unusable|classification_unrecognised) outcome="fresh_session_db" ;;
78
+ *) warn "SESSION-DB-RECOVERY-REPORT-UNKNOWN-REASON: ${reason}"; return 0 ;;
79
+ esac
80
+ if ! path="$(session_db_recovery_report_path)"; then
81
+ warn "SESSION-DB-RECOVERY-REPORT-PATH-UNAVAILABLE"
82
+ return 0
83
+ fi
84
+ at="${EPOCHREALTIME:-}"
85
+ [ -n "${at}" ] || warn "SESSION-DB-RECOVERY-REPORT-TIMESTAMP-UNAVAILABLE"
86
+ printf '{"v":1,"event":"session_db_recovery","at":"%s","stage":"restore","outcome":"%s","severity":"error","reason":"%s","litestream_exit_code":%s,"attempt":null,"replica_objects":null,"replica_bytes":null,"quarantine_destination":null,"quarantined_objects":null,"quarantine_failed_objects":null,"quarantined_bytes":null,"verified_restore_point":null,"restore_points_tried":null,"replication_suspended":true}\n' "${at}" "${outcome}" "${reason}" "${exit_code}" >>"${path}" || warn "SESSION-DB-RECOVERY-REPORT-APPEND-FAILED"
87
+ return 0
88
+ }
89
+
90
+ # Returns the CLI's own exit code. Domain outcomes (nothing persisted yet, a
91
+ # corrupt object) are LOGGED and exit 0, the predicates answer "no" with 10, and
92
+ # `session-db-classify`'s three typed answers are 30 (fatal)/31 (replica
93
+ # unusable)/32 (retry) — see `runner/synchroniser/src/cli.ts`'s own
94
+ # comment for what each means, not restated here. Any OTHER non-zero status
95
+ # means the tool itself broke, which is the only case worth an ERROR here —
96
+ # EXCEPT 124/137 (a `timeout` deadline/SIGKILL) when the caller asked for one:
97
+ # that is an intentional bound firing, not a broken tool, so the caller
98
+ # classifies it instead (restore_credentials, #930). A trailing
99
+ # `--evident-deadline=N` (stripped below before forwarding to the CLI, and
100
+ # never produced by anything but timed_synchroniser) is how a caller opts in;
101
+ # every other caller — sync_credentials, restore_session_db's `env` /
102
+ # `litestream-config` / `session-db-classify`, and this file's own contract
103
+ # test — passes none, so 124/137 there still means a genuine external SIGKILL
104
+ # (e.g. an OOM kill) and must keep producing the ERROR below.
105
+ run_synchroniser() {
106
+ local -a call_args=("$@")
107
+ local deadline=""
108
+ local last=$(( ${#call_args[@]} - 1 ))
109
+ if [ "${last}" -ge 0 ] && [[ "${call_args[last]}" == --evident-deadline=* ]]; then
110
+ deadline="${call_args[last]#--evident-deadline=}"
111
+ call_args=("${call_args[@]:0:${last}}")
112
+ fi
113
+
114
+ local rc=0
115
+ local -a launcher=()
116
+ [ -n "${deadline}" ] && launcher=(timeout -k "${CREDENTIAL_RESTORE_KILL_GRACE_SECONDS}" "${deadline}")
117
+ "${launcher[@]}" "${SYNCHRONISER}" "${call_args[@]}" || rc=$?
118
+ case "${rc}" in
119
+ 0 | 10 | 30 | 31 | 32) ;;
120
+ 124 | 137)
121
+ [ -n "${deadline}" ] || error "synchroniser '${call_args[*]}' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw"
122
+ ;;
123
+ *) error "synchroniser '${call_args[*]}' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw" ;;
124
+ esac
125
+ return "${rc}"
126
+ }
127
+
128
+ # Wall-clock milliseconds. ${EPOCHREALTIME} is a bash 5 builtin (the image is
129
+ # node:22-bookworm-slim → bash 5.2) rather than `date +%s%3N`, which has to
130
+ # exec /bin/date inside the caller's command substitution: measured on a loaded
131
+ # box, that exec put ~190ms of its own cost INSIDE the window being measured,
132
+ # against ~23ms for this — overhead charged to the very number this exists to
133
+ # learn. The remaining ~23ms is the command substitution's subshell, kept
134
+ # because removing it means an out-parameter global, and ~2% of a ~1.2s call
135
+ # biases the reading generous, which is the safe direction for sizing a
136
+ # deadline.
137
+ #
138
+ # The `[.,]` is not paranoia: ${EPOCHREALTIME} renders its decimal separator
139
+ # from LC_NUMERIC, so a comma locale would otherwise silently produce garbage
140
+ # here. Stripping either turns it into whole microseconds, which /1000 makes
141
+ # milliseconds.
142
+ #
143
+ # Never fails its caller: a timing line is diagnostics, and hardening a
144
+ # currently-working path is worse than the gap it closes (#931). The `date`
145
+ # fallback covers a pre-5.0 bash; a 0 means "could not read the clock", which
146
+ # timed_synchroniser turns into `elapsed_ms=unknown` rather than aborting /run
147
+ # under `set -e` — and rather than a 0 that would read as a fast healthy call.
148
+ now_ms() {
149
+ local now="${EPOCHREALTIME:-}"
150
+ if [ -n "${now}" ]; then
151
+ now="${now/[.,]/}"
152
+ echo "$((now / 1000))"
153
+ return 0
154
+ fi
155
+ date +%s%3N 2>/dev/null || echo 0
156
+ }
157
+
158
+ # Reports what one step COST, in a deliberately machine-greppable line (`op=`,
159
+ # `elapsed_ms=`, `rc=`) so "how long does this actually take in the fleet?" is a
160
+ # log query rather than another spike. `at_s` is where the step landed on the
161
+ # hook's own SECONDS clock, which is what the pre-opencode budget in
162
+ # hook-scripts.test.ts is derived against.
163
+ #
164
+ # Callable directly, not only through timed_synchroniser below, because the
165
+ # steps that dominate the pre-opencode window CANNOT be wrapped: their stdout is
166
+ # captured (`x="$(run_synchroniser env)"`, later `eval`'d), so a `log` line
167
+ # emitted inside the substitution would be evaluated as configuration.
168
+ #
169
+ # `unknown`, never a number, when either end failed to read the clock (now_ms's
170
+ # 0). Subtracting them would report `elapsed_ms=0` — indistinguishable from a
171
+ # very fast healthy call, which would quietly bias the fleet-wide sizing data
172
+ # this line exists to produce, in the DANGEROUS direction (a deadline sized too
173
+ # tight). A non-numeric value drops out of an aggregate instead of poisoning it.
174
+ log_elapsed_since() {
175
+ local op="$1" started_ms="$2" rc="$3"
176
+
177
+ local finished_ms elapsed_ms="unknown"
178
+ finished_ms="$(now_ms)"
179
+ if [ "${started_ms}" -gt 0 ] && [ "${finished_ms}" -gt 0 ]; then
180
+ elapsed_ms="$((finished_ms - started_ms))"
181
+ fi
182
+
183
+ log "SYNCHRONISER-TIMING op=${op} elapsed_ms=${elapsed_ms} rc=${rc} at_s=${SECONDS}"
184
+ }
185
+
186
+ # Runs ONE synchroniser call and reports what it COST, without changing what it
187
+ # MEANS: the call's own status is returned untouched, so every caller's
188
+ # `|| return 1` / `|| status=$?` keeps working exactly as before.
189
+ #
190
+ # `deadline` is an explicit parameter, forwarded to run_synchroniser as a
191
+ # trailing `--evident-deadline=N` (never a global or dynamically-scoped
192
+ # variable, #930) — every current caller (restore_credentials) always has a
193
+ # positive remaining-budget value by the time it calls this, having already
194
+ # decided to skip the call entirely otherwise.
195
+ timed_synchroniser() {
196
+ local op="$1" deadline="$2"
197
+ shift 2
198
+
199
+ local started_ms rc=0
200
+ started_ms="$(now_ms)"
201
+ run_synchroniser "$@" "--evident-deadline=${deadline}" || rc=$?
202
+ log_elapsed_since "${op}" "${started_ms}" "${rc}"
203
+ return "${rc}"
204
+ }
205
+
206
+ # Exports what `runner-synchroniser` resolves its object-store location from
207
+ # (runner/synchroniser/src/config.ts). It treats either being empty as
208
+ # "persistence disabled" and then reports every restore as a WARNING it still
209
+ # exits 0 for — so an unset value has to be caught HERE, where it can still be
210
+ # told apart from "the store is simply empty".
211
+ load_state_config() {
212
+ if [ -z "${LITESTREAM_BUCKET:-}" ]; then
213
+ error "LITESTREAM_BUCKET is unset; the image is missing the durable-state bucket the stack bakes in"
214
+ return 1
215
+ fi
216
+
217
+ LITESTREAM_PREFIX="$(cat "${STATE_PREFIX_FILE}" 2>/dev/null || true)"
218
+ if [ -z "${LITESTREAM_PREFIX}" ]; then
219
+ error "no durable-state prefix at ${STATE_PREFIX_FILE}; /run records the payload's state_prefix there before anything can be restored or flushed"
220
+ return 1
221
+ fi
222
+
223
+ export LITESTREAM_BUCKET LITESTREAM_PREFIX
224
+
225
+ # Mirrors runner-synchroniser's own persistenceEnabled predicate (config.ts):
226
+ # both non-empty, already checked above, so by this point PERSISTENCE_BUCKET
227
+ # is just LITESTREAM_BUCKET. start_litestream and flush_session_db (#812
228
+ # WI-3/WI-4) read ONLY this var, never LITESTREAM_BUCKET/LITESTREAM_PREFIX
229
+ # directly, so every entry point agrees on one predicate regardless of which
230
+ # earlier step in THIS hook process set it: restore_session_db's
231
+ # `eval "$(run_synchroniser env)"` on /run, or this function on /resume,
232
+ # /suspend and /terminate, none of which ever call the synchroniser before
233
+ # needing the answer.
234
+ export PERSISTENCE_BUCKET="${LITESTREAM_BUCKET}"
235
+ }
236
+
237
+ # /run starts no LLM task — it restores state, starts opencode and dials the
238
+ # tunnel, none of which needs a model credential. Which credential a turn needs
239
+ # depends on the model that turn's message asks for, so that question has no
240
+ # single boot-time answer; it is answered per message, not here. What IS still
241
+ # fatal is credential persistence being unavailable at all (an unset bucket/
242
+ # prefix, or a broken synchroniser bundle) — that would silently drop refreshed
243
+ # tokens on every suspend, so those two restores stay `|| return 1`.
244
+ # `model-auth-ready` therefore stays a boot-time diagnostic: its only output is
245
+ # a log line, since the runtime forwards no hook stderr to the doorbell caller.
246
+
247
+ # How long restore_credentials' shared step budget is, covering the runner-secret
248
+ # fetch plus its three synchroniser calls (restore claude, restore opencode,
249
+ # model-auth-ready) as one window rather than a timeout apiece — a fixed per-call cap at the same
250
+ # total would false-fire on the ordinary case of one slow call (a slow S3 GET),
251
+ # which is precisely the boot this exists to keep healthy. From #930's 3-boot
252
+ # sample: the step measured ~3-5s total (~1.2s per synchroniser call, three
253
+ # node cold starts of a 1.8 MB bundle) — a WEAK estimate this file's own
254
+ # SYNCHRONISER-TIMING lines are what will sharpen for real. Too tight and a
255
+ # routine slow call boots this VM with no model credentials until an operator
256
+ # reconnects it; too loose and a hung call burns more of the hook's own
257
+ # SIGTERM budget before the VM is destroyed mid-boot anyway. The env override
258
+ # is for tests only, so they need not burn wall clock.
259
+ CREDENTIAL_RESTORE_DEADLINE_SECONDS="${EVIDENT_CREDENTIAL_RESTORE_DEADLINE_SECONDS:-8}"
260
+ # The SIGKILL backstop `timeout -k` applies after its own SIGTERM, exactly like
261
+ # SESSION_DB_RESTORE_KILL_GRACE_SECONDS above (a bare `timeout` only SIGTERMs,
262
+ # and a call that ignored it would be unbounded again). Hard ceiling on the
263
+ # step: DEADLINE + this = 10s, once — not per call.
264
+ CREDENTIAL_RESTORE_KILL_GRACE_SECONDS=2
265
+ GITHUB_PROBE_DEADLINE_SECONDS="${EVIDENT_GITHUB_PROBE_DEADLINE_SECONDS:-10}"
266
+
267
+ # What is left of the shared step budget, in whole seconds, `step_started_s`
268
+ # seconds after it began. `SECONDS` (a bash builtin with no failure mode,
269
+ # unlike `now_ms`) truncates, so a step that began at true time `s0` can read
270
+ # less elapsed time than actually passed — the `- 1` restores the invariant
271
+ # that no call is granted more than DEADLINE + GRACE from step start.
272
+ remaining_credential_budget() {
273
+ local step_started_s="$1"
274
+ echo $(( CREDENTIAL_RESTORE_DEADLINE_SECONDS - (SECONDS - step_started_s) - 1 ))
275
+ }
276
+
277
+ # Runs one of the step's bounded calls: skips it with a named warn if the
278
+ # shared budget is already exhausted, treats a timeout (124/137) as the
279
+ # non-fatal warn-and-continue D1 approved, and keeps every other non-zero
280
+ # fatal exactly as restore_credentials always has (`|| return 1` aborts /run
281
+ # before opencode and the tunnel start).
282
+ bounded_restore_call() {
283
+ local op="$1" step_started_s="$2"
284
+ shift 2
285
+
286
+ local remaining
287
+ remaining="$(remaining_credential_budget "${step_started_s}")"
288
+ if [ "${remaining}" -lt 1 ]; then
289
+ warn "CREDENTIAL-RESTORE-SKIPPED: ${op} skipped; the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore budget is exhausted"
290
+ return 0
291
+ fi
292
+
293
+ local rc=0
294
+ timed_synchroniser "${op}" "${remaining}" "$@" || rc=$?
295
+ case "${rc}" in
296
+ 0) ;;
297
+ 124 | 137)
298
+ 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"
299
+ ;;
300
+ *) return 1 ;;
301
+ esac
302
+ return 0
303
+ }
304
+
305
+ fetch_runner_secret() {
306
+ local step_started_s="$1" remaining rc=0 payload value stderr_file started_ms populated=0 missing=0
307
+ if [ -z "${RUNNER_SECRET_ARN:-}" ]; then
308
+ log "runner secret is not configured; continuing without GitHub and MCP credentials"
309
+ return 0
310
+ fi
311
+ remaining="$(remaining_credential_budget "${step_started_s}")"
312
+ if [ "${remaining}" -lt 1 ]; then
313
+ warn "CREDENTIAL-RESTORE-SKIPPED: runner-secret skipped; the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore budget is exhausted"
314
+ return 0
315
+ fi
316
+ if ! stderr_file="$(mktemp /dev/shm/runner-secret-stderr.XXXXXX)"; then
317
+ warn "RUNNER-SECRET-STDERR-UNAVAILABLE: could not allocate diagnostic storage; continuing without runner credentials"
318
+ return 0
319
+ fi
320
+ started_ms="$(now_ms)"
321
+ 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=$?
322
+ log_elapsed_since runner-secret-fetch "${started_ms}" "${rc}"
323
+ if [ "${rc}" -eq 124 ] || [ "${rc}" -eq 137 ]; then
324
+ 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"
325
+ rm -f "${stderr_file}"
326
+ return 0
327
+ fi
328
+ if [ "${rc}" -ne 0 ]; then
329
+ warn "RUNNER-SECRET-UNREADABLE: $(<"${stderr_file}")"
330
+ rm -f "${stderr_file}"
331
+ return 0
332
+ fi
333
+ rm -f "${stderr_file}"
334
+ if ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"${payload}"; then
335
+ warn "RUNNER-SECRET-UNPARSEABLE: secret value is not a JSON object"
336
+ return 0
337
+ fi
338
+ local -a populated_keys=()
339
+ for key in GH_TOKEN BRAVE_API_KEY CLOUDFLARE_API_TOKEN NEON_API_KEY; do
340
+ value="$(jq -r --arg k "${key}" '.[$k] // empty' <<<"${payload}")"
341
+ if [ -n "${value}" ]; then
342
+ export "${key}=${value}"
343
+ populated=$((populated + 1))
344
+ populated_keys+=("${key}")
345
+ else
346
+ missing=$((missing + 1))
347
+ fi
348
+ done
349
+ if [ "${populated}" -eq 0 ]; then
350
+ warn "RUNNER-SECRET-UNPOPULATED: populate the runner secret as documented in infrastructure/evident-microvm/README.md"
351
+ else
352
+ log "RUNNER-SECRET-OK: populated ${populated_keys[*]}; ${missing} allow-listed keys empty or absent"
353
+ fi
354
+ return 0
355
+ }
356
+
357
+ restore_credentials() {
358
+ local step_started_ms step_started_s
359
+ step_started_ms="$(now_ms)"
360
+ step_started_s="${SECONDS}"
361
+
362
+ load_state_config || return 1
363
+ # This shares the existing bounded window so /run's worst-case duration does not grow.
364
+ fetch_runner_secret "${step_started_s}"
365
+ bounded_restore_call restore-claude "${step_started_s}" restore claude || return 1
366
+ bounded_restore_call restore-opencode "${step_started_s}" restore opencode || return 1
367
+
368
+ # `restore` exits 0 whether it restored, found nothing, or discarded a corrupt
369
+ # object, so the synchroniser's own predicate is the only reliable answer to
370
+ # "can this VM authenticate a model right now". Its status is captured, not
371
+ # used as a condition, so it can never become this function's return status —
372
+ # `run` calls this bare (no `||`) under `set -euo pipefail`, and any
373
+ # non-zero return here would abort the hook before opencode and the tunnel
374
+ # ever start.
375
+ local auth_status=0 auth_remaining
376
+ auth_remaining="$(remaining_credential_budget "${step_started_s}")"
377
+ if [ "${auth_remaining}" -lt 1 ]; then
378
+ warn "CREDENTIAL-RESTORE-SKIPPED: model-auth-ready skipped; the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore budget is exhausted"
379
+ else
380
+ timed_synchroniser model-auth-ready "${auth_remaining}" model-auth-ready || auth_status=$?
381
+ case "${auth_status}" in
382
+ 0) ;; # some model auth is configured; the two restores above already logged what they found
383
+ 10)
384
+ warn "no model credentials under s3://${LITESTREAM_BUCKET}/${LITESTREAM_PREFIX}/ \
385
+ (neither claude/credentials.json nor opencode/auth.json yielded valid JSON) and neither \
386
+ ANTHROPIC_API_KEY nor OPENAI_API_KEY is set. This VM boots and connects; a turn that needs \
387
+ a model provider fails until one is connected. See 'Seeding a credential store' in \
388
+ infrastructure/evident-microvm/README.md."
389
+ ;;
390
+ 124 | 137)
391
+ 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"
392
+ ;;
393
+ *)
394
+ # run_synchroniser has already `error`ed the tool-broke line for a
395
+ # broken tool; any other unrecognised code lands here too. Do not
396
+ # claim there are no credentials — absent evidence is not contrary
397
+ # evidence (development-workflow.mdc).
398
+ warn "could not determine whether this VM has model credentials"
399
+ ;;
400
+ esac
401
+ fi
402
+ # The step total, in the SAME greppable shape as the per-call lines above, so
403
+ # one query answers both "what does credential restore cost?" and "which of
404
+ # its four operations cost it". Bounded now by CREDENTIAL_RESTORE_DEADLINE_SECONDS
405
+ # + CREDENTIAL_RESTORE_KILL_GRACE_SECONDS as one shared window (see the
406
+ # comment above that constant) rather than per call. `rc=0` is deliberate
407
+ # here, not an absence of failure modes: a timed-out call already warned by
408
+ # name above and is non-fatal by design (D1); every failure that DOES abort
409
+ # this step has already returned 1 above.
410
+ log_elapsed_since credential-restore-step "${step_started_ms}" 0
411
+ return 0
412
+ }
413
+
414
+ apply_runner_opencode_config() {
415
+ if [ -z "${RUNNER_OPENCODE_CONFIG:-}" ]; then
416
+ log "runner OpenCode config is not configured; using the baked project config"
417
+ return 0
418
+ fi
419
+ local source="${RUNNER_OPENCODE_CONFIG}" target="opencode.json"
420
+ [[ "${source}" = /* ]] || source="${WORKSPACE}/${source}"
421
+ [ -f "${WORKSPACE}/opencode.jsonc" ] && target="opencode.jsonc"
422
+ if [ ! -f "${source}" ]; then
423
+ error "RUNNER-OPENCODE-CONFIG-MISSING: ${source} is not a file; headless turns will wedge on the first external-directory permission prompt (#563)"
424
+ return 0
425
+ fi
426
+ cp "${source}" "${WORKSPACE}/${target}"
427
+ git -C "${WORKSPACE}" update-index --skip-worktree "${target}" 2>/dev/null \
428
+ || warn "could not mark ${target} skip-worktree; it may show as a local change"
429
+ log "Applied runner OpenCode config ${source} to ${WORKSPACE}/${target}"
430
+ }
431
+
432
+ configure_github_access() {
433
+ if [ -z "${GH_TOKEN:-}" ]; then
434
+ warn "GITHUB-CREDENTIALS-MISSING: GH_TOKEN is unavailable; see RUNNER-SECRET-* above"
435
+ return 0
436
+ fi
437
+ export GIT_CONFIG_GLOBAL=/tmp/gitconfig
438
+ if ! : >"${GIT_CONFIG_GLOBAL}" ||
439
+ ! git config --global user.name "${GIT_USER_NAME:-evident-bot}" ||
440
+ ! git config --global user.email "${GIT_USER_EMAIL:-evident-bot@users.noreply.github.com}" ||
441
+ ! git config --global init.defaultBranch main ||
442
+ ! 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 ||
443
+ ! chmod 0700 /tmp/git-credential-helper.sh ||
444
+ ! git config --global credential."https://github.com".helper /tmp/git-credential-helper.sh; then
445
+ warn "GITHUB-SETUP-FAILED: could not configure local git credentials; continuing without GitHub access"
446
+ return 0
447
+ fi
448
+ (
449
+ local output rc=0 login repo_url repo
450
+ output="$(timeout -k 1 "${GITHUB_PROBE_DEADLINE_SECONDS}" gh api user --jq .login 2>&1)" || rc=$?
451
+ if [ "${rc}" -eq 124 ] || [ "${rc}" -eq 137 ]; then warn "GITHUB-PROBE-TIMEOUT: auth probe exceeded ${GITHUB_PROBE_DEADLINE_SECONDS}s"; return; fi
452
+ if [ "${rc}" -ne 0 ]; then warn "GITHUB-AUTH-REJECTED: ${output}"; return; fi
453
+ log "GITHUB-AUTH-OK: ${output}"
454
+ repo_url="$(git -C "${WORKSPACE}" remote get-url origin 2>/dev/null || true)"
455
+ repo="$(printf '%s' "${repo_url}" | sed -E 's#(https://github.com/|git@github.com:)##; s#\.git$##')"
456
+ [ -n "${repo}" ] || return
457
+ output="$(timeout -k 1 "${GITHUB_PROBE_DEADLINE_SECONDS}" gh api "repos/${repo}" --jq .full_name 2>&1)" || rc=$?
458
+ if [ "${rc}" -eq 124 ] || [ "${rc}" -eq 137 ]; then warn "GITHUB-PROBE-TIMEOUT: repository probe for ${repo} exceeded ${GITHUB_PROBE_DEADLINE_SECONDS}s"; return; fi
459
+ [ "${rc}" -eq 0 ] || warn "GITHUB-REPO-INACCESSIBLE: ${repo}: ${output}"
460
+ ) &
461
+ }
462
+
463
+ # Best-effort by design: /suspend must still drop the tunnel and /terminate must
464
+ # still clean up, so a flush that cannot happen is loud but never fatal.
465
+ sync_credentials() {
466
+ load_state_config || return 0
467
+ run_synchroniser sync-once claude || true
468
+ run_synchroniser sync-once opencode || true
469
+ }
470
+
471
+ # --- Session DB restore (#812 WI-2) ----------------------------------------
472
+ #
473
+ # Restores opencode.db from S3 before opencode opens it — the read half only.
474
+ # Writes NOTHING to S3 itself; replication back to S3 is started separately by
475
+ # `start_litestream` (below) from the `run`/`resume` hooks. `ensure_litestream_config`/
476
+ # `restore_session_db` are the MicroVM side of the same contract
477
+ # runner/docker-images/fargate/entrypoint.sh's inlined restore already speaks, going
478
+ # through the SAME runner-synchroniser CLI.
479
+
480
+ # Reads the ~30 MB litestream binary into the page cache, in the background, so
481
+ # the FIRST exec of it does not pay that read on the critical path.
482
+ #
483
+ # Why it is on the critical path at all: restore_session_db must finish before
484
+ # start_opencode, and HOOK_SIGTERM_SECONDS is a POINT on the hook's SECONDS
485
+ # clock, so a second spent here is a second taken from the rest of /run — and a
486
+ # /run that overruns is SIGTERMed mid-boot and its VM destroyed. (Until #1172
487
+ # the binding point was the tighter in-hook opencode readiness deadline; that
488
+ # deadline is gone, the hook's own SIGTERM is not.)
489
+ # Boot measurements put ~6.6-7.3s between the credential restore and the
490
+ # litestream version line, of which only ~0.2s is accounted for by the two node
491
+ # calls in between — the remainder is INFERRED to be this read, never measured.
492
+ # The `litestream-prewarm` and `litestream-version` timings are what settle it
493
+ # on the next boot.
494
+ #
495
+ # Nothing waits on it and nothing reads its output, so if that inference is
496
+ # wrong this costs one backgrounded `cat`. Deleting the `litestream version`
497
+ # diagnostic instead would save nothing: `litestream restore` two calls later
498
+ # pays the identical read.
499
+ prewarm_litestream() {
500
+ local binary
501
+ binary="$(command -v litestream 2>/dev/null || true)"
502
+ if [ -z "${binary}" ]; then
503
+ warn "litestream is not on PATH; skipping the boot pre-warm"
504
+ return 0
505
+ fi
506
+
507
+ # `cat` rather than a throwaway `litestream version`: a sequential read gets
508
+ # the whole file with readahead, where an exec demand-pages it.
509
+ (
510
+ local started_ms rc=0
511
+ started_ms="$(now_ms)"
512
+ cat "${binary}" >/dev/null 2>&1 || rc=$?
513
+ log_elapsed_since litestream-prewarm "${started_ms}" "${rc}"
514
+ ) &
515
+ log "pre-warming ${binary} in the background"
516
+ }
517
+
518
+ # Generates ${LITESTREAM_CONFIG_FILE} from runner-synchroniser's own renderer
519
+ # — the SAME config module the credential restore/sync already goes through,
520
+ # so there is no second copy of the bucket/prefix/path logic to drift
521
+ # (litestream-config.ts's own header makes the same point). Only when absent
522
+ # or empty, so `/resume` (WI-4) restarting litestream after a suspend/resume
523
+ # snapshot — /dev/shm intact — pays nothing to regenerate it.
524
+ #
525
+ # Returns 1 on failure — fatal to the CALLER, never to the hook: every caller
526
+ # (restore_session_db here, start_litestream in WI-3) must still let opencode
527
+ # and the tunnel start regardless.
528
+ ensure_litestream_config() {
529
+ if [ -s "${LITESTREAM_CONFIG_FILE}" ]; then
530
+ return 0
531
+ fi
532
+
533
+ # Timed OUTSIDE the substitution, not with timed_synchroniser: this call's
534
+ # stdout IS the config, so a timing line emitted inside `$(...)` would land in
535
+ # litestream.yml.
536
+ local rendered config_started_ms config_rc=0
537
+ config_started_ms="$(now_ms)"
538
+ rendered="$(run_synchroniser litestream-config)" || config_rc=$?
539
+ log_elapsed_since litestream-config "${config_started_ms}" "${config_rc}"
540
+ if [ "${config_rc}" -ne 0 ]; then
541
+ error "could not generate ${LITESTREAM_CONFIG_FILE}: runner-synchroniser litestream-config failed (see the error above)"
542
+ return 1
543
+ fi
544
+ printf '%s\n' "${rendered}" >"${LITESTREAM_CONFIG_FILE}" || {
545
+ error "could not write ${LITESTREAM_CONFIG_FILE}"
546
+ return 1
547
+ }
548
+
549
+ # Q7's diagnostic, for whoever can read this VM's CloudWatch log: which way
550
+ # litestream resolved its AWS region, and whether it can even see the
551
+ # names it would need to (never the VALUES of any OTHER AWS_* variable —
552
+ # AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN may be present here).
553
+ local region_empty="yes"
554
+ if printf '%s\n' "${rendered}" | grep -Eq '^ *region: *[^[:space:]]'; then
555
+ region_empty="no"
556
+ fi
557
+ # Hoisted out of the log line below so it can be timed: this is the FIRST exec
558
+ # of the litestream binary on the boot, and the pre-warm /run fires (see
559
+ # prewarm_litestream) is aimed squarely at what this number measures.
560
+ local version_started_ms litestream_version
561
+ version_started_ms="$(now_ms)"
562
+ litestream_version="$(litestream version 2>/dev/null || echo 'unknown')"
563
+ log_elapsed_since litestream-version "${version_started_ms}" 0
564
+ log "litestream ${litestream_version}; AWS_REGION=${AWS_REGION:-<unset>} AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-<unset>}; rendered litestream.yml region empty: ${region_empty}"
565
+ }
566
+
567
+ # Writes the marker AND names the reason, in one call, so the marker can never
568
+ # appear silently. Every give-up path in restore_session_db calls this EXCEPT
569
+ # the classifier's 31 (#1106 — see that branch), and no success path ever does.
570
+ # It is what a later `/resume`/`start_litestream` (WI-3) reads to skip
571
+ # replicating this boot: a local DB that is only PARTIALLY restored must never
572
+ # be allowed to overwrite a replica this boot never proved safe to write over.
573
+ # A merely FRESH local DB is not that danger — litestream continues the txid
574
+ # chain — which is precisely why 31 no longer belongs here.
575
+ mark_no_replicate() {
576
+ : >"${SESSION_DB_NO_REPLICATE_MARKER}"
577
+ warn "SESSION-DB-NO-REPLICATE: $1"
578
+ }
579
+
580
+ # Removes local session-DB debris a give-up path leaves behind. Called on
581
+ # EVERY give-up path and NO success path: skipping it on a give-up is a real
582
+ # bug (a truncated file left in place makes opencode fail to open a malformed
583
+ # database, which fails /run outright — worse than the history loss this
584
+ # discards); calling it on a success path would delete a good restore. One
585
+ # `rm -f` per path, not a single command, so a failure removing one does not
586
+ # skip the other two.
587
+ discard_session_db_debris() {
588
+ rm -f "${OPENCODE_DB_PATH}" "${OPENCODE_DB_PATH}-wal" "${OPENCODE_DB_PATH}-shm"
589
+ }
590
+
591
+ # How long restore_session_db waits for ONE `litestream restore` before giving
592
+ # up. From M4 (see the plan's §2): ~50 MB/s extrapolated from the x86_64 ECS
593
+ # container's own live replica, so 7s covers roughly 300-350 MB — an
594
+ # EXTRAPOLATION, not a MicroVM measurement (different arch, network path and
595
+ # credential source); the boot line ensure_litestream_config logs, plus the
596
+ # ${SECONDS} stamps in /run, are how it gets measured here for real.
597
+ #
598
+ # 7 rather than the 8 it was: hook-scripts.test.ts's pre-opencode ladder held an
599
+ # allowance of 4s for the rest of this step, which live boots measured at
600
+ # 6.7-7.4s, so the ladder passed while real boots blew the deadline. Correcting
601
+ # that allowance is what takes this second — the remedy the ladder's own failure
602
+ # message prescribes, and the one that leaves the SIGTERM worst case untouched.
603
+ # The cost is real and unmeasured on this platform: ~50 MB less restorable
604
+ # replica before a truncation that silently loses history. A fresh
605
+ # state_prefix starts at zero, so this is generous for a long time, but
606
+ # RoutingStrategy is 'per_user' only (routing-strategy.ts) and every prefix is
607
+ # deterministic and permanent per (pool, routing key) — so a long-lived
608
+ # runner's opencode.db grows monotonically across VM generations, exactly
609
+ # like the ~1 GB ECS one M4 measured, and will eventually hit this ceiling.
610
+ # Past it the restore is truncated (SESSION-DB-RESTORE-TRUNCATED, below) and
611
+ # this boot skips replication — silent, permanent history loss for that
612
+ # runner unless somebody reads the log. Not solved here (WI-0 Task 0.2).
613
+ # The env override is for tests only, so they need not burn 7s of wall clock.
614
+ SESSION_DB_RESTORE_DEADLINE_SECONDS="${EVIDENT_SESSION_DB_RESTORE_DEADLINE_SECONDS:-7}"
615
+ # The SIGKILL backstop `timeout -k` applies after its own SIGTERM: `timeout`
616
+ # alone only SIGTERMs, and a litestream that ignored it would be unbounded
617
+ # again. Hard ceiling on this step: DEADLINE + this = 9s.
618
+ SESSION_DB_RESTORE_KILL_GRACE_SECONDS=2
619
+
620
+ # The one bounded restore attempt, run before start_opencode (opencode opens
621
+ # the DB the moment it starts, so this is the only place it can happen).
622
+ # Every branch returns 0 — /run calls this bare under `set -euo pipefail`,
623
+ # exactly like restore_credentials, and nothing about the session DB may ever
624
+ # fail /run (Q3): a /run that exits 1 fails the whole lifecycle transition
625
+ # and the user gets NO runner at all, which is worse than losing history.
626
+ restore_session_db() {
627
+ # Cleanup, not a decision, and unconditionally first: /dev/shm survives
628
+ # suspend/resume and a /run retry, so a marker left by an earlier boot must
629
+ # never silently disable replication for the rest of this VM's life.
630
+ rm -f "${SESSION_DB_NO_REPLICATE_MARKER}"
631
+ local report_path
632
+ if report_path="$(session_db_recovery_report_path)"; then
633
+ # The default report directory is provisioned in the image, so this is a
634
+ # no-op there; a shared-path override (EVIDENT_SESSION_DB_RECOVERY_REPORT)
635
+ # can name a directory that isn't, and the typed synchroniser writer
636
+ # already creates it for that same override (session-db-recovery-report.ts).
637
+ # Best-effort only: report_session_db_giveup below still warns and
638
+ # continues if this didn't leave a writable path. `${path%/*}` leaves a
639
+ # bare relative filename (no `/`) unchanged rather than reducing to `.`
640
+ # like dirname(1) — without the explicit check, mkdir would create a
641
+ # directory AT the report path itself.
642
+ local report_dir="${report_path%/*}"
643
+ [ "${report_dir}" = "${report_path}" ] && report_dir="."
644
+ [ -d "${report_dir}" ] || mkdir -p "${report_dir}" 2>/dev/null || true
645
+ : >"${report_path}" || warn "SESSION-DB-RECOVERY-REPORT-TRUNCATE-FAILED"
646
+ else
647
+ warn "SESSION-DB-RECOVERY-REPORT-PATH-UNAVAILABLE"
648
+ fi
649
+
650
+ # Config resolved ONCE here, in the synchroniser (config.ts owns
651
+ # OPENCODE_DB_PATH; no second copy of that path here), exactly as
652
+ # entrypoint.sh's own line does. Unlike ECS's `|| die`, a failure here must
653
+ # not abort the hook — `x="$(cmd)"` under `set -e` WOULD abort the whole
654
+ # shell on a failing substitution, which is exactly why it is caught
655
+ # explicitly rather than left to `set -e`.
656
+ # Timed OUTSIDE the substitution for the same reason ensure_litestream_config
657
+ # is: this call's stdout is `eval`'d, so a timing line emitted inside `$(...)`
658
+ # would be evaluated as configuration.
659
+ local synchroniser_env env_started_ms env_rc=0
660
+ env_started_ms="$(now_ms)"
661
+ synchroniser_env="$(run_synchroniser env)" || env_rc=$?
662
+ log_elapsed_since session-db-env "${env_started_ms}" "${env_rc}"
663
+ if [ "${env_rc}" -ne 0 ]; then
664
+ mark_no_replicate "could not resolve the runner-synchroniser configuration (see the ERROR above)"
665
+ report_session_db_giveup synchroniser_config_unresolved
666
+ return 0
667
+ fi
668
+ # Guarded for the SAME reason as the substitution above, which is easy to miss:
669
+ # `eval` returns the status of what it ran, so a malformed line from a skewed
670
+ # bundle would abort /run right here under `set -e` — before start_opencode,
671
+ # so the user gets NO runner at all. entrypoint.sh:110 leaves the identical
672
+ # line bare because on ECS the blast radius is a crash-loop-and-replace; here
673
+ # it is the whole lifecycle transition, so Q3 makes it a give-up instead.
674
+ eval "${synchroniser_env}" || {
675
+ mark_no_replicate "the runner-synchroniser configuration could not be evaluated; the installed bundle likely does not match this hook"
676
+ report_session_db_giveup synchroniser_config_unevaluable
677
+ return 0
678
+ }
679
+
680
+ # #931's exact lesson, one shell over (runner/docker-images/fargate/entrypoint.sh):
681
+ # the `|| { ... }` above only catches a non-zero EXIT — an `env` that exits
682
+ # 0 with an INCOMPLETE contract (a runner-synchroniser version/build skew)
683
+ # would otherwise abort right here under `set -u` the moment
684
+ # `${OPENCODE_DB_PATH}` is dereferenced below. entrypoint.sh may `die` on
685
+ # this (ECS just crash-loops and replaces the task); this hook cannot —
686
+ # Q3 says nothing about the session DB may ever fail /run — so a missing
687
+ # OPENCODE_DB_PATH is a give-up, not a silent default (a guessed path would
688
+ # be actively wrong, not merely absent).
689
+ if [ -z "${OPENCODE_DB_PATH+x}" ]; then
690
+ mark_no_replicate "run_synchroniser env did not define OPENCODE_DB_PATH; the installed runner-synchroniser build likely does not match this hook"
691
+ report_session_db_giveup synchroniser_config_incomplete
692
+ return 0
693
+ fi
694
+
695
+ # #770's arm, checked FIRST of the decisions and with its OWN message —
696
+ # never the SESSION-DB-REPLICA-UNUSABLE one below (#764's finding: an
697
+ # operator grepping that marker must not hit the disabled case).
698
+ # PERSISTENCE_BUCKET is empty exactly when LITESTREAM_BUCKET or
699
+ # LITESTREAM_PREFIX is unset (config.ts's persistenceEnabled) — the
700
+ # IDENTICAL predicate ECS's own litestream-launch gate uses
701
+ # (entrypoint.sh), so the two images agree on what "disabled" means.
702
+ if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
703
+ warn "SESSION-DB-PERSISTENCE-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; opencode starts with a fresh session DB and nothing is replicated."
704
+ return 0
705
+ fi
706
+
707
+ if ! ensure_litestream_config; then
708
+ mark_no_replicate "could not generate ${LITESTREAM_CONFIG_FILE} (see the error above)"
709
+ report_session_db_giveup litestream_config_unavailable
710
+ return 0
711
+ fi
712
+
713
+ local restore_rc=0 restore_started_ms
714
+ restore_started_ms="$(now_ms)"
715
+ timeout -k "${SESSION_DB_RESTORE_KILL_GRACE_SECONDS}" "${SESSION_DB_RESTORE_DEADLINE_SECONDS}" \
716
+ litestream restore -config "${LITESTREAM_CONFIG_FILE}" \
717
+ -if-db-not-exists -if-replica-exists "${OPENCODE_DB_PATH}" || restore_rc=$?
718
+ log_elapsed_since session-db-restore "${restore_started_ms}" "${restore_rc}"
719
+
720
+ # `timeout`'s OWN codes, handled BEFORE classifying: 124/137 are a
721
+ # TRUNCATED restore, not a corrupt replica — handing them to
722
+ # session-db-classify would mislabel a slow/large replica as one. 125-127
723
+ # mean `timeout` (or litestream itself) is broken, not the replica.
724
+ case "${restore_rc}" in
725
+ 124 | 137)
726
+ discard_session_db_debris
727
+ 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"
728
+ report_session_db_giveup restore_deadline_exceeded "${restore_rc}"
729
+ return 0
730
+ ;;
731
+ 125 | 126 | 127)
732
+ error "litestream restore could not even run (timeout exited ${restore_rc})"
733
+ discard_session_db_debris
734
+ mark_no_replicate "restore tool is broken (timeout exited ${restore_rc}); opencode starts with a fresh session DB and nothing is replicated this boot"
735
+ report_session_db_giveup restore_tool_unusable "${restore_rc}"
736
+ return 0
737
+ ;;
738
+ esac
739
+
740
+ local classify_rc=0
741
+ run_synchroniser session-db-classify "${restore_rc}" 1 --on-unusable-replica=leave --fresh-db-fallback || classify_rc=$?
742
+ case "${classify_rc}" in
743
+ 0) ;; # restored, or no replica yet — the CLI already logged which
744
+ 31)
745
+ # The classifier already discarded the local debris (session-db.ts) — and
746
+ # that is exactly why this is the ONE give-up path that does NOT
747
+ # mark_no_replicate (#1106). Every other give-up here may be sitting on a
748
+ # half-restored DB, so the marker stays; a 31 is structurally guaranteed
749
+ # to be a FRESH one, and replicating it starts a new backup chain instead
750
+ # of leaving this boot with a zero-width backup window. Same reasoning,
751
+ # and same one-line change, as runner/docker-images/fargate/entrypoint.sh's `31)`.
752
+ warn "SESSION-DB-REPLICA-UNUSABLE: booting with a fresh opencode.db and replicating into the existing prefix this boot (see the WARNING above)"
753
+ ;;
754
+ 32)
755
+ # The CLI's own contract for 32 is "re-run litestream restore and ask
756
+ # again", but this hook has budget for exactly ONE attempt (Q4) — name
757
+ # the deviation so nobody reads this as a bug.
758
+ discard_session_db_debris
759
+ 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"
760
+ ;;
761
+ 30)
762
+ # The CLI already logged its own FATAL line above; Q3 still says boot
763
+ # fresh rather than fail /run.
764
+ discard_session_db_debris
765
+ mark_no_replicate "session-db-classify returned fatal (30); see the FATAL message above"
766
+ report_session_db_giveup classification_fatal
767
+ ;;
768
+ *)
769
+ # run_synchroniser already logged the "tool broke" ERROR for this.
770
+ discard_session_db_debris
771
+ mark_no_replicate "session-db-classify exited ${classify_rc}, which is none of its documented answers"
772
+ report_session_db_giveup classification_unrecognised
773
+ ;;
774
+ esac
775
+
776
+ return 0
777
+ }
778
+ # --- Session DB restore (end) -----------------------------------------------
779
+
780
+ # `kill -0` answers "does this pid exist", which is not the question any caller
781
+ # here is asking. A process that has exited but has not been reaped — a zombie —
782
+ # still exists, so `kill -0` reports a corpse as ALIVE. That condition is the
783
+ # normal case for everything these hooks start: `start_tunnel`/`start_opencode`
784
+ # background a process that outlives the hook, the hook shell must return so AWS
785
+ # gets its 200, and PID 1 in this image is a bare node hook server with no init
786
+ # (Dockerfile) — so nothing ever wait()s for the orphan. It stayed a zombie for
787
+ # the rest of the VM's life, which made `stop_tunnel` burn its whole budget
788
+ # SIGKILLing a corpse and made a stale pid file suppress the next
789
+ # `start_tunnel`, leaving a resumed VM permanently offline (#718).
790
+ #
791
+ # This is zombie-safe, NOT identity-safe: a recycled pid reads R/S and is still
792
+ # called alive, so the SIGKILL backstop could in principle hit an unrelated
793
+ # process. Pre-existing limitation of the pid-file approach, out of scope here —
794
+ # named so nobody over-trusts the helper (src/image/hook-scripts.test.ts).
795
+ #
796
+ # Also sets PROC_DEAD_REASON on every return path — "gone" for a `kill -0`
797
+ # miss, "zombie" for an unreaped `Z`, reset to empty on the alive path so a
798
+ # stale value from a previous call can never be read — for the caller that
799
+ # needs to say WHICH death it was (stop_tunnel's outcome line), never for this
800
+ # function itself: see the silence note below. File-scope init (below) is what
801
+ # keeps a read of it safe under `set -u` before this has ever run.
802
+ PROC_DEAD_REASON=""
803
+
804
+ process_is_alive() {
805
+ if ! kill -0 "$1" 2>/dev/null; then
806
+ PROC_DEAD_REASON="gone"
807
+ return 1
808
+ fi
809
+
810
+ # `/proc/<pid>/status` rather than `/proc/<pid>/stat`, whose fields cannot be
811
+ # split safely when a comm contains a space or a paren. If the state cannot be
812
+ # read at all, keep the `kill -0` answer: absent evidence is not "dead", and
813
+ # reading it as dead would SIGTERM-and-forget a live, still-draining CLI.
814
+ # Three fields, not two: `read` hands the whole remainder to its LAST variable,
815
+ # so a trailing `_` is what keeps the state letter out of `Z (zombie)`.
816
+ #
817
+ # That deferral is silent HERE on purpose: `stop_tunnel` polls this 10x/s, so a
818
+ # warning inside it would bury the log. The flag lets `is_running` say it once
819
+ # per guard instead — absent evidence must warn as well as defer
820
+ # (development-workflow.mdc).
821
+ PROC_STATE_UNREADABLE=0
822
+ local state
823
+ read -r _ state _ < <(grep -m1 '^State:' "/proc/$1/status" 2>/dev/null) || {
824
+ PROC_STATE_UNREADABLE=1
825
+ PROC_DEAD_REASON="" # unreadable /proc still means "alive" below, not a dead reason
826
+ return 0
827
+ }
828
+
829
+ if [ "${state}" = "Z" ]; then
830
+ PROC_DEAD_REASON="zombie"
831
+ return 1
832
+ fi
833
+
834
+ PROC_DEAD_REASON=""
835
+ return 0
836
+ }
837
+
838
+ is_running() {
839
+ [ -s "$1" ] || return 1
840
+
841
+ local pid
842
+ pid="$(cat "$1")"
843
+ process_is_alive "${pid}" || return 1
844
+
845
+ # The one state in which this whole helper is back to being a bare `kill -0`
846
+ # (a `hidepid=` mount, a `/proc` that is not mounted): every liveness answer
847
+ # silently reverts to #718 — a corpse reads as alive, so suspend SIGKILLs it
848
+ # after the full wait and the next start is suppressed for the VM's life.
849
+ [ "${PROC_STATE_UNREADABLE}" = 0 ] \
850
+ || warn "could not read /proc/${pid}/status; falling back to 'kill -0', which reports an exited-but-unreaped process as alive (#718)"
851
+
852
+ return 0
853
+ }
854
+
855
+ # The snapshot bakes one machine id into every VM launched from this image
856
+ # version, so /run replaces it. Best-effort per file: the hook runs as uid 10001
857
+ # and these live in root-owned directories, so a VM that kept the snapshot's id
858
+ # must still boot — but it says so, because it is a state worth being able to see.
859
+ regenerate_machine_id() {
860
+ local machine_id
861
+ machine_id="$(tr -d '-' </proc/sys/kernel/random/uuid)"
862
+
863
+ for path in /etc/machine-id /var/lib/dbus/machine-id; do
864
+ printf '%s\n' "${machine_id}" >"${path}" 2>/dev/null \
865
+ || warn "could not rewrite ${path}; this VM keeps the snapshot's machine id"
866
+ done
867
+ }
868
+
869
+ tunnel_is_running() { is_running "${TUNNEL_PID_FILE}"; }
870
+ opencode_is_running() { is_running "${OPENCODE_PID_FILE}"; }
871
+ litestream_is_running() { is_running "${LITESTREAM_PID_FILE}"; }
872
+
873
+ # `jq -e` alone is not enough: its exit status reflects the LAST OUTPUT VALUE,
874
+ # and an interpolation of a missing field is still a non-empty string, so a
875
+ # payload with no runner_key would sail through as the literal "null".
876
+ payload_is_complete() {
877
+ jq -e '
878
+ (.runner_key | type == "string" and length > 0)
879
+ and (.endpoints.api | type == "string" and length > 0)
880
+ and (.endpoints.tunnel | type == "string" and length > 0)
881
+ and (.state_prefix | type == "string" and length > 0)
882
+ ' >/dev/null 2>&1
883
+ }
884
+
885
+ # `setsid` so opencode outlives this hook: the script must return so AWS gets its
886
+ # 200, while opencode keeps serving. The guard makes /run idempotent — a retry
887
+ # after a killed hook must not put a second instance on the same port.
888
+ start_opencode() {
889
+ if opencode_is_running; then
890
+ warn "opencode already running (pid $(cat "${OPENCODE_PID_FILE}")); reusing it"
891
+ return 0
892
+ fi
893
+
894
+ setsid opencode serve \
895
+ --hostname 127.0.0.1 \
896
+ --port "${OPENCODE_PORT}" \
897
+ --print-logs &
898
+
899
+ echo $! >"${OPENCODE_PID_FILE}"
900
+ log "opencode starting (pid $(cat "${OPENCODE_PID_FILE}"))"
901
+ }
902
+
903
+ stop_opencode() {
904
+ if ! opencode_is_running; then
905
+ # Clear the file here too, for the same reason stop_tunnel does: opencode
906
+ # has exit paths that never reach this function (a crash, an OOM kill), so
907
+ # the pid file routinely outlives the process it names, and leaving it is
908
+ # #718 again the moment anything starts reaping orphans and the pid is
909
+ # recycled.
910
+ rm -f "${OPENCODE_PID_FILE}"
911
+ log "no opencode to stop"
912
+ return 0
913
+ fi
914
+
915
+ kill -TERM "$(cat "${OPENCODE_PID_FILE}")" 2>/dev/null || true
916
+ rm -f "${OPENCODE_PID_FILE}"
917
+ log "opencode stopped"
918
+ }
919
+
920
+ # The ceiling stop_opencode_and_wait (below) waits before its SIGKILL backstop.
921
+ # /terminate can afford a real drain — the VM is being torn down either way —
922
+ # unlike /run's cleanup, which cannot (stop_opencode's own cheapness above is
923
+ # load-bearing for that budget, §4, and must not grow a wait).
924
+ OPENCODE_STOP_WAIT_SECONDS="${EVIDENT_OPENCODE_STOP_WAIT_SECONDS:-5}"
925
+
926
+ # The graceful stop /terminate needs (#812 WI-4): SIGTERM, poll for exit,
927
+ # SIGKILL backstop, clear the pid file. A NEW function rather than growing
928
+ # stop_opencode itself, for the reason above — mirrors stop_tunnel's and
929
+ # stop_litestream's shape, including PROC_DEAD_REASON's one deciding read
930
+ # after the poll loop (never inside it, which would bury the log at 10x/s).
931
+ # This is half of the ordered-shutdown invariant (plan §6): /terminate must
932
+ # stop opencode BEFORE flush_session_db's final sync, or litestream could
933
+ # snapshot while opencode is still writing its WAL and the last session
934
+ # writes would be missing from S3.
935
+ stop_opencode_and_wait() {
936
+ if ! opencode_is_running; then
937
+ rm -f "${OPENCODE_PID_FILE}"
938
+ log "no opencode to stop"
939
+ return 0
940
+ fi
941
+
942
+ local pid
943
+ pid="$(cat "${OPENCODE_PID_FILE}")"
944
+ kill -TERM "${pid}" 2>/dev/null || true
945
+
946
+ local tenths=0
947
+ local deadline_tenths=$(( OPENCODE_STOP_WAIT_SECONDS * 10 ))
948
+ while [ "${tenths}" -lt "${deadline_tenths}" ]; do
949
+ process_is_alive "${pid}" || break
950
+ sleep 0.1
951
+ tenths=$(( tenths + 1 ))
952
+ done
953
+
954
+ local outcome
955
+ if process_is_alive "${pid}"; then
956
+ warn "opencode ${pid} ignored SIGTERM; killing"
957
+ kill -KILL "${pid}" 2>/dev/null || true
958
+ outcome="after SIGKILL (ignored SIGTERM for ${OPENCODE_STOP_WAIT_SECONDS}s)"
959
+ else
960
+ outcome="cleanly in $(( tenths / 10 )).$(( tenths % 10 ))s (${PROC_DEAD_REASON})"
961
+ fi
962
+
963
+ rm -f "${OPENCODE_PID_FILE}"
964
+ log "opencode stopped ${outcome}"
965
+ }
966
+
967
+ # --- litestream replicate (#812 WI-3) ---------------------------------------
968
+ #
969
+ # The write half of session-DB persistence. restore_session_db (above) is the
970
+ # read half only and never starts this. Q8 chose one backgrounded, unsupervised
971
+ # `litestream replicate` over a periodic flush or a `replicate -exec` wrapper
972
+ # around opencode: SIGTERM is `replicate`'s own final-sync trigger, so
973
+ # stop_litestream (below) doubles as the checked flush /suspend and /terminate
974
+ # need, and there is no supervisor in this image to hand the process to (no
975
+ # init, no `wait -n` shell that could stay alive after the hook returns).
976
+
977
+ # The graceful wait before the SIGKILL backstop in stop_litestream, below.
978
+ # litestream's own sync is normally sub-second, so 10s is generous headroom —
979
+ # chosen the same way TUNNEL_STOP_WAIT_SECONDS was, not a measured worst case.
980
+ LITESTREAM_STOP_WAIT_SECONDS="${EVIDENT_LITESTREAM_STOP_WAIT_SECONDS:-10}"
981
+
982
+ # Guards, in this exact order and no other — reordering any of them re-arms the
983
+ # invariant it exists to protect:
984
+ #
985
+ # 1. persistence disabled (#770) — bucket AND prefix both non-empty — is its
986
+ # OWN arm with its OWN message, checked FIRST, and must never log
987
+ # SESSION-DB-REPLICA-UNUSABLE: an operator grepping that marker must not
988
+ # hit the disabled case (#764).
989
+ # 2. the no-replicate marker (restore_session_db above) — set when this boot's
990
+ # local DB may be only PARTIALLY restored, so starting replicate here would
991
+ # let it overwrite a replica this boot never proved safe to write over.
992
+ # A classifier 31 no longer sets it (#1106): that DB is guaranteed fresh,
993
+ # and skipping replicate for it was what made the history loss unbounded.
994
+ # 3. no usable config — ensure_litestream_config (restore_session_db, above)
995
+ # already logged why it is missing; this is not a place to retry it.
996
+ # 4. already running — idempotence, mirroring start_tunnel's own guard.
997
+ #
998
+ # Only past all four does it actually spawn, backgrounded with `setsid` for the
999
+ # same reason start_opencode/start_tunnel are: the hook must return so AWS gets
1000
+ # its 200, while replicate keeps running. litestream's OWN stderr is left to
1001
+ # reach CloudWatch — never redirected — because there is no supervisor in this
1002
+ # image, so that is the only channel that can say why a replicator died.
1003
+ start_litestream() {
1004
+ if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
1005
+ warn "SESSION-DB-PERSISTENCE-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; nothing is replicated."
1006
+ return 0
1007
+ fi
1008
+
1009
+ if [ -e "${SESSION_DB_NO_REPLICATE_MARKER}" ]; then
1010
+ log "skipping litestream replicate: this boot's session DB was not proven safe to replicate (see the SESSION-DB-* warning above)"
1011
+ return 0
1012
+ fi
1013
+
1014
+ if [ ! -s "${LITESTREAM_CONFIG_FILE}" ]; then
1015
+ error "no usable ${LITESTREAM_CONFIG_FILE}; not starting litestream replicate"
1016
+ return 0
1017
+ fi
1018
+
1019
+ if litestream_is_running; then
1020
+ warn "litestream already running (pid $(cat "${LITESTREAM_PID_FILE}")); reusing it"
1021
+ return 0
1022
+ fi
1023
+
1024
+ setsid litestream replicate -config "${LITESTREAM_CONFIG_FILE}" &
1025
+
1026
+ echo $! >"${LITESTREAM_PID_FILE}"
1027
+ log "litestream replicate starting (pid $(cat "${LITESTREAM_PID_FILE}"))"
1028
+ }
1029
+
1030
+ # The graceful stop: mirrors stop_tunnel exactly, including PROC_DEAD_REASON's
1031
+ # one deciding read AFTER the poll loop (never inside it, which polls 10x/s and
1032
+ # would bury the log) — gone / zombie / SIGKILLed are the three outcomes an
1033
+ # operator needs told apart, not a flatter clean/killed binary. /suspend and
1034
+ # /terminate call this: the SIGTERM it sends IS the checked flush they need.
1035
+ stop_litestream() {
1036
+ if ! litestream_is_running; then
1037
+ rm -f "${LITESTREAM_PID_FILE}"
1038
+ log "no litestream to stop"
1039
+ return 0
1040
+ fi
1041
+
1042
+ local pid
1043
+ pid="$(cat "${LITESTREAM_PID_FILE}")"
1044
+ kill -TERM "${pid}" 2>/dev/null || true
1045
+
1046
+ local tenths=0
1047
+ local deadline_tenths=$(( LITESTREAM_STOP_WAIT_SECONDS * 10 ))
1048
+ while [ "${tenths}" -lt "${deadline_tenths}" ]; do
1049
+ process_is_alive "${pid}" || break
1050
+ sleep 0.1
1051
+ tenths=$(( tenths + 1 ))
1052
+ done
1053
+
1054
+ local outcome
1055
+ if process_is_alive "${pid}"; then
1056
+ warn "litestream ${pid} ignored SIGTERM; killing"
1057
+ kill -KILL "${pid}" 2>/dev/null || true
1058
+ outcome="after SIGKILL (ignored SIGTERM for ${LITESTREAM_STOP_WAIT_SECONDS}s)"
1059
+ else
1060
+ outcome="cleanly in $(( tenths / 10 )).$(( tenths % 10 ))s (${PROC_DEAD_REASON})"
1061
+ fi
1062
+
1063
+ rm -f "${LITESTREAM_PID_FILE}"
1064
+ log "litestream stopped ${outcome}"
1065
+ }
1066
+
1067
+ # The cheap stop: one SIGTERM, one rm, one log line, NO poll loop — `/run`'s
1068
+ # own cleanup calls this, never stop_litestream, because that path only runs
1069
+ # after /run has already FAILED: no session happened yet, so a graceful final
1070
+ # sync is worth nothing against a SIGTERM budget that cannot afford another
1071
+ # wait on top of stop_tunnel's own. A distinct function rather than a mode
1072
+ # argument on stop_litestream:
1073
+ # stop_tunnel's own comment argues against an argument a future caller can get
1074
+ # wrong. litestream_is_running is the same O(1) check stop_litestream's own
1075
+ # no-op branch uses, not a poll — /run's cleanup trap fires this before
1076
+ # start_litestream is ever reached whenever an earlier step failed, and an
1077
+ # operator reading that log must not be told a stop signal went to a process
1078
+ # that never started.
1079
+ kill_litestream() {
1080
+ if ! litestream_is_running; then
1081
+ rm -f "${LITESTREAM_PID_FILE}"
1082
+ log "no litestream to stop"
1083
+ return 0
1084
+ fi
1085
+
1086
+ kill -TERM "$(cat "${LITESTREAM_PID_FILE}")" 2>/dev/null || true
1087
+ rm -f "${LITESTREAM_PID_FILE}"
1088
+ log "litestream stop signalled (no wait)"
1089
+ }
1090
+ # --- litestream replicate (end) ----------------------------------------------
1091
+
1092
+ # --- flush_session_db (#812 WI-4) -------------------------------------------
1093
+ #
1094
+ # The checked, synchronous flush /suspend and /terminate need before they
1095
+ # finish tearing down. Never fatal, for the same reason every session-DB
1096
+ # function in this file is: /suspend and /terminate must complete their own
1097
+ # teardown regardless of whether S3 could be reached.
1098
+
1099
+ # Ceiling for the one-shot `litestream replicate -once` flush below. litestream's
1100
+ # own sync is normally sub-second (M5, the plan's grounding); 10s is generous
1101
+ # headroom, chosen the same way LITESTREAM_STOP_WAIT_SECONDS was above — not a
1102
+ # measured worst case. The SIGKILL backstop below reuses
1103
+ # SESSION_DB_RESTORE_KILL_GRACE_SECONDS rather than its own literal: it is the
1104
+ # identical concept (bare `timeout` only SIGTERMs, and a litestream that
1105
+ # ignored it would be unbounded again), and there is no reason for the restore
1106
+ # and flush paths to ever drift on how long a SIGTERM gets to land.
1107
+ SESSION_DB_FLUSH_DEADLINE_SECONDS="${EVIDENT_SESSION_DB_FLUSH_DEADLINE_SECONDS:-10}"
1108
+
1109
+ # Guards mirror start_litestream's first three exactly (disabled -> marker ->
1110
+ # config) — a flush must never attempt work those guards would have refused
1111
+ # to start in the first place. PERSISTENCE_BUCKET is the SAME variable
1112
+ # start_litestream reads, so both agree regardless of which earlier step in
1113
+ # THIS process set it (load_state_config, above, or restore_session_db's
1114
+ # eval, on /run).
1115
+ #
1116
+ # Then: stop the daemon FIRST — its SIGTERM IS litestream's own final-sync
1117
+ # trigger — and only once it is confirmed stopped does the synchronous -once
1118
+ # flush run, as a second, checked pass. Running -once while the daemon is
1119
+ # still alive would put two writers on one prefix, the single-writer
1120
+ # invariant this whole feature exists to protect (Q8) — this ordering is not
1121
+ # negotiable.
1122
+ #
1123
+ # If the daemon has already died (a missing or stale pid file), that IS the
1124
+ # detection this needs: SESSION-DB-REPLICATOR-DIED names it loudly instead of
1125
+ # silently skipping straight to the flush (development-workflow.mdc: every
1126
+ # recovery branch emits a server-visible signal). The flush still runs either
1127
+ # way — it is what actually gets any unreplicated writes to S3 before the
1128
+ # caller's next step.
1129
+ flush_session_db() {
1130
+ if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
1131
+ warn "SESSION-DB-PERSISTENCE-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; nothing to flush."
1132
+ return 0
1133
+ fi
1134
+
1135
+ if [ -e "${SESSION_DB_NO_REPLICATE_MARKER}" ]; then
1136
+ log "skipping session-DB flush: this boot's session DB was not proven safe to replicate (see the SESSION-DB-* warning above)"
1137
+ return 0
1138
+ fi
1139
+
1140
+ if [ ! -s "${LITESTREAM_CONFIG_FILE}" ]; then
1141
+ error "no usable ${LITESTREAM_CONFIG_FILE}; cannot flush the session DB"
1142
+ return 0
1143
+ fi
1144
+
1145
+ if litestream_is_running; then
1146
+ stop_litestream
1147
+ else
1148
+ warn "SESSION-DB-REPLICATOR-DIED: no live litestream at ${LITESTREAM_PID_FILE}; flushing one-shot anyway"
1149
+ fi
1150
+
1151
+ # Only reached with the daemon confirmed stopped, or never running — never
1152
+ # concurrently with it (see above).
1153
+ local flush_rc=0
1154
+ timeout -k "${SESSION_DB_RESTORE_KILL_GRACE_SECONDS}" "${SESSION_DB_FLUSH_DEADLINE_SECONDS}" \
1155
+ litestream replicate -once -config "${LITESTREAM_CONFIG_FILE}" || flush_rc=$?
1156
+
1157
+ if [ "${flush_rc}" -eq 0 ]; then
1158
+ log "SESSION-DB-FINAL-FLUSH: succeeded, ${SECONDS}s into the hook"
1159
+ else
1160
+ warn "SESSION-DB-FINAL-FLUSH: litestream replicate -once exited ${flush_rc}, ${SECONDS}s into the hook; some writes since the last sync may not have reached S3"
1161
+ fi
1162
+ }
1163
+ # --- flush_session_db (end) --------------------------------------------------
1164
+
1165
+ # How long the guest CLI runs with no activity before it exits itself
1166
+ # (`evident run --idle-timeout`, apps/cli/src/commands/run.ts), which is what
1167
+ # turns a truly-abandoned VM into the clean-offline POST that lets Evident
1168
+ # suspend it (#732). Sized from the measured cost of guessing wrong rather than
1169
+ # the saving: suspend reaches SUSPENDED in ~7 s and a resume is RUNNING in
1170
+ # ~0.6 s with the tunnel back ~2 s later (README, "Measured, end to end"), so
1171
+ # napping a VM whose user comes straight back costs ~10 s — against ~$0.30/h
1172
+ # that is cheap enough that the balance sits far nearer the floor than the
1173
+ # ceiling. Not AT the floor, though: the CLI's idle detector needs 2 clear poll
1174
+ # cycles (`run.ts`'s `idlePolls >= 2`, ≥4 s of real time), so a value near that
1175
+ # would spend more time suspending/resuming than idle.
1176
+ # ECS's waker uses 900 s instead only because *its* cold start is far slower
1177
+ # than this VM's ~2 s resume — not evidence this default should match it.
1178
+ #
1179
+ # NOT part of the hook's own teardown budget (TUNNEL_STOP_WAIT_SECONDS et al.,
1180
+ # below): it governs the CLI's lifetime long after the hook has already
1181
+ # returned its 200, so it is deliberately outside that arithmetic.
1182
+ #
1183
+ # EVIDENT_IDLE_TIMEOUT_SECONDS, deliberately the SAME env var name
1184
+ # runner/docker-images/fargate/entrypoint.sh uses for the ECS runner's own
1185
+ # idle-timeout flag, so an operator who knows one knows the other. A
1186
+ # non-numeric override must never silently DROP the flag — that degrades to
1187
+ # an always-on VM burning ~$2.40/day, exactly the bug this closes — so it
1188
+ # warns and falls back to the default instead.
1189
+ #
1190
+ # Deliberately NOT in the /run payload yet: doing so would touch the doorbell
1191
+ # contract in four coordinated places for no capability today. #716 shipped
1192
+ # the swarm/per-user provisioner but explicitly scoped control-plane
1193
+ # idle/suspend policy OUT (its own "Phase 3" — see the issue's "Out of
1194
+ # Scope"), so a per-pool idle policy is still a clean follow-up, not
1195
+ # something #716 already provides a home for.
1196
+ IDLE_TIMEOUT_SECONDS=120
1197
+ if [ -n "${EVIDENT_IDLE_TIMEOUT_SECONDS:-}" ]; then
1198
+ if [[ "${EVIDENT_IDLE_TIMEOUT_SECONDS}" =~ ^[0-9]+$ ]]; then
1199
+ IDLE_TIMEOUT_SECONDS="${EVIDENT_IDLE_TIMEOUT_SECONDS}"
1200
+ else
1201
+ warn "EVIDENT_IDLE_TIMEOUT_SECONDS='${EVIDENT_IDLE_TIMEOUT_SECONDS}' is not numeric; using the default ${IDLE_TIMEOUT_SECONDS}s instead of leaving the VM always-on"
1202
+ fi
1203
+ fi
1204
+
1205
+ # `setsid` so the tunnel outlives this hook: the script must return so AWS gets
1206
+ # its 200, while the tunnel keeps serving.
1207
+ start_tunnel() {
1208
+ local runner_key="$1" api_url="$2" tunnel_url="$3"
1209
+
1210
+ if tunnel_is_running; then
1211
+ warn "tunnel already running (pid $(cat "${TUNNEL_PID_FILE}")); not starting a second one"
1212
+ return 0
1213
+ fi
1214
+
1215
+ # The key travels in the child's environment ONLY: argv is world-readable
1216
+ # through /proc.
1217
+ # --enable-file-sync-to is what makes the UI's "connect a provider account"
1218
+ # flow reach this VM: without it the CLI declines every queued file and the
1219
+ # page can only say "this runner isn't accepting files". There is no shell
1220
+ # into a MicroVM, so it is the ONLY way to seed model credentials on a runner
1221
+ # that is already up — the S3 credential store is read at /run and /resume,
1222
+ # never mid-life. The allow-list stays a single directory, as on ECS
1223
+ # (runner/docker-images/fargate/entrypoint.sh): the flag is repeatable, but every
1224
+ # extra entry widens what Evident can write into a VM that executes agent
1225
+ # code. ${HOME} is set by the image (ENV HOME=/home/runner) and `set -u` makes
1226
+ # an unset one abort rather than silently allow-list "/.claude".
1227
+ EVIDENT_RUNNER_KEY="${runner_key}" setsid evident run \
1228
+ --port "${OPENCODE_PORT}" \
1229
+ --endpoint "${api_url}" \
1230
+ --tunnel "${tunnel_url}" \
1231
+ --idle-timeout "${IDLE_TIMEOUT_SECONDS}" \
1232
+ --enable-file-sync-to "${HOME}/.claude" &
1233
+
1234
+ echo $! >"${TUNNEL_PID_FILE}"
1235
+ log "tunnel started (pid $(cat "${TUNNEL_PID_FILE}"))"
1236
+ }
1237
+
1238
+ # The worst case `evident run` can take to shut down gracefully on SIGTERM, in
1239
+ # whole seconds: 25 s in-flight drain (SHUTDOWN_DRAIN_TIMEOUT_MS,
1240
+ # apps/cli/src/commands/run.ts) + 2 s offline POST (notifyAgentDisconnected,
1241
+ # apps/cli/src/commands/agent-lookup.ts) + 5 s telemetry flush
1242
+ # (TELEMETRY_SHUTDOWN_TIMEOUT_MS, run.ts). Each of the three is bounded there, so
1243
+ # this is a ceiling rather than a typical cost — an idle suspend finishes in a
1244
+ # couple of seconds. This is the ONE place the budget is written down; the CLI
1245
+ # only points back here, because restating it in three places produced #657.
1246
+ #
1247
+ # DOCUMENTATION ONLY — nothing is derived from this any more. The wait below
1248
+ # used to be pinned above it, which is the reasoning that talked #699 into 40 s;
1249
+ # since #718 an exited CLI is noticed on the first poll, so the wait is a
1250
+ # backstop chosen on its own merits and the two numbers are unrelated. Nothing
1251
+ # cross-checks the 32 against apps/cli either: it is a hand-maintained sum of the
1252
+ # three bounds cited above, so if one of them moves, update it here.
1253
+ # shellcheck disable=SC2034 # documentation; deliberately read by nothing
1254
+ CLI_SHUTDOWN_CEILING_SECONDS=32
1255
+
1256
+ # How long stop_tunnel waits for that shutdown before the SIGKILL backstop.
1257
+ # Since #718 this binds ONLY for a CLI that is still draining: one that has
1258
+ # already exited is detected on the first 0.1 s poll whatever this says, because
1259
+ # process_is_alive no longer mistakes its unreaped corpse for a live process.
1260
+ # That is what takes a routine suspend from ~41 s back to the ~2 s it measures.
1261
+ #
1262
+ # So 10 is chosen, not derived, and it sits in the middle of a real range. The
1263
+ # measured shutdown was 1255 ms of drain inside a 2322 ms total (#697): 5 s is
1264
+ # only ~2.2x that and re-arms #657's complaint of a kill landing mid-drain,
1265
+ # while 30 s never truncates a legitimate drain but rebuilds the headroom
1266
+ # problem #699 created — 41 s against a 60 s lifecycleTimeout leaves 19 s for
1267
+ # everything else in the hook. 10 s is ~4x the measured shutdown and gives a
1268
+ # provable worst case of 10 + the 10 s `suspend` spends in sync_credentials
1269
+ # before us = 20 s, inside the ~55 s the in-VM server allows this script
1270
+ # (DEFAULT_TIMEOUT_SECONDS, aws/lambda-microvm-runtime/src/runtime.ts),
1271
+ # itself inside AWS's 60 s (HOOK_TIMEOUT_SECONDS, src/constants.ts) —
1272
+ # overrunning that fails the whole lifecycle transition, which is worse than the
1273
+ # kill this replaces. The residual cost is a drain longer than 10 s being
1274
+ # truncated; that work stays `processing` server-side and is re-adopted on the
1275
+ # next start (ADR-0046).
1276
+ #
1277
+ # src/image/hook-scripts.test.ts holds both that this stays small and that it
1278
+ # fits the hook timeout, and times a real SIGKILL against the override to prove
1279
+ # the loop HONOURS it rather than a hardcoded deadline. The env override is for
1280
+ # tests only, so they need not burn 10 s of wall clock.
1281
+ TUNNEL_STOP_WAIT_SECONDS="${EVIDENT_TUNNEL_STOP_WAIT_SECONDS:-10}"
1282
+
1283
+ stop_tunnel() {
1284
+ if ! tunnel_is_running; then
1285
+ # Clear the file here too, not only on the path below: `evident run` has
1286
+ # self-exit paths that never reach this function (auth expired, idle
1287
+ # timeout, a crash), so the pid file routinely outlives the process it
1288
+ # names. Leaving it means every later guard re-reads a dead pid — harmless
1289
+ # while process_is_alive agrees it is dead, and #718 again the moment
1290
+ # anything starts reaping orphans and the pid gets recycled.
1291
+ rm -f "${TUNNEL_PID_FILE}"
1292
+ log "no tunnel to stop"
1293
+ return 0
1294
+ fi
1295
+
1296
+ local pid
1297
+ pid="$(cat "${TUNNEL_PID_FILE}")"
1298
+ # One drain length for every caller: /suspend and /terminate always run over
1299
+ # a connected tunnel, and /run's and /resume's own cleanup traps only reach a
1300
+ # live tunnel here when it was never started (the cheap no-op branch above) —
1301
+ # there is no path left where a just-spawned, not-yet-connected tunnel is the
1302
+ # one being stopped, so there is nothing left to special-case.
1303
+ kill -TERM "${pid}" 2>/dev/null || true
1304
+
1305
+ local tenths=0
1306
+ local deadline_tenths=$(( TUNNEL_STOP_WAIT_SECONDS * 10 ))
1307
+ while [ "${tenths}" -lt "${deadline_tenths}" ]; do
1308
+ process_is_alive "${pid}" || break
1309
+ sleep 0.1
1310
+ tenths=$(( tenths + 1 ))
1311
+ done
1312
+
1313
+ # This call, not the loop's, is the deciding read: it is what the `if` below
1314
+ # branches on, so PROC_DEAD_REASON is fresh by construction. A corpse can
1315
+ # still be reaped between the loop's last poll and this call, so the reason
1316
+ # named below is what THIS call saw, not necessarily what the loop saw.
1317
+ local outcome
1318
+ if process_is_alive "${pid}"; then
1319
+ warn "tunnel ${pid} ignored SIGTERM; killing"
1320
+ kill -KILL "${pid}" 2>/dev/null || true
1321
+ outcome="after SIGKILL (ignored SIGTERM for ${TUNNEL_STOP_WAIT_SECONDS}s)"
1322
+ else
1323
+ outcome="cleanly in $(( tenths / 10 )).$(( tenths % 10 ))s (${PROC_DEAD_REASON})"
1324
+ fi
1325
+
1326
+ rm -f "${TUNNEL_PID_FILE}"
1327
+ log "tunnel stopped ${outcome}"
1328
+ }
1329
+
1330
+ # --- check_runner_key (#1172) ------------------------------------------------
1331
+ #
1332
+ # Answers exactly one question before opencode/the tunnel start spending this
1333
+ # boot's SIGTERM budget on a key that cannot work: "does the runner key in this
1334
+ # payload authenticate against Evident?" Delegates entirely to the CLI's own
1335
+ # `evident status --json` (apps/cli/src/commands/status.ts) rather than
1336
+ # reimplementing its auth logic here — that command's `reason` field is the
1337
+ # published contract this function reads, and its own header states the
1338
+ # absent-vs-contrary distinction this function must honour.
1339
+ #
1340
+ # Keyed on the JSON `reason`, NEVER on the exit code: an older CLI without a
1341
+ # `status` subcommand exits 1 from Commander's own "unknown command" handling,
1342
+ # which under an exit-code mapping would read as "the key was rejected" and
1343
+ # destroy every VM on a hook/CLI version skew — precisely the hazard this
1344
+ # image's README's "version skew" section flags. So no parseable JSON line
1345
+ # means no verdict, whatever the exit code says.
1346
+ #
1347
+ # Returns 0 for positive AND absent evidence (network/timeout/5xx/404/old-CLI/
1348
+ # crash) — the caller continues either way — and 1 ONLY for contrary evidence
1349
+ # (401, another 4xx, or no credentials resolved at all): the key is actually
1350
+ # wrong, not merely untested (development-workflow.mdc's "never fail a gate on
1351
+ # absent evidence"). Every branch logs a named, greppable RUNNER-KEY-* signal
1352
+ # so a caller that treats the return value as fatal (or doesn't) still leaves
1353
+ # a trace of which branch fired.
1354
+ #
1355
+ # A 404 is deliberately NOT contrary: it means the endpoint has no /me route,
1356
+ # so the key was never tested. Treating it as a rejection destroyed every VM
1357
+ # for a day when the run payload shipped a bare origin instead of the CLI's
1358
+ # /v1-prefixed one.
1359
+ check_runner_key() {
1360
+ local runner_key="$1" api_url="$2"
1361
+
1362
+ # status.ts's own exit-code contract (its header comment) means this exits
1363
+ # non-zero on EVERY branch except `ok` — guarded exactly like
1364
+ # ensure_litestream_config's `run_synchroniser litestream-config` call,
1365
+ # above, so that expected non-zero does not abort this function under the
1366
+ # caller's `set -e` before the case below ever runs. Only stdout is
1367
+ # captured: status.ts's own contract is one parseable JSON line and nothing
1368
+ # else there, and — like litestream's stderr elsewhere in this file — its
1369
+ # stderr is left to reach CloudWatch directly rather than being folded in,
1370
+ # since a stray stderr line ahead of the JSON would otherwise break `jq`.
1371
+ local response rc=0
1372
+ response="$(EVIDENT_API_URL="${api_url}" EVIDENT_RUNNER_KEY="${runner_key}" evident status --json)" || rc=$?
1373
+
1374
+ # `-z` as well as jq's own exit code, and not as belt-and-braces: on EMPTY
1375
+ # input jq has no value to report on, so `jq -e` exits 0 with empty output
1376
+ # rather than its documented 4 (verified on jq 1.6, the image's). Empty
1377
+ # stdout is exactly the old-CLI-skew and missing-binary shape this branch
1378
+ # exists for, so keying only on the exit code sent precisely those cases to
1379
+ # the unrecognised-reason arm below — same safe return, but a log line
1380
+ # blaming the API for an odd answer instead of naming the CLI skew, which is
1381
+ # the one thing an operator needs told here.
1382
+ local reason
1383
+ reason="$(printf '%s' "${response}" | jq -er '.reason' 2>/dev/null)" || reason=""
1384
+ if [ -z "${reason}" ]; then
1385
+ warn "RUNNER-KEY-UNVERIFIABLE: 'evident status --json' exited ${rc} with no parseable JSON on stdout (an old CLI without this subcommand, a missing binary, or a crash — see its stderr above, if any); the key was NOT validated, continuing anyway"
1386
+ return 0
1387
+ fi
1388
+ local detail
1389
+ detail="$(printf '%s' "${response}" | jq -er '.error // empty' 2>/dev/null)" || detail=""
1390
+
1391
+ case "${reason}" in
1392
+ ok)
1393
+ log "RUNNER-KEY-OK: evident status confirmed the runner key against ${api_url}"
1394
+ return 0
1395
+ ;;
1396
+ unauthorized | no_credentials | http_error)
1397
+ error "RUNNER-KEY-REJECTED: evident status reason=${reason} against ${api_url}${detail:+: ${detail}}"
1398
+ return 1
1399
+ ;;
1400
+ unreachable)
1401
+ warn "RUNNER-KEY-UNREACHABLE: could not reach ${api_url} to validate the runner key; the key was NOT validated, continuing anyway"
1402
+ return 0
1403
+ ;;
1404
+ endpoint_not_found)
1405
+ # Absent evidence, NOT contrary: a 404 means this endpoint has no /me
1406
+ # route (typically a bare origin where the CLI wants the /v1-prefixed
1407
+ # one), which says nothing about the key. Loud, because the runner will
1408
+ # keep failing every API call until the endpoint is fixed.
1409
+ warn "RUNNER-KEY-ENDPOINT-NOT-FOUND: ${api_url} has no /me route${detail:+: ${detail}}; the key was NOT validated, continuing anyway"
1410
+ return 0
1411
+ ;;
1412
+ *)
1413
+ warn "RUNNER-KEY-UNKNOWN-REASON: evident status returned an unrecognised reason='${reason}'; the key was NOT validated, continuing anyway"
1414
+ return 0
1415
+ ;;
1416
+ esac
1417
+ }
1418
+ # --- check_runner_key (end) ---------------------------------------------------