Skip to content

Commit a609fa6

Browse files
committed
fix: resolve SessionLockedError in compact mode with async flow
Compact mode was failing with SessionLockedError because session.summarize() was being called synchronously while the session was still locked during active agent processing. Implemented event-driven async compaction flow: - tool.execute.after: Abort agent after tool completes (100ms delay) - session.idle #1: Trigger compaction after first abort - session.compacted: Immediately abort after compaction done (100ms delay) - session.idle #2: Send handoff message to new agent Added comprehensive logging at every state transition to .opencode/logs/session-plugin.log for debugging and verification of the flow. The two strategic aborts ensure full control over agent state and prevent any race conditions between compaction completion and message delivery.
1 parent 814c4eb commit a609fa6

1 file changed

Lines changed: 196 additions & 67 deletions

File tree

index.ts

Lines changed: 196 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -183,33 +183,205 @@ export const SessionPlugin: Plugin = async (ctx) => {
183183
})
184184
.join("\n")
185185

186-
// Store pending messages for agent relay communication
187-
// Map<sessionID, { agent?, text }>
186+
// State machine for compaction flow
187+
type CompactionState = {
188+
phase: 'waiting_for_first_abort' | 'compacting' | 'waiting_for_compaction_complete' | 'compaction_done' | 'ready_to_send'
189+
providerID: string
190+
modelID: string
191+
agent?: string
192+
text: string
193+
}
194+
const compactionState = new Map<string, CompactionState>()
195+
196+
// Store pending messages for agent relay communication (message mode only)
188197
const pendingMessages = new Map<string, { agent?: string, text: string }>()
189198

199+
// Store tool call IDs for compact mode
200+
const compactCalls = new Map<string, string>() // callID -> sessionID
201+
202+
// Store metadata for compact mode
203+
const compactMetadata = new Map<string, { providerID: string, modelID: string, agent?: string, text: string }>()
204+
190205
return {
191206
// Hook: Before tool execution - save args for message and compact modes
192207
"tool.execute.before": async (input, output) => {
193208
if (input.tool === "session") {
194209
const args = output.args as { mode: string, text: string, agent?: string }
195210

196-
if (args.mode === "message" || args.mode === "compact") {
211+
if (args.mode === "message") {
197212
// Store message for later - will be sent on session.idle event
198213
pendingMessages.set(input.sessionID, {
199214
agent: args.agent,
200215
text: args.text
201216
})
217+
log('[tool.execute.before] Message mode: stored pending message', {
218+
sessionID: input.sessionID,
219+
agent: args.agent
220+
})
221+
} else if (args.mode === "compact") {
222+
// Track compact calls for tool.execute.after
223+
compactCalls.set(input.callID, input.sessionID)
224+
log('[tool.execute.before] Compact mode: tracked call', {
225+
callID: input.callID,
226+
sessionID: input.sessionID
227+
})
228+
}
229+
}
230+
},
231+
232+
// Hook: After tool execution - handle compact mode with abort
233+
"tool.execute.after": async (input, output) => {
234+
const sessionID = compactCalls.get(input.callID)
235+
236+
if (sessionID) {
237+
const metadata = compactMetadata.get(sessionID)
238+
239+
if (metadata) {
240+
log('=== TOOL.EXECUTE.AFTER: Compact mode detected ===', {
241+
callID: input.callID,
242+
sessionID,
243+
metadata
244+
})
245+
246+
// Store compaction state
247+
compactionState.set(sessionID, {
248+
phase: 'waiting_for_first_abort',
249+
providerID: metadata.providerID,
250+
modelID: metadata.modelID,
251+
agent: metadata.agent,
252+
text: metadata.text
253+
})
254+
255+
log('[tool.execute.after] Stored compaction state', compactionState.get(sessionID))
256+
257+
// Clean up call tracking and metadata
258+
compactCalls.delete(input.callID)
259+
compactMetadata.delete(sessionID)
260+
261+
// Wait 100ms for agent to finish processing tool result
262+
log('[tool.execute.after] Waiting 100ms before first abort...')
263+
await new Promise(resolve => setTimeout(resolve, 100))
264+
265+
// Abort #1: Stop the agent
266+
log('[tool.execute.after] Calling first abort to stop agent...')
267+
try {
268+
await ctx.client.session.abort({
269+
path: { id: sessionID }
270+
})
271+
log('[tool.execute.after] First abort succeeded, waiting for session.idle #1')
272+
} catch (error) {
273+
log('[tool.execute.after] First abort failed', error)
274+
// Clean up state on error
275+
compactionState.delete(sessionID)
276+
}
202277
}
203278
}
204279
},
205280

206-
// Hook: Listen for session.idle event to send queued messages
281+
// Hook: Listen for session.idle and session.compacted events
207282
event: async ({ event }) => {
283+
// Type guard for events with sessionID
284+
if (!('properties' in event) || !('sessionID' in event.properties)) {
285+
return
286+
}
287+
288+
const sessionID = event.properties.sessionID as string
289+
290+
// ===== COMPACTION FLOW: Handle session.idle =====
291+
if (event.type === "session.idle") {
292+
const state = compactionState.get(sessionID)
293+
294+
// IDLE #1: After first abort - start compaction
295+
if (state?.phase === 'waiting_for_first_abort') {
296+
log('=== EVENT: session.idle #1 - Starting compaction ===', { sessionID })
297+
state.phase = 'compacting'
298+
299+
try {
300+
log('[event] Calling session.summarize', {
301+
providerID: state.providerID,
302+
modelID: state.modelID
303+
})
304+
305+
await ctx.client.session.summarize({
306+
path: { id: sessionID },
307+
body: {
308+
providerID: state.providerID,
309+
modelID: state.modelID
310+
}
311+
})
312+
313+
state.phase = 'waiting_for_compaction_complete'
314+
log('[event] Compaction started, waiting for session.compacted event')
315+
} catch (error) {
316+
log('[event] Compaction failed', error)
317+
compactionState.delete(sessionID)
318+
}
319+
return
320+
}
321+
322+
// IDLE #2: After second abort - send message
323+
if (state?.phase === 'ready_to_send') {
324+
log('=== EVENT: session.idle #2 - Sending message ===', {
325+
sessionID,
326+
agent: state.agent,
327+
text: state.text
328+
})
329+
330+
try {
331+
await ctx.client.session.prompt({
332+
path: { id: sessionID },
333+
body: {
334+
agent: state.agent,
335+
parts: [{ type: "text", text: state.text }]
336+
}
337+
})
338+
log('[event] Message sent successfully, cleaning up state')
339+
} catch (error) {
340+
log('[event] Failed to send message', error)
341+
} finally {
342+
compactionState.delete(sessionID)
343+
}
344+
return
345+
}
346+
}
347+
348+
// ===== COMPACTION FLOW: Handle session.compacted =====
349+
if (event.type === "session.compacted") {
350+
const state = compactionState.get(sessionID)
351+
352+
if (state?.phase === 'waiting_for_compaction_complete') {
353+
log('=== EVENT: session.compacted - Compaction done! ===', { sessionID })
354+
state.phase = 'compaction_done'
355+
356+
// Wait 100ms for lock to fully release
357+
log('[event] Waiting 100ms for lock to fully release...')
358+
await new Promise(resolve => setTimeout(resolve, 100))
359+
360+
// Abort #2: Ensure clean state
361+
log('[event] Calling second abort immediately after compaction')
362+
try {
363+
await ctx.client.session.abort({
364+
path: { id: sessionID }
365+
})
366+
state.phase = 'ready_to_send'
367+
log('[event] Second abort succeeded, ready to send message on next idle')
368+
} catch (error) {
369+
log('[event] Second abort failed', error)
370+
compactionState.delete(sessionID)
371+
}
372+
}
373+
}
374+
375+
// ===== MESSAGE MODE: Handle session.idle for normal message relay =====
208376
if (event.type === "session.idle") {
209-
const sessionID = event.properties.sessionID
210377
const pending = pendingMessages.get(sessionID)
211378

212379
if (pending) {
380+
log('=== EVENT: session.idle - Message mode ===', {
381+
sessionID,
382+
agent: pending.agent
383+
})
384+
213385
// Remove from queue
214386
pendingMessages.delete(sessionID)
215387

@@ -222,8 +394,9 @@ export const SessionPlugin: Plugin = async (ctx) => {
222394
parts: [{ type: "text", text: pending.text }]
223395
}
224396
})
397+
log('[event] Message mode: message sent successfully')
225398
} catch (error) {
226-
console.error('[session-plugin] Failed to send message on session.idle:', error)
399+
log('[event] Message mode: failed to send message', error)
227400
}
228401
}
229402
}
@@ -391,73 +564,29 @@ EXAMPLES:
391564
}]
392565
}
393566
})
394-
log('Context message injected successfully')
567+
log('[tool.execute] Context message injected successfully')
395568

396-
// Trigger compaction with model parameters
397-
log('Calling session.summarize with model parameters...')
398-
log('Request body:', { providerID, modelID })
399-
const startTime = Date.now()
400-
401-
const result = await ctx.client.session.summarize({
402-
path: { id: toolCtx.sessionID },
403-
body: {
404-
providerID,
405-
modelID
406-
}
407-
})
408-
409-
const duration = Date.now() - startTime
410-
log('session.summarize returned', {
411-
result,
412-
duration_ms: duration
413-
})
414-
415-
// Check if summarize returned an error
416-
if (result.error) {
417-
log('session.summarize returned error:', result.error)
418-
419-
// Check for SessionLockedError using type-safe approach
420-
const errorObj = result.error as any
421-
if (errorObj.name === 'SessionLockedError' || errorObj.data?.message?.includes('locked')) {
422-
log('=== COMPACT MODE END (FAILED - SESSION LOCKED) ===\n')
423-
return "Error: Cannot compact session while it is active. Session compaction can only be performed when the session is idle."
424-
}
425-
426-
log('=== COMPACT MODE END (FAILED - API ERROR) ===\n')
427-
const errorMessage = errorObj.data?.message || errorObj.message || 'Unknown error'
428-
throw new Error(`Compaction failed: ${errorObj.name || 'APIError'} - ${errorMessage}`)
429-
}
430-
431-
// Verify compaction happened
432-
log('Fetching messages to verify compaction...')
433-
const msgsAfter = await ctx.client.session.messages({
434-
path: { id: toolCtx.sessionID }
435-
})
436-
437-
log('Post-compaction state', {
438-
total_messages: msgsAfter.data.length,
439-
assistant_messages: msgsAfter.data.filter(m => m.info.role === "assistant").length,
440-
user_messages: msgsAfter.data.filter(m => m.info.role === "user").length,
441-
message_count_before: msgs.data.length,
442-
message_count_after: msgsAfter.data.length,
443-
compaction_occurred: msgs.data.length > msgsAfter.data.length
569+
// Store metadata in map for tool.execute.after hook (using sessionID as key)
570+
// DO NOT call session.summarize() here - causes SessionLockedError!
571+
compactMetadata.set(toolCtx.sessionID, {
572+
providerID,
573+
modelID,
574+
agent: args.agent,
575+
text: args.text
444576
})
445577

446-
// Check for compacted messages
447-
const compactedMsgs = msgsAfter.data.filter(m =>
448-
m.info.role === "assistant" && (m.info as any).summary === true
449-
)
450-
log('Compacted messages found:', {
451-
count: compactedMsgs.length,
452-
message_ids: compactedMsgs.map(m => m.info.id)
578+
log('[tool.execute] Stored metadata for tool.execute.after', {
579+
sessionID: toolCtx.sessionID,
580+
providerID,
581+
modelID,
582+
agent: args.agent
453583
})
584+
log('=== COMPACT MODE END (returning) ===\n')
454585

455-
log('=== COMPACT MODE END (SUCCESS) ===\n')
456-
457-
// Message stored in tool.execute.before will be sent via session.idle
586+
// Return immediately - compaction will happen asynchronously via events
458587
return args.agent
459-
? `Compacting session with ${providerID}/${modelID}... ${args.agent} agent will respond after completion.`
460-
: `Compacting session with ${providerID}/${modelID}... response will continue after completion.`
588+
? `Compacting session and handing off to ${args.agent}. This will complete shortly.`
589+
: `Compacting session. This will complete shortly.`
461590

462591
} catch (error) {
463592
log('=== COMPACT MODE ERROR ===', {

0 commit comments

Comments
 (0)