Skip to content

fix(session-pool): stop opencode's title-gen call from racing the pool - #84

Merged
justin-carper merged 1 commit into
mainfrom
fix/session-pool-title-race
Jul 30, 2026
Merged

fix(session-pool): stop opencode's title-gen call from racing the pool#84
justin-carper merged 1 commit into
mainfrom
fix/session-pool-title-race

Conversation

@rfhold

@rfhold rfhold commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Root cause

opencode forks its title-generation call (agent: "title") on the exact same sessionID as a session's real first turn, concurrently, with an empty system prompt (system: []). See packages/opencode/src/session/prompt.ts's runLoop: title({...}).pipe(Effect.ignore, Effect.forkIn(scope)).

classifyTurn's side-call detection (transcript-fingerprint.ts) only fires once a prior pool record exists to compare systemHash against. On a brand-new session's first turn there is no prior record, so classifyTurn returns {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 shared pool Map in session-pool.ts (keyed by sessionID). 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 ephemeral flag. language-model.ts already checked providerOptions?.["ephemeral"] === true to always force "side-call" classification (added in df220e8), but nothing ever set it — dead code since introduction. The plugin's chat.params hook now sets output.options.ephemeral = true when input.agent === "title" (opencode passes the agent's name here, confirmed via packages/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-sessionID async lock, chained via a Map<string, Promise<unknown>>) in session-pool.ts, and wrapped agentRun's classify-then-acquire span in it. Concurrent turns for the same session now always serialize: the second call's classifyTurn is 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.params sets ephemeral: true only for the "title" agent.
  • test/session-pool.test.ts: unit tests for withSessionLock (serializes same-key calls, runs different-key calls concurrently, doesn't let an error block the next call, no-op for undefined sessionID), plus an integration regression test wiring withSessionLock + classifyTurn + fingerprint + acquireAgent together to reproduce the exact two-concurrent-first-turns scenario and prove the pool ends up correct.
  • test/language-model-system.test.ts: updated its session-pool.js mock to include a pass-through withSessionLock (it mocks the whole module, so the new export needed stubbing).

bun run typecheck and bun run test (364 tests) both pass.

@rfhold
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
rfhold force-pushed the fix/session-pool-title-race branch from 6889861 to 202074b Compare July 30, 2026 18:06
@justin-carper
justin-carper merged commit 1696147 into main Jul 30, 2026
6 checks passed
@justin-carper
justin-carper deleted the fix/session-pool-title-race branch July 30, 2026 18:10
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