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 CONTINUATION_COOLDOWN_MS = 5000
|
||||||
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||||
|
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
||||||
|
|
||||||
export function createAtlasEventHandler(input: {
|
export function createAtlasEventHandler(input: {
|
||||||
ctx: PluginInput
|
ctx: PluginInput
|
||||||
@@ -123,9 +124,42 @@ export function createAtlasEventHandler(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
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`, {
|
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt),
|
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt),
|
||||||
|
pendingRetry: !!state.pendingRetryTimer,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -191,6 +225,10 @@ export function createAtlasEventHandler(input: {
|
|||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionInfo = props?.info as { id?: string } | undefined
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) {
|
||||||
|
const deletedState = sessions.get(sessionInfo.id)
|
||||||
|
if (deletedState?.pendingRetryTimer) {
|
||||||
|
clearTimeout(deletedState.pendingRetryTimer)
|
||||||
|
}
|
||||||
sessions.delete(sessionInfo.id)
|
sessions.delete(sessionInfo.id)
|
||||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: 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") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
|
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
|
const compactedState = sessions.get(sessionID)
|
||||||
|
if (compactedState?.pendingRetryTimer) {
|
||||||
|
clearTimeout(compactedState.pendingRetryTimer)
|
||||||
|
}
|
||||||
sessions.delete(sessionID)
|
sessions.delete(sessionID)
|
||||||
log(`[${HOOK_NAME}] Session compacted: cleaned up`, { 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
|
// then - should continue because start-work updated session agent to atlas
|
||||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
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
|
lastContinuationInjectedAt?: number
|
||||||
promptFailureCount: number
|
promptFailureCount: number
|
||||||
lastFailureAt?: number
|
lastFailureAt?: number
|
||||||
|
pendingRetryTimer?: ReturnType<typeof setTimeout>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user