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:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,4 +29,5 @@ export interface SessionState {
|
||||
lastContinuationInjectedAt?: number
|
||||
promptFailureCount: number
|
||||
lastFailureAt?: number
|
||||
pendingRetryTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user