From 6dc31b2c4f3518c7297a1f797eea0f5664061b3e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 14:18:33 +0900 Subject: [PATCH] fix(atlas): scope no-tool-progress counter to active plan path before stall Oracle review on PR #4132 flagged that resetStallStateForPlanChange only reset state when stalledContinuationReason was already set, so partial in-progress counts from plan A could carry to plan B and abort the new plan on its first idle. Adds activeContinuationPlanPath to SessionState and resets the no-tool-progress counter + awaiting state whenever the active plan path changes, even when no stall has occurred yet. The existing stalled-plan reset still fires when the previously stalled plan path differs from the new one. --- src/hooks/atlas/idle-event.ts | 5 +++- src/hooks/atlas/index.test.ts | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 716f3e2df..4cba2c831 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -27,6 +27,7 @@ import { resolveActiveBoulderSession } from "./resolve-active-boulder-session" import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates" import { markContinuationStalled, + resetStallStateForPlanChange, shouldAbortForNoToolProgress, updateNoToolProgressIterations, } from "./tool-progress" @@ -352,6 +353,8 @@ export async function handleAtlasSessionIdle(input: { } const now = Date.now() + const activePlanPath = resolveBoulderPlanPath(ctx.directory, boulderState) + resetStallStateForPlanChange(sessionState, activePlanPath) if (sessionState.waitingForFinalWaveApproval) { log(`[${HOOK_NAME}] Skipped: waiting for explicit final-wave approval`, { sessionID }) @@ -368,7 +371,7 @@ export async function handleAtlasSessionIdle(input: { const noProgressIterations = updateNoToolProgressIterations(sessionState) if (shouldAbortForNoToolProgress(sessionState)) { - markContinuationStalled(sessionState, boulderState.plan_name) + markContinuationStalled(sessionState, boulderState.plan_name, activePlanPath) if (sessionState.pendingRetryTimer) { clearTimeout(sessionState.pendingRetryTimer) sessionState.pendingRetryTimer = undefined diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 9835bb0ed..1dfa0743e 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -2015,6 +2015,54 @@ session_id: ses_untrusted_999 } }) + test("#given one plan stalls #when a different boulder plan becomes active #then Atlas continues the new plan", async () => { + // given - a boulder plan that reaches the stalled no-tool-progress threshold + const firstPlanPath = join(TEST_DIR, "first-blocked-loop-plan.md") + writeFileSync(firstPlanPath, "# Plan\n- [ ] Wait for external approval") + + writeBoulderState(TEST_DIR, { + active_plan: firstPlanPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "first-blocked-loop-plan", + }) + + const mockInput = createMockPluginInput() + const hook = createTestAtlasHook(mockInput) + + const originalDateNow = Date.now + let now = 0 + Date.now = () => now + + try { + for (let iteration = 0; iteration < 4; iteration += 1) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } + expect(mockInput._promptMock).toHaveBeenCalledTimes(3) + + const secondPlanPath = join(TEST_DIR, "second-plan.md") + writeFileSync(secondPlanPath, "# Plan\n- [ ] Fresh task") + writeBoulderState(TEST_DIR, { + active_plan: secondPlanPath, + started_at: "2026-01-02T10:10:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "second-plan", + }) + now += 6000 + + // when - the same session id receives a different active plan + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + + // then - stale stall state from the previous plan does not permanently block continuation + expect(mockInput._promptMock).toHaveBeenCalledTimes(4) + } finally { + Date.now = originalDateNow + } + }) + test("#given continuation makes tangible tool progress #when idle repeats #then no-progress stall counter resets", async () => { // given - boulder state with incomplete work and a successful edit between continuation turns const planPath = join(TEST_DIR, "progress-plan.md")