fix(session-pool): stop opencode's title-gen call from racing the pool - #84
Merged
Conversation
rfhold
marked this pull request as ready for review
July 30, 2026 18:01
opencode forks a title-generation call on the exact same sessionID as a session's real first turn, concurrently, with an empty system prompt. classifyTurn's side-call detection only fires once a prior pool record exists, so on turn 1 both calls could independently classify as "new" and both write to the pool — whichever agent-creation round-trip resolved last silently and permanently overwrote the other's entry, poisoning the session's fingerprint (matching the reported symptom: a session behaving as if it only ever had the title prompt). Two changes, both needed: - Wire up the plugin's chat.params hook to mark opencode's "title" agent call as providerOptions.cursor.ephemeral = true. The provider already supported this flag (added in df220e8) but nothing ever set it, so it was dead code. - Add withSessionLock (per-sessionID async lock) in session-pool.ts and wrap agentRun's classify-then-acquire span in it, so concurrent turns for the same session always serialize: the second call's classify always observes the first call's completed pool write. This closes the race structurally, not just for the title-agent case.
rfhold
force-pushed
the
fix/session-pool-title-race
branch
from
July 30, 2026 18:06
6889861 to
202074b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
opencode forks its title-generation call (
agent: "title") on the exact samesessionIDas a session's real first turn, concurrently, with an empty system prompt (system: []). Seepackages/opencode/src/session/prompt.ts'srunLoop:title({...}).pipe(Effect.ignore, Effect.forkIn(scope)).classifyTurn's side-call detection (transcript-fingerprint.ts) only fires once a prior pool record exists to comparesystemHashagainst. On a brand-new session's first turn there is no prior record, soclassifyTurnreturns{kind: "new"}unconditionally — for both the real turn and the concurrent title call.Both calls could then independently classify as
"new"and both write to the sharedpoolMap insession-pool.ts(keyed bysessionID). Since the pool write happens only after an async Cursor agent-creation round-trip, whichever call's round-trip resolved last silently and permanently overwrote the other's entry — with no locking anywhere in the pool. If the title call's tiny fingerprint won, the session's pool record would be poisoned for the rest of the session: every future turn would see a systemHash mismatch and get stuck classifying as"side-call"forever, never resuming the real pooled agent again. This matches the reported symptom: a session behaving as if it just had the title prompt.Fix (two parts)
Part A — wire up the
ephemeralflag.language-model.tsalready checkedproviderOptions?.["ephemeral"] === trueto always force"side-call"classification (added in df220e8), but nothing ever set it — dead code since introduction. The plugin'schat.paramshook now setsoutput.options.ephemeral = truewheninput.agent === "title"(opencode passes the agent's name here, confirmed viapackages/opencode/src/session/llm/request.ts), so the title call reliably self-identifies and never touches the pool, even without a prior record.Part B — close the race structurally, not just for the title-agent case. Added
withSessionLock(a per-sessionIDasync lock, chained via aMap<string, Promise<unknown>>) insession-pool.ts, and wrappedagentRun's classify-then-acquire span in it. Concurrent turns for the same session now always serialize: the second call'sclassifyTurnis guaranteed to observe the first call's already-completed pool write, so it correctly detects a systemHash mismatch instead of blindly classifying as"new". Calls for different sessions are unaffected and still run fully concurrently.Tests
test/plugin-tools.test.ts:chat.paramssetsephemeral: trueonly for the"title"agent.test/session-pool.test.ts: unit tests forwithSessionLock(serializes same-key calls, runs different-key calls concurrently, doesn't let an error block the next call, no-op forundefinedsessionID), plus an integration regression test wiringwithSessionLock+classifyTurn+fingerprint+acquireAgenttogether to reproduce the exact two-concurrent-first-turns scenario and prove the pool ends up correct.test/language-model-system.test.ts: updated itssession-pool.jsmock to include a pass-throughwithSessionLock(it mocks the whole module, so the new export needed stubbing).bun run typecheckandbun run test(364 tests) both pass.