fix(todo-continuation): implement hybrid abort detection
- Add event-based abort detection as primary method - Keep API-based detection as fallback - Track abort events via session.error with 3s time window - Clear abort flag on user/assistant activity and tool execution - Add comprehensive tests for hybrid approach (8 new test cases) - All 663 tests pass Fixes #577
This commit is contained in:
@@ -36,6 +36,7 @@ interface SessionState {
|
||||
countdownInterval?: ReturnType<typeof setInterval>
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
abortDetectedAt?: number
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
@@ -254,6 +255,13 @@ export function createTodoContinuationEnforcer(
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const error = props?.error as { name?: string } | undefined
|
||||
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
|
||||
const state = getState(sessionID)
|
||||
state.abortDetectedAt = Date.now()
|
||||
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
|
||||
}
|
||||
|
||||
cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
@@ -281,6 +289,18 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
// Check 1: Event-based abort detection (primary, most reliable)
|
||||
if (state.abortDetectedAt) {
|
||||
const timeSinceAbort = Date.now() - state.abortDetectedAt
|
||||
const ABORT_WINDOW_MS = 3000
|
||||
if (timeSinceAbort < ABORT_WINDOW_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID })
|
||||
state.abortDetectedAt = undefined
|
||||
return
|
||||
}
|
||||
state.abortDetectedAt = undefined
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -290,6 +310,7 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
// Check 2: API-based abort detection (fallback, for cases where event was missed)
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
@@ -298,7 +319,7 @@ export function createTodoContinuationEnforcer(
|
||||
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
|
||||
|
||||
if (isLastAssistantMessageAborted(messages)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted`, { sessionID })
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -367,10 +388,13 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
}
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
@@ -382,6 +406,8 @@ export function createTodoContinuationEnforcer(
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
@@ -390,6 +416,8 @@ export function createTodoContinuationEnforcer(
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user