fix(hooks): improve TODO continuation race condition handling with state machine pattern
- Replace multiple Set-based tracking with explicit SessionStatus state machine - Implement setTimeout+clearTimeout pattern for robust race condition handling - SessionStatus tracks: idle → continuation-sent or aborted states - Increase grace period to 500ms to accommodate event ordering delays - Add cleanupSession utility for proper resource cleanup This addresses ESC abort not canceling continuation prompts when session.idle arrives before session.error event, which can occur due to async event processing in OpenCode plugin system
This commit is contained in:
@@ -7,6 +7,8 @@ interface Todo {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionStatus = "idle" | "aborted" | "continuation-sent"
|
||||||
|
|
||||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||||
|
|
||||||
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
||||||
@@ -15,6 +17,8 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
|
|||||||
- Mark each task complete when finished
|
- Mark each task complete when finished
|
||||||
- Do not stop until all tasks are done`
|
- Do not stop until all tasks are done`
|
||||||
|
|
||||||
|
const CONTINUATION_DELAY_MS = 500
|
||||||
|
|
||||||
function detectInterrupt(error: unknown): boolean {
|
function detectInterrupt(error: unknown): boolean {
|
||||||
if (!error) return false
|
if (!error) return false
|
||||||
if (typeof error === "object") {
|
if (typeof error === "object") {
|
||||||
@@ -33,43 +37,36 @@ function detectInterrupt(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||||
const remindedSessions = new Set<string>()
|
const sessionStates = new Map<string, SessionStatus>()
|
||||||
const interruptedSessions = new Set<string>()
|
const pendingContinuations = new Map<string, Timer>()
|
||||||
const errorSessions = new Set<string>()
|
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
const cleanupSession = (sessionID: string) => {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const timer = pendingContinuations.get(sessionID)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
pendingContinuations.delete(sessionID)
|
||||||
|
sessionStates.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
const cancelPendingContinuation = (sessionID: string) => {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const timer = pendingContinuations.get(sessionID)
|
||||||
if (sessionID) {
|
if (timer) {
|
||||||
errorSessions.add(sessionID)
|
clearTimeout(timer)
|
||||||
if (detectInterrupt(props?.error)) {
|
pendingContinuations.delete(sessionID)
|
||||||
interruptedSessions.add(sessionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
sessionStates.set(sessionID, "aborted")
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === "session.idle") {
|
const scheduleContinuation = (sessionID: string) => {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const prev = pendingContinuations.get(sessionID)
|
||||||
if (!sessionID) return
|
if (prev) clearTimeout(prev)
|
||||||
|
|
||||||
// Wait for potential session.error events to be processed first
|
sessionStates.set(sessionID, "idle")
|
||||||
await new Promise(resolve => setTimeout(resolve, 150))
|
|
||||||
|
|
||||||
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
|
const timer = setTimeout(async () => {
|
||||||
|
pendingContinuations.delete(sessionID)
|
||||||
|
|
||||||
interruptedSessions.delete(sessionID)
|
const state = sessionStates.get(sessionID)
|
||||||
errorSessions.delete(sessionID)
|
if (state !== "idle") return
|
||||||
|
|
||||||
if (shouldBypass) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remindedSessions.has(sessionID)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let todos: Todo[] = []
|
let todos: Todo[] = []
|
||||||
try {
|
try {
|
||||||
@@ -93,13 +90,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
remindedSessions.add(sessionID)
|
sessionStates.set(sessionID, "continuation-sent")
|
||||||
|
|
||||||
// Re-check if abort occurred during the delay
|
|
||||||
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
|
|
||||||
remindedSessions.delete(sessionID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ctx.client.session.prompt({
|
await ctx.client.session.prompt({
|
||||||
@@ -115,24 +106,46 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
|||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
remindedSessions.delete(sessionID)
|
sessionStates.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
}, CONTINUATION_DELAY_MS)
|
||||||
|
|
||||||
|
pendingContinuations.set(sessionID, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
if (event.type === "session.error") {
|
||||||
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
if (sessionID && detectInterrupt(props?.error)) {
|
||||||
|
cancelPendingContinuation(sessionID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const state = sessionStates.get(sessionID)
|
||||||
|
if (state === "continuation-sent") return
|
||||||
|
|
||||||
|
scheduleContinuation(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = info?.sessionID as string | undefined
|
||||||
if (sessionID && info?.role === "user") {
|
if (sessionID && info?.role === "user") {
|
||||||
remindedSessions.delete(sessionID)
|
sessionStates.delete(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionInfo = props?.info as { id?: string } | undefined
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) {
|
||||||
remindedSessions.delete(sessionInfo.id)
|
cleanupSession(sessionInfo.id)
|
||||||
interruptedSessions.delete(sessionInfo.id)
|
|
||||||
errorSessions.delete(sessionInfo.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user