Merge pull request #3065 from code-yeongyu/fix/issue-2984-v2
fix: reset consecutiveFailures on abort so session recovers after user cancel
This commit is contained in:
@@ -104,4 +104,62 @@ describe("createEventHandler", () => {
|
|||||||
expect(abortCalls).toEqual([])
|
expect(abortCalls).toEqual([])
|
||||||
expect(state.pendingFallbackModel).toBe(undefined)
|
expect(state.pendingFallbackModel).toBe(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("#given a cancelled session #when session.error receives an abort error #then fallback retry state is reset", async () => {
|
||||||
|
const sessionID = "session-cancelled"
|
||||||
|
const deps = createDeps()
|
||||||
|
const abortCalls: string[] = []
|
||||||
|
const clearCalls: string[] = []
|
||||||
|
const state = createFallbackState("google/gemini-2.5-pro")
|
||||||
|
state.currentModel = "openai/gpt-5.4"
|
||||||
|
state.fallbackIndex = 1
|
||||||
|
state.attemptCount = 2
|
||||||
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
||||||
|
state.failedModels.set("google/gemini-2.5-pro", Date.now())
|
||||||
|
deps.sessionStates.set(sessionID, state)
|
||||||
|
deps.sessionRetryInFlight.add(sessionID)
|
||||||
|
deps.sessionAwaitingFallbackResult.add(sessionID)
|
||||||
|
deps.sessionStatusRetryKeys.set(sessionID, "retry:2")
|
||||||
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||||
|
|
||||||
|
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "AbortError" } } } })
|
||||||
|
|
||||||
|
const resetState = deps.sessionStates.get(sessionID)
|
||||||
|
expect(resetState?.originalModel).toBe("google/gemini-2.5-pro")
|
||||||
|
expect(resetState?.currentModel).toBe("google/gemini-2.5-pro")
|
||||||
|
expect(resetState?.fallbackIndex).toBe(-1)
|
||||||
|
expect(resetState?.attemptCount).toBe(0)
|
||||||
|
expect(resetState?.pendingFallbackModel).toBe(undefined)
|
||||||
|
expect(resetState?.failedModels.size).toBe(0)
|
||||||
|
expect(deps.sessionRetryInFlight.has(sessionID)).toBe(false)
|
||||||
|
expect(deps.sessionAwaitingFallbackResult.has(sessionID)).toBe(false)
|
||||||
|
expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false)
|
||||||
|
expect(clearCalls).toEqual([sessionID])
|
||||||
|
expect(abortCalls).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given a cancelled session #when session.idle fires #then fallback retry state stays cleared", async () => {
|
||||||
|
const sessionID = "session-cancelled-idle"
|
||||||
|
const deps = createDeps()
|
||||||
|
const abortCalls: string[] = []
|
||||||
|
const clearCalls: string[] = []
|
||||||
|
const state = createFallbackState("google/gemini-2.5-pro")
|
||||||
|
state.currentModel = "openai/gpt-5.4"
|
||||||
|
state.fallbackIndex = 1
|
||||||
|
state.attemptCount = 2
|
||||||
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
||||||
|
deps.sessionStates.set(sessionID, state)
|
||||||
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||||
|
|
||||||
|
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "MessageAbortedError" } } } })
|
||||||
|
clearCalls.length = 0
|
||||||
|
|
||||||
|
await handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
|
|
||||||
|
const resetState = deps.sessionStates.get(sessionID)
|
||||||
|
expect(resetState?.currentModel).toBe("google/gemini-2.5-pro")
|
||||||
|
expect(resetState?.attemptCount).toBe(0)
|
||||||
|
expect(clearCalls).toEqual([sessionID])
|
||||||
|
expect(abortCalls).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableErro
|
|||||||
import { createFallbackState } from "./fallback-state"
|
import { createFallbackState } from "./fallback-state"
|
||||||
import { getFallbackModelsForSession } from "./fallback-models"
|
import { getFallbackModelsForSession } from "./fallback-models"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
|
import { isAbortError } from "../../shared/is-abort-error"
|
||||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||||
import { createSessionStatusHandler } from "./session-status-handler"
|
import { createSessionStatusHandler } from "./session-status-handler"
|
||||||
@@ -13,6 +14,19 @@ import { createSessionStatusHandler } from "./session-status-handler"
|
|||||||
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||||
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps
|
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps
|
||||||
const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys)
|
const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys)
|
||||||
|
const cancelledSessions = new Set<string>()
|
||||||
|
|
||||||
|
const resetRetryState = (sessionID: string) => {
|
||||||
|
const state = sessionStates.get(sessionID)
|
||||||
|
if (state) {
|
||||||
|
sessionStates.set(sessionID, createFallbackState(state.originalModel))
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionRetryInFlight.delete(sessionID)
|
||||||
|
sessionAwaitingFallbackResult.delete(sessionID)
|
||||||
|
sessionStatusRetryKeys.delete(sessionID)
|
||||||
|
helpers.clearSessionFallbackTimeout(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
|
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
|
||||||
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
|
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
|
||||||
@@ -32,6 +46,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID })
|
log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID })
|
||||||
|
cancelledSessions.delete(sessionID)
|
||||||
sessionStates.delete(sessionID)
|
sessionStates.delete(sessionID)
|
||||||
sessionLastAccess.delete(sessionID)
|
sessionLastAccess.delete(sessionID)
|
||||||
sessionRetryInFlight.delete(sessionID)
|
sessionRetryInFlight.delete(sessionID)
|
||||||
@@ -46,28 +61,35 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
helpers.clearSessionFallbackTimeout(sessionID)
|
|
||||||
|
|
||||||
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
||||||
await helpers.abortSessionRequest(sessionID, "session.stop")
|
await helpers.abortSessionRequest(sessionID, "session.stop")
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionRetryInFlight.delete(sessionID)
|
cancelledSessions.add(sessionID)
|
||||||
sessionAwaitingFallbackResult.delete(sessionID)
|
resetRetryState(sessionID)
|
||||||
sessionStatusRetryKeys.delete(sessionID)
|
|
||||||
|
|
||||||
const state = sessionStates.get(sessionID)
|
|
||||||
if (state?.pendingFallbackModel) {
|
|
||||||
state.pendingFallbackModel = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Cleared fallback retry state on session.stop`, { sessionID })
|
log(`[${HOOK_NAME}] Cleared fallback retry state on session.stop`, { sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMessageUpdated = (props: Record<string, unknown> | undefined) => {
|
||||||
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
|
const sessionID = info?.sessionID as string | undefined
|
||||||
|
const role = info?.role as string | undefined
|
||||||
|
if (!sessionID || role !== "user") return
|
||||||
|
|
||||||
|
cancelledSessions.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
|
const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
|
if (cancelledSessions.has(sessionID)) {
|
||||||
|
resetRetryState(sessionID)
|
||||||
|
log(`[${HOOK_NAME}] Cleared fallback retry state for cancelled session on idle`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionAwaitingFallbackResult.has(sessionID)) {
|
if (sessionAwaitingFallbackResult.has(sessionID)) {
|
||||||
log(`[${HOOK_NAME}] session.idle while awaiting fallback result; keeping timeout armed`, { sessionID })
|
log(`[${HOOK_NAME}] session.idle while awaiting fallback result; keeping timeout armed`, { sessionID })
|
||||||
return
|
return
|
||||||
@@ -100,6 +122,13 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
|
|
||||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
||||||
|
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
cancelledSessions.add(sessionID)
|
||||||
|
resetRetryState(sessionID)
|
||||||
|
log(`[${HOOK_NAME}] session.error matched cancellation; cleared retry state`, { sessionID, resolvedAgent })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionRetryInFlight.has(sessionID)) {
|
if (sessionRetryInFlight.has(sessionID)) {
|
||||||
log(`[${HOOK_NAME}] session.error skipped - retry in flight`, {
|
log(`[${HOOK_NAME}] session.error skipped - retry in flight`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -176,6 +205,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
if (event.type === "session.created") { handleSessionCreated(props); return }
|
if (event.type === "session.created") { handleSessionCreated(props); return }
|
||||||
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
|
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
|
||||||
if (event.type === "session.stop") { await handleSessionStop(props); return }
|
if (event.type === "session.stop") { await handleSessionStop(props); return }
|
||||||
|
if (event.type === "message.updated") { handleMessageUpdated(props); return }
|
||||||
if (event.type === "session.idle") { handleSessionIdle(props); return }
|
if (event.type === "session.idle") { handleSessionIdle(props); return }
|
||||||
if (event.type === "session.status") { await sessionStatusHandler(props); return }
|
if (event.type === "session.status") { await sessionStatusHandler(props); return }
|
||||||
if (event.type === "session.error") { await handleSessionError(props); return }
|
if (event.type === "session.error") { await handleSessionError(props); return }
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export async function injectContinuation(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state?.wasCancelled) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped injection: session was cancelled`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isContinuationStopped?.(sessionID)) {
|
if (isContinuationStopped?.(sessionID)) {
|
||||||
log(`[${HOOK_NAME}] Skipped injection: continuation stopped for session`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped injection: continuation stopped for session`, { sessionID })
|
||||||
return
|
return
|
||||||
@@ -145,6 +150,11 @@ Remaining tasks:
|
|||||||
${todoList}`
|
${todoList}`
|
||||||
|
|
||||||
const injectionState = sessionStateStore.getExistingState(sessionID)
|
const injectionState = sessionStateStore.getExistingState(sessionID)
|
||||||
|
if (injectionState?.wasCancelled) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped injection: session was cancelled before prompt`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (injectionState) {
|
if (injectionState) {
|
||||||
injectionState.inFlight = true
|
injectionState.inFlight = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ export function createTodoContinuationHandler(args: {
|
|||||||
const error = props?.error as { name?: string } | undefined
|
const error = props?.error as { name?: string } | undefined
|
||||||
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
|
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
|
||||||
const state = sessionStateStore.getState(sessionID)
|
const state = sessionStateStore.getState(sessionID)
|
||||||
|
state.wasCancelled = true
|
||||||
state.abortDetectedAt = Date.now()
|
state.abortDetectedAt = Date.now()
|
||||||
|
state.lastIncompleteCount = undefined
|
||||||
|
state.lastInjectedAt = undefined
|
||||||
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
|
state.stagnationCount = 0
|
||||||
|
state.consecutiveFailures = 0
|
||||||
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
|
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export async function handleSessionIdle(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.wasCancelled) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: session was cancelled`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (state.abortDetectedAt) {
|
if (state.abortDetectedAt) {
|
||||||
const timeSinceAbort = Date.now() - state.abortDetectedAt
|
const timeSinceAbort = Date.now() - state.abortDetectedAt
|
||||||
if (timeSinceAbort < ABORT_WINDOW_MS) {
|
if (timeSinceAbort < ABORT_WINDOW_MS) {
|
||||||
|
|||||||
@@ -25,14 +25,20 @@ export function handleNonIdleEvent(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state) state.abortDetectedAt = undefined
|
if (state) {
|
||||||
|
state.abortDetectedAt = undefined
|
||||||
|
state.wasCancelled = false
|
||||||
|
}
|
||||||
sessionStateStore.cancelCountdown(sessionID)
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "assistant") {
|
if (role === "assistant") {
|
||||||
const state = sessionStateStore.getExistingState(sessionID)
|
const state = sessionStateStore.getExistingState(sessionID)
|
||||||
if (state) state.abortDetectedAt = undefined
|
if (state) {
|
||||||
|
state.abortDetectedAt = undefined
|
||||||
|
state.wasCancelled = false
|
||||||
|
}
|
||||||
sessionStateStore.cancelCountdown(sessionID)
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,7 +66,10 @@ export function handleNonIdleEvent(args: {
|
|||||||
const sessionID = properties?.sessionID as string | undefined
|
const sessionID = properties?.sessionID as string | undefined
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
const state = sessionStateStore.getExistingState(sessionID)
|
const state = sessionStateStore.getExistingState(sessionID)
|
||||||
if (state) state.abortDetectedAt = undefined
|
if (state) {
|
||||||
|
state.abortDetectedAt = undefined
|
||||||
|
state.wasCancelled = false
|
||||||
|
}
|
||||||
sessionStateStore.cancelCountdown(sessionID)
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -70,7 +79,10 @@ export function handleNonIdleEvent(args: {
|
|||||||
const sessionID = properties?.sessionID as string | undefined
|
const sessionID = properties?.sessionID as string | undefined
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
const state = sessionStateStore.getExistingState(sessionID)
|
const state = sessionStateStore.getExistingState(sessionID)
|
||||||
if (state) state.abortDetectedAt = undefined
|
if (state) {
|
||||||
|
state.abortDetectedAt = undefined
|
||||||
|
state.wasCancelled = false
|
||||||
|
}
|
||||||
sessionStateStore.cancelCountdown(sessionID)
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1270,7 +1270,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
expect(promptCalls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should inject when abort flag is stale (>3s old)", async () => {
|
test("should keep skipping after cancel even when the abort window is stale", async () => {
|
||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with incomplete todos and old abort timestamp
|
// given - session with incomplete todos and old abort timestamp
|
||||||
const sessionID = "main-stale-abort"
|
const sessionID = "main-stale-abort"
|
||||||
@@ -1299,8 +1299,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
await wait(3000)
|
await wait(3000)
|
||||||
|
|
||||||
// then - continuation injected (abort flag is stale)
|
expect(promptCalls).toHaveLength(0)
|
||||||
expect(promptCalls.length).toBeGreaterThan(0)
|
|
||||||
}, { timeout: 15000 })
|
}, { timeout: 15000 })
|
||||||
|
|
||||||
test("should clear abort flag on user message activity", async () => {
|
test("should clear abort flag on user message activity", async () => {
|
||||||
@@ -1343,6 +1342,44 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls.length).toBeGreaterThan(0)
|
expect(promptCalls.length).toBeGreaterThan(0)
|
||||||
}, { timeout: 15000 })
|
}, { timeout: 15000 })
|
||||||
|
|
||||||
|
test("should reset failure state and keep skipping after a cancelled run", async () => {
|
||||||
|
fakeTimers.restore()
|
||||||
|
const sessionID = "main-reset-after-cancel"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
mockMessages = [
|
||||||
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
|
{ info: { id: "msg-2", role: "assistant" } },
|
||||||
|
]
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(2500)
|
||||||
|
expect(promptCalls.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
promptCalls.length = 0
|
||||||
|
|
||||||
|
await hook.handler({
|
||||||
|
event: {
|
||||||
|
type: "session.error",
|
||||||
|
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(3100)
|
||||||
|
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(2500)
|
||||||
|
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
}, { timeout: 15000 })
|
||||||
|
|
||||||
test("should clear abort flag on assistant message activity", async () => {
|
test("should clear abort flag on assistant message activity", async () => {
|
||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with abort detected
|
// given - session with abort detected
|
||||||
@@ -1866,4 +1903,64 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
expect(promptCalls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should reset consecutiveFailures after user-initiated abort and resume after fresh activity [regression #2984]", async () => {
|
||||||
|
fakeTimers.restore()
|
||||||
|
const sessionID = "main-abort-recovery"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
const mockInput = createMockPluginInput()
|
||||||
|
mockInput.client.session.todo = async () => ({
|
||||||
|
data: [
|
||||||
|
{ id: "1", content: "Write tests", status: "pending", priority: "high" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
let shouldFail = true
|
||||||
|
let promptCallCount = 0
|
||||||
|
mockInput.client.session.promptAsync = async (_opts: PromptRequestOptions) => {
|
||||||
|
promptCallCount++
|
||||||
|
if (shouldFail) {
|
||||||
|
throw new Error("promptAsync failed (3ms) unknown error")
|
||||||
|
}
|
||||||
|
promptCalls.push({
|
||||||
|
sessionID: _opts.path.id,
|
||||||
|
agent: _opts.body.agent,
|
||||||
|
model: _opts.body.model,
|
||||||
|
text: _opts.body.parts[0].text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
|
await wait(2500)
|
||||||
|
expect(promptCallCount).toBe(1)
|
||||||
|
|
||||||
|
await hook.handler({
|
||||||
|
event: {
|
||||||
|
type: "session.error",
|
||||||
|
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
shouldFail = false
|
||||||
|
await wait(9000)
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
|
await wait(2500)
|
||||||
|
expect(promptCallCount).toBe(1)
|
||||||
|
|
||||||
|
await hook.handler({
|
||||||
|
event: {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: { info: { sessionID, role: "user" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
|
await wait(2500)
|
||||||
|
|
||||||
|
expect(promptCallCount).toBe(2)
|
||||||
|
expect(promptCalls).toHaveLength(1)
|
||||||
|
}, { timeout: 20000 })
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface SessionState {
|
|||||||
countdownTimer?: ReturnType<typeof setTimeout>
|
countdownTimer?: ReturnType<typeof setTimeout>
|
||||||
countdownInterval?: ReturnType<typeof setInterval>
|
countdownInterval?: ReturnType<typeof setInterval>
|
||||||
isRecovering?: boolean
|
isRecovering?: boolean
|
||||||
|
wasCancelled?: boolean
|
||||||
countdownStartedAt?: number
|
countdownStartedAt?: number
|
||||||
abortDetectedAt?: number
|
abortDetectedAt?: number
|
||||||
lastIncompleteCount?: number
|
lastIncompleteCount?: number
|
||||||
|
|||||||
@@ -181,4 +181,37 @@ describe("unstable-agent-babysitter hook", () => {
|
|||||||
expect(promptCalls.length).toBe(1)
|
expect(promptCalls.length).toBe(1)
|
||||||
Date.now = originalNow
|
Date.now = originalNow
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("skips follow-up reminder after the main session is cancelled", async () => {
|
||||||
|
setMainSession("main-1")
|
||||||
|
const promptCalls: Array<{ input: unknown }> = []
|
||||||
|
const ctx = createMockPluginInput({
|
||||||
|
messagesBySession: {
|
||||||
|
"main-1": [
|
||||||
|
{ info: { agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-4" } } },
|
||||||
|
],
|
||||||
|
"bg-1": [
|
||||||
|
{ info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
promptCalls,
|
||||||
|
})
|
||||||
|
const backgroundManager = createBackgroundManager([createTask()])
|
||||||
|
const hook = createUnstableAgentBabysitterHook(ctx, {
|
||||||
|
backgroundManager,
|
||||||
|
config: { timeout_ms: 120000 },
|
||||||
|
})
|
||||||
|
const firstNow = Date.now()
|
||||||
|
const originalNow = Date.now
|
||||||
|
let currentNow = firstNow
|
||||||
|
Date.now = () => currentNow
|
||||||
|
|
||||||
|
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
|
||||||
|
await hook.event({ event: { type: "session.error", properties: { sessionID: "main-1", error: { name: "AbortError" } } } })
|
||||||
|
currentNow += 5 * 60 * 1000 + 1
|
||||||
|
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
|
||||||
|
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
Date.now = originalNow
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BackgroundManager } from "../../features/background-agent"
|
|||||||
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||||
|
import { isAbortError } from "../../shared/is-abort-error"
|
||||||
import {
|
import {
|
||||||
buildReminder,
|
buildReminder,
|
||||||
extractMessages,
|
extractMessages,
|
||||||
@@ -117,17 +118,70 @@ async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Pr
|
|||||||
|
|
||||||
export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) {
|
export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) {
|
||||||
const reminderCooldowns = new Map<string, number>()
|
const reminderCooldowns = new Map<string, number>()
|
||||||
|
const cancelledSessions = new Set<string>()
|
||||||
|
|
||||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
const eventHandler = 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 || !isAbortError(props?.error)) return
|
||||||
|
|
||||||
|
cancelledSessions.add(sessionID)
|
||||||
|
reminderCooldowns.clear()
|
||||||
|
log(`[${HOOK_NAME}] Marked session cancelled`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.stop") {
|
||||||
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
cancelledSessions.add(sessionID)
|
||||||
|
reminderCooldowns.clear()
|
||||||
|
log(`[${HOOK_NAME}] Marked session cancelled via session.stop`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "message.updated") {
|
||||||
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
|
const sessionID = info?.sessionID as string | undefined
|
||||||
|
const role = info?.role as string | undefined
|
||||||
|
if (!sessionID || (role !== "user" && role !== "assistant")) return
|
||||||
|
|
||||||
|
cancelledSessions.delete(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||||
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
cancelledSessions.delete(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.deleted") {
|
||||||
|
const sessionInfo = props?.info as { id?: string } | undefined
|
||||||
|
if (!sessionInfo?.id) return
|
||||||
|
|
||||||
|
cancelledSessions.delete(sessionInfo.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type !== "session.idle") return
|
if (event.type !== "session.idle") return
|
||||||
|
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const mainSessionID = getMainSessionID()
|
const mainSessionID = getMainSessionID()
|
||||||
if (!mainSessionID || sessionID !== mainSessionID) return
|
if (!mainSessionID || sessionID !== mainSessionID) return
|
||||||
|
|
||||||
|
if (cancelledSessions.has(mainSessionID)) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped reminder: session was cancelled`, { sessionID: mainSessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID)
|
const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID)
|
||||||
if (tasks.length === 0) return
|
if (tasks.length === 0) return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function isAbortError(error: unknown): boolean {
|
||||||
|
if (!error) return false
|
||||||
|
|
||||||
|
if (typeof error === "object") {
|
||||||
|
const errObj = error as Record<string, unknown>
|
||||||
|
const name = errObj.name as string | undefined
|
||||||
|
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
|
||||||
|
|
||||||
|
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||||
|
if (name === "DOMException" && message.includes("abort")) return true
|
||||||
|
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
const lower = error.toLowerCase()
|
||||||
|
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user