fix(atlas): schedule delayed retry when cooldown blocks boulder continuation

When atlas injects a boulder continuation via promptAsync() and the
model's response is immediately aborted (MessageAbortedError), OpenCode
fires a burst of session.idle events within milliseconds. Atlas blocks
all of them due to the 5-second cooldown. After the burst, OpenCode
stops generating session.idle events (it's state-change based, not
periodic), leaving the session stuck forever.

Fix: When cooldown blocks an idle event for a boulder session with an
incomplete plan, schedule a one-shot setTimeout (cooldown + 1s) to
re-attempt injection. The timer callback re-checks boulder state, plan
progress, and continuation-stopped flag before injecting. Only one timer
per session is allowed (deduped via pendingRetryTimer field). Timers are
cleaned up on session.deleted and session.compacted events.
This commit is contained in:
YeonGyu-Kim
2026-03-06 16:14:24 +09:00
parent 764ca0c51b
commit cecb78e944
3 changed files with 216 additions and 0 deletions
+42
View File
@@ -11,6 +11,7 @@ import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
export function createAtlasEventHandler(input: {
ctx: PluginInput
@@ -123,9 +124,42 @@ export function createAtlasEventHandler(input: {
}
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
if (!state.pendingRetryTimer) {
state.pendingRetryTimer = setTimeout(async () => {
state.pendingRetryTimer = undefined
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
state.lastContinuationInjectedAt = Date.now()
const currentRemaining = currentProgress.total - currentProgress.completed
try {
await injectBoulderContinuation({
ctx,
sessionID,
planName: currentBoulder.plan_name,
remaining: currentRemaining,
total: currentProgress.total,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
backgroundManager,
sessionState: state,
})
} catch (err) {
log(`[${HOOK_NAME}] Delayed retry failed`, { sessionID, error: err })
state.promptFailureCount++
}
}, RETRY_DELAY_MS)
}
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
sessionID,
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt),
pendingRetry: !!state.pendingRetryTimer,
})
return
}
@@ -191,6 +225,10 @@ export function createAtlasEventHandler(input: {
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const deletedState = sessions.get(sessionInfo.id)
if (deletedState?.pendingRetryTimer) {
clearTimeout(deletedState.pendingRetryTimer)
}
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
}
@@ -200,6 +238,10 @@ export function createAtlasEventHandler(input: {
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
if (sessionID) {
const compactedState = sessions.get(sessionID)
if (compactedState?.pendingRetryTimer) {
clearTimeout(compactedState.pendingRetryTimer)
}
sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID })
}
+173
View File
@@ -1419,5 +1419,178 @@ describe("atlas hook", () => {
// then - should continue because start-work updated session agent to atlas
expect(mockInput._promptMock).toHaveBeenCalled()
})
describe("delayed retry timer (abort-stuck fix)", () => {
test("should schedule delayed retry when cooldown blocks idle for incomplete boulder", async () => {
// given - boulder with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when - first idle injects, second idle within cooldown schedules retry timer
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
// then - wait for retry timer to fire (RETRY_DELAY_MS = 6000ms)
await new Promise(resolve => setTimeout(resolve, 7000))
await flushMicrotasks()
expect(mockInput._promptMock).toHaveBeenCalledTimes(2)
}, 15000)
test("should not schedule duplicate retry timers for rapid idle events", async () => {
// given - boulder with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when - first idle injects, then 3 rapid idles within cooldown
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
// then - wait for retry timer, only one retry should fire
await new Promise(resolve => setTimeout(resolve, 7000))
await flushMicrotasks()
expect(mockInput._promptMock).toHaveBeenCalledTimes(2)
}, 15000)
test("should not retry if plan completes before timer fires", async () => {
// given - boulder with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when - first idle injects, second schedules retry, then plan completes before timer fires
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
// then - wait for retry timer, it should bail out seeing complete plan
await new Promise(resolve => setTimeout(resolve, 7000))
await flushMicrotasks()
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
}, 15000)
test("should cleanup pending retry timer on session.deleted", async () => {
// given - boulder with incomplete plan, schedule retry timer
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
// when - delete session before timer fires
await hook.handler({
event: { type: "session.deleted", properties: { info: { id: MAIN_SESSION_ID } } },
})
// then - wait for timer period, prompt should only have been called once
await new Promise(resolve => setTimeout(resolve, 7000))
await flushMicrotasks()
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
}, 15000)
test("should cleanup pending retry timer on session.compacted", async () => {
// given - boulder with incomplete plan, schedule retry timer
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
})
// when - compact session before timer fires
await hook.handler({
event: { type: "session.compacted", properties: { sessionID: MAIN_SESSION_ID } },
})
// then - wait for timer period, prompt should only have been called once
await new Promise(resolve => setTimeout(resolve, 7000))
await flushMicrotasks()
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
}, 15000)
})
})
})
+1
View File
@@ -29,4 +29,5 @@ export interface SessionState {
lastContinuationInjectedAt?: number
promptFailureCount: number
lastFailureAt?: number
pendingRetryTimer?: ReturnType<typeof setTimeout>
}