@@ -41,6 +41,7 @@ import {
4141 acquireAgent ,
4242 dropSessionRecord ,
4343 getSessionRecord ,
44+ withSessionLock ,
4445} from "./session-pool.js" ;
4546import {
4647 classifyTurn ,
@@ -192,132 +193,153 @@ export class CursorLanguageModel implements LanguageModelV3 {
192193
193194 // Decide create-vs-resume and whether to pool, from the turn classification.
194195 const usePool = sessionEnabled && Boolean ( sessionID ) && ! explicitAgentId ;
195- let resumeAgentId : string | undefined = explicitAgentId ;
196- let poolKey : string | undefined ;
197- let record :
198- | { systemHash : string ; userHashes : string [ ] ; mcpHash ?: string }
199- | undefined ;
200- // Number of new trailing user messages for a multi-message interjection
201- // (>= 2). Stays 0 for every other turn kind. When set, and the agent is
202- // resumed, we replay just those new messages as sequential turns instead
203- // of a cold full-transcript replay.
204- let multiNewUserCount = 0 ;
205- if ( usePool ) {
206- const classification = ephemeral
207- ? {
208- kind : "side-call" as const ,
209- fingerprint : fingerprint ( options . prompt ) ,
210- }
211- : classifyTurn ( getSessionRecord ( sessionID ! ) , options . prompt ) ;
212- switch ( classification . kind ) {
213- case "continuation" :
214- case "continuation-multi" : {
215- const prev = getSessionRecord ( sessionID ! ) ;
216- // A resumed agent keeps its original MCP servers, so only resume
217- // when the live MCP set is unchanged; otherwise create fresh so the
218- // new server set takes effect (re-pooled under the same session).
219- if ( prev ?. mcpHash === mcpHash ) {
220- resumeAgentId = prev ?. agentId ;
221- }
222- poolKey = sessionID ;
223- record = { ...classification . fingerprint , mcpHash } ;
224- if ( classification . kind === "continuation-multi" ) {
225- multiNewUserCount = classification . newUserCount ?? 0 ;
196+
197+ // The whole classify -> acquire span below is wrapped in a per-session
198+ // lock (withSessionLock). opencode can run a concurrent side call (e.g.
199+ // its title-generation turn) against the SAME sessionID as a session's
200+ // real first turn; classifyTurn's side-call detection only works once a
201+ // prior pool record exists, so on turn 1 both calls can independently
202+ // classify as "new" and both write to the pool — whichever's agent
203+ // creation round-trip resolves last silently and permanently overwrites
204+ // the other's entry. Serializing per sessionID here means the second
205+ // call's classification always sees the first call's completed write.
206+ const {
207+ acquired,
208+ multiTurns,
209+ idempotencyKey,
210+ systemMode,
211+ baseAcquire,
212+ record,
213+ } = await withSessionLock ( usePool ? sessionID : undefined , async ( ) => {
214+ let resumeAgentId : string | undefined = explicitAgentId ;
215+ let poolKey : string | undefined ;
216+ let record :
217+ | { systemHash : string ; userHashes : string [ ] ; mcpHash ?: string }
218+ | undefined ;
219+ // Number of new trailing user messages for a multi-message interjection
220+ // (>= 2). Stays 0 for every other turn kind. When set, and the agent is
221+ // resumed, we replay just those new messages as sequential turns instead
222+ // of a cold full-transcript replay.
223+ let multiNewUserCount = 0 ;
224+ if ( usePool ) {
225+ const classification = ephemeral
226+ ? {
227+ kind : "side-call" as const ,
228+ fingerprint : fingerprint ( options . prompt ) ,
229+ }
230+ : classifyTurn ( getSessionRecord ( sessionID ! ) , options . prompt ) ;
231+ switch ( classification . kind ) {
232+ case "continuation" :
233+ case "continuation-multi" : {
234+ const prev = getSessionRecord ( sessionID ! ) ;
235+ // A resumed agent keeps its original MCP servers, so only resume
236+ // when the live MCP set is unchanged; otherwise create fresh so the
237+ // new server set takes effect (re-pooled under the same session).
238+ if ( prev ?. mcpHash === mcpHash ) {
239+ resumeAgentId = prev ?. agentId ;
240+ }
241+ poolKey = sessionID ;
242+ record = { ...classification . fingerprint , mcpHash } ;
243+ if ( classification . kind === "continuation-multi" ) {
244+ multiNewUserCount = classification . newUserCount ?? 0 ;
245+ }
246+ break ;
226247 }
227- break ;
248+ case "new" :
249+ case "divergence" :
250+ poolKey = sessionID ;
251+ record = { ...classification . fingerprint , mcpHash } ;
252+ break ;
253+ case "side-call" :
254+ // fresh ephemeral agent; pool left untouched.
255+ break ;
256+ }
257+ if ( process . env [ "OPENCODE_CURSOR_DEBUG" ] === "1" ) {
258+ const label =
259+ classification . kind === "continuation"
260+ ? "resume"
261+ : classification . kind === "continuation-multi"
262+ ? `resume-multi:${ multiNewUserCount } `
263+ : `fresh:${ classification . kind } ` ;
264+ pluginLog ( "debug" , "turn classification" , { label, session : sessionID } ) ;
228265 }
229- case "new" :
230- case "divergence" :
231- poolKey = sessionID ;
232- record = { ...classification . fingerprint , mcpHash } ;
233- break ;
234- case "side-call" :
235- // fresh ephemeral agent; pool left untouched.
236- break ;
237- }
238- if ( process . env [ "OPENCODE_CURSOR_DEBUG" ] === "1" ) {
239- const label =
240- classification . kind === "continuation"
241- ? "resume"
242- : classification . kind === "continuation-multi"
243- ? `resume-multi:${ multiNewUserCount } `
244- : `fresh:${ classification . kind } ` ;
245- pluginLog ( "debug" , "turn classification" , { label, session : sessionID } ) ;
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 ;
@@ -461,7 +483,10 @@ export class CursorLanguageModel implements LanguageModelV3 {
461483 // original resume failure as the cause for diagnosability.
462484 let retry : Awaited < ReturnType < typeof acquireAgent > > ;
463485 try {
464- retry = await acquireAgent ( { ...baseAcquire } ) ;
486+ retry = await withSessionLock (
487+ usePool ? sessionID : undefined ,
488+ ( ) => acquireAgent ( { ...baseAcquire } ) ,
489+ ) ;
465490 } catch ( retryErr ) {
466491 if ( retryErr instanceof Error && retryErr . cause === undefined ) {
467492 retryErr . cause = err ;
0 commit comments