Skip to content

feat(runtime): cross-request prefix seeding via a content-addressed seed store - #90

Closed
igorls wants to merge 62 commits into
Neroued:masterfrom
igorls:feat/prefix-seed-store
Closed

feat(runtime): cross-request prefix seeding via a content-addressed seed store#90
igorls wants to merge 62 commits into
Neroued:masterfrom
igorls:feat/prefix-seed-store

Conversation

@igorls

@igorls igorls commented Aug 25, 2026

Copy link
Copy Markdown

Draft, opened as the architecture discussion this change requires. It adds a serving-visible capability and a new home for sequence state, which per CONTRIBUTING should be discussed before implementation is treated as a candidate: this PR carries a working, measured implementation as the concrete basis for that discussion. The listed doc/test work is intentionally deferred until maintainer direction on the shape.

Problem

Compatible-prefix reuse restores retained sequence state only at generation-commit boundaries of the same logical conversation. Sibling conversations sharing a long identical system block (the common shape for assistant/agent serving: one stable system+policy head, many independent sessions) always pay a full prefill. Measured on Qwen3.8-27B NVFP4: six requests sharing a 57k-token head, differing only in the final user line, all take reuse=full_reset. The retained-checkpoint path cannot cover this: each lane owns exactly one checkpoint slot, and a restore is consumed by the first extension.

Design

A PrefixSeedStore (target-local, src/targets/qwen3_6/impl/runtime/prefix_seed_store.{h,cpp}): a content-addressed, bounded store of immutable prefix snapshots.

  • Entry = complete sequence state at a message-boundary frontier F produced by a real prefill: per-layer Linear Attention conv+recurrent images, Text (and MTP backend) KV page payloads for [0,F), the hidden state at F-1, and the host ledger + prefix identity. Exact-match keyed (FNV-64 candidate hash, then full token/identity compare).
  • Capture rides the existing in-graph rewrite-checkpoint mechanism: the chunk containing the seed frontier passes it as that chunk capture frontier, and the executor exports the lane checkpoint slot into the store after the chunk completes. When the request rewrite capture falls in the same chunk at a different frontier, the chunk is split at the seed frontier (with a large head plus a short live turn, that collision is the common shape, not the exception). The split is budgeted in the service-work projection. The frontier itself is computed by the chat template as a byte offset just past the rendered leading system block, converted to a token frontier exactly like the rewrite-checkpoint spec.
  • Restore is copy-only into a freshly admitted lane (new PrefixReusePath::SeedPrefixCache, request log reuse=seed_prefix), so any number of concurrent requests seed from one entry; nothing is claimed or consumed. Prefill continues from F for the suffix.
  • Residency: one startup-fixed device arena behind --prefix-cache-mib (default 0 = off; EngineOptions::prefix_cache_bytes). The store holds no KV-pool pages, so shared-pool admission accounting is unchanged. Eviction is a wholesale generation flush (captures are cheap and repopulate on demand). A 256-token minimum frontier gates capture (an entry costs a fixed Linear Attention image regardless of frontier).
  • v1 scope: text-only prompts, single seed frontier, MTP and no-spec backends (DFlash excluded), no cross-artifact sharing, no persistence across restarts.

Two standalone fixes ride below this branch and can be split out on request: the device SM-count portability fix (also opened separately) and acceptance of text/image/video content parts in tool messages (agentic clients return screenshots inside tool results; the template already renders tool turns through the ordinary media-placeholder path, so this is a serve-schema change only).

Affected contracts

  • Public: one new PrefixReusePath value, one EngineOptions field, one serving flag, one request-log reuse tag.
  • Internal: a third home for sequence state alongside the lane current/checkpoint slots; a planner probe when lane matching yields FullReset; an admission branch; per-chunk capture-frontier selection.

Correctness argument and verification

The snapshot is the exact finite-precision state produced by a real prefill of those tokens; restore is bit-identical by construction (same discipline as turn checkpoints; no replay or reconstruction, so no interaction with the ReplaySSM constraints).

Environment: RTX PRO 6000 Blackwell Workstation Edition (sm_120a), Docker (nvidia/cuda:13.1.2) under WSL2, driver CUDA 13.3, Qwen3.8-27B NVFP4, MTP-5, INT8 G64 KV, CUDA Graphs on.

Run, with results:

  • Exact-output oracle: identical request served cold vs seeded, greedy: byte-identical outputs (multiple prompts, 10.5k and 11.3k heads).
  • Sibling integration: three sibling conversations after one capture, all seed_prefix, TTFT 1120 ms to 57-63 ms at a 10.5k head; multi-reader confirmed (later siblings unaffected by earlier extensions); 6-way concurrent burst all seeded with correct outputs.
  • Distinct heads capture distinct entries; no cross-entry contamination observed (differentiated fact sets answered correctly per head).
  • Seed-then-continuation composition: turn 2 of a seeded conversation takes append_frontier.
  • Arena exhaustion: generation flush then recapture, verified.
  • Service-work projection: the initial implementation violated the quanta budget (surfaced by the startup warmup failing and the engine 503ing) and is now budgeted like the rewrite-capture split, verified against the full serving configuration (131k max-context, 1M-token KV pool, vision enabled).
  • Quality regression: a 29-scenario domain QA suite, three passes, with roughly 86 of 90 requests on the seeded path: score parity with the same checkpoint on vLLM.
  • Sustained mixed traffic: several hundred live agentic requests (native-tool loops, 20-80k contexts, screenshots in tool results) with zero server errors.

Not run / known gaps (why this is a draft):

  • No in-tree tests added yet (tests/), and no updates yet to the affected authorities (docs/maintainer/concurrent-inference-architecture.md retained-state section, docs/serving.md flag + log field, CLI docs). I would rather align the shape with the maintainer first.
  • Not validated on an RTX 5090 or native Linux (all measurements above are Docker/WSL2 on the RTX PRO 6000; binary-compatible sm_120a).
  • Lane selection does not yet prefer a longer resident continuation over a shorter seed when both match (costs a few dozen suffix tokens of prefill, never correctness).
  • The two stderr notices (store enabled, seed captured) bypass the serve logger and should be converted or dropped.
  • Restore performs per-plane, per-page-run device copies; a packed gather would cut launch count if measurements ever justify it.

Motivation data

At a 10.5k-token shared head, a seeded sibling costs a sub-millisecond state copy plus suffix prefill instead of roughly 1.2 s of head prefill; against a real agent client with a stable 28.8k system+tools prompt, measured hits covered 20.7-28.8k tokens per request. VRAM cost is roughly 290-560 MiB per entry (the fixed Linear Attention image dominates), which is why the feature is flag-gated: it pays where VRAM headroom exists and stays off elsewhere.

Disclosure

Implementation generated by Claude Opus 5 (Anthropic, model id claude-opus-5), directed and reviewed by the submitter. Every changed path was exercised by the verification above.

igorls added 4 commits August 24, 2026 22:25
GDN chunked output and sparse-MoE prefill sized their persistent grids from
a hardcoded RTX 5090 constant (170 SMs). Query the multiprocessor count of
the active device once and cache it, so any sm_120a part gets one full
resident wave: wider dies (RTX PRO 6000 Blackwell, 188 SMs) no longer leave
SMs idle and narrower parts are no longer oversubscribed.

Measured performance-neutral on an RTX PRO 6000 for the Qwen3.8-27B NVFP4
target (57.6k-token prefill: 6321 stock vs 6232 patched tok/s, within
run-to-run noise) — these kernels are not that target's prefill bottleneck.
The change is portability correctness, not a speedup. The device query
falls back to the 170-SM reference value if it fails.
…eed store

New requests sharing a prompt's leading system block previously always paid a
full prefill: retained sequence state restores only at generation-commit
boundaries of the same conversation, so sibling conversations never reuse the
shared head. This adds a PrefixSeedStore: an immutable, content-addressed
store of complete sequence-state snapshots (Linear Attention conv+recurrent
images, Text and MTP KV page payloads, tail hidden, host ledger and prefix
identity) captured at the rendered system block's token frontier during an
ordinary prefill, and restored by copy into a fresh lane for any later
request whose prompt begins with the identical tokens.

Capture rides the existing in-graph rewrite-checkpoint mechanism: the chunk
containing the seed frontier passes it as that chunk's capture frontier, and
the executor exports the lane checkpoint slot into the store after the chunk
completes. When the request's own rewrite capture falls in the same chunk at
a different frontier, the chunk is split at the seed frontier so both
captures get their own chunk (a large stable head plus a short live turn
makes that collision the common shape). Restore is copy-only, so concurrent
requests can all seed from one entry; nothing is claimed or consumed, and
the store holds no KV-pool pages, leaving admission accounting unchanged.

The store is a startup-fixed device arena behind --prefix-cache-mib
(EngineOptions::prefix_cache_bytes; default 0 = off), evicting wholesale
when full. Requests log the new reuse path as reuse=seed_prefix.

Measured on an RTX PRO 6000 (Qwen3.8-27B NVFP4, MTP-5, int8 KV, 10.5k-token
system head, greedy): sibling requests drop from ~1120ms TTFT (full_reset)
to 57-63ms (seed_prefix, cache=10487), parallel siblings both hit the same
entry, and a seeded request's output is bit-identical to its cold twin.

v1 scope: text-only prompts, single seed frontier (end of the rendered
system block), MTP and no-spec backends (DFlash excluded), no persistence
across restarts. Lane selection does not yet prefer a longer resident
continuation over a shorter seed when both match; the seeded plan can win
with a marginally smaller reuse base.
The seed-capture chunk split added one prefill unit the service-work
projection did not budget, so any prompt whose seed and rewrite frontiers
shared a chunk exceeded its quanta (the startup warmup being the smallest
such prompt, which marked the engine unavailable and 503d every request).
Count a planned seed capture as one prefill split in both the base and the
per-lane projections, mirroring the rewrite-capture accounting.

Also gate capture behind a 256-token minimum frontier: an entry costs a
fixed Linear Attention state image regardless of frontier, so warmup probes
and other tiny prompts are not worth 150 MiB of arena.

Verified on the full serving configuration (131k max-context, 1M-token KV
pool, vision, preserve-thinking, 4 GiB arena): two tenant system heads
captured as independent entries with no cross-tenant bleed, siblings seed at
44-60ms vs ~1180ms cold, and a thinking-on request seeds from the shared
head and reasons correctly on top of it.
Tool messages required plain string content, rejecting the multimodal tool
results agentic clients send back (VS Code agent mode returns screenshots as
text+image_url parts inside the tool message, and vLLM accepts that shape),
which killed the agent loop with 400 tool-messages-must-contain-string.

Parse tool-message content through the ordinary content grammar instead: a
string, or an array of text/image/video parts. The chat template already
renders tool turns through the same media-placeholder path as user turns, so
images inside <tool_response> flow through the existing Vision pipeline
unchanged. Engines without --vision keep the existing clean rejection.

Verified against the exact failing flow: assistant tool_call, then a tool
message with a text part plus a data-URI PNG — 200, vision tokens counted,
and the model answers about the image content.
@igorls

igorls commented Aug 25, 2026

Copy link
Copy Markdown
Author

Cross-referencing related open PRs I should have surveyed before opening this — noting the relationships explicitly so review effort is not duplicated:

igorls and others added 24 commits August 25, 2026 06:05
Map chat_template_kwargs.enable_thinking onto the existing top-level
enable_thinking option for Chat Completions and Responses. Conflicting
spellings return 400. Unknown non-null kwargs stay rejected so a
misspelled disable cannot leave thinking on. Protocol value high remains
unsupported by the registered templates and is not aliased to xhigh.

GPU greedy byte-identity is documented in RUNBOOK.md for a scheduled
window; the 27B NVFP4 artifact does not fit the compact 16 GiB cap.
…eroued#50)

Reviewed and independently verified by coordinator: schema tests rebuilt
and re-run green in a clean nvidia/cuda:13.1.2 container. Greedy
byte-identity validation deferred to RUNBOOK.md pending a GPU window.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr
The top-8 selection ran as its own single-warp grid between D1 and D3. Node-level profiling
attributes 2.85 us of its 4.83 us to the node itself rather than to the selection, so the block
that arrives last in D1 now runs the identical routine. The ticket uses atomicInc with wrap, so
it resets itself and needs no workspace slot or host-side initialisation.
Record the three-boot, c=1 n=16, MTP-5 protocol and the greedy cold-vs-seeded
bit-identity check so the coordinator can run them in a scheduled GPU window.
Port the engine to build and run natively on Windows (no WSL2), serving the same .ninfer artifacts with byte-identical output and equal-or-better throughput.

Platform code:

- artifact/reader.cpp: MappedFile Windows branch (CreateFileW/MapViewOfFile/SetFilePointerEx+ReadFile); fix LARGE_INTEGER aggregate-init truncating file offsets >= 4 GiB (must set .QuadPart)

- request_log.cpp: getpid -> GetCurrentProcessId; load_progress.cpp: isatty -> GetConsoleMode; acquire.cpp: Winsock branch

- CMake: NINFER_BUILD_MEDIA option (OFF on Windows) + decode/acquire stubs; MSVC C++20 friction fixes

NVFP4 TMA fix (Windows-only crash at T>=1024 prefill):

MSVC cannot pass the 128-aligned CUtensorMap by value (C2719), so the kernels take a pointer. The TMA unit reads tensor maps through a separate tensormap proxy: kernel-side (generic-proxy) writes to a descriptor staged in local memory are invisible to it without fence.proxy.tensormap, which surfaced as 'Illegal instruction' at the first cp.async.bulk.tensor. Both TMA kernels now read the descriptor directly from the host-written global buffer (cudaMalloc'd, 256-byte-aligned, cudaMemcpyAsync H2D) - no in-kernel staging, no fence.

Verified: 1024-token repro + 24k needle (ZEBRA-42-QUARTZ-7719) pass; WSL<->Windows byte-identical parity (seed 42, content + reasoning); compute-sanitizer clean (zero memory errors); prefill 6378 tok/s, decode 172.5 tok/s (WSL baseline 115-125).
Enable vision (media acquire/decode) on the native Windows build:

- CMakeLists.txt: on Windows, discover FFMPEG and libcurl via
  find_path/find_library against third_party roots (BtbN shared DLLs
  and a source-built libcurl with SCHANNEL TLS) instead of pkg-config,
  exposing them as PkgConfig::FFMPEG / PkgConfig::LIBCURL imported
  targets. Non-Windows pkg-config path is unchanged.
- src/CMakeLists.txt: link ws2_32 for ninfer_media_acquire on Windows.
- artifact/reader.cpp: ReadFile returns 0 bytes at EOF with
  GetLastError() == 0; aligned read spans can extend past file content
  (e.g. vision tensors at the end of the artifact). Break on a zero-byte
  read and let the caller's short-read check decide, instead of throwing
  a confusing 'direct artifact read: success' error.
- media_acquire/acquire.cpp: compare against the wide literal L'..' on
  Windows (path::native() is wstring) instead of the narrow literal.
Keep the inherited thinking-off byte-identity checks and add matched-flag
prefill, decode, TTFT, boot-wall, and soak checks for the native MSVC
ninfer-serve.exe versus the WSL2 container.
… (issue #6, phase 1)

Both picks patch-identical to upstream Neroued#84. Native ninfer-serve.exe builds
(MSVC 19.44 + CUDA 13.3, sm_120a); Linux container lane verified green by
coordinator after merge-gating on the cross-platform changes. WSL2-tax A/B
remains per RUNBOOK.md.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr
Add a device-scope fence on the winning D1 block before it reads all 257
router scores, and document that the ticket assumes one Engine and one
compute stream. Extend the greedy oracle to 2048 tokens so 1-ulp rotary
drift can surface.
Emit OpenAI prompt_tokens_details.cached_tokens as a subset of prompt_tokens,
plus the request-log field names prefix_cache_hit_tokens and prefix_reuse_path,
on Chat Completions and Responses. Advertise --max-context as max_model_len on
/v1/models. Anthropic Messages is unchanged because its input_tokens already
excludes cache reads.
…ssue #3)

Upstream Neroued#55/Neroued#24 adapted onto the moved usage surface; fork extension adds
prefix_cache_hit_tokens + prefix_reuse_path (shared constexpr with the
request log, all five path names). Anthropic usage shape deliberately
unchanged. All four schema/log test suites independently re-run green.
Live four-path probe rides the next GPU window per RUNBOOK.md.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr
…upstream Neroued#79+Neroued#54)

Ports verified line-faithful to upstream; adversarial review confirmed the
exception paths and seed-store arena init cannot be swallowed. Fork adds a
pre-listen boot watchdog for wedge-shaped (non-throwing) hangs. Induced-
failure procedures ride the next GPU window per RUNBOOK.md.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr

# Conflicts:
#	RUNBOOK.md
Destructor disarm silenced the watchdog exactly when finding 3 needs it:
warmup throws, unwinding tears down the engine, and device.synchronize()
can hang with the socket still bound. The watchdog now stays armed until
the explicit pre-listen disarm; fast failures exit the process (and the
detached thread) before the deadline fires.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr
igorls added 27 commits August 25, 2026 22:27
Do not register mutation endpoints on an unauthenticated listener. --admin-vram is default off and requires --api-key; /admin never rides the empty-key bypass.
Elastic ranges (--kv-capacity-min/max, --prefix-cache-mib-min/max) with the
existing flags meaning min==max, capability-expressed floors, seed-store
release/reclaim, idle release, and --vram-observe-only. KV-tier release is
deliberately refused this phase (needs quiesce + graph recapture).

Admin endpoints are default-off: routes registered only under --admin-vram,
which additionally requires --api-key, and /admin never rides the empty-key
auth bypass. Default command line is behaviourally unchanged.

An adversarial review during development caught cached seed-cache lane plans
surviving release/reclaim, which threw out_of_range into the worker and
latched the executor-failure state -- fixed before delivery.

Live release/reclaim on hardware is NOT yet exercised; this ships as config
surface plus an unproven release path pending a GPU window.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr
…scoped failures (issue #10)

Request-scoped failures (RequestError) now complete the request and let the
worker continue; invariant violations, CUDA faults and foreign exceptions stay
fatal and latch the executor. Previously admit_planned_request completed the
request with an error AND rethrew, so ordinary conditions -- a missing media
payload, a stale prefix seed, an unavailable rewrite checkpoint, invalid
sampling parameters -- permanently killed the worker while the process kept
answering 200 on /health.

Several throws reclassified from logic_error/invalid_argument to RequestError
accordingly; two genuine internal invariants made explicitly logic_error (no
behaviour change, they were already outside RequestError).

/health now returns 503 when the executor has failed. /v1/models stays 200.

Partial against the approved contract: process exit on permanent failure is
NOT implemented, so the residual fatal cases remain visible-but-alive. Tracked
as follow-up on #10.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr

# Conflicts:
#	src/serve/generation_service.h
#	src/targets/qwen3_6/impl/runtime/program_impl.h
Suppressing the pre-call prefix inside the parser made the terminal body
shorter than what a streaming response had already emitted. ToolCallStreamFilter
only withholds the prefix when the whole <tool_call> marker lands in one chunk;
with token-by-token streaming the marker straddles chunks, the prefix is already
on the wire, and unstreamed_content then throws "streamed content exceeds
terminal content" -- aborting the request mid-stream with a 500 on exactly the
turn the suppression was meant to clean up.

The parser now retains the prefix. GenerationService clears it only when
streamed_content_bytes == 0, so a non-streaming tool turn still shows no
chatter while a streamed one stays consistent.

Regression test asserts terminal content is never shorter than emitted bytes,
for both the split-marker and whole-marker cases.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr
…ing, preamble (issue #9)

Closes the defect a peer team's 31-case replay gate found: raw tool-call syntax
reaching end users on ~6.5% of tool-needing turns where vLLM leaked 0.

- JSON object arguments inside an explicit <tool_call><function=NAME> wrapper now
  parse (brace-scanned, so a closing tag inside a JSON string cannot truncate the
  wrapper). Trigger requires the wrapper, so prose cannot fabricate a call.
- Upstream PR Neroued#65 adapted: parameters typed by declared schema plus vLLM's
  coercion table, fixing string-declared values arriving as objects and Python
  True surviving as "True" (upstream Neroued#66).
- Pre-call preamble suppressed at the caller rather than the parser, so terminal
  content is never shorter than what streaming already emitted.

Near-miss forms ([tool_use:, bare tool_call:) deliberately NOT recovered; they
are the fabrication-prone class and need a bounded allow-table behind
--tolerant-tool-calls plus a tools-declared check.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr
…ssion

The header still claimed a successful parse leaves content empty; it now
retains the preamble and GenerationService suppresses it. Same doc/impl drift
class as the prefix-seed-store eviction comment.

Claude-Session: https://claude.ai/code/session_01Wv1ehCcaeL86hBzw74iqgr
Loopback-only control surface: start/stop/restart with backoff and a crash-loop breaker, DXGI VRAM query, optional admin/vram and request-log stats, tray menu, and a self-contained SSE dashboard. Engine API key is read from a file and never sent to the browser.
#11)

grok's delivery, reviewed. Adds apps/ninfer-supervisor/: job-object process
supervision (backoff 1-60s, crash-loop breaker at 5 exits/60s, health 503x3
restart), a 127.0.0.1:8099 dashboard with SSE, a Windows tray entry, and
--install-login. Pure addition: 18 files, no engine code touched beyond
registering the test.

Verified independently rather than on report:
- control_allowed(remote_addr) gates every mutating route (server.cpp:115)
  and returns 403 "control is loopback-only"; --bind-any cannot widen it.
- the engine API key is read only inside engine_client() and set as bearer
  auth toward the engine; it never enters state_json(), so it cannot reach
  the browser.

Known gap, filed as follow-up rather than fixed here: the VRAM card reports
DXGI CurrentUsage labelled "this process", which is the SUPERVISOR's usage
(~0, it makes no CUDA allocations) and never the engine's. QueryVideoMemoryInfo
is per-process, so this stays wrong even for a spawned child, and our
production engine is a WSL2 container in a different process tree. Budget is
the cross-process-meaningful figure; device-wide totals need nvidia-smi.
Issue #10 made /health answer 503 once the inference executor has failed,
which stopped the process from claiming health it did not have. But a 503
nothing polls is not actionable: the container still runs, still holds its
VRAM, and still answers every inference request with an error.

/health is the only unauthenticated endpoint, so the probe needs no API key.

Two things this deliberately does not pretend to do:

- It does not recover anything. Docker restart policies act on process EXIT,
  not on health status, so `--restart unless-stopped` will not restart an
  unhealthy container. This makes the failed state visible to `docker ps`,
  to monitoring, and to a watcher; recovery still needs Swarm, an external
  monitor, or the native supervisor's health-restart path. Process exit on
  permanent executor failure remains the outstanding half of #10.
- It does not follow --port. HEALTHCHECK is static, so the probe reads
  NINFER_PORT (default 8080, matching EXPOSE and the ServeOptions default)
  and operators must set it to match --port.

start-period is 180s because the server binds before loading the model and
only listens afterwards: the probe sees connection-refused for the whole
load, which is ~35s for Qwen3.8-27B NVFP4 here but scales with the artifact.

curl is added to the runtime stage — it shipped libcurl4t64 (the library)
but no HTTP client.

Verified: `docker build --check` clean; `curl -fsS /health` exits 0 against
the live lane; `curl -f` exits 22 on an error status, so a 503 marks the
container unhealthy rather than passing.

Note the production lane does not use this image — it runs the CUDA devel
base with a mounted build tree — so no running container changes here.

Claude-Session: https://claude.ai/code/session_01ADxBwAyzHaGcZz8DsYd8jB
…r-only mode

Three things, all in apps/ninfer-supervisor/. grok's delivery for issue #11
phase 2 (P0 + P1), plus four adversarial Host cases added in review.

SECURITY (the addendum, and it corrects my own earlier review). A loopback
peer-address check is not authorization when the browser is the confused
deputy, because the browser is itself on loopback. Any page could POST to
/api/stop with no preflight and kill the engine; nothing validated Host, so
a rebound attacker domain could also read /api/state and /api/events.

  - Host allowlist on EVERY route via a pre-routing handler: loopback names
    plus the configured host when --bind-any names a specific interface.
    Binding 0.0.0.0 does not open the allowlist.
  - Mutating routes additionally require X-NInfer-Supervisor: 1. This works
    only because the server sends no CORS headers, so the preflight that a
    custom header forces goes unanswered and the request is never sent.
    That absence is load-bearing, not incidental.
  - Order is peer check, then header check, then the monitor-only 409. The
    409 is layered on top and is not a substitute: managed mode is default.

Matching is exact, never substring or prefix. Added tests for the trap that
reintroduces this as a bug — localhost.evil.com, 127.0.0.1.evil.com,
evil-localhost, and a prefix of the --bind-any host all reject.

VRAM LABELS. The card previously showed DXGI CurrentUsage as "this process",
which is the supervisor's own footprint (~0, it makes no CUDA allocations)
and never the engine's. QueryVideoMemoryInfo reports the calling process, so
this stays wrong even for a spawned child, and our production engine is a
WSL2 container in a different process tree. Budget is now labelled as the
system-wide WDDM pressure signal it actually is, device used/total comes
from nvidia-smi, and the per-process row says plainly it is not the engine.

MONITOR-ONLY. The supervisor could only observe an engine it spawned, which
excluded the containerised production lane. --monitor-only (or an unmanaged
engine entry) observes without spawning or restarting: DXGI budget and
nvidia-smi from its own process, /health for liveness, bearer auth for the
rest. Control routes return 409.

Tests pass under Linux/g++ as well as MSVC; the logic header stays portable.

Claude-Session: https://claude.ai/code/session_01ADxBwAyzHaGcZz8DsYd8jB
…bels, monitor-only

Reviewed grok's delivery and verified each claim against the code rather
than the summary:

- No Access-Control-* header anywhere in the app; only a comment noting the
  absence is what makes the custom-header CSRF brake work.
- Host allowlist is a pre-routing handler, so it covers /, /api/state and
  the /api/events SSE stream, not just the control routes. Rebinding is
  what the read paths needed defending against.
- Name matching is exact (==), so localhost.evil.com and 127.0.0.1.evil.com
  reject. Added tests for those and for a prefix of the --bind-any host,
  since a substring refactor is how this defense usually dies.
- The dashboard's own POST sends X-NInfer-Supervisor: 1.
- The monitor-only 409 sits after the peer and header checks, not instead
  of them.
- Diff is confined to apps/ninfer-supervisor/ and tests/; src/serve and
  src/runtime untouched, as reported.

Logic tests build and pass under Linux/g++ as well as MSVC. Unlike the
first pass at issue #10's exit path, these tests call the real functions
and include negative cases, so deleting the feature would fail them.
The tray used LoadIconW(IDI_APPLICATION) — the generic Windows box, which
tells you nothing and is indistinguishable from any other app in the tray.

Now it draws a rounded "N" tile whose fill colour is the engine's status:
green running, amber starting/stopping/backing-off/unreachable, red halted
or crash-looped or reporting unhealthy, grey idle. A 1 Hz timer repaints
only on change, and the tooltip names the status in words.

Drawn at runtime rather than shipped as an .ico resource: no build-system
change, no binary asset in the tree, and it renders at whatever size
SM_CXSMICON reports instead of being pinned to one raster.

Two details that are easy to get wrong and were worth pinning down:

- The colour bitmap is an explicit 24bpp DIB section, NOT
  CreateCompatibleBitmap. A screen-compatible DDB is 32bpp on any modern
  display, and CreateIconIndirect then reads its alpha channel as per-pixel
  alpha. GDI never writes alpha, so every pixel comes out fully transparent
  and the icon silently vanishes. At 24bpp there is no alpha to misread and
  the 1bpp mask alone decides the shape.
- The colour fill is full-bleed and the rounded corners are cut by the mask,
  so the glyph antialiases against its own fill instead of fringing against
  a background colour.

In monitor-only mode the colour still reflects OBSERVED health rather than a
neutral "not my process": EngineChild maintains st_.health for unmanaged
engines via observe_health(), so this is measured, not invented. The
managed/observing distinction goes in the tooltip, where it can be stated in
words instead of being guessed from a hue. Status is read from EngineChild's
in-memory status — never Collector::snapshot(), which spawns nvidia-smi and
makes two HTTP calls and has no business on a 1 Hz UI timer.

gdi32 added to the link libraries; the GDI calls this needs were not covered
by the existing list.

Claude-Session: https://claude.ai/code/session_01ADxBwAyzHaGcZz8DsYd8jB
The request-mix panel read a permanent zero. poll_request_log() selected
candidate lines by substring on "request_done" — which matched — and then
rejected every one of them with j.value("type", "") != "request_done".

The engine writes {"event":"request_done"}. There is no "type" field, so the
lookup returned "" for every record and each one hit `continue`. The two
checks disagreed about the key name, and only the second one was load-bearing.

Nothing else was wrong: result.completion_tokens, result.prefix_reuse_path,
timings_seconds.ttft and timings_seconds.decode all match the schema (v10).

Against live production traffic this now reads done=12, decode 167.1 tok/s,
TTFT 231.8 ms, reuse mix full_reset 5 / seed 5 / append 2 — where it read
zeroes before.

Also points the monitor-only example at the host-visible log path. The
production container now bind-mounts P:/NInfer/supervisor-logs to /logs and
writes its JSONL there, because /tmp/prod.jsonl lived inside the container
where a native Windows supervisor has no path to it. That also fills in the
boot capacity line, which is parsed from the same file's server_start record.

Claude-Session: https://claude.ai/code/session_01ADxBwAyzHaGcZz8DsYd8jB
…-smi

grok's issue #11 P2 delivery, plus a sampling-cadence fix found in review.

THE SERIES. A 6000-sample ring (10 minutes at 10 Hz) rendered as an inline
SVG polyline — no chart library, the page stays offline and CSP-tight. DXGI
Budget and nvidia-smi device-used share one time axis, and engine up/down
and /admin/vram actions are drawn as event markers so a budget collapse can
be read against the action that caused it. Deliberately unsmoothed, and
labelled as such in the UI: the oscillation is the finding, and an average
erases exactly the artifact issue #7 exists to characterise.

THE FIX. series_loop() called poll_nvidia_smi() on every tick. That is a
PROCESS SPAWN, measured at ~51 ms on this box, against a 100 ms period.

  measured before: ~9.3 spawns/s, ~48% of one core in spawns, 5% supervisor
  measured after:  ~1.0 spawns/s, ~5%  of one core in spawns, 2% supervisor

The waste is the smaller half. The real problem is that this instrument is
meant to observe a machine under a game-test workload, and a monitor burning
half a core and issuing driver queries ten times a second is competing with
the very thing it is measuring. A perturbing instrument reports its own
interference.

DXGI is an in-process call and stays at the full rate, because the budget
oscillation must not be decimated. Device totals move slowly, so they are
polled at 1 Hz and the last reading is carried forward into the fast series.
Verified live: 253 samples over 27.4 s, 9 distinct nvidia readings across
that span, event markers present.

Claude-Session: https://claude.ai/code/session_01ADxBwAyzHaGcZz8DsYd8jB
…and operators

grok's issue #12 first cut, merged with the P2 series and reviewed live
against production traffic.

JSON FIRST. GET /api/insights returns structured findings; the HTML renders
those same objects. One computation, two consumers — an agent cannot read a
chart, and a second rendering path would drift from the first without anyone
noticing.

Each finding carries id, severity, title, statement, evidence, confidence,
measured_over and availability. Evidence includes raw figures and sample
request_ids so a consumer can verify rather than trust, and inferred causal
claims are prefixed as such rather than stated as measurement.

THE HONESTY RULE EARNED ITS KEEP IMMEDIATELY, and it caught an error in the
spec I wrote. Issue #12 asked for a detector for "finish_reason output_limit
with empty content but non-empty reasoning_content". Those fields do not
exist in the request log — schema_version 10 writes completion_tokens,
finish_reason, prefix_* and tool_call_count, and nothing of the visitor-facing
text. Rather than fabricate the check or silently drop it, the layer emits it
as availability=unavailable with looked_for and schema_version in evidence,
and reports the part that IS measurable: 5 of 40 request_done finished on
output_limit with enable_thinking=true. Same for narrated-tool-intent, which
needs text the log does not carry — it reports tool_count (9 of 40 requests
declared tools) and says plainly what it cannot score.

That is the whole point of the contract. A layer that had answered "0
problems" to both would have been confidently, invisibly wrong — which is
the failure mode that has produced four instrument defects on this project
in two days.

First real finding on production traffic: 40 of 40 paired, 0 queued, 11
prefill-dominated, 29 decode-dominated, mean queue wait 0.015 ms, scheduler
peak waiting=0. The lane is not saturated and its latency is decode-bound.
Queue wait is derived by pairing request_start/request_done on request_id and
subtracting timings_seconds.total from the wall delta.

Also hides pid/uptime/restarts and the empty log tail in monitor-only, where
there is no child process and those read as real zeroes.

Merge note: conflicts with the P2 series were additive on both sides except
in the test file, where each side ended mid-function sharing a trailing
return; resolved by closing the first function rather than nesting them.
Logic tests pass under Linux/g++ and MSVC.

Claude-Session: https://claude.ai/code/session_01ADxBwAyzHaGcZz8DsYd8jB
…vailable

Second cut of issue #12, verified live against production traffic.

TTFT DECOMPOSITION. Splits mean TTFT into prepare / prefill / vision and
names the dominant component, holding decode aside because it happens after
the first token and is not part of TTFT at all. On the live lane: TTFT
441.5 ms = prepare 11.95 + prefill 415.3 + vision 13.8, so prefill is 94% of
it. The recommendation follows from the split rather than from a guess --
"--prefill-chunk is the lever, not decode kernels". Paired with the
saturation insight, an operator now gets both halves of "why is this slow":
nothing is queueing, and time-to-first-token is prefill-bound.

MEASURED_OVER NOW DESCRIBES WHAT WAS EXAMINED. Previously an unavailable
finding reported measured_over.requests = 0 while its own evidence carried
counts drawn from 40 records. A consumer reading the honesty field alone
concluded nothing had been looked at, which made the honesty field itself
misleading. Examined-count and reachability are now separate: measured_over
says what was read, availability says whether a conclusion was possible. A
genuinely missing log still reports 0, because in that case nothing was.

Empty recommendation strings are omitted rather than serialised, so a
missing key means "nothing to recommend" instead of reading as a dropped
value.

Claude-Session: https://claude.ai/code/session_01ADxBwAyzHaGcZz8DsYd8jB
Third cut of issue #12. Distinguishes an expected reset from a miss: a
single-turn request on full_reset is correct behaviour, a MULTI-TURN request
on full_reset is reuse that should have happened and did not. The flat
reuse-mix counter merged earlier cannot express that difference, which is
why it read as unremarkable.

It found a real one on the live lane immediately. Independently confirmed
against the raw log rather than taken from the insight: 19 of 42 request_done
are multi-turn full_reset — 19 of 31 multi-turn requests, so 61% of
conversational traffic gets no reuse at all. Every one has
computed_prefill_tokens == prompt_tokens and prefix_cache_hit_tokens == 0,
so each is re-prefilling its entire history from scratch.

The cost is visible in the same log. Request 28: 9547 prompt tokens, 9547
prefilled, 0 reused, TTFT 1.000 s. Request 29: 12431 prompt tokens, only
4411 prefilled because 8020 were reused, TTFT 0.524 s. A LARGER prompt
served in HALF the time. That pair is the argument for caring about this.

Not a cold-cache artifact of the container recreate at 04:24 — reuse was
already working 60 s after boot (ids 7, 8, 10 on seed_prefix), and the miss
streak runs from t+496 s to t+607 s with a warm cache.

Cause is NOT established and the insight does not claim one. prompt_tokens
across the streak moves 2969, 5028, 7180, 4756, 7559 ... it goes DOWN as
often as up while message_count climbs, so this is either several
interleaved conversations or a client whose prompt head is not stable turn
to turn. Prefix reuse needs a stable prefix; anything variable near the
start defeats it. The request log carries no conversation or prefix
identifier, so the two cannot be separated from this source — which is
itself worth knowing.

Claude-Session: https://claude.ai/code/session_01ADxBwAyzHaGcZz8DsYd8jB
…ath recovery tests

- Consolidate error formatting in ConcurrentExecutor::handle_fatal_error as the single
  owner, extracting exception details (type and what()) and constructing the fatal log.
- EngineOptions::on_fatal_error callback receives (exception_ptr, const string& message),
  allowing GenerationService to log via write_console_log without redundant formatting,
  while test probes can record without process termination.
- Unhandled fatal exceptions in worker_loop notify active requests (best-effort before exit),
  flush stderr/stdout, and exit immediately with code 1 via std::_Exit(1) to ensure Docker
  restart policies recover without hanging in device synchronization.
- Preserve RequestError boundary: request-scoped errors complete the client request and
  continue worker loop serving without triggering fatal handlers or process exits.
- Rewrite tests/test_executor_recovery.cpp with real-path execution tests over a lightweight
  mock program/frontend:
  * In-process probe classification verifying fatal error logging and unhealthy status.
  * Negative test asserting RequestError does NOT trigger on_fatal_error and allows
    subsequent requests to succeed on the recovered lane.
  * Real subprocess exit tests asserting exit code 1, stderr flush, and post-mortem markers.
…10)

gemini's rework, accepted after an independent build, test run, and mutation
check. This closes the half of #10 that observability could not: a
permanently failed executor now terminates the process so a restart policy
can recover it, instead of leaving a container that holds ~58 GiB and serves
503 to every request until a human notices.

CLASSIFICATION IS UNCHANGED AND THAT IS THE POINT. admit_planned_request
already separated request-scoped from executor-fatal: RequestError cleans up,
completes the request and continues; anything else completes the request and
rethrows to the worker loop. Everything that now exits was ALREADY latching
fail_all at that same catch, i.e. already a permanently dead engine. This
converts "alive process, dead engine, 503 forever" into "process exits,
restart policy recovers it". The fatal set is not widened.

ONE OWNER FOR FORMAT-AND-EXIT. handle_fatal_error formats the message once
and hands (exception_ptr, message) to the callback; GenerationService logs
the pre-formatted message and exits. The earlier version had four copies of
the formatting and the executor's own logging was unreachable in the serve
path, because the callback exited before it could return.

std::_Exit over a clean unwind, for the reason the boot watchdog documents:
a permanently failed executor is exactly the state where teardown is most
likely to block in device synchronization, and a process wedged in teardown
is the same alive-but-useless failure one layer down.

WHY THE TESTS ARE TRUSTED THIS TIME. The first attempt re-implemented the
format-and-exit body inside the test file and asserted on its own copy --
deleting the feature would have left it green. These construct a real
ConcurrentExecutor over a fake Program that throws on demand and drive it
through submit()/wait():
  - fatal path: probe records without exiting; asserts it fired, the detail,
    both message markers, and !is_healthy()
  - NEGATIVE guard: RequestError must NOT reach on_fatal_error, is_healthy()
    stays true, and a following request completes on the recovered lane.
    This is what keeps a poison request from becoming a crash-loop, which
    matters now that live tenant traffic is on this lane.
  - subprocess exits now drive the real executor, asserting code 1 and the
    post-mortem line

Verified independently rather than on report: built in a SEPARATE volume
(ninfer-main-build is mounted into the production container as /build, so
rebuilding there would leave production one restart from an ungated binary),
all six tests pass, and a mutation removing the handle_fatal_error call from
the worker-loop catch makes them FAIL with "on_fatal_error probe was NOT
invoked". That is the property the previous submission lacked.

Note for CI: this binary links cudart and will not load without libcuda.so.1,
and it needs the ffmpeg runtime libs. In a bare container the loader error
reads like a test failure and is not one.

Follow-up, not blocking: the exit now belongs to the callback, so a future
callback that forgets to terminate would silently reintroduce the zombie
state this fixes. EngineOptions::on_fatal_error should say so in a comment.
…pinned tiers

record_transitions() skipped every event until last_transition was already
non-empty, so the FIRST release -- the empty -> "release" edge -- was stored
as the baseline and never emitted. In practice that is the only release most
sessions see, so the markers looked entirely broken. Now baselines on the
first poll and emits on every subsequent change.

Detects on last_transition / last_reason rather than diffing held_bytes: a
release measured at 42 ms is invisible to a 1 Hz poll, and the endpoint
already reports the transition and a label ("seed store released").

Adds vram.tier_pinned_unreleasable -- min == max while --admin-vram is on is
self-contradictory, an admin surface for releasing memory nothing can
release. That configuration silently disabled VRAM cooperation on the
production lane and nothing surfaced it; the tier just reported
reclaimable_bytes 0 and looked healthy.

Dashboard now shows released-now and time-since-release, because a released
engine is serving degraded (no cross-request prefix seeding) and the page
did not say so.
Fixes a first-seen bug that made the markers appear entirely broken:
record_transitions() emitted nothing until last_transition was already
non-empty, so the FIRST release -- the empty -> "release" edge -- became the
baseline and was swallowed. That is the only release most sessions ever see.

Detection now reads last_transition/last_reason instead of diffing
held_bytes. A release measured at 42 ms on hardware is invisible to a 1 Hz
poll, so a diff-based detector could never have worked; the endpoint already
reports the transition and a usable label.

Also adds vram.tier_pinned_unreleasable. --prefix-cache-mib N pins
min == max, which silently made the seed tier un-releasable and disabled
VRAM cooperation on the production lane for a day. Nothing surfaced it: the
tier reported reclaimable_bytes 0, which reads as a fact rather than as a
disabled capability. min == max while --admin-vram is enabled is
self-contradictory and now says so.
The supervisor crashed with 0xC0000409 (__fastfail) during a real eviction
event -- the exact moment it exists to record -- and took its in-memory
10-minute ring with it, so there is no recording of the event at all. A UE5
title hit by the same eviction froze ~5 s and recovered cleanly, so this was
our defect rather than an unavoidable consequence.

CAUSATION IS NOT ESTABLISHED and this commit does not claim otherwise. The
hypothesis is that query_dxgi_local() called cudaGetDeviceProperties() on
every 10 Hz sample to LUID-match the adapter, and a CUDA runtime call during
a TDR or device reset can abort the process rather than return an error.
That fits the fault code and the timing, but it was not reproduced: forcing
eviction to test it would have evicted the production engine and every other
tenant on the card, which is not an acceptable price for a diagnosis. The
hardening below is correct regardless of which hypothesis holds.

- No CUDA on the sampling path. The adapter is now selected by NVIDIA vendor
  id and enumeration index, the factory and IDXGIAdapter3 are cached, and
  only QueryVideoMemoryInfo runs per sample. This also removes the ~9 ms per
  tick that made the 10 Hz loop measure a 109 ms median gap.
- Every DXGI HRESULT is checked and a failed query degrades the sample to
  "dxgi unavailable" instead of propagating. The loop tolerates the adapter
  going away and coming back.
- try/catch around the worker loops so a throw cannot take the process down.
- The series and its events are appended to logs_dir/series.jsonl and the
  tail is reloaded at startup.

Verified: 108 samples written in 12 s, and after a restart with 6 s of
uptime the page showed 280 samples spanning 32.7 s -- i.e. the pre-restart
history is back. That is the property that would have preserved the record
of the crash.

KNOWN LIMITATION introduced by dropping the LUID match: the adapter is now
"Nth NVIDIA adapter in DXGI enumeration order", which is exact on a
single-GPU host but is not guaranteed to agree with CUDA device ordering on
a multi-GPU one, since CUDA_DEVICE_ORDER can order by compute capability or
PCI bus. Acceptable here; it needs a LUID match done off the hot path if
this ever runs on a multi-GPU box.
…later failure

An agentic client received raw <tool_call><function=replace_string_in_file>
markup as visible assistant text in production; the request log recorded
tool_call_count 0. The parser was all-or-nothing: any imperfection anywhere
in the response discarded every call already parsed, and a parse yielding
nothing causes finish(false) to flush the buffered region as content, which
is how the markup reached the user.

Measured before, strict mode (production does not run --tolerant-tool-calls):

  two blocks with a sentence between them   0 calls   <- both valid calls lost
  one block plus a trailing sentence        0 calls
  valid block plus a truncated second       0 calls   <- complete call lost
  <function=name > (space before close)     0 calls

The truncated-tail case is the clearest: a model cut off by a token budget
mid-second-call also lost its first, fully valid call.

Now N parsed calls are returned even if parsing later fails, in BOTH strict
and tolerant mode. Strict should mean "I do not guess", not "I discard what
I already proved".

THIS IS NOT THE NEAR-MISS RECOVERY REJECTED IN #5 AND #9. Those could
fabricate a call out of prose resembling tool syntax. This invents nothing:
every returned call was fully parsed from an explicit <tool_call><function=
NAME> wrapper with a complete parameter block. Verified — prose mentioning
<tool_call> and <function=foo> still yields zero calls.

Verified independently rather than on report:
- Same case matrix run against base and against this tree: 0/0/0/0 becomes
  2/1/1/1, and the trailing-space case yields the correctly TRIMMED name
  "replace_string_in_file" rather than a name with a stray space.
- Two calls followed by junk return both, with correct names.
- Mutation check: reverting the parser makes four tests fail, including
  "truncated tail retains complete prior call".

Two hypotheses were tested and disproved before reaching this: the parser
mishandling the canonical multi-call shape (six reconstructed variants all
parse), and ToolCallStreamFilter leaking across chunk boundaries (fuzzed
over every 2-split, 3-split, 1-byte chunking and 1000 random splits, zero
leaked bytes). The filter is correct; the failure was always upstream of it.

Not addressed here, deliberately: a parameter value containing </parameter>
or </function> truncates the scan (fails in tolerant mode too). Narrower and
separate from the all-or-nothing defect.

The original leaked bytes were never captured, so which case fired in the
reported incident is unconfirmed; all of them are real defects regardless.
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.

5 participants