Skip to content

Adopt refuses a terminal Claude that runs on relay's model broker: model_unknown #562

Closed

Description

@barelyworkingcode

Problem

A person runs Claude Code in a terminal through relay's model broker, which serves a local model under a Claude alias. The session goes idle. Adopt from eve answers 409 model_unknown ("the model of the session is unknown; send model"). Eve's Adopt button sends no model by design (barelyworkingcode/eve#597 contract), so the person has no way to adopt the session.

Shown on a verification stack (relay epic/525-session-adoption at 2faa421, the default stack, where Claude Code runs on the broker's local model), by eve's outside-adopt journey through eve's Adopt button:

JOURNEY outside-adopt FAIL POST /api/outside-sessions/<id>/adopt answered 409 ({"error":"model_unknown","message":"the model of the session is unknown; send model"})

The outside transcript's assistant record carries the local model id, not a claude-* id. On the same stack with real Claude, the same journey passes.

Root cause

modelFamily (internal/sessions/adopt/recovery.go) maps only ids that start with claude-haiku-, claude-sonnet- or claude-opus-. A broker-served id answers "", so the door refuses with model_unknown unless the caller sends model.

Fix

To be decided by relay's planner. Relay owns the broker's alias map, so one direction is to map a broker-served model id back to the alias that routes to it.

Effect on eve: barelyworkingcode/eve#598's verification runs outside-adopt on a default stack, so it fails until this is fixed, or until the run uses real Claude.

Found while working barelyworkingcode/eve#597. Epic: #525.

🤖 Generated with Claude Code

Contract

ship-blocker: yes. G1 is must-have in docs/FEATURES.md, and eve's Adopt button (G1.50's simple door) fails on every broker-served Claude.

Rule

With no model in the body, the adopt door picks the model in this order. The first step that gives an answer wins:

  1. The body's model (unchanged).
  2. Tail.ModelFamily, the claude-haiku-/claude-sonnet-/claude-opus- family (unchanged).
  3. New: the broker resolution of Tail.Model, the newest main-chain assistant record's message.model:
    a. Relay reads a fresh catalog: Cache.Resolve(ctx, Tail.Model), the same cache /v1/messages classification reads. When Tail.Model is itself a modelMap row (owned_by: anthropic-map), the keys are [Tail.Model]. Otherwise the keys are every modelMap row whose target equals Tail.Model. The first case covers relayLLM's translated dialect, which echoes the requested key. The second covers the native dialect, which echoes the target.
    b. Each key maps to a Claude alias F in haiku, sonnet, opus when relay's process environment sets ANTHROPIC_DEFAULT_<F>_MODEL (HAIKU, SONNET, OPUS) to that key. relay-sessions inherits relay's environment, so claude --model F in the adopted session sends that key to the broker, which routes it to the same target as the outside session did.
    c. Exactly one distinct alias across all keys is the model. Zero aliases, two or more, no matching row, an empty Tail.Model, or an unreadable catalog all answer 409 model_unknown with the current message. An unreadable catalog also logs a warning with outside_id and the error. Nothing is signalled, because this step stays where the model step is now: before AuthorizeLaunch and the intent rows.
  • The adopted session's model is the alias (haiku), never the key or the target. AuthorizeLaunch checks that alias against allowed_models, as today. The session_adopt audit model arg carries the alias.
  • Freshness: the catalog goes through Cache.Resolve once per adopt request, and only when steps 1 and 2 give nothing. The environment is read per request with os.Getenv.
  • The wire does not change: there is no new code, field or message. fakerelay does not change, because its world spec states the family directly.

Interfaces

  • internal/modelbroker/normalise.go: func ModelMapKeysFor(rows []Row, id string) []string. This is pure logic: rule 3a in catalog order, nil when nothing matches. It reuses findRow and classify.
  • cmd/relay/model_endpoint.go: func (m *ModelEndpointServer) ModelMapKeys(ctx context.Context, id string) ([]string, error) is m.catalog.Resolve plus ModelMapKeysFor. It returns an error when the catalog cannot be read. It mirrors IsSystemModel.
  • cmd/relay/session_routes.go: sessionRouteDeps gains modelMapKeys func(ctx context.Context, id string) ([]string, error). A nil value means step 3 answers model_unknown.
  • cmd/relay/server_core.go: modelMapKeys: app.modelEndpoint.ModelMapKeys, beside systemModel.
  • internal/sessions/adopt/adopt.go: Tail gains Model string, the newest assistant record's model id, skipping "" and <synthetic>. internal/sessions/adopt/recovery.go fills it in ReadTail's assistant case. ModelFamily keeps its current meaning.
  • cmd/relay/session_adopt.go: steps 3a to 3c sit behind one helper that adoptOutside calls where model == "". The helper's name and shape are the dev's choice. internal/ does not import cmd/relay, and the adopt package stays evidence-only: it is handed no resolver.

File ownership

Dev task:

  • internal/modelbroker/normalise.go
  • cmd/relay/model_endpoint.go
  • cmd/relay/session_routes.go: only the new field
  • cmd/relay/server_core.go: only the wiring line
  • cmd/relay/session_adopt.go
  • internal/sessions/adopt/adopt.go: only the Tail.Model field
  • internal/sessions/adopt/recovery.go: only ReadTail's assistant case
  • docs/outside-sessions.md: only "The resumed session" bullet, rewritten to the Rule
  • docs/model-endpoint.md: one sentence in "Choosing a project's models" or "Client model routing" naming the adopt door as a reader of the modelMap through the same cache, which never shows the target to a caller
  • docs/FEATURES.md: only the G1.50 row. The description gains one clause (a broker-served transcript model adopts with the Claude alias that relay's environment maps to its modelMap key). The proof cell adds e2e:TestAdoptBrokerModelAdoptsWithAlias and e2e:TestAdoptBrokerModelUnknownRefused.

Test task (test writer):

  • e2e/features/bug562_adopt_broker_model_test.go (new). It reuses the rig and helpers in e2e/features/g1_outside_adopt_test.go (adStart, fake, answer/transcript, adopt, adCreated, adStartLine, adHasFlag, otRefusal, adNoSignals) and does not edit that file. relay#561 owns it in this campaign.

Overlap: relay#561 also edits recovery.go (ReadTail), docs/outside-sessions.md (the "Idle check" bullet) and the G1.50 proof cell. Merge #561 first. This branch then merges epic/525-session-adoption and resolves conflicts by keeping both changes.

Paths

  • Simple: eve > Chief of Staff > Agents rail > Adopt. The person makes no choice, and relay resolves the model.
  • Power: the body's model (the claudeSessionId selector) still wins. The operator configures through the existing relayLLM router.anthropic.modelMap and the ANTHROPIC_DEFAULT_<F>_MODEL variables in relay's environment. There is no new config.

Acceptance mapping

  • AC-a: when an idle outside Claude's transcript names a modelMap key that ANTHROPIC_DEFAULT_HAIKU_MODEL names, and no model is sent, the adopt returns 201 and the resumed session starts with --model haiku. Owner: the dev task (rule 3; ModelMapKeys, Tail.Model). Test: TestAdoptBrokerModelAdoptsWithAlias/key, using the outsideId selector as eve's button does. It is red on the epic head (7ec4af5): 409 model_unknown.
  • AC-a2: the same holds when the transcript names that key's target. Test: TestAdoptBrokerModelAdoptsWithAlias/target, which is red on the epic head.
  • AC-b: a transcript model that is not a claude-* id and has no unique alias returns 409 model_unknown, and the process gets no signal. Owner: the dev task (rule 3c). Test: TestAdoptBrokerModelUnknownRefused, with the subtests not served (the id is not in the catalog), no alias (the key is served and no environment alias names it) and two aliases (HAIKU and SONNET both name the key). These are guards and are green on the epic head.

Test setup: harness.Options.FakeModelHost with rows {"id":"acme-lab/fake-echo","owned_by":"acme-lab"} and {"id":"Acme-Local-27B","owned_by":"anthropic-map","target":"acme-lab/fake-echo"}, plus Options.Env ANTHROPIC_DEFAULT_HAIKU_MODEL=Acme-Local-27B (two aliases also sets ANTHROPIC_DEFAULT_SONNET_MODEL). No model_endpoint listener is needed. The outside fake is the existing adSpawn fake, and answer(at, model, text) writes the model id.

Test surface

  • e2e feature tests: all the criteria above. There are no unit tests (repo rule).
  • Real-app check (user-visible): eve's outside-adopt journey on a default stack. It is red with relay at 7ec4af5 and green with relay at this PR's head.

Waits

  • Dev task: Waits: none. The resolution is a synchronous call inside the adopt request, and Cache.Resolve returns when its fetch completes.
  • Test task: Waits:
    • relay boot: harness.Start's ready.json;
    • first inventory probe: osStart's /ws/outside-sessions subscription;
    • model host registered: i.WaitModelHost, the model.host.register ok event. This wait is required before every adopt in both tests, or an unregistered host turns AC-b green for the wrong reason;
    • the fake outside process: its existing start line, TERM line and exit channel (expect, signalAt, gone);
    • the resumed session's argv: adStartLine, whose reply is the ready signal;
    • no signal sent: adNoSignals after the 409, the existing pattern.

Verification task

  • On this PR's head: repo-suite, which is (cd e2e && go test -race -timeout 30m -parallel 32 ./features/).
  • real-app: run stack-up r562 --relay <PR worktree> --eve <eve main> (a fresh stack), then eve's node devboxverify/main.js --checkout <eve wt> --stack <stack.json>. Expect outside-adopt PASS, then run stack-down r562. The task takes no devlock.
  • Post Verified: <head sha> · repo-suite: <result> · real-app: <result> · log: <path>. The epic's --stack runs belong to the close child.

Budget

  • Dev: 10 files, about 120 changed lines (code about 70, docs about 50).
  • Test: 1 new file, about 150 lines.

Open questions

  1. INSIDE. Recommendation: cover both a key and a target in the transcript (3a). The stack transcript was not inspected. relayLLM's translate path echoes the key, and its native path echoes the target.
  2. INSIDE. Recommendation: keep ModelFamily before broker resolution. A transcript with an older claude-* record and a newer broker record then adopts on the claude family. This is theoretical and has not been shown.
  3. INSIDE. Recommendation: leave relay's session-adopt journey sending "model":"haiku". A harness change is a gate item, and eve's outside-adopt is this bug's real-app proof.
  4. BEYOND. Recommendation: put it on the theoretical list and add no code. With a claude-shaped modelMap key, the family alias may make the adopted Claude send its current tier id instead of the key, so the request goes to Anthropic, not the broker. This behaviour predates this issue and has not been shown.

Activity

  1. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    Done by #565, merged into epic/525-session-adoption. Verified on the epic head b6f530d (see #532).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions