Skip to content

Investigation: suspected cross-session routing leakage between concurrent OpenCode sessions — wire evidence shows clean per-session isolation, client-side mix-up more likely #718

Description

@matdev83

Summary

Reported suspicion: cross-session routing/model-state contamination in the proxy between two concurrent OpenCode sessions. After a model change in one window, the other window appeared to switch models without user action.

Investigation result (based on log + CBOR wire-capture analysis of the incident run): no proxy-level cross-session leakage found. Wire evidence shows two cleanly isolated sessions. The session that actually changed models at the wire level is the OpenCode session the user did not believe they touched; the most plausible explanation is a client-side session mix-up (which window got the model change). The analysis could not visibly confirm which physical window was which, so an authoritative confirmation still requires a controlled reproduction (see "Weak spots" and "How to settle this").


1. User observations (from incident report)

  • Two separate OpenCode agent sessions running in the same repo, each working on different tasks.

  • Both initially configured with a hybrid route (thinker = Muse Spark xhigh, executor = DeepSeek Flash):

    • Session A: [thinker]commandcode-openai:meta/muse-spark-1.2-contributor?reasoning_effort=xhigh^[weight=10]commandcode-openai:deepseek/deepseek-v4-flash?reasoning_effort=max
    • Session B: [thinker]commandcode-openai:meta/muse-spark-1.2-contributor?reasoning_effort=xhigh^[weight=10]opencode-go:deepseek-v4-flash?reasoning_effort=max
  • User interrupted the session they call Add backend abstraction and registry #2 and switched it to a plain strong model:

    commandcode-openai:meta/muse-spark-1.2-contributor?reasoning_effort=xhigh

  • Immediately after resuming, the window the user calls Fix missing datetime import in proxy #1 (which they did NOT change) appeared to switch to the exact same plain Muse route: its characteristic heavy DeepSeek "spammy" reasoning output completely disappeared.

  • Suspicion: the proxy confused the two sessions (a B2BUA should keep them separate), which would be critical for the intended public-proxy use case.

Evidence analyzed

  • Log: var/logs/proxy-20260817_224450-p2528.log (run start 22:44:50 → ~23:38)
  • Wire capture: var/wire_captures_cbor/proxy-20260817_224450-p2528.cbor (full duplex: client→proxy and proxy→backend, byte-precise)
  • OpenCode local storage (message-level model records, session titles)

Session identity (ground truth)

OpenCode session (from its own DB) Proxy A-leg (B2BUA session) Behavior in wire data
ses_feefc45e : "Code review workflow for git changes (fork #1)" llm-b2bua-e42ff8d3-d910 Hybrid 22:47:46 → 23:13:30, then plain Muse from 23:13:31 (94 requests)
ses_fef3cd61 : "Code review of staged files in current worktree" llm-b2bua-9b424477-df73 Hybrid the whole time (117 requests; internal change opencode-gocommandcode-openai at 23:10:48)
  • The mapping (auth scope, x-session-id) → A-leg was stable for the entire run: every ses_feefc45e… request hit proxy A-leg e42ff8d3…, every ses_fef3cd61… hit 9b424477… — zero crossing, verified at request level in the capture.

How the check was done (6 independent layers)

  1. Client request bodies (capture): every client→proxy request carries the model string exactly as configured per session. Only A-leg e42ff8d3 (fork Fix missing datetime import in proxy #1) switched to plain Muse — at 23:13:31. The other session kept the hybrid route in all 117 requests. The proxy never rewrites the requested model route.
  2. Session mapping: B2BUA (scope + session-id) → A-leg mapping stable; no interleave of OpenCode ses* ids across A-legs.
  3. Interleaved-thinking cycle state: cycle state (session.interleaved_thinking_weighted_cycle_state) is per-session. Log shows each session cycling its own 1/6 → 6/6, then 9b424477 correctly reset its own cycle 1/11 → 11/11 at 23:10:48 (its own route change). Each session's cycle advanced and reset exactly when its own route changed — never when the other session's route changed.
  4. Backend routing (proxy→backend): for every request the proxy sent to the backend exactly the cycle-selected leaf: DeepSeek on executor turns, Muse on 1-in-N thinker turns. E.g. P→B 23:16:30 session 9b424477 model=meta/muse-spark… (thinker turn) → P→B 23:16:36 same session model=deepseek-v4-flash… (executor turn). Never the reverse.
  5. Streamed responses (chunk-level, capture): responses are forwarded 1:1 with matching backend chunk ids (gen_01M8…) and model labels. DeepSeek executor turns delivered true thousands of reasoning characters to the client (e.g., 30,922 in one turn); Muse turns delivered essentially no streamed reasoning (heartbeats-only gaps of 50+ s before a short answer).
  6. Code audit of state keying: memo store, cycle state, suppression state, redaction caches, weighted-first flags — all keyed per-session (session.update_state). backends.static_route is None and never applied to explicit selectors; model-replacement service not active. No global mutable "current route" exists anywhere in the request path.

What actually happened (most plausible explanation)

  • The session that switched to plain Muse is the session recorded under it: fork #1 (proxy A-leg e42ff8d3, OpenCode title "Code review workflow…", ses_feefc45e) — the same session identity that had been running the hybrid route until 23:13:30.
  • The other session (ses_fef3cd61, "staged files", proxy A-leg 9b424477) never sent a plain Muse request and its client streams continued carrying DeepSeek reasoning (e.g. 30,922 reasoning chars in a single turn at ~23:16).
  • Therefore the observation "window with hybrid config lost its DeepSeek reasoning" cannot be explained by the proxy: that window's identity shows, at wire level, that it kept requesting hybrid and kept receiving DeepSeek. The user likely applied the model change to the window they believe is Add backend abstraction and registry #2 but which wire data shows as the window that switched — i.e. a client-side session mapping/identity mix-up (which window is called Fix missing datetime import in proxy #1 vs Add backend abstraction and registry #2), or OpenCode-side model resolution effects.

Important: I'm NOT claiming the user mis-clicked in a hostile sense — this is about evidence: the data we have cannot make the proxy responsible, the wire data is explicit about the switch origin. The client-side hypothesis (window identity confusion) is the simplest fit, but final confirmation requires a reproducible session-mapping test (below).

Weak spots / where the analysis is less than conclusive

The analysis should be treated as strong but not yet authoritative:

  1. Window identity is inferred, not observed. I mapped OpenCode ses_… ids → session titles via OpenCode's local DB, and proxy ids via B2BUA logs. I could NOT observe which physical windows the user calls "Fix missing datetime import in proxy #1"/"Add backend abstraction and registry #2", nor screenshot the model pickers at the moment of switching. If the user's manual Fix missing datetime import in proxy #1/Add backend abstraction and registry #2 attribution differs from the DB titles (window title vs session), the "confusion" is fully explained client-side. This is the Fix missing datetime import in proxy #1 remaining uncertainty.
  2. Log level was not DEBUG for the whole run; several correlation steps rely on inference from log lines (HEARTBEAT vs reasoning headers etc.) instead of explicit per-turn "selected model" log records. A DEBUG/TRACE run would remove inference.
  3. No controlled reproduction. Two live sessions with un-instrumented client; no fingerprint marker (e.g., distinct user/system flags) to bind a UI-rendered chunk to a window. I could not verify what OpenCode's UI actually displayed to the user at the moment of the switch.
  4. Chunk attribution caveat: before this repository change, PROXY_TO_CLIENT capture entries carried no session/request id (only ci/sc), so client stream attribution relied on order-based correlation (backend streams and client streams interleaved). This gave clean 1:1 results, but is a weakest link: a subtle interleaving/buffering bug could in principle have produced a client stream with mismatched content — I saw none, but order-based attribution is not as airtight as id-based.
    • A fix for this is already implemented in the working tree (context threading → request_id/session_id on outbound capture entries, ready to PR) and will make future attribution id-based and conclusive.
  5. One anomaly worth a separate ticket: a Muse-bound backend stream was produced around 23:13:38 but held ~54 s and released to the client at 23:14:32 — right before that session's next request. In the chunk-level analysis the delivered content was byte-identical to the backend's (no cross-stream mixing), so it looks like a latency/queueing artifact in the streaming path, but the "hold" behavior deserves its own investigation.

How to authoritatively confirm or deny

  • Run a controlled repro: two OpenCode sessions with distinct hybrid routes; switch only one to plain Muse mid-run while capturing DEBUG (ideally TRACE) logs + CBOR the whole time; keep the other session idle, then ask a simple reasoning-heavy question.
    • Expected if proxy-clean: the untouched session's requests keep the hybrid string and the backend keeps emitting DeepSeek-style reasoning; the switched session emits plain Muse responses with no reasoning; attribution via new sid/rid metadata on every PROXY_TO_CLIENT entry will be unambiguous.
  • Fingerprint windows client-side: set distinct user/system markers before the switch so that streams in the UI can be matched to client config, and screenshot the model picker before/after.
  • Add one debug log line after route resolution: session_id=… effective_model=… route_source=client|override — will make any future "routing confusion" decision trivial from a single log line.
  • After the capture-metadata PR, re-run the chunk-attribution tooling (dev/ scripts) on a new incident with an id-based correlation path and confirm equivalence.

If any follow-up reveals a proxy-side anomaly, please re-open with that evidence.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions