Skip to content

Queue fairness: per-fit-class queues, a queue-wait metric, and NOTIFY-driven scanning - #559

Merged
nikhilunni merged 7 commits into
mainfrom
feat/queue-fairness
Jul 6, 2026
Merged

nikhilunni merged 7 commits into
mainfrom
feat/queue-fairness

Conversation

@nikhilunni

@nikhilunni nikhilunni commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Closes #537

Summary

Three mechanical changes to the ADR 0048 session-queue scanner, per the issue's design — no schema migration, no wire/proto change, no SandboxBackend change:

  1. Per-fit-class queues (crates/engram-coordinator/src/queue_scanner.rs): the scanner now partitions the FIFO-ordered queue by (mem_budget_mib, cpu_budget_vcpus) — the exact 2D fit place_queued_session uses — and sweeps each class independently, oldest-head-first. A create's NoCapacity stops only its class; a resume's "zero schedulable hosts fleet-wide" still stops the whole sweep (the one legitimate global case). queued_demand/engram_sessions_queued{,_mib} are untouched.
  2. Queue-wait metric (crates/engram-coordinator/src/metrics.rs): new engram_queue_wait_seconds histogram (origin=create/resume, outcome=placed/timeout) and engram_queue_head_age_seconds gauge, with a dedicated minutes-scale bucket override.
  3. NOTIFY-driven scanning (crates/engram-postgres/src/lib.rs, crates/engram-coordinator/src/pg_listener.rs, lib.rs): a new placement_changed PG NOTIFY fires at 5 discrete placement-feasibility sites (enqueue_session_create/enqueue_session_resume, upsert_host, set_host_cordoned(false), transition_session leaving a memory-reserving state, delete_pending_session). The scanner now parks on a shared Arc<Notify> that pg_listener wakes; ENGRAM_QUEUE_POLL_SECS becomes a 30s fallback (was the 5s primary drive) and a new ENGRAM_QUEUE_RETRY_SECS (default 5s) re-arms a short retry after a sweep that requeued/errored a session.

Acceptance criteria

  • HOL is gone (headline) — hol_break_is_per_class_not_global (live-PG): an unfittable older session no longer blocks a fitting newer session of a different class in the same sweep.
  • Per-class FIFO preserved — per_class_fifo_head_block_is_scoped_to_its_class: a same-class second member stays blocked behind an unfittable head; a third, smaller class still places the same sweep.
  • Autoscaler signal unchanged — asserted inline (queued_demand still counts the blocked head).
  • Queue wait measurable — engram_queue_wait_seconds/engram_queue_head_age_seconds wired at the 3 record sites (placed-create, placed-resume, timeout) + head-age gauge in sample_queue_metrics. Not scraped end-to-end in a test (no Prometheus exporter installed in the live-PG harness); verified instead via the durable queue_timeout event's waited_secs payload, which is the same value fed to the histogram — see create_origin_timeout_fails_session_and_records_wait. Flagging as needs-prod-verification for the actual scrape/dashboard.
  • Push-driven dequeue — notify_placement_changed_fires_at_every_site (raw sqlx::PgListener observes one placement_changed per site) + scanner_wakes_on_notify_and_places_within_the_wake_not_the_fallback (a real scanner+listener pair, 120s fallback poll, places within 10s of a freeing event).
  • Bounded regression, stated — documented in the queue_scanner module doc (30s fallback / 5s retry re-arm / first-sweep startup delay).
  • Not claimed: raw capacity waits improve — no code or doc claims this; out of scope per the issue.

Deviations / drift from the issue's anchors

  • All file:line anchors in the issue (verified against f6602259) were re-verified against this branch's base (42ed9bb2, a few commits later) and matched almost exactly — no drift worth calling out.
  • queue_scanner::run_once is pub (issue anticipated pub(crate)) — the new live-PG tests live in tests/ (a separate crate, matching every other live-PG test in this file), so pub(crate) wouldn't be visible there. Documented in the fn's own doc comment and the test file's header.
  • Test isolation: the shared-Postgres, no-cleanup convention already used across this crate's live-PG tests interacts badly with tests that sweep the entire queue table (mine are the first to call run_once/spawn a real scanner). Worked around with cordon_unmeasured_hosts (neutralizes choose_placement_host's "unmeasured host fits any budget" fallback tier being tripped by other tests' zero-allocatable_mib leftovers), unique_fitting_budget_in (per-session-id-derived budgets, disjoint per-test sub-ranges — see the post-review fixups below) so "fits" sessions don't collide with other tests' round fixed budgets or each other, and a noise-tolerant wait_for_reason for the NOTIFY test (the placement_changed channel is global). All discovered and fixed via repeated local runs against the dev Postgres at the exact --test-threads=1 invocation CI uses (ci.yml's "Postgres-gated ignored tests" step) — 3/3 clean runs of the full 17-file live-PG group after the fix.
  • Updated ADR 0048's now-stale "FIFO head-of-line blocking is deliberate" invariant + the queue-scanner intro bullet to describe the per-class shape and the NOTIFY-driven dequeue, per CLAUDE.md's "keep it accurate" directive — not requested by the issue but a direct factual contradiction otherwise.
  • Commit granularity (undeclared until this edit; review finding 5, CONFIRMED-convention): the issue's own landing plan calls for one commit per separable step (steps 1-2, 3, 4-6, 7); this PR fused steps 1-6 — the pure partition function, the inert metrics constants, and the NOTIFY plumbing (the riskiest piece: it changes prod dequeue-latency characteristics) — into a single commit (dc9a5d07), so a bisect/revert of the NOTIFY wiring alone isn't possible without also reverting per-class partitioning. Declaring it here per the review rather than rewriting already-pushed history.

Post-review fixups (deep review, findings 1–5)

A deep review pass (see PR comments) found 4 inline findings + 1 process finding. All addressed on top of the original 2 commits:

  1. [PLAUSIBLE, flake risk] Budget collision between hol_break's leftover B and per_class_fifo's y1 — unique_fitting_budget drew every "definitely fits" class from the same 1024..=1535 MiB range; a never-cleaned-up leftover B could collide with y1 on a later local rerun (~1/512) and steal its host. Fixed: disjoint per-test sub-ranges via unique_fitting_budget_in(id, base).
  2. [CONFIRMED, cleanup] Misleading "runs in parallel" header — the queue-fairness test-file header claimed nextest's default parallel-per-process execution, contradicting cordon_unmeasured_hosts's own doc comment 115 lines later (which correctly says CI serializes via --test-threads=1) — and the new 1ms-timeout tests are actually destructive whole-queue sweeps that require that serialization for correctness, not just determinism. Fixed: rewrote the header to state the real contract.
  3. [CONFIRMED, undeclared behavior change] Dropped skip-the-first-tick startup guard — the NOTIFY rewrite made run_once fire immediately at coordinator boot instead of after a beat, same regression evac_resumer::spawn deliberately guards against ("give hosts time to heartbeat in"). Fixed: restored the delay, kept it interruptible by a real NOTIFY (so scanner_wakes_on_notify_and_places_within_the_wake_not_the_fallback still holds).
  4. [PLAUSIBLE, nit] Unconditional NOTIFY on a no-op resume enqueue — enqueue_session_resume fired placement_changed even when its status='idle'-gated UPDATE matched 0 rows (a racing resume no-op), waking every scanner replica for nothing; inconsistent with delete_pending_session's existing rows_affected() > 0 guard. Fixed: added the same guard.
  5. [CONFIRMED-convention] Commit granularity — declared above rather than rewriting already-pushed commits.

Also folded in a test-fixture-dedupe pass (five of the six new live-PG tests repeated the same connect+cordon+build_app_state preamble verbatim; extracted a setup(cordon: bool) helper) flagged during the same review.

Test plan

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo hakari verify — all clean.
  • cargo nextest run --workspace — clean except 3 pre-existing, unrelated engram-host-agent chunk-cache write-through failures (tracked separately as Regression on main: chunk write-through cache not populated (3 test failures post-#522) #552; confirmed not touched by this diff — no engram-host-agent files in the changeset).
  • New unit tests (partition_queue — ordering, class grouping, oldest-head-first, empty) run in-crate, no PG needed.
  • New live-PG tests in crates/engram-coordinator/tests/queue_scanner_live_pg.rs — already wired into CI at ci.yml:367 (--test queue_scanner_live_pg, --run-ignored ignored-only, --test-threads=1); no workflow changes needed. Verified 3x clean against the full 17-file "Postgres-gated ignored tests" group locally at the exact CI invocation.
  • No .sqlx/ changes needed (engram-postgres uses runtime sqlx::query, no query! macros).
  • No migration — queued_at/queue_origin/queue_prompt already exist (migration 0064); the fit-class key is derived from existing columns.

Landing order: #541 -> #536 -> #533 -> #527p1 -> #528 -> #530 -> #537 -> #540 -> #539 -> #526 -> #531 -> #538 -> #529 -> #535

🤖 Generated with Claude Code

https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

nikhilunni and others added 2 commits July 1, 2026 22:49
… NOTIFY-driven wake

ADR 0048's session-queue scanner was a single global FIFO with deliberate
head-of-line blocking: one unplaceable session (e.g. a fat dev-brain
session on a 1-2 host fleet) stopped the whole sweep, starving every
smaller session behind it until the 30-minute timeout. Queue wait was
also invisible (outside engram_session_boot_seconds, since create
returns 201 {kind:"queued"} immediately on enqueue), and dequeue was a
blind 5s poll regardless of whether capacity ever changed.

- Partition the queue by fit class (mem_budget_mib, cpu_budget_vcpus) —
  the exact 2D predicate place_queued_session uses — and sweep each
  class independently, oldest-head-first. A create's NoCapacity now
  stops only its class; a resume's "zero schedulable hosts fleet-wide"
  still stops the whole sweep (the one legitimate global case).
  queued_demand/engram_sessions_queued{,_mib} are untouched, so the
  autoscaler signal stays exactly as honest as before.
- New engram_queue_wait_seconds histogram (origin=create/resume,
  outcome=placed/timeout) and engram_queue_head_age_seconds gauge — the
  queue wait is now measurable instead of ad-hoc SQL archaeology.
- New placement_changed PG NOTIFY, fired by engram-postgres at every
  discrete placement-feasibility event (a reservation freed, a pending
  reservation released, a host (re)registered/uncordoned, a session
  freshly enqueued). pg_listener wakes a shared Notify the queue scanner
  now parks on; ENGRAM_QUEUE_POLL_SECS becomes a 30s fallback (was the
  5s primary drive) and a new ENGRAM_QUEUE_RETRY_SECS (default 5) re-arms
  a short retry after a sweep that requeued/errored a session, keeping
  the old transient-failure retry latency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
Drives crate::queue_scanner::run_once directly against real Postgres
(made pub for exactly this) since the fit logic lives in
place_queued_session's SQL, which the mock MetadataStore's trivial
first-candidate default can't exercise:

- hol_break_is_per_class_not_global: the headline fix — an unfittable
  older session no longer blocks a fitting newer session of a different
  class in the same sweep.
- per_class_fifo_head_block_is_scoped_to_its_class: within-class FIFO
  stop is preserved (a same-class second member stays blocked behind an
  unfittable head); a third, smaller class still places the same sweep.
- create_origin_timeout_fails_session_and_records_wait /
  resume_origin_timeout_returns_to_idle: the timeout outcome paths
  (queue_timeout event + waited_secs, the durable proxy for the
  engram_queue_wait_seconds{outcome="timeout"} sample).
- notify_placement_changed_fires_at_every_site: a raw sqlx::PgListener
  observes one placement_changed per site (enqueue, upsert_host,
  uncordon, transition_session leaving a reserving state,
  delete_pending_session).
- scanner_wakes_on_notify_and_places_within_the_wake_not_the_fallback:
  a real queue_scanner::spawn + pg_listener::spawn pair, parked on a
  120s fallback poll, places a waiting session within 10s of a freeing
  event — provably via the NOTIFY wake, not the poll.

These tests share one live Postgres with the rest of the crate's
`--test-threads=1` "Postgres-gated ignored tests" CI lane (already
wired at ci.yml:367). Several isolation hazards had to be designed
around explicitly: other tests in this file/crate never clean up rows,
several leave a `ready`, zero-`allocatable_mib` host behind (which
choose_placement_host's last-resort tier treats as fitting ANY budget),
and the global placement_changed channel carries noise from
concurrently-scheduled tests — see the in-file comments on
cordon_unmeasured_hosts, unique_fitting_budget, and wait_for_reason.

Updates ADR 0048's now-stale "FIFO head-of-line blocking is deliberate"
invariant to describe the per-class shape + the NOTIFY-driven dequeue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

@nikhilunni nikhilunni left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review of the queue-fairness implementation (issue #537). Overall: solid — faithful to the spec, mergeable after considering a couple of test-hygiene and undeclared-deviation nits. The three mechanical changes land exactly as specified: FitClass + pure partition_queue with a proven oldest-head-first ordering, class-scoped NoCapacity breaks with resume's zero-schedulable-hosts case correctly kept global (verified against placement.rs — candidates_for/host_passes_filters filters schedulability, not fit, so the claimed semantics hold), the histogram/gauge at the specified record moments, and the placement_changed NOTIFY at all five sites feeding a shared Arc<Notify> with the 30s fallback + 5s retry re-arm. No type/lint laundering; self-wake loops are structurally avoided (requeue_session is deliberately not a notify site). Surviving findings are concentrated in live-PG test hygiene plus two undeclared deviations, all below.

CI

Green. All lanes pass (CI Gate ✅, including the FC lane at 13m23s and the Postgres-gated ignored-tests step that runs the new queue_scanner_live_pg tests at --test-threads=1). Nothing to diagnose.

Issue compliance

Substantively complete. (a) Per-fit-class queues: partition + per-class FIFO-stop as specified; create NoCapacity breaks only its class, resume Some(false) breaks the whole sweep with the required why-comment; timeout/lease/JoinSet/boot-concurrency semantics preserved; queued_demand untouched; module doc rewritten with the mandated starvation note. (b) Metrics: both constants, the exact bucket override, all three record sites (durable queued→pending flip, post-transition resume dequeue, time_out_session), head-age gauge from the fetched list, no unbounded labels. (c) NOTIFY: all five sites with the spec table's conditions and payloads (note: the unconditional fire at enqueue_session_resume is what the spec's table says — "always" — see finding 4 for the nit), best-effort let _ = post-commit, pg_listener arm + updated info!, Arc<Notify> threaded honestly through both spawn signatures, 5→30s poll default, ENGRAM_QUEUE_RETRY_SECS re-arm via RunSummary. Tests are wired at ci.yml:367 (unchanged) and green. Declared deviations (run_once pub, ADR 0048 doc edit) are justified. Undeclared deviations: (1) commit granularity — the spec's "one logical commit per step where separable (steps 1-2, 3, 4-6, 7)" collapsed into two commits (steps 1–6 in one); (2) the old scanner's deliberate skip-the-first-tick startup behavior was dropped (finding 3). Neither is mentioned in the PR body.

Findings

See inline comments (findings 1–4 anchored to the diff). Finding 5 (no clean diff anchor):

5. [CONFIRMED — convention] Commit granularity collapsed the issue's seams. The issue says "one logical commit per step where separable (steps 1-2, 3, 4-6, 7 are natural commit seams)"; the PR has two commits, with the scanner partition, the inert metrics constants, and the NOTIFY plumbing (the riskiest piece — it changes prod dequeue-latency characteristics) fused into one. A bisect/revert of the NOTIFY wiring can't be separated from the pure partition function. If you keep the two-commit shape, at least declare the deviation in the PR body.

Automated deep review (core-ops batch); findings verified against the branch — treat PLAUSIBLE items as questions.

// budget and never clean up) stealing the host's capacity mid-sweep.
let y1 = SessionId::new();
let (y1_mem, y1_cpu) = unique_fitting_budget(y1);
let _host = seed_ready_host(&meta, y1_mem as u64, 8).await;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. [PLAUSIBLE — flake risk, local reruns] A leftover requeued session from hol_break_is_per_class_not_global can steal this exactly-sized host when its random budget collides with y1's.

The HOL test's B gets placed, its boot fails (prepare_from_row — no enabled_images) and boot_placed_create requeues it; run_once awaits the boot JoinSet, so B is durably back in queued when that test ends, and the suite never cleans up. unique_fitting_budget draws from only 512 values (1024 + (low % 512), cpu always 1), so a leftover B collides with y1's class at ~1/512 per leftover. When they collide, B is the older class head and places first.

I verified this is benign in CI: the run is serial (--test-threads=1, fresh service Postgres per run) and placement_ttl() is 60s, so the HOL test's own exactly-b_mem-sized host is still schedulable seconds later — y1 lands on it even if B takes this host. The failure needs a local rerun against a shared dev Postgres 60s–30min after a previous run (leftover B still queued, its old host stale-filtered by the 60s TTL): then B consumes this host and the y1 must-place assertion fails.

Cheap fix: give each test a disjoint budget sub-range (e.g. HOL 1024..1535, this test 2048..2559 with the host sized to match) so cross-test collision is impossible by construction.

//
// These tests share ONE live Postgres with every other live-pg test in
// this crate (nextest runs each `#[ignore]`'d test as its own process, in
// parallel), and `run_once`'s placement path reads the FULL `hosts` table

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. [CONFIRMED — cleanup] This header claims parallel per-process execution, but the new 1ms-timeout tests are destructive whole-queue sweeps that are only safe under CI's serialized invocation — and this same file says so 115 lines later.

create_origin_timeout_fails_session_and_records_wait / resume_origin_timeout_returns_to_idle run run_once with timeout = 1ms, which times out (Failed / Idle) every queued row in the shared database, not just their own — run_once checks the timeout per row across all classes before any placement. CI is safe only because ci.yml:372 passes --test-threads=1 (which the cordon_unmeasured_hosts doc comment at ~line 371 correctly states, contradicting this line). A developer who trusts this header and runs the suite with nextest's default parallelism can have a timeout test brick hol_break's session A (asserted Queued after its own run_once) or any other live-pg file's in-flight queued row.

Fix: correct this comment to state the --test-threads=1 contract (and delete the "in parallel" rationale), or make the timeout tests non-destructive (not obviously possible with run_once's whole-queue sweep — the comment fix is the honest one).

tick.tick().await;
if let Err(e) = run_once(&cfg, &state).await {
tracing::warn!(error = %e, "queue-scanner tick failed; will retry");
let retry_needed = match run_once(&cfg, &state).await {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. [CONFIRMED — undeclared behavior change] The rewritten loop drops main's deliberate tick.tick().await; // skip the immediate first tick — run_once now executes immediately at coordinator boot instead of after the first poll interval.

Main's spawn (old queue_scanner.rs:80-82) explicitly skipped the immediate tick; this loop runs run_once first and only then parks on the select!. (The spec's wire-up phrasing — "replace the interval with tokio::select! {...} then run_once" — reads as wait-first, too.) Effect: on every coordinator (re)start, requeue_stale_pending + a full queue sweep + boot spawns run concurrently with the rest of startup, on every replica of a rolling deploy. Likely benign (the host-registry prewarm in run_with_registry_and_local runs before this spawn, and everything reads PG), and arguably even desirable with NOTIFY-driven wakes — but the deletion of a commented-as-deliberate guard deserves a conscious sign-off and a line in the PR body either way.

Comment thread crates/engram-postgres/src/lib.rs Outdated
.execute(&self.pool)
.await
.map_err(db_err)?;
self.notify_placement_changed("enqueued").await;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4. [PLAUSIBLE — nit] enqueue_session_resume NOTIFYs unconditionally even when its status='idle'-gated UPDATE matched 0 rows (the racing-resume no-op the WHERE clause exists for).

To be fair: this matches the issue's NOTIFY table ("always" for the enqueue sites), so it's spec-compliant — but the spec's "always" is motivated by the capacity-freed-between-reserve-and-enqueue race, which only applies when a row was actually enqueued. On the 0-row no-op path this wakes every replica's scanner into a full sweep (requeue_stale_pending UPDATE + whole-queue SELECT + queued_demand aggregate) for nothing, and it's inconsistent with delete_pending_session's rows_affected() > 0 guard 240 lines below. Capturing rows_affected() here is a two-line improvement; fine to defer or decline given the spec wording.

nikhilunni and others added 4 commits July 3, 2026 13:27
Review finding 4 (PLAUSIBLE, PR #559): `enqueue_session_resume`'s
`status='idle'`-gated UPDATE fired the `placement_changed` NOTIFY even
when it matched 0 rows (a racing resume that already advanced the row —
exactly the no-op the WHERE clause exists for). That wakes every coord
replica's queue scanner into a full sweep for nothing. Guard on
`rows_affected() > 0`, matching `delete_pending_session`'s existing guard
a few hundred lines below.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
… delay

Review finding 3 (CONFIRMED, PR #559): the NOTIFY-driven rewrite dropped
main's deliberate `tick.tick().await; // skip the immediate first tick` —
`run_once` now ran immediately at coordinator boot instead of after a
beat, same regression `evac_resumer::spawn` deliberately guards against
("coord just started, give hosts a beat to heartbeat in before we pick").

Restore the wait, but keep it interruptible by a real `placement_changed`
NOTIFY (a host registering, a session enqueuing — both written straight to
the shared Postgres this replica already reads), same as every later
`select!` iteration: only a truly cold, event-free start rides out the
full `poll_interval` before the first sweep. A plain unconditional sleep
would have broken `scanner_wakes_on_notify_and_places_within_the_wake_not_the_fallback`,
which spawns the scanner with a 120s `poll_interval` and expects it to
react to a NOTIFY well before that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
…udget-collision flake

Addresses review findings 1 and 2 (PR #559), plus a test-fixture-dedupe
nit from the same pass:

- Finding 1 (PLAUSIBLE, flake risk): `unique_fitting_budget` drew every
  test's "definitely fits" class from the same 1024..=1535 MiB range. A
  leftover requeued session from `hol_break_is_per_class_not_global`
  (its `B` never gets cleaned up — this suite doesn't clean up after
  itself, and its boot always fails fast against a harness with no
  `enabled_images` row) could collide with
  `per_class_fifo_head_block_is_scoped_to_its_class`'s `y1` class on a
  later local rerun against a shared dev Postgres (~1/512 chance),
  stealing y1's exactly-sized host before the sweep reached it. Split
  into `unique_fitting_budget_in(id, base)` with disjoint per-test
  sub-ranges (1024.. for hol_break, 2048.. for per_class_fifo) so the
  collision is impossible by construction instead of merely unlikely.

- Finding 2 (CONFIRMED, cleanup): the file's queue-fairness header
  claimed nextest's default parallel-per-process execution, but the
  1ms-timeout tests are destructive whole-queue sweeps only safe under
  CI's `--test-threads=1` serialization — a fact this same file already
  states 115 lines later in `cordon_unmeasured_hosts`'s doc comment,
  contradicting the header. Rewrote the header to state the actual
  contract and call out which tests require it.

- Test-fixture dedupe: five of the six new tests repeated the same
  three-line connect + cordon + `build_app_state` preamble verbatim.
  Extracted a `setup(cordon: bool)` helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
Small follow-up to the finding-3 fixup (restore skip-the-first-tick):
the module doc's "Push-driven, not polled" section didn't mention that
spawn's first sweep is also delayed, only that later sweeps race wake
against poll_interval. Per CLAUDE.md's "keep it accurate" directive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
@nikhilunni

Copy link
Copy Markdown
Contributor Author

Deep-review fixup pass

Addressed every finding from the deep review (review id 4618582617 + its 4 inline comments). just check is green locally (fmt/clippy/hakari clean; cargo nextest run --workspace clean except the 3 pre-existing, unrelated engram-host-agent chunk-cache write-through failures already called out in the PR body, tracked separately as #552 — zero engram-host-agent files touched by this branch, confirmed via git diff dc9a5d07..HEAD --name-only).

# Finding Verdict Resolution
1 queue_scanner_live_pg.rs:462 — a leftover requeued B from hol_break_is_per_class_not_global can collide with per_class_fifo_head_block_is_scoped_to_its_class's y1 budget on a shared-Postgres rerun (~1/512) PLAUSIBLE → confirmed real 4f67bb6a — disjoint per-test budget sub-ranges via unique_fitting_budget_in(id, base)
2 queue_scanner_live_pg.rs:257 — header claims nextest's default parallel execution, contradicting the file's own --test-threads=1 requirement 115 lines later; the 1ms-timeout tests are destructive whole-queue sweeps that need that serialization for correctness CONFIRMED 4f67bb6a — rewrote the header to state the actual contract
3 queue_scanner.rs:181 — the NOTIFY rewrite dropped main's deliberate tick.tick().await; // skip the immediate first tick, so run_once now fires immediately at coordinator boot CONFIRMED 7653d989 (+ doc note in 5ef59b4e) — restored the startup delay, kept it interruptible by a real placement_changed NOTIFY so scanner_wakes_on_notify_and_places_within_the_wake_not_the_fallback still holds
4 engram-postgres/src/lib.rs:949 — enqueue_session_resume NOTIFYs even on its 0-row status='idle' no-op path, inconsistent with delete_pending_session's rows_affected() > 0 guard PLAUSIBLE → confirmed real 58b33bb8 — added the same guard
5 Review body — commit granularity: the issue's landing plan wanted separable commits (steps 1-2, 3, 4-6, 7); this PR fused steps 1-6 into one commit, undeclared CONFIRMED (convention) Declared in the PR body's "Deviations" section rather than rewriting already-pushed history (dc9a5d07)

Also folded in a test-fixture-dedupe pass flagged during the same review: five of the six new live-PG tests repeated the same three-line connect() + cordon_unmeasured_hosts() + build_app_state() preamble verbatim — extracted into a setup(cordon: bool) helper (4f67bb6a).

PR body: re-walked the acceptance-criteria checklist — all items still hold as originally checked; the "Bounded regression" line now also mentions the restored first-sweep startup delay. Added the "Post-review fixups" section above and the undeclared commit-granularity deviation (finding 5) to "Deviations / drift from the issue's anchors".

Commits: 58b33bb8, 7653d989, 4f67bb6a, 5ef59b4e (pushed to feat/queue-fairness).

CI: just kicked off on push (congested — many concurrent batch-fixup runs in flight across sibling worktrees); not blocking on it here per the fixup-pass process.

@nikhilunni

Copy link
Copy Markdown
Contributor Author

CI-failure investigation: engram-sandbox-vz unit tests

The stored job log had expired (Azure BlobNotFound), so I reproduced locally and cross-checked GitHub's job-step metadata instead.

This PR does not touch VZ/core sandbox code. git diff origin/main...HEAD for this branch is scoped entirely to crates/engram-coordinator/, crates/engram-postgres/, and docs/adr/0048-fleet-autoscaling.md (the queue-fairness work) — zero changes to engram-sandbox-vz, engram-core's SandboxBackend/AuxRoDrive, or any other VZ-adjacent surface. (A naive diff against local main looked broader because local main was stale; origin/main already includes the VZ/ADR-0062 slot-ordering commit this branch was based on.)

The failed job died during checkout, not during tests. The job's step timeline (gh api .../actions/jobs/85067748068) shows step 3 (Run actions/checkout@v6) starting at 20:32:31Z with no completed_at, and every subsequent step (build, codesign, the actual cargo nextest run -p engram-sandbox-vz) never started at all. The job ran for exactly 10m1s before being marked failure — consistent with a stuck/hung checkout on the Blacksmith macOS runner (blacksmith-01kwmtx3dwmxzmg7yp9fkzfpsm-6vcpu), not a compile or assertion failure. No timeout-minutes is set on this job, so this reads as an infra-level timeout, not our code.

Local reproduction confirms this. In a worktree at this exact head (5ef59b4e): cargo build -p engram-coordinator -p engram-sandbox-vz --tests → codesign → cargo nextest run -p engram-sandbox-vz --no-fail-fast passed 18/18 (3 repeated runs, no flakiness) and --run-ignored ignored-only passed 5/5. cargo check --workspace --all-targets is clean. Separately, this branch's own prior CI run (bf8d2e00, two commits back) already had a green engram-sandbox-vz unit tests job, and origin/main's own latest push run also has this job green (its one failure was in tests (firecracker, ...), unrelated).

just check is green except for the pre-existing, already-tracked engram-host-agent chunk-cache write-through flake (issue #552, confirmed unrelated — no engram-host-agent files in this diff, matches this PR's own test-plan note).

Conclusion: no code change needed. I kicked a re-run of just the failed job (gh run rerun 28682152390 --job 85067748068) to get a fresh, retrievable log — expect it to go green.

# Conflicts:
#	crates/engram-coordinator/src/metrics.rs
@nikhilunni

Copy link
Copy Markdown
Contributor Author

Merge conflict against main resolved (a90800cf) — a purely additive collision in metrics.rs: this PR's queue_wait_buckets/QUEUE_WAIT_SECONDS registration and the already-merged #556's prompt_to_run_started_buckets/PROMPT_TO_RUN_STARTED_SECONDS registration both landed at the same insertion point. Kept both (order between them doesn't matter — Prometheus bucket-override precedence is by Matcher kind, not insertion order). cargo check/fmt/clippy -D warnings/hakari verify all clean post-merge.

@nikhilunni
nikhilunni marked this pull request as ready for review July 6, 2026 05:36
@nikhilunni
nikhilunni merged commit e398d68 into main Jul 6, 2026
17 checks passed
@nikhilunni
nikhilunni deleted the feat/queue-fairness branch July 6, 2026 05:36
nikhilunni added a commit that referenced this pull request Jul 6, 2026
… stages_images (this PR) with capabilities/RAM-ledger/capture-progress (main #557/#559/#561/#562/#563/#564)

Conflicts across ~37 files were purely additive (both sides adding a
sibling field/const/column at the same insertion point) except:

- crates/engram-postgres/src/lib.rs: touch_host_heartbeat's UPDATE grew
  to $21 placeholders (main's RAM-ledger + capabilities columns at
  $17-$20, this branch's stages_images renumbered to $21); SQL SET
  clause and .bind() call order kept in lockstep.
- crates/engram-protocol/proto/.../image.proto: both sides claimed
  field 11 on EnableJob; renumbered this branch's prestage_hosts to 15
  (next free after main's capture_phase/warm_stage/warm_stage_started_at/
  warm_stages/output_tail at 11-14). Regenerated TS bindings via
  `buf generate` instead of hand-merging orchestrator/web gen output.
- crates/engram-host-agent/src/{util.rs,main.rs} and
  crates/engram-chunk-store/src/cache.rs: this branch carried a
  pre-#557-follow-up snapshot of shared disk-budget code (the
  UtilizationProbe RAM-ledger signature, the mountpoint gate's
  ENGRAM_HOST_ROOT_REF_PATH fix, the sweeper's t=0-tick skip); took
  main's superseding versions wholesale since HEAD had nothing unique
  left in those blocks (post-merge diff against main is byte-identical).
- crates/engram-coordinator/src/queue_scanner.rs: git's line-based merge
  silently mis-nested the `PlaceOutcome::ImageGone` match arm outside
  its enclosing `match place_create(...)` block (no conflict markers,
  but invalid syntax) — caught by `cargo fmt --check`; moved it back
  inside as the last arm.
- Several HostRecord/HostHeartbeat struct literals in files git never
  flagged as conflicting (enable_scanner.rs, enable_jobs_live_pg.rs,
  placement_reservation_live_pg.rs) were missing the sibling branch's
  new field entirely, since only one side ever touched those literals;
  audited every HostRecord/HostHeartbeat literal in the tree and added
  the missing field.
- docs/adr/0067-chunk-cache-disk-budget.md (add/add): took main's
  fully-accepted version (includes the Post-review-fixes section for
  the two bugs above) — this branch's copy was a stale duplicate from
  before that ADR's own follow-up commits landed.

Migration 0081_enable_job_prestage.sql was already correctly numbered
after main's 0080_host_capabilities.sql; no renumbering needed.

Verified: cargo fmt --check, cargo clippy --workspace --all-targets (0
warnings), cargo hakari verify, cargo nextest run across
engram-{coordinator,host-agent,chunk-store,protocol,core,postgres}
(904 passed, 104 live-PG skipped), musl cross-clippy, web pnpm build
(tsc+vite), orchestrator bun typecheck, helm template with
storage.dedicatedDevice set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
nikhilunni added a commit that referenced this pull request Jul 6, 2026
…eue-fairness fixtures

PR #559's queue-fairness tests (hol_break_is_per_class_not_global,
per_class_fifo_head_block_is_scoped_to_its_class,
scanner_wakes_on_notify_and_places_within_the_wake_not_the_fallback)
enqueue create-origin sessions directly into the queue with no
`enabled_images` row for their `spec()` image. That was fine before
#565: `place_create` never consulted image readiness.

#565 added a live-only `get_enabled_image` gate to `place_create`
(review finding 3): a queued create whose image isn't a live enabled_images
row is now terminally failed via `fail_queued_create_image_gone`, and a
`required_image_digest` is threaded into `ScheduleContext` so a candidate
host must also advertise the digest in `ready_images`. Once #559 and #565
landed on the same branch, every session these three tests enqueue
(including ones deliberately sized to never fit, like the HOL tests' `A`
and the per-class test's `x1`/`x2`) hit the new gate before ever reaching
the capacity check and got failed image-shaped instead of staying queued
capacity-shaped, breaking the per-fit-class assertions.

Add `seed_enabled_image` (records a throwaway snapshot row to satisfy
`base_snapshot_id`'s FK, then upserts a live `enabled_images` row and
returns its digest) and thread a `ready_images: &[String]` parameter
through `seed_ready_host`. Every session driven through
`queue_scanner::run_once` now gets its own enabled-image row; only the
sessions meant to actually place also get their digest staged on the
fixture host. The 4 call sites that exercise the store layer directly
(`place_queued_session` / `reserve_placement`, bypassing `place_create`)
pass `&[]` — they were never gated and don't need one.

Also fixed queue_scanner.rs's now-stale module doc, which still claimed
`required_image_digest` is always `None` on the create path (true only for
`resume_has_capacity`) and that equal-budget queued creates are
equi-placeable by construction — #565 makes that no longer strictly true
across differing images within one fit class, though `PlaceOutcome::ImageGone`
already handles it correctly (drops only that session, continues the
class sweep).

No prod-code behavior changed — this is a test-fixture fix + a doc-comment
correction. All 11 tests in queue_scanner_live_pg.rs pass locally against
real Postgres, individually and as part of the full 85-test CI "Postgres-gated
ignored tests" batch (--test-threads=1), run twice to confirm no flake from
the shared, non-cleaned-up dev Postgres this file's tests depend on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
nikhilunni added a commit that referenced this pull request Jul 6, 2026
… (INTERIM, issue #538) (#565)

* feat(chunk-store): disk-derived cache budget + pin-aware arithmetic + periodic sweeper (ADR 0067)

Closes the structural half of the chunk-cache disk-exhaustion loop
(issue #528): the cache had eviction machinery, but no default byte
ceiling (NO_CEILING), no enforcement independent of populate traffic,
and no accounting for what pins make unevictable.

- ChunkCacheConfig::from_env_or_default now derives a real absolute
  ceiling from the disk backing `root` by default:
  min(fs_total * ENGRAM_CHUNK_CACHE_DISK_FRACTION [0.60],
      fs_total * (1 - free_floor_frac)) — ~179 GB on the 298.1 GB prod
  disk that grew to an unbounded 182.2 GB. ENGRAM_CHUNK_CACHE_BUDGET_BYTES
  still wins outright as an operator override; a filesystem-probe
  failure still fails soft to NO_CEILING.
- evict_to_budget now computes pinned_bytes every sweep and emits
  engram_chunk_cache_{pinned,budget}_bytes plus
  engram_chunk_cache_pins_over_budget (+ a rate-limited-by-sweep-
  interval error log) when pins alone exceed the budget. Pins are a
  floor, never auto-released under pressure.
- ChunkCacheConfig::eviction_enabled (default true) lets a cache
  populate without ever evicting — the single-evictor primitive the
  UFFD handler needs (wired in the next commit).
- ChunkCache::spawn_sweeper(interval) runs evict_to_budget on a timer
  independent of populate traffic (ENGRAM_CHUNK_CACHE_SWEEP_INTERVAL_SECS,
  default 60s, 0 disables) — closes the "host under disk pressure with
  no writes enforces nothing" gap.

Every existing ChunkCacheConfig struct-literal site (tests across
engram-chunk-store, engram-sandbox-firecracker, engram-host-agent)
picks up the new eviction_enabled field.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(uffd-handler): single evictor — never evict from the shared chunk cache (ADR 0067)

The UFFD handler ran a second, pin-blind LRU eviction policy over the
same cache_root the host-agent pins into: a stale fixed 200 GiB
--cache-budget-bytes default with zero pins. Under disk pressure this
sweep preferentially evicted the exact pinned base-image chunks the
host-agent was protecting (eviction is oldest-populate-first, and
host-boot-staged base chunks are the oldest).

Clean break: delete DEFAULT_CACHE_BUDGET_BYTES, the --cache-budget-bytes
flag, and its plumbing through ChunkedMemoryBackend::from_blob(_with_
session_json) — zero external users, no compat shim. The handler now
builds its ChunkCache with eviction_enabled: false (ADR 0067, previous
commit): it still populates (write-through of faulted chunks stays,
that's the locality win) but never unlinks. The host-agent, which holds
the pin set, is the one process per host that evicts.

Adds ChunkCache::eviction_enabled() (diagnostic accessor, mirrors
is_pinned/pinned_count) and a constructor test asserting every
from_blob-built cache has eviction disabled.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(host-agent): wire the periodic sweeper + kubelet-headroom and base-memfile gauges (ADR 0067)

Host-agent side of the disk-budget invariant:

- main.rs spawns chunk_cache.spawn_sweeper() right after cache
  construction (ENGRAM_CHUNK_CACHE_SWEEP_INTERVAL_SECS, default 60s),
  held for the process lifetime like the existing _base_shm_gc handle —
  this is the ONE eviction policy per host now that the UFFD handler's
  is disabled (previous commit).
- util.rs: UtilizationProbe now resolves ENGRAM_KUBELET_EVICT_PCT once
  at construction and emits engram_host_disk_headroom_to_kubelet_bytes
  (fs_free - fs_total * kubelet_evict_frac) every heartbeat tick,
  sharing the existing statvfs probe. This alarms on TOTAL disk
  pressure (snapshots, memfiles, OCI cache, anything on the mount), not
  just the chunk cache's slice — it's meant to fire before the kubelet
  acts, since the eviction itself is the churn amplifier (orphaned
  local cache -> cold GCS resume path).

  ENGRAM_KUBELET_EVICT_PCT defaults to 10 (GKE's documented
  nodefs.available < 10% and consistent with the 611v incident's
  arithmetic), but per explicit instruction for this issue this default
  is a PLACEHOLDER — it has NOT been cross-checked against the actual
  nodepool/kubelet config in the engrams-internal deploy repo. See the
  TODO on KUBELET_EVICT_PCT_ENV_VAR.
- image_prefetch.rs: the reconcile tick now gauges
  engram_host_base_memfile_bytes (summed on-disk size of every tracked
  base memfile) — unevictable disk in the same "floor the budget can't
  touch" category as pinned chunk bytes. Collects owned paths before
  awaiting metadata() — MemfileState::pin holds a raw, non-Sync pointer,
  so borrowing the map across an await breaks Send on the Linux target
  (caught via the required aarch64-unknown-linux-musl clippy cross-check;
  invisible to macOS clippy since MemfilePin never materializes there).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(host-agent): dedicated-volume mountpoint gate (ADR 0067)

Boot-time guard for the chart's paired storage.dedicatedDevice +
ENGRAM_WORK_DIR_REQUIRE_MOUNTPOINT=true knob: when set, refuse to
start unless work_dir resolves to a distinct filesystem from / (st_dev
comparison, walking up to the nearest existing ancestor since work_dir
may not exist yet on a fresh host). Runs before anything touches
work_dir or the coordinator registration.

Guards the base-shm-startup-race failure class: a rolled pod starting
before node-prep's dedicated-volume mount is visible would otherwise
silently write the chunk cache/snapshots/memfiles onto the boot disk,
defeating the whole point of the dedicated volume (moving that load
off the kubelet's nodefs signal). No-op when the env var is unset —
zero behavior change until a chart opts in (helm wiring in a follow-up
commit).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(deploy): helm chart wiring for the disk-derived budget + dedicated volume (ADR 0067)

engram-host-fleet chart changes (component 4 of the disk-budget issue):

- values.yaml: storage.chunkCacheDiskFraction (overrides the 0.60
  default fraction), storage.kubeletEvictPct (default 10 — flagged
  needs-prod-verification, see the TODO), storage.dedicatedDevice
  ("" = today's hostPath-on-boot-disk behavior).
- configmap.yaml: emits ENGRAM_CHUNK_CACHE_DISK_FRACTION when set,
  ENGRAM_KUBELET_EVICT_PCT always (has a sane default), and
  ENGRAM_WORK_DIR_REQUIRE_MOUNTPOINT=true when dedicatedDevice is set
  (picked up automatically by host-agent.daemonset.yaml's existing
  envFrom: configMapRef — no per-var daemonset wiring needed).
- node-prep.daemonset.yaml: idempotent format (blkid-gated, never
  reformats a device with an existing filesystem) + mount step for
  dedicatedDevice, in the host mount namespace via the same nsenter
  pattern as the base-shm tmpfs step. Ordered BEFORE that tmpfs step so,
  when both are set, the tmpfs mounts inside the dedicated volume's tree
  (uffdBaseDir normally lives under workDirHostPath).

Rendered + yaml.safe_load-validated both value shapes (dedicatedDevice
set/unset) — deploy YAML is outside `just check`.

Updates the stale storage.chunkCacheBudgetBytes comment ("host-agent
default (200 GiB)") to describe the new disk-derived default.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* docs(adr): ADR 0067 — hard host disk budget for the chunk cache

Bookend for the disk-derived budget + pin arithmetic + single evictor +
dedicated-volume work (issue #528, commits f2968bb/0ac3f55e/2cd69096/
b7ba7f9/21f01b2e). Status Accepted with the commit chain; records the
budget formula, the pins-as-floor decision, the single-evictor
relationship to epic-substrate-single-writer, the accounting-vs-bytes
framing of the dedicated volume, the ENGRAM_KUBELET_EVICT_PCT
needs-prod-verification caveat, and two divergences from the issue's
sketch (headroom_frac reuses the floor's resolved fraction rather than
a second knob; gauge-value assertions are behavioral, not
metrics-recorder-based).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* docs(readme): fix stale chunk-cache budget claim (ADR 0067)

The storage-substrate table said the cache was "bounded by
ENGRAM_CHUNK_CACHE_BUDGET_BYTES (default 200 GiB)" — already wrong
before this change (the actual default was NO_CEILING; 200 GiB was the
UFFD handler's separate, now-deleted default) and definitely wrong
after it (the default is now disk-derived, ADR 0067). Fixed while
touching this exact topic.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(coordinator+host-agent): ADR 0036 fleet chunk-prestage types, store, and wire (issue #538)

Lays the plumbing for gating an image's `ready` flip on fleet-wide chunk
prestage (INTERIM — the eventual end state is graded readiness,
epic-gcs-free-resume): a new `EnableJobState::Prestaging`, the
`enable_jobs.prestage_ref`/`prestage_hosts` + `hosts.stages_images`
columns (migration 0077), three new fenced MetadataStore methods
(`begin_enable_job_prestage`/`set_enable_job_prestage_hosts`/
`list_prestaging_refs`), and the heartbeat wire extension both
directions (`stages_images` request field, `prestage_images` response
field) so a host's image-prefetch supervisor can warm a prestaging
digest through the SAME watch-channel union it already uses for
`enabled_images` — zero supervisor changes needed.

Also extracts `enabled_image_ref` as the shared row->wire projection
`enabled_image_refs_from_rows` already used, so the enable-scanner's
prestage stage (next commit) can't drift from the live heartbeat-ack
builder.

The scanner's actual `prestaging` stage orchestration and the create-path
placement gate land in follow-up commits on this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(coordinator): enable-scanner prestaging stage (ADR 0036 amendment, issue #538)

Wires the pieces from the prior commit into the actual pipeline stage:
`advance_one` now advertises the freshly-captured base snapshot
(`begin_enable_job_prestage`) and polls `list_active_hosts` — via the
pure, unit-tested `eval_prestage` truth table — until every eligible
(schedulable ∧ `stages_images`) host reports the digest ready, or
`ENGRAM_ENABLE_PRESTAGE_TIMEOUT_SECS` (default 1200s) elapses. A
zero-staged timeout is a transient `Pipeline` error (retried under the
attempts budget, same #232 discipline as every other step); a
partial/empty-fleet outcome proceeds to `ready` with the per-host
audit map (`prestage_hosts`) recording strag­glers. Two new metrics
(`engram_enable_prestage_seconds`, `..._host_outcomes_total`).

Extends `enable_jobs_live_pg` with the new store surface: state
transitions to `prestaging` + `list_prestaging_refs` filtering,
`set_enable_job_prestage_hosts` recording, and the #232 fencing
extended to both new methods. The scanner's orchestration itself
(the eval_prestage truth table, the deadline policy, the outcome
classification) is pure and already unit-tested in enable_scanner.rs
— driving the full pipeline against a live DB would need a fake OCI
registry + capture host, which is the FC e2e suite's job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(coordinator): gate session-create placement on image chunk-prestage (issue #538)

Plumbs the enabled image's manifest digest into the create path's
`ScheduleContext.required_image_digest` — the per-host half of the
fleet chunk-prestage invariant the prior two commits built (an
`enabled_images` row only exists once the eligible fleet has staged
it, so this gate mostly no-ops in steady state, but closes the window
for a straggler host that missed the prestage wait). `PreparedBoot`
now carries `manifest_digest` from `prepare_inner`; both create entry
points (`prepare_from_grpc`/`prepare_from_row`) already flow through
it. `candidates_for` doesn't produce a distinct `ImageNotReady`
error — a digest match that filters every host out just yields empty
`RankedCandidates`, so `reserve_placement` already falls into the
same `enqueue_create` arm a capacity miss does; no new error-handling
branch was needed (the issue's plan implied one, but the existing
"empty candidates queues" path already provides it).

Queue-scanner's create-origin re-placement (`place_create`) gets the
same gate, resolving the digest via a tolerant `get_enabled_image_any`
lookup. Its RESUME-origin arm (`resume_has_capacity`) is deliberately
left ungated — it places by snapshot affinity, same as every other
resume/evac/admin call site — correcting a stale line-number pairing
in the issue text that would have wired the wrong function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(web): render the prestaging enable-job state (ADR 0036 amendment, issue #538)

Surfaces the new pipeline stage on the dashboard: `EnableJobState`
gains `"prestaging"`, labeled "staging chunks to hosts" in
`ImagesPanel`'s progress row. `isJobActive` already treats any
non-terminal state as active, so no change there. `EnableJob` also
carries the new `prestage_hosts` JSON string field (from the
`EnableJob` proto's field 11, added alongside `convert.rs` in the
first commit on this branch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* docs(adr): amend ADR 0036 with the fleet chunk-prestage stage (interim, issue #538)

Records the fifth pipeline stage (prestaging), why it's structured as
a binary all-hosts wait rather than the eventual graded-readiness end
state, and what's explicitly deferred to epic-gcs-free-resume when
that end state lands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(cli): surface prestage_hosts on 'engram image ... --json' (issue #538)

CLI parity with the dashboard's audit surface: enable_job_to_json
parses the wire-encoded prestage_hosts string back into a nested
object rather than leaving operators to double-decode it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(coordinator): prestage EmptyFleet must not vacuously pass a transient blip (PR #565 finding 1)

eval_prestage() collapsed two distinct cases into one EmptyFleet arm:
"no host in the fleet has stages_images at all" (genuinely vacuous,
e.g. a Process/dev fleet) and "staging-capable hosts exist but none
are currently schedulable" (e.g. a host-agent MIG roll leaves
heartbeats stale past the placement TTL for a few minutes). The wait
loop breaks immediately on EmptyFleet with no deadline, so the
transient case silently flipped the image ready with 0 hosts actually
staged, bypassing the zero-staged-timeout retry policy that exists
for exactly this scenario.

Split the check: EmptyFleet now requires that NO host in the fleet
claims stages_images; a fleet that has staging-capable hosts, all
transiently unschedulable, is Waiting{staged: 0, eligible: 0} and
keeps polling under the deadline like any other partial wait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(coordinator): prestage wait loop must respect the deadline on a persistent list_active_hosts failure (PR #565 finding 2)

The list_active_hosts error arm of the prestage wait loop retried
unconditionally, never consulting `deadline` — a PG read failure
persisting past ENGRAM_ENABLE_PRESTAGE_TIMEOUT_SECS spun the loop
forever, violating the documented hard cap (acceptance criterion 3).
Since run_once drives claimed jobs sequentially, a wedged wait here
also stalled every other claimed enable job.

The error arm now checks the deadline and breaks TimedOut (using the
staged/eligible counts from the last successful poll) instead of
retrying indefinitely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(coordinator): queue-scanner digest gate must use the live-only enabled-image lookup (PR #565 finding 3)

place_create's placement digest gate resolved required_image_digest
via the tolerant get_enabled_image_any (matching prepare_from_row's
lookup, per the original comment). That tolerance is the RESUME
path's invariant — a session pins its own lineage, so a soft-deleted
image is fine to resume onto — and does not hold for a CREATE that
hasn't placed yet.

Sequence this let wedge forever: session queued for capacity on image
X -> operator disables X -> every host's prefetch supervisor drops
the digest and unpins it (image_prefetch.rs) -> get_enabled_image_any
still returns the soft-deleted row -> required_image_digest pins to a
digest no host will ever report -> candidates are empty on every
sweep -> the session dies at queue timeout with a misleading
capacity-shaped error, instead of the crisp image-shaped failure the
fresh-create path (sessions.rs::prepare_from_grpc, get_enabled_image)
already gives.

Switch place_create to the live-only get_enabled_image; a live-lookup
miss now fails the queued session immediately (queue_image_gone event
+ Failed) via a new PlaceOutcome::ImageGone arm, instead of silently
degrading the digest gate and burning the full queue timeout. The
Err arm (transient PG error) keeps the old degrade-to-no-gate
behavior, since prepare_from_row's tolerant lookup still catches a
genuinely-gone image right after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(migrations): renumber 0077->0081 and correct the prestaging non-terminal claim (PR #565 finding 4)

Two fixes:

1. Batch-wide migration-number collision: this PR's migration
   (enable_jobs.prestage_ref/.prestage_hosts, hosts.stages_images) was
   numbered 0077, but five other in-flight sibling PRs in the same
   batch also land migrations around the same number. Renumbered to
   0081 (next-free as of this fixup pass) and updated every Rust
   comment referencing the migration number by filename/number.

2. The migration's header comment called the new `prestaging` state
   "terminal" -- the opposite of the point of the stage (the state
   diagram two lines below and EnableJobState::is_terminal both say
   prestaging is non-terminal). Migrations are checksum-immutable once
   applied, so this had to be fixed before merge or the contradiction
   would be permanent. Also fixed the related nit: the ADR 0036
   amendment header said "a fifth pipeline stage" while the amendment
   body, PR body, and code comments all say fourth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* feat(web): render prestage_hosts staged/eligible counts on ImagesPanel (PR #565 finding 5)

prestage_hosts was plumbed proto -> legacy EnableJob -> types.ts but
nothing in ImagesPanel actually rendered it, while this PR's body
claimed it was "surfaced on both the dashboard and engram image
--json" -- only the CLI half was true.

Add summarizePrestageHosts(), which parses the per-host outcome JSON
map and renders a "staged/eligible (N unschedulable)" badge on
EnableJobRow whenever the map has recorded at least one eligible
host's outcome. Pins the fix with a component test using a
prestaging-state job carrying a representative outcome map.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(coordinator): keep EnableScannerConfig::default() pure; move env I/O to from_env() (PR #565 finding 6)

EnableScannerConfig::default() read ENGRAM_ENABLE_PRESTAGE_TIMEOUT_SECS
via prestage_timeout_from_env(), doing I/O inside what should be a
pure default-value constructor, and silently swallowed a rejected
value (0, meant as "skip the wait", or a typo) with no log line --
discoverable only when a large-image enable blocked 20 minutes.
Every other field in this Default is a constant with env resolution
at use-sites (cf. placement::placement_ttl).

Default is now a pure constant (DEFAULT_PRESTAGE_TIMEOUT). A new
EnableScannerConfig::from_env() resolves the env var on top of it and
is the constructor the one production call site (lib.rs's scanner
spawn) now uses; it warn!s on an unparseable or non-positive value
instead of silently falling back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(coordinator): re-nest the ImageGone match arm dropped by the lint-staged stash dance

The merge commit's git-add happened before the queue_scanner.rs match-arm
fix (PlaceOutcome::ImageGone needs to be an arm of the inner
`match place_create(...).await` block, not a sibling statement under
QueueOrigin::Create — a mis-merge with no conflict markers, caught by
`cargo fmt --check`). husky's lint-staged runs a stash/restore dance
around commits that stashed this file's not-yet-staged fix, ran its
(no-op, .rs doesn't match its globs) tasks, and restored the fix to the
working tree — but `git commit` had already recorded the still-staged,
pre-fix (broken) blob. Re-staging and committing the actual fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(coordinator): satisfy #565's create-time digest gate in #559's queue-fairness fixtures

PR #559's queue-fairness tests (hol_break_is_per_class_not_global,
per_class_fifo_head_block_is_scoped_to_its_class,
scanner_wakes_on_notify_and_places_within_the_wake_not_the_fallback)
enqueue create-origin sessions directly into the queue with no
`enabled_images` row for their `spec()` image. That was fine before
#565: `place_create` never consulted image readiness.

#565 added a live-only `get_enabled_image` gate to `place_create`
(review finding 3): a queued create whose image isn't a live enabled_images
row is now terminally failed via `fail_queued_create_image_gone`, and a
`required_image_digest` is threaded into `ScheduleContext` so a candidate
host must also advertise the digest in `ready_images`. Once #559 and #565
landed on the same branch, every session these three tests enqueue
(including ones deliberately sized to never fit, like the HOL tests' `A`
and the per-class test's `x1`/`x2`) hit the new gate before ever reaching
the capacity check and got failed image-shaped instead of staying queued
capacity-shaped, breaking the per-fit-class assertions.

Add `seed_enabled_image` (records a throwaway snapshot row to satisfy
`base_snapshot_id`'s FK, then upserts a live `enabled_images` row and
returns its digest) and thread a `ready_images: &[String]` parameter
through `seed_ready_host`. Every session driven through
`queue_scanner::run_once` now gets its own enabled-image row; only the
sessions meant to actually place also get their digest staged on the
fixture host. The 4 call sites that exercise the store layer directly
(`place_queued_session` / `reserve_placement`, bypassing `place_create`)
pass `&[]` — they were never gated and don't need one.

Also fixed queue_scanner.rs's now-stale module doc, which still claimed
`required_image_digest` is always `None` on the create path (true only for
`resume_has_capacity`) and that equal-budget queued creates are
equi-placeable by construction — #565 makes that no longer strictly true
across differing images within one fit class, though `PlaceOutcome::ImageGone`
already handles it correctly (drops only that session, continues the
class sweep).

No prod-code behavior changed — this is a test-fixture fix + a doc-comment
correction. All 11 tests in queue_scanner_live_pg.rs pass locally against
real Postgres, individually and as part of the full 85-test CI "Postgres-gated
ignored tests" batch (--test-threads=1), run twice to confirm no flake from
the shared, non-cleaned-up dev Postgres this file's tests depend on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
nikhilunni added a commit that referenced this pull request Jul 6, 2026
…ransaction write-set + boot-bundle cache with #556/#559/#560/#562/#564/#565

Merges main's absorbed batch (#553-#565, #558) into this branch's create-as-plan
restructuring (per-image boot bundle, one-transaction session write-set, overlapped
boot legs, prompt-over-wire). Conflicts were in the functions both sides restructured
(reserve_and_persist_create replacing reserve_placement/enqueue_session_create,
pg_listener::spawn's params, the boot_prepared/prepare_inner create path) plus several
non-conflicting hunks that referenced symbols the other side renamed/removed (git
auto-merged clean but left dangling references — fixed by hand, verified by the full
verification gate below).

Key composition decisions:
- #556 PromptReceived-first ordering: intact — send_prompt_core still emits the
  durable receipt as the FIRST PG write, before ensure_active_and_resolve; #566's
  deliver_prompt runs after, unchanged in position.
- #559 NOTIFY equivalence: reserve_and_persist_create's Queued arm now fires
  notify_placement_changed("enqueued") after commit, replacing the retired
  enqueue_session_create's NOTIFY (was about to be silently dropped since #566
  subsumed that function). Verified live against Postgres
  (notify_placement_changed_fires_at_every_site).
- #562 span parenting: kept on the create boot_handle spawn (.instrument(boot_span));
  boot_on_reserved_host's overlapped legs use tokio::join! (same task/span), so no
  new spawn site needed instrumentation.
- #564 CapabilityRequirements / #565 digest gate: ScheduleContext.caps and
  required_image_digest survived the merge already wired to the per-image boot
  bundle's cached fields (bundle.enabled.manifest_digest /
  base_snapshot_memory_manifest) after fixing two dangling bare `enabled.` refs
  left by a non-conflicting auto-merge hunk.
- WIRE_VERSION stays at main's 9: #566's "prompt over the wire" changes are
  behavioral only (every prompt now rides the pre-existing HarnessCommand::Prompt
  frame instead of ENGRAM_INITIAL_PROMPT env) — no coord<->host-agent gRPC wire.rs
  shape change, no harness-proto frame shape change.
- #560 GuestReady removal: reserve_and_persist_create's reserved-mib query had its
  own copy of the status-list (duplicated by this branch's restructuring out from
  under main's edit); removed 'guest_ready' by hand to match.
- Migrations: this branch's 0082_session_selected_skills.sql sits cleanly after
  main's 0081; no renumbering needed. No ADR added by this branch (no collision).

Additional fixes for auto-merged-but-now-dangling references (both test files and
src, caught by clippy/cargo check, not by conflict markers):
- queue_scanner_live_pg.rs: ~10 test call sites main added independently (per-fit-
  class + digest-gate tests) still called the retired enqueue_session_create /
  reserve_placement; rewired onto this branch's enqueue()/reserve() shims (added a
  reserve() shim mirroring placement_reservation_live_pg.rs's). Its own
  pg_listener::spawn call site needed the boot_bundles arg too.
- boot_bundle.rs's CountingMeta test mock: record_snapshot return type (Result<()>
  -> Result<bool>), and SnapshotRecord/HostRecord test literals missing
  fc_snapshot_version / capabilities+stages_images.
- Three Session{} test literals (api/prompt.rs, queue_scanner.rs, state.rs) missing
  selected_skills.

Verification: cargo fmt --check, cargo clippy --workspace --all-targets -D warnings
(clean), cargo hakari verify (clean), cargo nextest run -p engram-coordinator
-p engram-host-agent -p engram-protocol -p engram-core -p engram-postgres (779/779
passed), all 17 Postgres-gated --test suites from ci.yml against a throwaway
engram_test_* DB (89/89 passed, --test-threads=1), nix develop musl clippy
cross-check (clean). No proto/web/orchestrator changes in this branch, so no buf
regen or pnpm/bun typecheck needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
nikhilunni added a commit that referenced this pull request Jul 6, 2026
…ed (T6, issue #537/#559)

Negative twin of notify_placement_changed_fires_at_every_site.
enqueue_session_resume is gated on status='idle'; resume_origin_
enqueue_requires_idle already pins the row-level no-op, but nothing
pinned the NOTIFY side. Same LISTEN harness, but asserts a bounded
absence of any placement_changed notification after the no-op call
instead of waiting for an expected payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc
nikhilunni added a commit that referenced this pull request Jul 6, 2026
–#566) (#590)

* fix(ci): stop test-e2e-stack from force-skipping on Rust-only changes

needs: included web/lint/test-linux/cross-musl-linux purely as a
cost-saving DAG dep, but GitHub Actions skips a job whenever ANY needed
job was skipped — overriding this job's own if:. Those jobs each gate
on their own path flags (test_web, test_rust, test_cross), so a
Rust-only PR that doesn't touch web/host-binary paths skipped one of
them and silently force-skipped the entire e2e-stack lane, even though
its own path-gate (needs.detect.outputs.e2e) said it should run.

Trim needs: to just the jobs whose artifacts this lane actually
downloads (detect's e2e flag, artifacts-e2e-stack's release binaries,
bake-harness-claude-artifact's harness tree), add !cancelled() so a
skip among them no longer force-skips this job, and check each
remaining need's .result explicitly so this job doesn't attempt to run
against artifacts that never got produced. The dropped lanes
(lint/test-linux/web/cross-musl-linux) are still independently
required via ci-gate's own needs:, so overall gate strength is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(coordinator): placement exclusion visibility on all NoCapacity paths

exclusion_summary + PLACEMENT_EXCLUDED_TOTAL were only emitted from
pick_for_session (resume/evac) — the wire-skew incident that motivated
this actually broke the queue-scanner's create path, which had zero
visibility into why every host was excluded. api/sessions.rs's create
path and queue_scanner.rs's resume precheck were silent too.

Add placement::log_empty_candidates(meta, ctx, origin) as the one
shared helper every empty-candidate-set call site invokes; it emits
the same exclusion_summary-driven counter + warn as before, now
labeled with origin ∈ {create, queue_create, queue_resume_precheck,
resume} so each path is distinguishable. Reword ADR 0068 to describe
the corrected coverage instead of claiming pick_for_session alone
covered the incident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(host-agent,postgres,coordinator): correction-pass items C-G

Four independent review-fix findings from the core-ops batch:

- eviction_finalize.rs::publish_disk_manifest: mirror run_memory_leg's
  idempotent VersionConflict arm. The first attempt always targets the
  same deterministic version derived from base_manifest, so a conflict
  on THAT SPECIFIC attempt can only mean a prior crash-redrive already
  published this exact content — treat as success instead of bumping
  to latest+1 and republishing under a version no caller will ever
  reference. Later, non-deterministic bumps in the same retry loop keep
  the generic race-retry behavior.

- engram-postgres retry_enable_job: also NULL capture_phase/warm_stage/
  warm_stage_started_at/warm_stages/output_tail (migration 0079) so a
  retried capture doesn't have the UI rendering the previous attempt's
  stage/tail as if it were live. Extended the existing live-PG test
  (progress_state_failure_and_retry_round_trip) to stamp capture
  progress before failing the job and assert it's cleared after retry.

- pooled_backend.rs run_warm_hook: the terminal violation_failure send
  is the one CaptureProgress event that actually matters (failing
  stage + output tail) — warn on TrySendError::Full there instead of
  silently dropping it. Bump the production progress channel
  (grpc_server.rs build_base_snapshot) from 64 to 256 so a burst of
  routine progress/keepalive traffic is far less likely to crowd out
  that terminal frame in the first place.

- lib.rs: extract stages_images_gate(has_chunk_store, has_chunk_cache)
  as the one pure source of truth for whether the image-prefetch
  supervisor spawns (and thus whether the heartbeat's `stages_images`
  field is true) — a debug_assert ties the derived bool to the actual
  match's spawn condition so they can't silently drift apart. Unit
  test covers all 4 branches.

- session_boot.rs / metrics.rs: add COORD_BOOT_OVERLAP_SECONDS, timing
  the tokio::join! between the restore leg and the env/egress leg
  (which can round-trip an external mint-mode connector) — a window
  neither coord_prepare nor coord_finalize covers, so a slow env/egress
  leg was previously invisible to both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* docs: fix ADR/comment drift batch (item H) + renumber 0067 chunk-cache ADR to 0070

Seven independent doc-accuracy fixes surfaced in the core-ops batch review:

- engram-sandbox-firecracker: restore_in_jail's leg_setup comment and
  net.rs's netns_name_for doc both described netns entry via `ip netns
  exec`; the actual mechanism (confirmed against spawn_firecracker) is
  a direct exec + pre_exec setns(2) closure. Reworded both to name it.

- ADR 0020: the P4 residual-spawns paragraph described the teardown
  veth-A delete as "conditional on the netns delete having failed" —
  that's the reverted pre-fixup design. The shipped code
  (net.rs::teardown_netns) does an unconditional, synchronous netlink
  delete of veth-A BEFORE allocator.free, precisely because netns
  teardown is asynchronous and a conditional gate would race
  allocator.free, reopening the double-bound-/30 hazard. Reworded to
  match.

- ADR 0034: the recovery-ladder comment named a separate `InstallHostCa
  (idempotent)` step between wait-ready and SpawnHarness; #554 folded
  InstallHostCa into SpawnHarness, so that step no longer exists
  standalone. Reworded to `wait-ready → SpawnHarness (installs CA +
  reattaches/respawns)`.

- ADR 0048: the Invariants bullet still said FIFO head-of-line blocking
  is global, contradicting the queue-fairness update earlier in the
  same doc (issue #537) that scoped it per `(mem_budget_mib,
  cpu_budget_vcpus)` fit class. Reworded to match the per-class scope.

- ADR 0069: `WIRE_VERSION 7 → 8` should read `8 → 9` — renumbered at
  merge since main had already taken 8 for #563. Confirmed against the
  current `engram_protocol::WIRE_VERSION = 9`.

- ADR 0067 duplicate: docs/adr/ had both
  0067-browser-stack-reliability-and-portable-bundle.md AND
  0067-chunk-cache-disk-budget.md. Renumbered the newer chunk-cache one
  (issue #528, #557) to 0070 (0068/0069 already taken) — renamed the
  file, updated its header + added a numbering note, and swept every
  `ADR 0067` reference in the repo that means the chunk-cache topic
  (engram-chunk-store, engram-host-agent, engram-uffd-handler, the
  host-fleet helm chart, README.md) to 0070. The two references that
  mean the browser-stack ADR (docs/adr/0027 and 0065, both explicitly
  paired with "issue #569") were left pointing at 0067, matching that
  ADR's own issue number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* style: cargo fmt fixups on the placement/enable-jobs correction commits

Whitespace-only: cargo fmt wrapping log_empty_candidates's signature and
one assert_eq! in the extended live-PG test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* test(host-agent): stage-deadline warm-hook watchdog test (T1, issue #539/#563)

Mirrors warm_hook_stall_fails_capture_with_stage_and_tail but proves the
OTHER watchdog arm: a stage exceeding its own deadline_secs is killed at
the budget even while the hook keeps emitting heartbeats fast enough
that the stall detector never fires. Asserts CaptureFailureKind::
WarmStageDeadline, the failing stage name, and the output tail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* test(host-agent): trace diagnostics for the flaky NBD survivor test (T2, #582)

survivor_reconfigure_resumes_parked_io is a known-flaky CI test with no
diagnostics on failure. Add the same tracing_subscriber init
two_host_live_teleport.rs already uses (env-filter + try_init, safe
under repeat init) so the next natural CI failure captures
reattach()/serve_loop diagnostics. No timeout/assert changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* test(coordinator): persist-before-reconcile heartbeat regression (T3, issue #531/#564)

Proves the ADR 0068 early-return: a heartbeat whose touch_host_heartbeat
persist fails must 5xx AND must never reach reconcile_host for that
tick. Calls the real heartbeat handler directly (constructible without a
full axum server) with a MiniMeta fail-flag
(fail_next_heartbeat_persist), and asserts on a new
reconcile_probe_calls counter (an override of
list_active_sandbox_assignments_on_host, the entry point
Reconciler::reconcile_with_deps hits on every tick it actually runs) —
not just "no flip happened", which MiniMeta's no-op default
apply_missing_sandbox_strikes would satisfy either way regardless of
whether reconcile ran.

host_http.rs's local build_state_for_session now also returns the
Arc<MiniMeta> (mirroring state::tests::build_state_for_session) so the
new test can drive the fail flag and read the probe counter; updated
its four existing call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* test(coordinator): prestage flip + straggler against live host rows (T4, issue #538/#565)

enable_scanner::run_once/advance_one/eval_prestage are pub(crate) or
module-private (unreachable from this external tests/ binary), and
advance_one unconditionally restarts every job from
fetch_and_seal_manifest (a real OCI registry fetch) regardless of state
— infra this live-PG-only lane doesn't wire (this file's own header note
already scopes the full pipeline exercise to the FC e2e suite).

So this drives the REAL fenced MetadataStore prestage surface
(begin_enable_job_prestage / set_enable_job_prestage_hosts /
list_active_hosts / touch_host_heartbeat) against two real polls of real
host rows: one host flips from un-staged to staged between polls, a
second never stages. Classification reuses the actual pub
placement::host_is_schedulable predicate (eval_prestage's own
unreachable logic reimplemented in ~10 lines), then sequences
prestaging -> ready exactly as advance_one's tail does. Asserts the
flipping host lands `staged` and the straggler lands `timed_out` in
prestage_hosts while the job still reaches `ready`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* test(fc): probe_sandbox map-vs-process desync coverage (T5, issue #531/#564)

FirecrackerBackend::probe_sandbox is the ground-truth check
reconcile::flip_missing uses to avoid mistaking "not yet reattached" for
"actually gone" (the fbd3794c incident shape). Its implementation only
touches the in-memory sandbox map and an on-disk manifest + /proc read —
no jailer, no KVM ioctl — so this needs no real Firecracker VM: a
FirecrackerBackend that never created/reattached the sandbox (map has no
entry) plus a hand-written manifest pointing at this test process's own
real pid/start_time_jiffies/comm (genuinely alive for the test's
duration) is the cheapest honest construction of the desync arm.

Two tests: (1) known_to_backend=false while process_alive=true — the
desync reconcile must not treat as "gone", and (2) once the manifest is
also gone (mirrors destroy()'s manifest delete), the probe returns the
honest negative on both fields.

Wired into ci.yml's test-firecracker lane's unprivileged --test list
alongside this crate's other #[ignore]'d tests, though — unlike its
siblings — it needs neither /dev/kvm nor the firecracker binary; that's
documented in the test file's module doc comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* test(coordinator): no-op resume enqueue must not fire placement_changed (T6, issue #537/#559)

Negative twin of notify_placement_changed_fires_at_every_site.
enqueue_session_resume is gated on status='idle'; resume_origin_
enqueue_requires_idle already pins the row-level no-op, but nothing
pinned the NOTIFY side. Same LISTEN harness, but asserts a bounded
absence of any placement_changed notification after the no-op call
instead of waiting for an expected payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* test(host-agent): disk-leg idempotent VersionConflict redrive (T7, correction-pass item C)

publish_disk_manifest's redrive arm treats a VersionConflict at the
FIRST-attempt deterministic ref (base_manifest.next_version()) as
idempotent success (a prior crash-redrive already published this exact
content), not an error or a bump-and-retry. No fake store needed: a real
ChunkStore genuinely returns VersionConflict when a manifest already
exists at that (manifest_id, version) key, so this seeds that conflict
directly and asserts the redrive returns the already-published ref.

The memory leg's mirror-image arm (inside run_memory_leg) is left
untested: unlike the disk leg it isn't factored into a standalone
callable, and exercising it needs a full EvictionFinalizeRecord +
EvictionFinalizer + a real binary memory.diff file dirty_ranges can
parse + a matching prev-manifest — meaningfully more scaffolding than
the ~50-line budget. Noted in a doc comment as a follow-up refactor
(extract a publish_memory_manifest helper mirroring publish_disk_manifest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* docs: correct ADR references across code and docs (audit sweep)

A line-by-line audit of ADR cross-references found five root-cause
clusters of drift and fixes each:

1. grpc_app never got its 0039→0051 sweep: ADR 0051 documents the
   coordinator's app-gRPC surface being renumbered from a draft 0039,
   but the sweep never touched crates/engram-coordinator/src/grpc_app/*
   or its grpc_app/grpc_smoke integration tests. Retargeted all of
   them; left the genuine ADR 0039 (memfile/checkpoint) references in
   idle_evictor, image_prefetch, pooled_backend, and placement.rs
   untouched.
2. Session profiles landed as ADR 0053, but proto comments, generated
   TS bindings, and orchestrator/web source + tests kept citing the
   working number 0052 (the persistent-streaming-harness ADR) from
   before the final renumber. Fixed the two source protos and
   regenerated the four gen mirrors via `just gen-proto` (never
   hand-edited), plus every orchestrator/web/docs citation; left the
   genuine 0052 (streaming harness) citations alone.
3. The disk-daemon util.rs kubelet-headroom gauge cites ADR 0067, but
   this branch's own 0067→0070 sweep (commit c6828c1) missed
   util.rs:176.
4. Two unrelated 0037 mentions dangle: ADR 0037 was never merged (its
   findings were folded into ADR 0052), so two_host_live_teleport.rs
   and ADR 0034's addendum now annotate the reference instead of
   pointing at a nonexistent doc; ADR 0052 itself gets one clarifying
   parenthetical at its first mention.
5. Two ADR numbers each had two files: 0061 (NBD read concurrency vs.
   VZ erofs skills) and 0042 (substrate survey vs. its evidence
   companion). Renumbered the younger of each pair to the next free
   numbers, 0071 and 0072, and retargeted the code comments + doc
   cross-reference that meant the renumbered ADR.

Also fixed a handful of standalone wrong-number citations (metrics.rs
boot-latency target, flush_scheduler.rs's idle-evictor and
session_bindings backreferences, and doc citations in 0015, 0016,
0060, 0064, and memory-substrate-and-restore.md) turned up by the same
audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4WkZddtVw8djsWz2RCcQc

* fix(enable-jobs): retry also resets chunks_done/chunks_total (review completeness)

The retry_enable_job stale-progress clear (this PR) covered capture_phase/
warm_stage/warm_stage_started_at/warm_stages/output_tail but not the
chunk-progress counters, which are the SAME live-progress class the UI
renders. The progress checkpoint writes chunks_done absolutely, so a
prior attempt's value read as live on a fresh retry until its first chunk
event (or forever on a pre-chunk failure). Reset them to 0/NULL; live-PG
test extended to assert it (they were 100/625 before the retry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jJi2vqAaxt3Q5UKxbe4Gx

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Queue fairness: per-fit-class queues, a queue-wait metric, and NOTIFY-driven scanning

1 participant