Repository navigation
Queue fairness: per-fit-class queues, a queue-wait metric, and NOTIFY-driven scanning - #559
Conversation
… 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
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
| .execute(&self.pool) | ||
| .await | ||
| .map_err(db_err)?; | ||
| self.notify_placement_changed("enqueued").await; |
There was a problem hiding this comment.
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.
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
Deep-review fixup passAddressed every finding from the deep review (review id 4618582617 + its 4 inline comments).
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 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: 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. |
CI-failure investigation:
|
# Conflicts: # crates/engram-coordinator/src/metrics.rs
|
Merge conflict against |
… 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
…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
… (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 stragglers. 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>
…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
…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
–#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>
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
SandboxBackendchange: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 fitplace_queued_sessionuses — and sweeps each class independently, oldest-head-first. A create'sNoCapacitystops 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.crates/engram-coordinator/src/metrics.rs): newengram_queue_wait_secondshistogram (origin=create/resume,outcome=placed/timeout) andengram_queue_head_age_secondsgauge, with a dedicated minutes-scale bucket override.crates/engram-postgres/src/lib.rs,crates/engram-coordinator/src/pg_listener.rs,lib.rs): a newplacement_changedPG NOTIFY fires at 5 discrete placement-feasibility sites (enqueue_session_create/enqueue_session_resume,upsert_host,set_host_cordoned(false),transition_sessionleaving a memory-reserving state,delete_pending_session). The scanner now parks on a sharedArc<Notify>thatpg_listenerwakes;ENGRAM_QUEUE_POLL_SECSbecomes a 30s fallback (was the 5s primary drive) and a newENGRAM_QUEUE_RETRY_SECS(default 5s) re-arms a short retry after a sweep that requeued/errored a session.Acceptance criteria
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_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.queued_demandstill counts the blocked head).engram_queue_wait_seconds/engram_queue_head_age_secondswired at the 3 record sites (placed-create, placed-resume, timeout) + head-age gauge insample_queue_metrics. Not scraped end-to-end in a test (no Prometheus exporter installed in the live-PG harness); verified instead via the durablequeue_timeoutevent'swaited_secspayload, which is the same value fed to the histogram — seecreate_origin_timeout_fails_session_and_records_wait. Flagging as needs-prod-verification for the actual scrape/dashboard.notify_placement_changed_fires_at_every_site(rawsqlx::PgListenerobserves oneplacement_changedper 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).queue_scannermodule doc (30s fallback / 5s retry re-arm / first-sweep startup delay).Deviations / drift from the issue's anchors
file:lineanchors in the issue (verified againstf6602259) 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_onceispub(issue anticipatedpub(crate)) — the new live-PG tests live intests/(a separate crate, matching every other live-PG test in this file), sopub(crate)wouldn't be visible there. Documented in the fn's own doc comment and the test file's header.run_once/spawn a real scanner). Worked around withcordon_unmeasured_hosts(neutralizeschoose_placement_host's "unmeasured host fits any budget" fallback tier being tripped by other tests' zero-allocatable_mibleftovers),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-tolerantwait_for_reasonfor the NOTIFY test (theplacement_changedchannel is global). All discovered and fixed via repeated local runs against the dev Postgres at the exact--test-threads=1invocation 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.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:
hol_break's leftoverBandper_class_fifo'sy1—unique_fitting_budgetdrew every "definitely fits" class from the same 1024..=1535 MiB range; a never-cleaned-up leftoverBcould collide withy1on a later local rerun (~1/512) and steal its host. Fixed: disjoint per-test sub-ranges viaunique_fitting_budget_in(id, base).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.run_oncefire immediately at coordinator boot instead of after a beat, same regressionevac_resumer::spawndeliberately guards against ("give hosts time to heartbeat in"). Fixed: restored the delay, kept it interruptible by a real NOTIFY (soscanner_wakes_on_notify_and_places_within_the_wake_not_the_fallbackstill holds).enqueue_session_resumefiredplacement_changedeven when itsstatus='idle'-gated UPDATE matched 0 rows (a racing resume no-op), waking every scanner replica for nothing; inconsistent withdelete_pending_session's existingrows_affected() > 0guard. Fixed: added the same guard.Also folded in a test-fixture-dedupe pass (five of the six new live-PG tests repeated the same connect+cordon+
build_app_statepreamble verbatim; extracted asetup(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, unrelatedengram-host-agentchunk-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 — noengram-host-agentfiles in the changeset).partition_queue— ordering, class grouping, oldest-head-first, empty) run in-crate, no PG needed.crates/engram-coordinator/tests/queue_scanner_live_pg.rs— already wired into CI atci.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..sqlx/changes needed (engram-postgresuses runtimesqlx::query, noquery!macros).queued_at/queue_origin/queue_promptalready 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