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:
@@ -27,6 +27,7 @@ import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
|||||||
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
|
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
|
||||||
import {
|
import {
|
||||||
markContinuationStalled,
|
markContinuationStalled,
|
||||||
|
resetStallStateForPlanChange,
|
||||||
shouldAbortForNoToolProgress,
|
shouldAbortForNoToolProgress,
|
||||||
updateNoToolProgressIterations,
|
updateNoToolProgressIterations,
|
||||||
} from "./tool-progress"
|
} from "./tool-progress"
|
||||||
@@ -352,6 +353,8 @@ export async function handleAtlasSessionIdle(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
const activePlanPath = resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||||
|
resetStallStateForPlanChange(sessionState, activePlanPath)
|
||||||
|
|
||||||
if (sessionState.waitingForFinalWaveApproval) {
|
if (sessionState.waitingForFinalWaveApproval) {
|
||||||
log(`[${HOOK_NAME}] Skipped: waiting for explicit final-wave approval`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: waiting for explicit final-wave approval`, { sessionID })
|
||||||
@@ -368,7 +371,7 @@ export async function handleAtlasSessionIdle(input: {
|
|||||||
|
|
||||||
const noProgressIterations = updateNoToolProgressIterations(sessionState)
|
const noProgressIterations = updateNoToolProgressIterations(sessionState)
|
||||||
if (shouldAbortForNoToolProgress(sessionState)) {
|
if (shouldAbortForNoToolProgress(sessionState)) {
|
||||||
markContinuationStalled(sessionState, boulderState.plan_name)
|
markContinuationStalled(sessionState, boulderState.plan_name, activePlanPath)
|
||||||
if (sessionState.pendingRetryTimer) {
|
if (sessionState.pendingRetryTimer) {
|
||||||
clearTimeout(sessionState.pendingRetryTimer)
|
clearTimeout(sessionState.pendingRetryTimer)
|
||||||
sessionState.pendingRetryTimer = undefined
|
sessionState.pendingRetryTimer = undefined
|
||||||
|
|||||||
@@ -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 () => {
|
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
|
// given - boulder state with incomplete work and a successful edit between continuation turns
|
||||||
const planPath = join(TEST_DIR, "progress-plan.md")
|
const planPath = join(TEST_DIR, "progress-plan.md")
|
||||||
|
|||||||
Reference in New Issue
Block a user