fix(hooks): fix TODO continuation abort handling with timer-based approach
Replace blocking await with non-blocking timer scheduling to handle race condition between session.idle and session.error events. When ESC abort occurs, session.error immediately cancels the pending timer, preventing unwanted continuation prompts. Changes: - Add pendingTimers Map to track scheduled continuation checks - Cancel timer on session.error (especially abort cases) - Cancel timer on message.updated and session.deleted for cleanup - Reduce delay to 200ms for faster response - Maintain existing Set-based flag logic for compatibility This fixes the issue where ESC abort would not prevent continuation prompts due to event ordering (idle before error)
This commit is contained in:
@@ -36,6 +36,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
|||||||
const remindedSessions = new Set<string>()
|
const remindedSessions = new Set<string>()
|
||||||
const interruptedSessions = new Set<string>()
|
const interruptedSessions = new Set<string>()
|
||||||
const errorSessions = new Set<string>()
|
const errorSessions = new Set<string>()
|
||||||
|
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
@@ -47,6 +48,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
|||||||
if (detectInterrupt(props?.error)) {
|
if (detectInterrupt(props?.error)) {
|
||||||
interruptedSessions.add(sessionID)
|
interruptedSessions.add(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel pending continuation if error occurs
|
||||||
|
const timer = pendingTimers.get(sessionID)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
pendingTimers.delete(sessionID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -55,68 +63,78 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
|||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
// Wait for potential session.error events to be processed first
|
// Cancel any existing timer to debounce
|
||||||
await new Promise(resolve => setTimeout(resolve, 150))
|
const existingTimer = pendingTimers.get(sessionID)
|
||||||
|
if (existingTimer) {
|
||||||
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
|
clearTimeout(existingTimer)
|
||||||
|
|
||||||
interruptedSessions.delete(sessionID)
|
|
||||||
errorSessions.delete(sessionID)
|
|
||||||
|
|
||||||
if (shouldBypass) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remindedSessions.has(sessionID)) {
|
// Schedule continuation check
|
||||||
return
|
const timer = setTimeout(async () => {
|
||||||
}
|
pendingTimers.delete(sessionID)
|
||||||
|
|
||||||
let todos: Todo[] = []
|
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
|
||||||
try {
|
|
||||||
const response = await ctx.client.session.todo({
|
interruptedSessions.delete(sessionID)
|
||||||
path: { id: sessionID },
|
errorSessions.delete(sessionID)
|
||||||
})
|
|
||||||
todos = (response.data ?? response) as Todo[]
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!todos || todos.length === 0) {
|
if (shouldBypass) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomplete = todos.filter(
|
if (remindedSessions.has(sessionID)) {
|
||||||
(t) => t.status !== "completed" && t.status !== "cancelled"
|
return
|
||||||
)
|
}
|
||||||
|
|
||||||
if (incomplete.length === 0) {
|
let todos: Todo[] = []
|
||||||
return
|
try {
|
||||||
}
|
const response = await ctx.client.session.todo({
|
||||||
|
path: { id: sessionID },
|
||||||
|
})
|
||||||
|
todos = (response.data ?? response) as Todo[]
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
remindedSessions.add(sessionID)
|
if (!todos || todos.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Re-check if abort occurred during the delay
|
const incomplete = todos.filter(
|
||||||
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
|
(t) => t.status !== "completed" && t.status !== "cancelled"
|
||||||
remindedSessions.delete(sessionID)
|
)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (incomplete.length === 0) {
|
||||||
await ctx.client.session.prompt({
|
return
|
||||||
path: { id: sessionID },
|
}
|
||||||
body: {
|
|
||||||
parts: [
|
remindedSessions.add(sessionID)
|
||||||
{
|
|
||||||
type: "text",
|
// Re-check if abort occurred during the delay/fetch
|
||||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
|
||||||
},
|
remindedSessions.delete(sessionID)
|
||||||
],
|
return
|
||||||
},
|
}
|
||||||
query: { directory: ctx.directory },
|
|
||||||
})
|
try {
|
||||||
} catch {
|
await ctx.client.session.prompt({
|
||||||
remindedSessions.delete(sessionID)
|
path: { id: sessionID },
|
||||||
}
|
body: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
remindedSessions.delete(sessionID)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
pendingTimers.set(sessionID, timer)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
@@ -124,6 +142,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
|||||||
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)
|
remindedSessions.delete(sessionID)
|
||||||
|
|
||||||
|
// Cancel pending continuation on user interaction
|
||||||
|
const timer = pendingTimers.get(sessionID)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
pendingTimers.delete(sessionID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +158,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
|||||||
remindedSessions.delete(sessionInfo.id)
|
remindedSessions.delete(sessionInfo.id)
|
||||||
interruptedSessions.delete(sessionInfo.id)
|
interruptedSessions.delete(sessionInfo.id)
|
||||||
errorSessions.delete(sessionInfo.id)
|
errorSessions.delete(sessionInfo.id)
|
||||||
|
|
||||||
|
// Cancel pending continuation
|
||||||
|
const timer = pendingTimers.get(sessionInfo.id)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
pendingTimers.delete(sessionInfo.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user