Skip to content

022: registry-driven LLM serving + operator model selection (offline slice, T461–T488)#65

Draft
kurtvalcorza wants to merge 2 commits into
masterfrom
claude/mlops-lite-architecture-6a7iw2
Draft

022: registry-driven LLM serving + operator model selection (offline slice, T461–T488)#65
kurtvalcorza wants to merge 2 commits into
masterfrom
claude/mlops-lite-architecture-6a7iw2

Conversation

@kurtvalcorza

Copy link
Copy Markdown
Owner

Implements the OFFLINE-executable slice of increment 022 registry-driven LLM serving (specs/022-registry-driven-llm-serving/) — the LLM becomes a registry-driven, console-operable engine like vision/embed/ASR/tabular. Promote = go live (Clarifications 2026-07-05): one operator action moves the @serving alias, records the active serving LLM, and triggers the immediate controlled reload.

What landed, by phase

Setup + Foundational (T461–T464)

  • platformlib/store: ActiveServingLLM singleton pointer table + accessors (mirrored in infra/postgres/init.sql; additive, no schema-version bump).
  • platformlib/llmresolve.py: ONE registry walk (name → @serving → kind → base) shared by agent and gateway — they cannot drift (the gateway image only ships gateway/app + platformlib, so the shared home is platformlib, stdlib-only, duck-typed client).
  • hostagent/serving_llm.py: the cold-load resolver → {model_name, version, base_gguf, adapter_gguf|null}; ResolutionError (refuse, FR-265) vs ResolutionUnavailable (env-default fallback, surfaced in health — never silent).
  • scripts/register_base_gguf.py: registers the local zoo bases as kind=full-model versions with base_id tags; idempotent; never downloads (R7).

US1 — promote → it actually serves (T465–T467)

  • The llama adapter rebinds base/adapter/alias/version from the registry at each cold load (TTL-cached on the hot health path; force-busted by the reload verb). MODEL/LORA/MODEL_ALIAS env knobs are now the fallback default only; spawn() appends --lora for adapters.
  • swap.reload_serving_llm: cross-tenant displacement reuses preempt_for; the same-tenant model switch force-reloads (unload → ensure_loaded) — the case preempt_for silently no-ops on (spec review PR 022 spec: registry-driven LLM serving + operator model selection #64 §1). Idempotent no-op, target-probe before any evict, jobs never preempted, operator confirm (preempt=true) required for any displacement. Exposed as secret-gated POST /control/reload on both agent transports.
  • Gateway promote route: registry-level resolvability is checked before the gate, so an unresolvable fine-tune is refused with the alias — and the served LLM — unchanged; a promoted alias move writes the pointer and requests the reload, with refused/deferred outcomes surfaced in the response.

US2 — honest served identity (T469–T471)

  • Agent health reports the actually-loaded model_name/registry_version/base/adapter; /serving/state, the /infer response, and prediction logging (REST + stream) all consume the agent-reported identity, degrading to unknown — never a SERVING_MODEL config guess. Kills the live divergence that corrupted a served fine-tune's quality window.

US3/US4 — fine-tunes first-class (T473–T479)

  • Adapter path: base_model lineage → registered full-model base in ONE hop (adapter chains refused); the fine-tune flow stamps task/serving_engine at registration; scripts/backfill_llm_task_tags.py fixes legacy versions (idempotent, non-clobber); kind→task fallback in the resolve/list surfaces; serving/tasks carries kind+lineage and filters text-generation to the active pointer (FR-276 — no stale duplicate LLM entries).

US5 offline slice + polish (T481–T483, T485–T488)

  • PromoteGate: promote-as-switch behind the shared ConfirmDialog naming the displaced model; resident-vs-promoted delta; reload outcome surfaced. StreamPanel shows base-vs-adapter provenance.
  • Allow-list delta: EMPTY — the switch rides the existing POST models/:name/promote and GET serving/state (the contract's preferred outcome).
  • Boundary guards: FR-273 (no new resident process — compose service set, engine registry, thread-free resolver import all pinned) and FR-275 (registry.promote — the choke-point the scheduler/suggestion paths call — structurally cannot switch the served LLM).
  • README serving section; AGENT_CONTROL_SECRET passed to the gateway in compose; constitution confirmed unchanged (the switch reuses the existing one-tenant swap).

Verification

  • pytest: 534 passed, 39 skipped (baseline before this change: 471/39). The only 3 failures are the pre-existing PIL-missing environment failures, identical before/after — zero regressions, Principle II admission/swap tests included.
  • next build (type-check): green. Allow-list conformance grep: every ui/ gateway call resolves to an existing gw-allowlist.ts entry. (next lint remains unconfigured in-repo — no ESLint config/dependency has ever existed in ui/; unchanged here.)
  • New offline suites: resolver (17), reload-under-lease (9), adapter binding (9), gateway wiring/identity (22), backfill/registration/finetune-tags (5), boundary guards (6).

Deliberate scope notes

  • The spec references the c28ca97 LORA env prototype — that commit is local-only (not in this remote); the adapter work here implements --lora support outright, subsuming it.
  • Left open, as planned: T484/T489 [HW] drills (RTX 5070 Ti) and the live-stack halves of the four quickstart validation tasks (T468/T472/T475/T480) — their pytest halves are green and each is annotated in tasks.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_013hoYEa8FN5s8eGqa78Fxjx


Generated by Claude Code

…8, offline slice)

Make the text-generation engine registry-driven like the other four: promoting
an LLM version from the console IS the go-live action (FR-254/255), with honest
served identity end-to-end and base+LoRA-adapter resolution from lineage.

Setup + foundational:
- platformlib/store: ActiveServingLLM singleton pointer table + accessors
  (T461; mirrored in infra/postgres/init.sql; additive, no version bump)
- scripts/register_base_gguf.py: register local zoo bases as kind=full-model
  versions with base_id tags, idempotent, never downloads (T462, R2/R7)
- platformlib/llmresolve.py: ONE registry walk (name -> @Serving -> kind ->
  base) shared by agent + gateway so the two resolution sites cannot drift
- hostagent/serving_llm.py: cold-load resolver -> {base_gguf, adapter_gguf,
  model_name, version}; ResolutionError (refuse, FR-265) vs
  ResolutionUnavailable (env fallback, surfaced) (T463)

US1 promote -> serve:
- llama adapter rebinds base/adapter/alias/version from the registry at each
  cold load (TTL-cached on the health path); MODEL/LORA/MODEL_ALIAS env knobs
  become the fallback default; spawn appends --lora for adapters (T465)
- swap.reload_serving_llm: cross-tenant preempt_for reuse + the same-tenant
  FORCE-RELOAD (unload -> ensure_loaded) preempt_for silently no-ops on;
  idempotent no-op, target-probe before evict, jobs never preempted,
  operator confirm (preempt=true) for any displacement (T466, PR #64 s1)
- agent POST /control/reload (secret-gated, both transports) (T466)
- gateway promote route: pre-gate resolvability refusal (409, alias unmoved),
  pointer write + reload request after a promoted alias move (T467, T474)

US2 honest identity:
- agent /engines/llm/health reports model_name + registry_version + base +
  adapter of what is ACTUALLY resident (T469)
- gateway gpu_state / serving/state / /infer response / prediction logging all
  consume the agent-reported identity; degrade to unknown, never a config
  guess (T470/T471 - kills the live mislabeled-window divergence)

US3/US4 fine-tunes first-class:
- adapter path: base_model lineage -> registered full-model base, single hop,
  adapter chains refused (T473); finetune flow stamps task/serving_engine at
  registration (T476); scripts/backfill_llm_task_tags.py for legacy versions,
  idempotent + non-clobber (T477); kind->task fallback in resolve/list (T478);
  serving/tasks carries kind+lineage and filters text-generation to the active
  pointer - no stale duplicate LLM entries (T479, FR-276)

US5 console switch (offline slice):
- PromoteGate: promote-as-switch with the ConfirmDialog naming the displaced
  model, resident-vs-promoted delta, reload outcome surfaced (T482)
- allow-list delta EMPTY: rides existing promote + serving/state (T483)
- StreamPanel shows base-vs-adapter provenance (FR-268/274)

Polish:
- boundary guards: FR-273 no new resident process; FR-275 the policy/auto-
  promote path structurally cannot switch the served LLM (T485)
- README serving section + tasks.md; AGENT_CONTROL_SECRET into gateway env

Tests: 534 passed, 39 skipped (was 471/39); the 3 remaining failures are the
pre-existing PIL-missing environment ones, unchanged. next build green.
[HW] tail (T484/T489) + the live quickstart drills stay open for the GPU box.

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

Copy link
Copy Markdown
Owner Author

@claude please review this PR — the offline slice of increment 022 (registry-driven LLM serving).

Focus areas where a second pair of eyes is most valuable:

  • hostagent/swap.py:reload_serving_llm — the two-case switch (cross-tenant preempt_for vs. the same-tenant force-reload), and whether any path can end up two-resident or leave serving wedged/empty (Principle II / FR-257).
  • platformlib/llmresolve.py — the shared base-resolution walk: single-hop enforcement (no adapter chains), and the not-found-vs-unreachable classification (error_code duck-typing vs. MlflowException).
  • Honest identity (gateway/app/serving.py + routers/infer.py/stream.py) — that no path falls back to the fixed SERVING_MODEL when attributing a served prediction (FR-261/262).
  • gateway/app/registry.py:list_tasks — the FR-276 active-pointer dedup when two LLM models both hold a promoted @serving alias.

Backend pytest is green (534 passed / 39 skipped; the 3 failures are pre-existing PIL-missing environment ones); next build is green with an empty allow-list delta. The [HW] drills and the live quickstart halves are intentionally deferred.


Generated by Claude Code

@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @kurtvalcorza's task in 6m 16s —— View job


Review complete

  • Gather context (PR body, changed files, diff)
  • Review hostagent/swap.py:reload_serving_llm (two-case switch, two-resident/wedged risk)
  • Review platformlib/llmresolve.py (single-hop enforcement, not-found vs unreachable classification)
  • Review honest-identity paths (gateway/app/serving.py, routers/infer.py, routers/stream.py)
  • Review gateway/app/registry.py:list_tasks FR-276 dedup logic
  • Post final review

Overall this is a well-reasoned slice — the swap/reload logic, the shared resolver, and the identity plumbing are carefully thought through, and the docstrings/comments make the intent easy to follow. Found three concrete gaps in the focus areas, ranked by severity.

1. hostagent/swap.py:reload_serving_llm — same-tenant switch has no rollback on a failed load (Principle II / FR-257)

reload_serving_llm's same-tenant branch (hostagent/swap.py:162-166):

result = rt.unload(drain_timeout_s=drain_timeout_s)
if result.get("status") not in ("unloaded", "idle"):
    raise SwapError(...)
return {"status": "reloaded", "load_ms": rt.ensure_loaded(), **_identity(adapter)}

unloads the old (working) model and calls rt.ensure_loaded() with no rollback if that raises. adapter.available() (called earlier, line 131) only checks file existence — a target GGUF can pass that probe and still fail at actual llama-server spawn/readiness (corrupt file, OOM, port issue). Compare to the cross-tenant path in preempt_for (lines 84-99), which explicitly retargets the admission reservation and best-effort reloads the previous holder when the target's ensure_loaded() fails after eviction — precisely to avoid leaving the GPU empty when the prior tenant was healthy.

Worse, there's nothing left to roll back to here: adapter.rebind(force=True) (line 130, top of the function) already overwrites self.model/self.lora/self.alias/self.registry_version to the new target before the unload happens, so the old binding is discarded before the risky step even runs. If the new artifact fails to spawn, the llm engine is left cold/empty until the next request retries loading the same (still-bad) target — a real "serving wedged/empty" outcome the PR description explicitly asks about. tests/test_serving_llm_reload.py doesn't exercise a post-probe spawn failure, so this gap isn't caught by the new suite either.

Suggested fix: capture the old bound identity before rebind(), and on ensure_loaded() failure in this branch, restore the old binding and best-effort reload it (mirroring preempt_for's recovery), then propagate the original error.

Fix this →

2. platformlib/llmresolve.py:_is_not_found — the string-matching fallback is broader than the module's own contract

def _is_not_found(exc: Exception) -> bool:
    if getattr(exc, "error_code", None) == "RESOURCE_DOES_NOT_EXIST":
        return True
    text = str(exc)
    return "RESOURCE_DOES_NOT_EXIST" in text or "not found" in text.lower()

The module header states: "Client/connection exceptions PROPAGATE (the callers classify unreachable-vs-invalid); only 'does not exist' is normalized here." The error_code check upholds that. The "not found" in text.lower() fallback doesn't — a generic HTTP 404 from a misconfigured MLFLOW_TRACKING_URI/reverse proxy, or any transport-level error whose message happens to contain "not found" (plausible for a misrouted/misconfigured endpoint), gets normalized to "the alias/model doesn't exist." That flows into hostagent/serving_llm.py's resolve() as LLMResolutionError/ResolutionError (fail-loud refusal) instead of ResolutionUnavailable (env-default fallback) — inverting the intended degrade-gracefully-on-outage behavior for what's actually a connectivity problem, not an invalid target. tests/test_serving_llm_resolver.py::test_registry_outage_is_unavailable_not_an_error only exercises ConnectionError("registry down"), which doesn't contain "not found," so this gap isn't covered.

Suggest narrowing this to the error_code check (or another mlflow-specific signal) rather than a broad substring match.

Fix this →

3. gateway/app/routers/stream.py — the MLflow trace still attributes to the static SERVING_MODEL config

infer_stream's trace attributes (lines 54 and 126) set "model": serving.SERVING_MODEL — the static env default — rather than the agent-reported identity. That's inconsistent with:

  • routers/infer.py's trace, which correctly uses served_model from _served_identity() (the agent-reported name), and
  • this same file's own _log() prediction-log call (lines 148-161), which correctly resolves identity via serving.llm_identity().

So within one file, the prediction log (the quality-window data FR-261 cares about) is honest, but the trace (a separate observability surface) isn't — a streamed request against a promoted fine-tune will trace under the fixed default model name. Low blast-radius (traces are observability, not the quality window itself), but it's the same class of bug 022 set out to eliminate, just missed in one spot.

Fix this →

Minor note (not a blocker)

gateway/app/registry.py:list_tasks (lines 384-388): the FR-276 dedup does keep = next((e for e in text_gen if e["model"] == active), text_gen[0]). If the ActiveServingLLM pointer ever names a model that currently has no @serving alias (a stale/broken pointer — shouldn't happen given promote's resolvability pre-check, but nothing structurally prevents it), the fallback silently picks whichever text-generation entry search_registered_models() returns first, rather than surfacing that the pointer is unresolvable. Not urgent, since a broken pointer already surfaces via the agent's health/ResolutionError path — just noting the UI list could show a duplicate that doesn't match what's really being resolved in that edge case.

What I didn't find issues with

  • preempt_for's cross-tenant path (rollback-via-retarget) is solid and well-tested.
  • Single-hop adapter-base enforcement in resolve_base_version/effective_kind correctly refuses adapter chains.
  • gateway/app/serving.py's _identity_from_health/llm_identity/gpu_state never fall back to SERVING_MODEL — this is the one file that fully upholds FR-261/262.
  • The promote route's ordering (resolvability check → gate → alias move → pointer write → reload request, each failure surfaced rather than silent) looks correct, including the "alias moved but pointer write failed" partial-failure case.

…, stream trace identity

Three concrete gaps from the @claude review, all in the flagged focus areas:

1. hostagent/swap.py:reload_serving_llm — the same-tenant force-reload unloaded
   the working model then called ensure_loaded() with NO rollback; a target that
   passes available() (file check) can still fail at llama-server spawn/readiness
   (corrupt GGUF, OOM, port), leaving the llm engine cold/empty — and rebind()
   had already discarded the old binding. Now snapshot the prior binding BEFORE
   rebind, and on a post-probe load failure restore + best-effort reload it
   (mirroring preempt_for's cross-tenant rollback), then propagate the error.
   Adds LlamaAdapter.snapshot_binding/restore_binding (restore freezes the
   binding so the recovery reload re-spawns the restored model, not the bad
   target). New test exercises the post-probe spawn failure -> rollback path.
   (Principle II / FR-257; data-model.md "serving is never left empty".)

2. platformlib/llmresolve.py:_is_not_found — the "not found" substring fallback
   was broader than the module contract: a transport 404 from a misconfigured
   MLFLOW_TRACKING_URI/proxy got mis-normalized to "does not exist" -> refuse
   (ResolutionError) instead of propagating -> env-default fallback
   (ResolutionUnavailable). Narrowed to the mlflow-specific RESOURCE_DOES_NOT_EXIST
   code (attribute or verbatim string). Tests cover a "not found"-carrying
   transport error and the unit classifier.

3. gateway/app/routers/stream.py — the MLflow trace still attributed to the
   static SERVING_MODEL at both trace sites, inconsistent with infer.py and this
   file's own prediction log. Resolve the agent-reported identity ONCE up front
   (agent reachable post-health-check; a request holds the GPU lease for its
   whole generation so identity can't drift) and thread it through both the
   trace and the log; the pre-generation failure trace reports "unknown" (agent
   down). Drops the detached _log task (identity is pre-resolved; log_prediction
   is already fire-and-forget) and the now-unused background import. (FR-261/262.)

The minor list_tasks stale-pointer note is left as-is: a broken pointer already
surfaces via the agent's ResolutionError, and dedup-to-one is the intended
FR-276 behavior even then.

pytest: 537 passed, 39 skipped (was 534/39; +3 new tests). The 3 failures remain
the pre-existing PIL-missing environment ones, unchanged.

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

Copy link
Copy Markdown
Owner Author

Thanks — all three findings were valid and are fixed in ed1ca8a:

  1. Same-tenant reload rollback (Principle II / FR-257) — the force-reload now snapshots the prior binding before rebind() overwrites it, and on a post-probe ensure_loaded() failure restores + best-effort reloads the previously-serving model, then propagates the error — mirroring preempt_for's cross-tenant recovery. Added snapshot_binding/restore_binding on the adapter (restore freezes the binding so the recovery reload re-spawns the old model, not the still-bad target) and a test exercising the passes-available()-but-fails-to-spawn path.
  2. _is_not_found classification — narrowed to the mlflow-specific RESOURCE_DOES_NOT_EXIST code (attribute or verbatim string); dropped the generic "not found" substring so a transport 404 from a misconfigured tracking URI propagates as ResolutionUnavailable (env-default fallback), not ResolutionError (refuse). Tests cover a "not found"-carrying transport error + the unit classifier.
  3. Stream trace identity (FR-261/262) — the trace now uses the agent-reported identity, resolved once up front (agent is reachable post-health-check; a request holds the GPU lease for its whole generation, so identity can't drift). This let me drop the detached _log task entirely — identity is pre-resolved and log_prediction is already fire-and-forget — so there's no new teardown-path await. The pre-generation failure trace reports unknown (agent down).

The minor list_tasks stale-pointer note I left as-is: a broken pointer already surfaces via the agent's ResolutionError, and dedup-to-one is the intended FR-276 behavior even in that edge.

pytest: 537 passed / 39 skipped (was 534/39; +3 tests). The 3 failures remain the pre-existing PIL-missing environment ones, unchanged.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@claude please re-review — commit ed1ca8a addresses all three findings from your first pass. Focus on whether the fixes are complete and correct:

  1. Same-tenant reload rollback (hostagent/swap.py:reload_serving_llm + hostagent/adapters/llama.py) — snapshot_binding() is taken before rebind(force=True); on a post-probe ensure_loaded() failure the same-tenant branch calls restore_binding() + best-effort reloads, then re-raises. Please check: is the freeze (restore_binding setting a fresh _bound_at) actually sufficient to stop the recovery ensure_loaded()available()rebind() from re-resolving the still-bad target within the TTL and clobbering the restore? And is there any double-failure path (recovery reload also fails) that leaves admission or _loaded inconsistent?
  2. platformlib/llmresolve.py:_is_not_found — now keys only on RESOURCE_DOES_NOT_EXIST (attribute or verbatim string). Confirm no legitimate not-found path relied on the dropped "not found" substring.
  3. gateway/app/routers/stream.py — identity resolved once up front and threaded into both the trace and the log; the detached _log task + background import were removed. Please verify the up-front resolve can't run on an unreachable agent (it's after the health check) and that dropping the detached task didn't reintroduce a teardown-path await.

pytest: 537 passed / 39 skipped (+3 new tests; the 3 failures are pre-existing PIL-missing environment ones).


Generated by Claude Code

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Claude finished @kurtvalcorza's task in 3m 29s —— View job


Re-review complete

  • Verify same-tenant rollback: snapshot_binding/restore_binding freeze semantics vs. recovery ensure_loaded() TTL re-resolution
  • Verify double-failure path (recovery reload also fails) leaves admission/_loaded state consistent
  • Verify _is_not_found narrowing didn't drop a legitimate not-found path
  • Verify stream.py up-front identity resolve ordering + no reintroduced teardown-path await

All three fixes in ed1ca8a are complete and correct. Details below, per the questions raised in the trigger comment.

1. Same-tenant reload rollback — freeze is sufficient, double-failure is clean

restore_binding() (hostagent/adapters/llama.py:140-148) sets self._bound_at = time.monotonic() as its last step. The recovery rt.ensure_loaded() call in swap.py:178-181 immediately calls adapter.available()self.rebind() (unforced). Since rebind()'s early-return guard is if not force and self._bound_at is not None and (now - self._bound_at) < REBIND_TTL_S: return (llama.py:87), and the elapsed time between the restore_binding() write and this read is sub-millisecond (well under the 5s default REBIND_TTL_S), the recovery load re-spawns the restored fields, not a re-resolved (still-bad) target. Confirmed structurally, not just by the new test.

Double-failure (recovery ensure_loaded() also fails, swap.py:178-181, except Exception: pass): both the original and the recovery ensure_loaded() calls go through the same EngineRuntime.ensure_loaded() (hostagent/lifecycle.py:108-112), whose own except BaseException: self.unload(...); raise unconditionally tears down and releases the admission slot on any failure (short of an uninterruptible child — the pre-existing, surfaced wedged state, not a new issue). So after a double failure the slot is legitimately free, not double-held or leaked.

One nuance worth noting (not a bug, but worth being aware of): restore_binding() also restores self._loaded to the pre-switch snapshot. If both loads fail, _loaded momentarily claims the old identity while nothing is actually resident. This doesn't leak into health() (llama.py:265-285), because that method gates on the caller-supplied resident flag (derived from actual child/process state, not _loaded) before using _loaded — it falls through to reporting the (correctly restored) binding as "would load next," honest either way. And the loaded_identity() == bound_identity() idempotent no-op check in swap.py:158 can only be reached when admission's holder is genuinely "llm", which — per the teardown logic above — only happens following a successful spawn (which itself resets _loaded truthfully). So the stale-snapshot window never produces an observable false "noop" or false "resident" report.

2. _is_not_found narrowing — correctly scoped, no fallout

_is_not_found (platformlib/llmresolve.py:50-60) has exactly one call site, _by_alias (line 69), which is itself called only from resolve_base_version and resolve_serving_record for get_model_version_by_alias lookups. No other function in the module does string-based not-found classification (resolve_base_version's search_model_versions calls return empty lists rather than raising for an unmatched filter, so they're unaffected). The new tests (tests/test_serving_llm_resolver.py) directly cover the case the narrowing targets — a RuntimeError("... upstream not found") with no RESOURCE_DOES_NOT_EXIST code now correctly propagates to ResolutionUnavailable, and the unit-level _is_not_found classifier test confirms the boundary. No legitimate not-found path relied on the dropped generic substring.

3. stream.py identity resolve — ordering and teardown path both check out

The resolve (stream.py:67-74) sits after await serving.health() (line 47) and before the generator starts, so it only runs when the agent was just confirmed reachable. serving.llm_identity() (serving.py:96-108) never raises — on any transport error in the small gap between the two calls it degrades to the honest unknown identity rather than crashing, which is the correct FR-262 behavior, not a bug.

On the teardown-path concern: the removed detached task existed only because the old code ran the identity resolve inside gen()'s finally block. Now that the resolve happens once, up front, outside the generator, the finally block (stream.py:139-173) contains no await at all — tracing.emit (tracing.py:154, def emit, synchronous) and quality.log_prediction (quality.py:194-216, explicitly fire-and-forget: generates the prediction id and spawns a daemon thread, returns synchronously) are both plain synchronous calls. So dropping the detached task did not reintroduce an await on the cancellation/teardown path — if anything, the teardown path is now strictly simpler (zero awaits) than before.

Verdict

All three findings from the first pass are fully and correctly addressed. No new issues found in the fix itself.

@kurtvalcorza

Copy link
Copy Markdown
Owner Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

if (v) promote(v, true);

P2 Badge Route LLM overrides through the switch confirmation

When the gate-blocked version is an LLM and any serving model is resident, this override path bypasses promoteLLMAware/askSwitch and sends preempt: false. The API still moves @serving and the active pointer, but the agent refuses the reload with “confirmation required”; because the version is now marked serving, the console disables the promote button, leaving no UI path to send the required preempt: true and the override does not go live until a cold/manual reload.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

instead of a spawn crash. Rebinds first (TTL-bounded) so the swap/reload target-probe
checks the artifacts that WOULD load — an unresolvable serving-LLM target or a missing
base/adapter GGUF refuses here rather than evicting a working holder (FR-265)."""
self.rebind()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep resident LLM health from rebinding to bad targets

When an LLM child is already resident, /engines/llm/health still calls available(), so this rebinds to the newly selected pointer and validates the new artifact instead of the loaded child. If a promote writes the pointer but the immediate reload is refused because the new full-model/adapter source is missing, health starts returning 503 even though the old child was intentionally not evicted and can still serve; the gateway's serving.health() then rejects /infer, turning a refused switch into an outage. The resident health path should remain based on _loaded or avoid rebinding while resident.

Useful? React with 👍 / 👎.

state = null;
}
setLiveState(state);
if (state?.resident) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Confirm switches when another engine holds the GPU

When a non-LLM serving engine such as vision holds the GPU, /serving/state reports holder: 'vision' but resident: false because resident is only whether the LLM runtime is loaded. This check therefore skips the confirmation and promotes with preempt: false; the backend moves the alias and active pointer, then /control/reload refuses because a resident engine would be displaced, so the operator's requested LLM switch does not go live from the normal promote flow.

Useful? React with 👍 / 👎.

Comment thread gateway/app/registry.py
Comment on lines +385 to +388
if len(text_gen) > 1:
active = active_serving_llm_name()
keep = next((e for e in text_gen if e["model"] == active), text_gen[0])
out = [e for e in out if e["task"] != llmresolve.TEXT_GENERATION or e is keep]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Filter single stale LLM aliases from serving tasks

This only applies the active-pointer filter when two or more text-generation aliases exist. On an upgraded install with one pre-022 promoted LLM alias and no serving_llm pointer, or whenever the sole promoted alias is not the active model, /serving/tasks still advertises that stale alias as the live LLM even though the agent serves the configured default/active pointer, so the Infer tab can show and route against the wrong registry identity.

Useful? React with 👍 / 👎.

Comment thread gateway/app/registry.py
Comment on lines +171 to +173
conn = s.connect()
try:
s.set_serving_llm(conn, model_name, time.time(), actor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bootstrap the pointer table before writing it

On a Postgres volume created before this migration, infra/postgres/init.sql will not run again, and this new pointer path opens a connection but never calls store.bootstrap() like the existing quality/policy/job clients do. A first LLM promote on such an upgraded install can move the MLflow alias and then fail here with relation "serving_llm" does not exist, leaving the switch unapplied until some other subsystem happens to bootstrap the additive DDL.

Useful? React with 👍 / 👎.

Comment thread docker-compose.yml
# 022 (FR-255): the gated promote requests the agent's /control/reload so a promoted LLM
# goes live at once. Empty when the agent runs open (the default); must MATCH the agent's
# AGENT_CONTROL_SECRET when one is set (sent as X-Agent-Control).
AGENT_CONTROL_SECRET: ${AGENT_CONTROL_SECRET:-}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pass the legacy control secret into the gateway

The agent still accepts SWAP_CONTROL_SECRET as a compatibility fallback, and hostagent/run.sh sources .env, but the gateway container only receives AGENT_CONTROL_SECRET here. In an existing deployment that set only SWAP_CONTROL_SECRET, the agent protects /control/reload while the gateway sends no X-Agent-Control header, so an LLM promote moves the alias/pointer but every immediate reload is refused with 403.

Useful? React with 👍 / 👎.

Comment on lines +285 to 286
onClick={() => promoteLLMAware(v)}
disabled={v.serving || busy === v.version}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow re-promoting an inactive serving LLM

With the new active pointer, v.serving only means this model has its own @serving alias; it does not mean this LLM is the active served model. When two LLM models each retain a serving alias and the operator wants to switch back to the inactive one, this LLM-aware promote handler is disabled by the adjacent v.serving check, so the UI provides no way to re-promote the same version to rewrite the active pointer and reload it.

Useful? React with 👍 / 👎.

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.

2 participants