Skip to content

refactor(backends): route intrinsic generation through adapter_scope (Epic #929 Phase 2) #1465

Closed

Description

@planetf1

Parent epic: #929
Phase: 2
Depends on: #1141
Blocks: #1466 (generate / parse spans only)

Problem

Adapter-function generation does not run inside adapter_scope. LocalFileBinding
can now prepare / activate / deactivate / release a PEFT adapter, and
adapter_scope() demonstrably flips the real model's active adapter set — but the
model call in _generate_from_intrinsic still happens outside that scope, and the
standard generation path deactivates adapters first
(_generate_with_adapter_lock). So the lifecycle verbs and the generation they
exist to serve are not yet connected.

This was #1141's acceptance criterion 66 — "E2E adapter call (prepare →
activate → generate → deactivate → release) passes" — and it is not excluded by
#1141's out-of-scope list. It was deferred during implementation of PR #1454, and
the deferral is disclosed only in a test docstring
(test/backends/test_adapters/test_local_file_e2e.py) which refers to a follow-up
issue that was never created. This issue is that follow-up.

It is also not what the epic informally calls "4.1". 4.1 is #1144 (shim
removal plus the intrinsics_and_adapters.md rewrite), whose scope does not
mention generation, activation, scoping or locking. Without a separate issue this
work falls through the gap between the two.

Known constraint: the activation lock is not reentrant

_adapter_activation_lock returns _generation_lock, which is not reentrant.
Once generation runs inside adapter_scope and activate() re-acquires that
lock, it deadlocks. This was raised in PR #1454 review. Separately, the lock is
weaker than its docstring implies today — it does not serialise everything the
docstring claims — so the fix should reconcile behaviour and documentation rather
than only unblocking the deadlock.

Any solution needs to keep the current guarantee that a caller cannot observe
another caller's adapter active, which is what the existing
deactivate-before-generate behaviour buys.

Scope

  • Route the model call in _generate_from_intrinsic through adapter_scope, so
    the prepared and activated adapter is the one used for generation.
  • Resolve the activation/generation lock reentrancy, and align the docstring with
    actual behaviour.
  • Extend the existing e2e coverage so the full lifecycle is exercised with
    generation inside the scope, and remove the deferral note from
    test_local_file_e2e.py.

Out of scope

Acceptance criteria

  • _generate_from_intrinsic performs its model call inside adapter_scope
  • Generation demonstrably uses the activated adapter, asserted rather than
    smoke-tested
  • No deadlock: activate() inside adapter_scope with generation nested
    completes, with a regression test that would deadlock under the current lock
  • _adapter_activation_lock / _generation_lock docstrings match observed
    behaviour
  • A concurrent-callers test shows one caller cannot generate against another
    caller's adapter
  • test_local_file_e2e.py covers prepare → activate → generate → deactivate →
    release, and its deferral note is gone
  • uv run pytest test/ -m "not qualitative" passes; the e2e test passes on
    GPU hardware

References

Activity

  1. added
    p1High: important bugs (workaround exists) or high-value core features. Do soon, not on fire.
    area/aloraLoRA/aLora adapter training
    area/backendsProvider-specific work: Ollama, HF, LiteLLM, OpenAI, Bedrock, vLLM
    area/adapter-functionsGranite adapter functions: framework and adaptiers including RAG, Guardian, Core
    on Jul 29, 2026
  2. self-assigned this
    on Aug 10, 2026
  3. planetf1 commented on Aug 11, 2026

    @planetf1
    ContributorAuthor

    Heads-up from PR #1454's verification pass: #1528 is worth reading before this issue picks a lifetime for its bindings.

    LocalFileBinding.release() unloads the weights but does not deregister — unload_peft_adapter clears the backend's loaded set, while _added_adapters (the registered set that add_adapter checks for duplicates) keeps its entry, and there is no inverse verb to call. So a released qualified_name stays claimed for the backend's lifetime and no later binding can register under it.

    That bites here specifically if this cutover ends up preparing a binding per call rather than once per session — the second call cannot register. #1528 frames the prior question: the merged WeightsBinding ABC says release() is terminal and the binding is not reusable, while #1141's text promised a release()+prepare() cycle. Worth settling which is intended before choosing a binding lifetime here, even if the answer is "terminal is correct, prepare once".

  4. planetf1 commented on Aug 11, 2026

    @planetf1
    ContributorAuthor

    Heads up: #1528 is deciding whether a WeightsBinding is reusable after release(). Worth a quick check before this issue fixes a binding lifetime, per #1528's own note ("worth resolving before #1465 picks a lifetime for its bindings").

  5. 4 remaining items

  6. planetf1 commented on Aug 20, 2026

    @planetf1
    ContributorAuthor

    Design note from the PR #1558 review (adapter_function lifecycle spans, now ready-state) — two things to settle in #1465's design before adding the generate/parse phases:

    1. Phase-complete is success-only, by contract — and that contract is about to meet the highest-failure-cost phase. AdapterFunctionPhaseCompletePayload has no outcome/error fields: a phase that raised fires phase-start but never phase-complete, and the failure is attributed only at the span level — finish_adapter_function_span defensively closes any still-open adapter_function.<phase> child with the invocation's exception and ERROR status. That is accurate today because the lifecycle phases are sequential (at most one child open at a time), and it will stay accurate for a failed generate phase the same way. The gap is for plugin consumers that don't use tracing: AdapterFunctionMetricsPlugin records phase_duration only on phase-complete, so a phase that raised yields no histogram sample and no plugin-visible event — the span is the only record of it. If that matters for generation (arguably nowhere else — it's where failures are most expensive to attribute), #1465 needs either a failure-carrying phase event, or outcome/error symmetry with invocation_complete, and that's a contract decision that should be made once, before the generate phase is built on the current one.

    2. The attribution path it will flow through has a known robustness gap. The dangling-child cleanup that performs that span-level attribution runs without isolation between span operations (#1577, filed from the same review; unguarded in the new completion path, plus a pre-existing handler-dispatch routing exposure shared by all plugin-closed span families). Hardening that before #1465 adds more phases through it is free leverage — one fix covers the two new phases for free.

    No action requested on this issue — recording so the design discussion has the context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area/adapter-functionsGranite adapter functions: framework and adaptiers including RAG, Guardian, Corearea/aloraLoRA/aLora adapter trainingarea/backendsProvider-specific work: Ollama, HF, LiteLLM, OpenAI, Bedrock, vLLMp1High: important bugs (workaround exists) or high-value core features. Do soon, not on fire.refactor

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions