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.
This commit is contained in:
YeonGyu-Kim
2026-05-18 14:18:33 +09:00
parent 1a66b96bb8
commit 6dc31b2c4f
2 changed files with 52 additions and 1 deletions
+4 -1
View File
@@ -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
+48
View File
@@ -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")