Skip to content

fix(serving): ephemeral eval lane fails LOUD with the real cause, not a masked 240s /health timeout (#205 unmask) - #2055

Open
joelteply wants to merge 3 commits into
canaryfrom
fix/eval-lane-bringup-fail-loud
Open

fix(serving): ephemeral eval lane fails LOUD with the real cause, not a masked 240s /health timeout (#205 unmask)#2055
joelteply wants to merge 3 commits into
canaryfrom
fix/eval-lane-bringup-fail-loud

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

Found running the live coding measurement

Every agent/solve on this Mac failed with a bare "llama-server not ready after 240s (/health request failed)" — the real cause discarded. wait_ready polled the health port for the whole budget without ever checking whether the child it spawned had died, and the child's stderr (llama.cpp banner / ggml-Metal fault) was never surfaced. Diagnosing it took three separate log-digs.

Two unmask fixes in wait_ready (benefit live and ephemeral lanes)

  1. Fail loud the instant our child EXITSchild_exit_status() (non-blocking try_wait) turns a crash-at-launch (bad args, model-load failure, aborted Metal init) into an immediate Spawn error with the exit status + stderr tail, instead of polling a dead port for 240s.
  2. Surface stderr STATE on the hang-timeout — a child that hangs in early init (the actual failure here: a co-resident second Metal context that never acquires the device, so llama.cpp prints nothing) leaves an empty stderr log. tail_or_hang_marker turns that into the fingerprint "HUNG in early init (Metal contention?), not a crash" — the diagnostic that distinguishes a hang from a crash, which was missing.

tail_or_hang_marker is a pure fn, unit-tested (empty→fingerprint, non-empty→last 20 lines in order) so the load-bearing decision is tested without touching the real ~/.continuum/logs path (#72's env-dependent-test lesson).

Scope

This makes the eval-lane bring-up failure diagnosable — it does not fix the underlying hang. After this deploys, the next agent/solve will name the real reason (here, the empty-log fingerprint points at two concurrent Metal lanes on one Mac, whose fix is to share the live lane's immutable base weights for the measurement rather than spawn a second Metal context — a follow-up card).

Validation

continuum-core compiles (--features metal,accelerate, 0 errors); new tail_or_hang_marker_fingerprints_empty_and_tails_nonempty test green.

…a masked 240s /health timeout (#205 unmask)

Glass-boxed 2026-07-27 running the live coding measurement (agent/solve): every
`agent/solve` on this Mac failed with a bare "llama-server not ready after 240s
(/health request failed)". The real cause was thrown away — `wait_ready` polled the
health port for the WHOLE budget without ever checking whether the child it spawned had
died, and never surfaced the child's stderr.

ROOT CAUSE, proven this session: the ephemeral eval lane forges a SECOND Devstral-24B
(~14 GB) while the live persona lane already holds ~26 GB. With only ~9.6 GB free, macOS
jetsam SIGKILLs the second llama-server the instant it maps the model — before llama.cpp
prints a single byte (reproduced by hand: exit 137, zero-byte log). It is an OS
out-of-memory kill, NOT Metal-context contention and NOT a hang. The masking bug made it
look like a mysterious timeout.

Two unmask fixes in `wait_ready`, benefiting live AND ephemeral lanes:
1. **Fail loud the instant our child EXITS** — `child_exit_status()` (non-blocking
   `try_wait`) turns any crash-at-launch — including the jetsam SIGKILL/137 above — into
   an immediate `Spawn` error carrying the exit status + stderr tail, instead of polling a
   dead port for 240s. This is the arm that fires for the memory-wall failure.
2. **Fingerprint the empty stderr on the hang-timeout** — `tail_or_hang_marker` turns an
   empty log into a marker that, read with the exit status, names the two empty-log
   causes: an OOM/jetsam kill (SIGKILL/137, child exited) vs. a genuine early-init hang
   (no exit status). A crash from bad args / model-load fault prints its banner first, so
   a non-empty tail carries that directly.

`tail_or_hang_marker` is a pure fn (unit-tested: empty→OOM/hang marker, non-empty→last 20
lines in order) so the load-bearing decision is tested without touching the real
`~/.continuum/logs` path (#72 env-dependent-test lesson).

This makes the failure DIAGNOSABLE; the underlying fix (don't forge a second 24B for eval
while the live 24B is resident — reuse the live lane's weights or serialize via the
governor, #59/#234) is a follow-up. Validated: continuum-core compiles
(--features metal,accelerate, 0 errors); `tail_or_hang_marker_fingerprints_empty_and_tails_nonempty`
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply
joelteply force-pushed the fix/eval-lane-bringup-fail-loud branch from 6f90bf4 to 8e4d77f Compare July 28, 2026 03:42
joelteply and others added 2 commits July 27, 2026 22:58
…ot just pressure LEVEL — kills the jetsam SIGKILL at the root (#205)

The companion to the unmask commit: don't just REPORT the eval-lane OOM loudly, PREVENT it.

The gate (`await_eval_lane_memory_headroom` → `refuse_eval_lane_under_memory_pressure`)
vetoed only on macOS *pressure LEVEL* + the sustained-pressure gate. But on unified
memory the level reads "Normal" while sitting atop only a few GB of real free RAM — it
counts compressible/cached pages as available. So the gate green-lit standing up a SECOND
llama-server of a known ~14 GB footprint into 9.6 GB of actual headroom, and the OS
jetsam-SIGKILLed it (exit 137, zero-byte log — the exact failure this session reproduced
by hand). Neither the pressure gate NOR the GPU/CPU placement lease caught it: on unified
memory the weights need the RAM on *either* device, so "spill to CPU" doesn't save you.

Fix — size against the honest free-bytes number:
- `MemoryPressureMonitor` already reads `sysinfo::available_memory()` each poll; publish it
  to a new lock-free global `current_available_bytes()` alongside the pressure level (the
  level is a ratio and lies; the bytes don't).
- `eval_lane_ram_veto(available, footprint, headroom)` — a PURE, unit-tested guard that
  refuses ONLY when a KNOWN footprint won't fit in the KNOWN free bytes (+2 GiB headroom),
  and NEVER when either number is unknown (an unread probe must not starve a node — the
  pressure gate + placement lease stay the backstops). The refusal names the OOM wall, so
  the detached ledger carries a real cause and `await_eval_lane_memory_headroom` retries it
  as deferrable load instead of crashing.
- One footprint sizing (`eval_lane_footprint`) now shared by the gate and the placement
  decision (compression — was duplicated inline).
- Both eval-lane spawn sites resolve `base` and size the lane BEFORE the gate, so the
  RAM check runs pre-cold-load.

This is the reliability doctrine [[reliability-is-it-works-not-that-it-reports-failure-well]]:
the prior commit made the failure legible; this makes the machine refuse cleanly instead of
being OOM-killed. Sibling of the #175 GPU-OOM-poisons-the-backend class — the level-vs-real-
bytes gap is the same shape.

Validated: continuum-core compiles (--features metal,accelerate, 0 errors); new
`eval_lane_ram_veto_refuses_only_a_known_oversize_lane` + existing pressure-veto test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…symlink into the ephemeral cargo target dir

The flaky mess where `continuum` vanishes post-boot ([[deploy-cli-binary-deleted-from-target-dir-post-boot]]):
`start-server.sh` symlinked ~/.local/bin/continuum → the cargo target-dir binary. But that
dir is a BUILD artifact — cargo replaces the binary mid-rebuild, `cargo clean` and
rust-analyzer's feature-mismatched rebuilds delete it — and the PATH symlink then dangles,
so `continuum <cmd>` dies with "no such file or directory" (hit twice this session).

Fix: COPY the binary to ~/.local/bin (atomic temp+mv so a concurrent `continuum` invocation
never sees a half-written file), and `rm -f` any pre-existing entry first so a leftover
symlink from an old install can't make `cp` follow it back into the target dir. The PATH
binary is now decoupled from cargo's churn — it changes only on deploy. Same self-
provisioning intent, minus the ephemeral-artifact coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant