@@ -40,6 +40,7 @@ import {
4040 acquireAgent ,
4141 dropSessionRecord ,
4242 getSessionRecord ,
43+ withSessionLock ,
4344} from "./session-pool.js" ;
4445import {
4546 classifyTurn ,
@@ -190,134 +191,155 @@ export class CursorLanguageModel implements LanguageModelV3 {
190191
191192 // Decide create-vs-resume and whether to pool, from the turn classification.
192193 const usePool = sessionEnabled && Boolean ( sessionID ) && ! explicitAgentId ;
193- let resumeAgentId : string | undefined = explicitAgentId ;
194- let poolKey : string | undefined ;
195- let record :
196- | { systemHash : string ; userHashes : string [ ] ; mcpHash ?: string }
197- | undefined ;
198- // Number of new trailing user messages for a multi-message interjection
199- // (>= 2). Stays 0 for every other turn kind. When set, and the agent is
200- // resumed, we replay just those new messages as sequential turns instead
201- // of a cold full-transcript replay.
202- let multiNewUserCount = 0 ;
203- if ( usePool ) {
204- const classification = ephemeral
205- ? {
206- kind : "side-call" as const ,
207- fingerprint : fingerprint ( options . prompt ) ,
208- }
209- : classifyTurn ( getSessionRecord ( sessionID ! ) , options . prompt ) ;
210- switch ( classification . kind ) {
211- case "continuation" :
212- case "continuation-multi" : {
213- const prev = getSessionRecord ( sessionID ! ) ;
214- // A resumed agent keeps its original MCP servers, so only resume
215- // when the live MCP set is unchanged; otherwise create fresh so the
216- // new server set takes effect (re-pooled under the same session).
217- if ( prev ?. mcpHash === mcpHash ) {
218- resumeAgentId = prev ?. agentId ;
219- }
220- poolKey = sessionID ;
221- record = { ...classification . fingerprint , mcpHash } ;
222- if ( classification . kind === "continuation-multi" ) {
223- multiNewUserCount = classification . newUserCount ?? 0 ;
194+
195+ // The whole classify -> acquire span below is wrapped in a per-session
196+ // lock (withSessionLock). opencode can run a concurrent side call (e.g.
197+ // its title-generation turn) against the SAME sessionID as a session's
198+ // real first turn; classifyTurn's side-call detection only works once a
199+ // prior pool record exists, so on turn 1 both calls can independently
200+ // classify as "new" and both write to the pool — whichever's agent
201+ // creation round-trip resolves last silently and permanently overwrites
202+ // the other's entry. Serializing per sessionID here means the second
203+ // call's classification always sees the first call's completed write.
204+ const {
205+ acquired,
206+ multiTurns,
207+ idempotencyKey,
208+ systemMode,
209+ baseAcquire,
210+ record,
211+ } = await withSessionLock ( usePool ? sessionID : undefined , async ( ) => {
212+ let resumeAgentId : string | undefined = explicitAgentId ;
213+ let poolKey : string | undefined ;
214+ let record :
215+ | { systemHash : string ; userHashes : string [ ] ; mcpHash ?: string }
216+ | undefined ;
217+ // Number of new trailing user messages for a multi-message interjection
218+ // (>= 2). Stays 0 for every other turn kind. When set, and the agent is
219+ // resumed, we replay just those new messages as sequential turns instead
220+ // of a cold full-transcript replay.
221+ let multiNewUserCount = 0 ;
222+ if ( usePool ) {
223+ const classification = ephemeral
224+ ? {
225+ kind : "side-call" as const ,
226+ fingerprint : fingerprint ( options . prompt ) ,
227+ }
228+ : classifyTurn ( getSessionRecord ( sessionID ! ) , options . prompt ) ;
229+ switch ( classification . kind ) {
230+ case "continuation" :
231+ case "continuation-multi" : {
232+ const prev = getSessionRecord ( sessionID ! ) ;
233+ // A resumed agent keeps its original MCP servers, so only resume
234+ // when the live MCP set is unchanged; otherwise create fresh so the
235+ // new server set takes effect (re-pooled under the same session).
236+ if ( prev ?. mcpHash === mcpHash ) {
237+ resumeAgentId = prev ?. agentId ;
238+ }
239+ poolKey = sessionID ;
240+ record = { ...classification . fingerprint , mcpHash } ;
241+ if ( classification . kind === "continuation-multi" ) {
242+ multiNewUserCount = classification . newUserCount ?? 0 ;
243+ }
244+ break ;
224245 }
225- break ;
246+ case "new" :
247+ case "divergence" :
248+ poolKey = sessionID ;
249+ record = { ...classification . fingerprint , mcpHash } ;
250+ break ;
251+ case "side-call" :
252+ // fresh ephemeral agent; pool left untouched.
253+ break ;
254+ }
255+ if ( process . env [ "OPENCODE_CURSOR_DEBUG" ] === "1" ) {
256+ const label =
257+ classification . kind === "continuation"
258+ ? "resume"
259+ : classification . kind === "continuation-multi"
260+ ? `resume-multi:${ multiNewUserCount } `
261+ : `fresh:${ classification . kind } ` ;
262+ console . error (
263+ `[cursor:debug] turn classification=${ label } session=${ sessionID } ` ,
264+ ) ;
226265 }
227- case "new" :
228- case "divergence" :
229- poolKey = sessionID ;
230- record = { ...classification . fingerprint , mcpHash } ;
231- break ;
232- case "side-call" :
233- // fresh ephemeral agent; pool left untouched.
234- break ;
235- }
236- if ( process . env [ "OPENCODE_CURSOR_DEBUG" ] === "1" ) {
237- const label =
238- classification . kind === "continuation"
239- ? "resume"
240- : classification . kind === "continuation-multi"
241- ? `resume-multi:${ multiNewUserCount } `
242- : `fresh:${ classification . kind } ` ;
243- console . error (
244- `[cursor:debug] turn classification=${ label } session=${ sessionID } ` ,
245- ) ;
246266 }
247- }
248267
249- // A multi-message interjection: two-or-more user messages were queued while
250- // the agent was busy, forming a contiguous user-turn tail (the classifier
251- // guarantees this shape for "continuation-multi"). On a resumed agent we
252- // replay just those new messages as sequential turns.
253- //
254- // Defensive invariant check: if the recovered tail doesn't match the
255- // classifier's count (unreachable today, but one classifier refactor away
256- // from real), we must NOT degrade to sending only the latest message —
257- // the session record keeps the full N-message fingerprint, so messages
258- // 1..N-1 would be silently lost. Instead force the cold path: clear the
259- // resume id so a FRESH agent gets the FULL transcript, which matches the
260- // record being written and loses nothing.
261- //
262- // Computed before acquireAgent so a mismatched tail can clear
263- // resumeAgentId in time to affect which agent we acquire.
264- let multiTurns : SDKUserMessage [ ] | undefined ;
265- if ( multiNewUserCount >= 2 ) {
266- const turns = trailingUserMessages ( options . prompt , multiNewUserCount ) ;
267- if ( turns . length === multiNewUserCount ) {
268- multiTurns = turns ;
269- } else {
270- resumeAgentId = undefined ;
268+ // A multi-message interjection: two-or-more user messages were queued
269+ // while the agent was busy, forming a contiguous user-turn tail (the
270+ // classifier guarantees this shape for "continuation-multi"). On a
271+ // resumed agent we replay just those new messages as sequential turns.
272+ //
273+ // Defensive invariant check: if the recovered tail doesn't match the
274+ // classifier's count (unreachable today, but one classifier refactor
275+ // away from real), we must NOT degrade to sending only the latest
276+ // message — the session record keeps the full N-message fingerprint,
277+ // so messages 1..N-1 would be silently lost. Instead force the cold
278+ // path: clear the resume id so a FRESH agent gets the FULL transcript,
279+ // which matches the record being written and loses nothing.
280+ //
281+ // Computed before acquireAgent so a mismatched tail can clear
282+ // resumeAgentId in time to affect which agent we acquire.
283+ let multiTurns : SDKUserMessage [ ] | undefined ;
284+ if ( multiNewUserCount >= 2 ) {
285+ const turns = trailingUserMessages ( options . prompt , multiNewUserCount ) ;
286+ if ( turns . length === multiNewUserCount ) {
287+ multiTurns = turns ;
288+ } else {
289+ resumeAgentId = undefined ;
290+ }
271291 }
272- }
273292
274- const latestUser = latestUserMessage ( options . prompt ) ;
275- const idempotencyKey = sendIdempotencyKey (
276- sessionID ,
277- record ,
278- latestUser ?. text ?? JSON . stringify ( options . prompt ) ,
279- ) ;
293+ const latestUser = latestUserMessage ( options . prompt ) ;
294+ const idempotencyKey = sendIdempotencyKey (
295+ sessionID ,
296+ record ,
297+ latestUser ?. text ?? JSON . stringify ( options . prompt ) ,
298+ ) ;
280299
281- // In "rules" mode (default), deliver opencode's system prompt through
282- // Cursor's authoritative rules channel instead of the user transcript.
283- // Degrades to inline "message" delivery when the user explicitly opted
284- // out of the "project" settings layer, when the rule file is user-owned,
285- // or when the write fails (read-only checkout etc.).
286- const delivery = resolveSystemDelivery ( {
287- mode : this . config . systemPrompt ?? "rules" ,
288- settingSources : this . config . settingSources ,
289- cwd : this . config . cwd ,
290- systemText : extractSystemText ( options . prompt ) ,
291- warn : ( message ) => this . warnOnce ( message ) ,
292- } ) ;
293- const systemMode : SystemPromptMode = delivery . mode ;
294- const settingSources = delivery . settingSources ;
300+ // In "rules" mode (default), deliver opencode's system prompt through
301+ // Cursor's authoritative rules channel instead of the user transcript.
302+ // Degrades to inline "message" delivery when the user explicitly opted
303+ // out of the "project" settings layer, when the rule file is user-owned,
304+ // or when the write fails (read-only checkout etc.).
305+ const delivery = resolveSystemDelivery ( {
306+ mode : this . config . systemPrompt ?? "rules" ,
307+ settingSources : this . config . settingSources ,
308+ cwd : this . config . cwd ,
309+ systemText : extractSystemText ( options . prompt ) ,
310+ warn : ( message ) => this . warnOnce ( message ) ,
311+ } ) ;
312+ const systemMode : SystemPromptMode = delivery . mode ;
313+ const settingSources = delivery . settingSources ;
295314
296- // Shared acquire params. The retry path reuses this verbatim (minus
297- // resumeAgentId) so a fresh agent can never drift from the first attempt's
298- // config (sandbox, settingSources, MCP, etc.).
299- const baseAcquire = {
300- apiKey : this . requireApiKey ( ) ,
301- modelSelection,
302- mode,
303- cwd : this . config . cwd ,
304- ...( settingSources ? { settingSources } : { } ) ,
305- ...( this . config . sandbox !== undefined
306- ? { sandbox : this . config . sandbox }
307- : { } ) ,
308- ...( this . config . autoReview !== undefined
309- ? { autoReview : this . config . autoReview }
310- : { } ) ,
311- ...( mcpServers ? { mcpServers } : { } ) ,
312- ...( this . config . agents ? { agents : this . config . agents } : { } ) ,
313- ...( poolKey ? { name : `opencode/${ sessionID ! . slice ( - 8 ) } ` } : { } ) ,
314- ...( poolKey ? { poolKey } : { } ) ,
315- ...( record ? { record } : { } ) ,
316- } ;
315+ // Shared acquire params. The retry path reuses this verbatim (minus
316+ // resumeAgentId) so a fresh agent can never drift from the first
317+ // attempt's config (sandbox, settingSources, MCP, etc.).
318+ const baseAcquire = {
319+ apiKey : this . requireApiKey ( ) ,
320+ modelSelection,
321+ mode,
322+ cwd : this . config . cwd ,
323+ ...( settingSources ? { settingSources } : { } ) ,
324+ ...( this . config . sandbox !== undefined
325+ ? { sandbox : this . config . sandbox }
326+ : { } ) ,
327+ ...( this . config . autoReview !== undefined
328+ ? { autoReview : this . config . autoReview }
329+ : { } ) ,
330+ ...( mcpServers ? { mcpServers } : { } ) ,
331+ ...( this . config . agents ? { agents : this . config . agents } : { } ) ,
332+ ...( poolKey ? { name : `opencode/${ sessionID ! . slice ( - 8 ) } ` } : { } ) ,
333+ ...( poolKey ? { poolKey } : { } ) ,
334+ ...( record ? { record } : { } ) ,
335+ } ;
317336
318- const acquired = await acquireAgent ( {
319- ...baseAcquire ,
320- ...( resumeAgentId ? { resumeAgentId } : { } ) ,
337+ const acquired = await acquireAgent ( {
338+ ...baseAcquire ,
339+ ...( resumeAgentId ? { resumeAgentId } : { } ) ,
340+ } ) ;
341+
342+ return { acquired, multiTurns, idempotencyKey, systemMode, baseAcquire, record } ;
321343 } ) ;
322344
323345 let yielded = false ;
@@ -460,7 +482,10 @@ export class CursorLanguageModel implements LanguageModelV3 {
460482 // original resume failure as the cause for diagnosability.
461483 let retry : Awaited < ReturnType < typeof acquireAgent > > ;
462484 try {
463- retry = await acquireAgent ( { ...baseAcquire } ) ;
485+ retry = await withSessionLock (
486+ usePool ? sessionID : undefined ,
487+ ( ) => acquireAgent ( { ...baseAcquire } ) ,
488+ ) ;
464489 } catch ( retryErr ) {
465490 if ( retryErr instanceof Error && retryErr . cause === undefined ) {
466491 retryErr . cause = err ;
0 commit comments