Skip to content

feat(opencode): per-session sidebar routing#47

Closed
iceteaSA wants to merge 3 commits into
cortexkit:mainfrom
iceteaSA:fix/per-session-sidebar-routing
Closed

feat(opencode): per-session sidebar routing#47
iceteaSA wants to merge 3 commits into
cortexkit:mainfrom
iceteaSA:fix/per-session-sidebar-routing

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

⚠️ Stacked on #46

This branch is stacked on #46 (dynamic quota windows) and forks from it, not from main. Until #46 merges, the diff shown here includes #46's commits. Review the single commit per-session sidebar routing (or diff against feat/dynamic-quota-windows); once #46 merges I'll rebase onto main so this reduces to the sidebar-routing change alone.

Problem

The sidebar snapshot is a single machine-global file (sidebar-state.json, shared by every OpenCode process/session on the machine). It mixes two kinds of state:

  • Machine-global (correct to share): per-account quota — used %, window length, reset, reset credits. Same OpenAI account across sessions, so identical everywhere.
  • Per-session (wrong to share): activeId (which account this session is routed to) and route (this session's routing mode).

Storing a per-session fact as one shared top-level value makes it last-writer-wins: a session on main (or a boot-seed / background-refresh write that defaults activeId to main) overwrites the file, so another session that is actually serving from a fallback displays main as active. Operator-reported; the live file showed the contradiction directly (route: fallback-first with activeId: main).

pid keying does not fix this — one process hosts many sessions (subagents, serve/attach), so a per-process file would rebuild the same collision one level down. activeId is session-grain.

Fix

Add a session-keyed activeRouting: { [sessionId]: { activeId, route, updatedAt } } map inside the shared file.

  • Quota stays machine-global in the same file, so any session's request still freshens the quota display for all (no regression to idle-session freshness).
  • Only request-scoped writes upsert a session entry, keyed by the request session header (x-session-affinity → x-opencode-session → x-session-id → session-id). Background and boot writes touch machine-global fields only and never create a session entry — this structurally removes the boot/refresh clobber.
  • Cross-process writes read the latest file inside the serialized write chain, merge only the writer's own session key (never rewrite/drop foreign keys), prune against the write-time clock, and atomically temp-write + rename. The residual TOCTOU (two renames interleaving) self-heals on the next write.
  • The TUI renders its own session's entry (the sidebar slot receives session_id). When its entry is absent or names a removed/disabled account, it derives the account from the routing mode (fallback-first + an enabled fallback → that fallback; else main) — never a bare main default.
  • Pruning by age (1h), validity (account still enabled), and a 128-entry cap runs on every request write (a crashed session never fires session.deleted, so the write path is the durable GC) and on session.deleted.
  • Legacy top-level activeId/route are retained one release (written by request paths only, preserved by machine writes) so an older TUI half reading the shared file doesn't blank; marked deprecated.

Testing

  • bun run build · bun run typecheck · bunx biome check src — clean.
  • bun test638 pass / 0 fail (25 new tests: schema normalization, roster-based validity + prune, read-latest merge preserving foreign keys, header-precedence table, two-session isolation, headerless legacy compatibility, TUI keyed/absent/invalid-account derive, deletion cleanup, and a write-time-clock regression test proving a concurrent newer foreign entry survives a delayed upsert).
  • Reviewed by a cross-family council over two rounds. The blocking finding was a cross-process race: pruning used a clock captured outside the serialized write queue, so under write-chain backlog a concurrent process's newer entry read as future-dated and was silently dropped. Fixed by capturing the prune clock inside the queued operation; the regression test above is red-first against the pre-fix code.

Operator check after merge

Two sessions on different accounts: each sidebar shows its own active account + route (a fallback-routing session shows the fallback, not main); a brand-new session with no entry yet shows the mode-derived account, not main.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Make sidebar routing per-session so each terminal/session sees its own active account and mode, fixing last-writer-wins clobbering in the shared sidebar state. This keeps quota machine-global while scoping activeId/route to the calling session.

  • New Features

    • Store per-session routing in activeRouting: { [sessionId]: { activeId, route, updatedAt } } inside the shared file.
    • Only request-scoped writes upsert the caller’s session entry; boot/refresh writes touch machine-global fields only.
    • Cross-process safe writes: read-latest inside the serialized queue, merge only the writer’s key, atomic temp-write + rename.
    • Prune by age, validity, and a small cap on every request write and on session.deleted.
    • TUI renders the current session’s entry; when missing/invalid, it derives the account from the routing mode (e.g., fallback-first → enabled fallback, else main).
    • Keep legacy top-level activeId/route for one release to support older TUIs.
  • Migration

    • No action required, but send a stable session id so routing is tracked per session. Header precedence: x-session-affinityx-opencode-sessionx-session-idsession-id.
    • Plan to remove the legacy top-level activeId/route in the next release.

Written for commit 4bb65c4. Summary will update on new commits.

Review in cubic

…-window policy, per-account reset credits)

OpenAI removed the 5h usage window; the live wire now sends a single 7-day
window in the primary slot with the window length on every source. Carry the
window length onto the quota window type and derive the display label and
pacing ruler from it instead of the slot name, render only the windows that
are actually present, evaluate killswitch/quota policy across present windows
only (an absent window is not-applicable, not unknown), and surface the
wham-only rate_limit_reset_credits.available_count per account in the sidebar
and the /openai-quota modal. Slot names primary/secondary remain the stable
keys for storage, marks, thresholds, and mid-stream reset attribution.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 22 files

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread packages/opencode/src/sidebar-state.ts Outdated
Comment thread packages/opencode/src/tui.tsx
Comment thread packages/opencode/src/quota-normalize.ts Outdated
Comment thread packages/opencode/src/sidebar-state.ts Outdated
Comment thread packages/opencode/src/tests/integration.test.ts
iceteaSA added 2 commits July 17, 2026 00:38
…-window policy, per-account reset credits)

OpenAI removed the 5h usage window; the live wire now sends a single 7-day
window in the primary slot with the window length on every source. Carry the
window length onto the quota window type and derive the display label and
pacing ruler from it instead of the slot name, render only the windows that
are actually present, evaluate killswitch/quota policy across present windows
only (an absent window is not-applicable, not unknown), and surface the
wham-only rate_limit_reset_credits.available_count per account in the sidebar
and the /openai-quota modal. Slot names primary/secondary remain the stable
keys for storage, marks, thresholds, and mid-stream reset attribution. A
retired window is detected by its explicit zero-length marker, not by the
absence of optional metadata.
The sidebar snapshot is a single machine-global file, but which account a
session is routed to (activeId) and its routing mode are per-session facts.
Storing them as one shared top-level value made concurrent sessions clobber
each other last-writer-wins, so a session serving from a fallback could show
main as active. Add a session-keyed activeRouting map inside the shared file:
machine-global quota stays shared and any-writer-fresh, while each session
owns its own routing entry. Only request-scoped writes upsert an entry (keyed
by the request session header); background and boot writes touch machine-global
fields only. Every write path reads-latest inside a serialized chain, rechecks
the file immediately before commit and re-merges if a concurrent process wrote,
merges only the writer's own key against the write-time clock, advances a
monotonic version, and atomically renames. The TUI and the quota modal render
their own session's entry and derive the account from the routing mode when it
is absent or names a removed account. Entries are pruned by age, validity, and
a cap on every write and on session deletion. Legacy top-level activeId/route
are retained one release for older TUI halves.
@iceteaSA
iceteaSA force-pushed the fix/per-session-sidebar-routing branch from 4bb65c4 to 3d528d1 Compare July 16, 2026 23:05
@ualtinok

Copy link
Copy Markdown
Contributor

Superseded by the integrated implementation prepared for main. The final version adds fenced cross-process sidebar writes, Request-header support with fetch precedence, internal-header stripping, session-aware account dialogs, and failure propagation without breaking the write queue. Thank you for the implementation.

@ualtinok ualtinok closed this Jul 17, 2026
@iceteaSA
iceteaSA deleted the fix/per-session-sidebar-routing branch July 17, 2026 17:03
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