Merge pull request #4132 from code-yeongyu/fix/3446-atlas-runaway-loop

fix(atlas): stop runaway boulder continuation loops
This commit is contained in:
YeonGyu-Kim
2026-05-18 14:43:03 +09:00
committed by GitHub
10 changed files with 408 additions and 2 deletions
+2 -1
View File
@@ -12,11 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Relanded BLOCKER-4 delegated child-session empty-history fallback. Runtime fallback now consumes the captured bootstrap prompt when a delegated child session fails before history is persisted, while preserving delegated system prompts and tool permissions for the retry.
- Team Mode fresh-install diagnostics now log the resolved `team_mode` config and tool-registry team tool count, making #3893-style missing `team_*` registrations visible instead of silent.
- Added a regression test proving a fresh minimal user config with `{ "team_mode": { "enabled": true } }` registers all 12 `team_*` tools.
- Atlas boulder continuation now hard-stalls after three consecutive continuation turns with no successful bash/edit/write tool progress, preventing the #3446 runaway loop where text-only blocker reports kept the session alive for hours.
- Strengthened the boulder continuation prompt so externally blocked tasks must be marked in the plan as `- [~]` via an actual file edit before Atlas moves on.
### Documentation
- Marked the v4.2.0 BLOCKER-4 known issue as resolved in v4.2.1.
## [4.2.0] - 2026-05-15
### Added
@@ -9,6 +9,7 @@ import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools }
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { HOOK_NAME } from "./hook-name"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
import { markContinuationInjectedAwaitingToolProgress } from "./tool-progress"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import type { BackgroundTaskStatusProvider, SessionState } from "./types"
@@ -122,6 +123,7 @@ export async function injectBoulderContinuation(input: {
}
sessionState.promptFailureCount = 0
markContinuationInjectedAwaitingToolProgress(sessionState)
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
return "injected"
} catch (err) {
+2
View File
@@ -96,6 +96,7 @@ export function createAtlasEventHandler(input: {
const deletedState = sessions.get(sessionID)
if (deletedState?.pendingRetryTimer) {
clearTimeout(deletedState.pendingRetryTimer)
deletedState.pendingRetryTimer = undefined
}
sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
@@ -109,6 +110,7 @@ export function createAtlasEventHandler(input: {
const compactedState = sessions.get(sessionID)
if (compactedState?.pendingRetryTimer) {
clearTimeout(compactedState.pendingRetryTimer)
compactedState.pendingRetryTimer = undefined
}
sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID })
+33
View File
@@ -25,6 +25,12 @@ import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
import {
markContinuationStalled,
resetStallStateForPlanChange,
shouldAbortForNoToolProgress,
updateNoToolProgressIterations,
} from "./tool-progress"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
@@ -172,6 +178,7 @@ function scheduleRetry(input: {
sessionState.pendingRetryTimer = undefined
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) return
if (sessionState.stalledContinuationReason) return
if (sessionState.waitingForFinalWaveApproval) return
const now = Date.now()
@@ -346,12 +353,38 @@ 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 })
return
}
if (sessionState.stalledContinuationReason) {
log(`[${HOOK_NAME}] Skipped: boulder continuation stalled`, {
sessionID,
reason: sessionState.stalledContinuationReason,
})
return
}
const noProgressIterations = updateNoToolProgressIterations(sessionState)
if (shouldAbortForNoToolProgress(sessionState)) {
markContinuationStalled(sessionState, boulderState.plan_name, activePlanPath)
if (sessionState.pendingRetryTimer) {
clearTimeout(sessionState.pendingRetryTimer)
sessionState.pendingRetryTimer = undefined
}
log(`[${HOOK_NAME}] Aborting boulder continuation after repeated no-tool-progress iterations`, {
sessionID,
plan: boulderState.plan_name,
noProgressIterations,
reason: sessionState.stalledContinuationReason,
})
return
}
if (sessionState.lastEventWasAbortError) {
sessionState.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
+154
View File
@@ -1950,6 +1950,160 @@ session_id: ses_untrusted_999
expect(callArgs.body.parts[0].text).toContain("ses_auth_flow_123")
})
test("#given blocked-task continuation #when prompt is built #then it requires a plan edit to mark the task blocked", async () => {
// given - boulder state with an incomplete externally blockable task
const planPath = join(TEST_DIR, "blocked-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Wait for external credentials")
writeBoulderState(TEST_DIR, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "blocked-plan",
})
const mockInput = createMockPluginInput()
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - prompt enforces the behavioral invariant instead of allowing text-only blocker reports
const callArgs = mockInput._promptMock.mock.calls[0][0]
const promptText = callArgs.body.parts[0].text
expect(promptText).toContain("- [~]")
expect(promptText).toMatch(/edit the plan file/i)
expect(promptText).toMatch(/text-only explanation.*not progress/i)
})
test("#given continuation emits text without tool progress #when three continuation iterations repeat #then Atlas stalls instead of looping", async () => {
// given - boulder state with one externally blocked task that never receives a tool edit
const planPath = join(TEST_DIR, "blocked-loop-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Wait for external approval")
writeBoulderState(TEST_DIR, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "blocked-loop-plan",
})
const mockInput = createMockPluginInput()
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
// when - each idle represents a completed text-only continuation turn with no bash/edit/write progress
for (let iteration = 0; iteration < 4; iteration += 1) {
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
}
// then - K=3 continuations are attempted, and the fourth idle aborts cleanly instead of injecting again
expect(mockInput._promptMock).toHaveBeenCalledTimes(3)
} finally {
Date.now = originalDateNow
}
})
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")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
writeBoulderState(TEST_DIR, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "progress-plan",
})
const mockInput = createMockPluginInput()
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
// when - the first continuation is followed by a successful edit tool result
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await hook["tool.execute.after"](
{ tool: "edit", sessionID: MAIN_SESSION_ID, callID: "progress-edit" },
{ title: "Edited file", output: "Updated plan", metadata: { filePath: planPath } },
)
now += 6000
for (let iteration = 0; iteration < 3; iteration += 1) {
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
}
// then - one progress reset plus three no-progress continuation attempts are allowed
expect(mockInput._promptMock).toHaveBeenCalledTimes(4)
} finally {
Date.now = originalDateNow
}
})
test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => {
// given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work)
const planPath = join(TEST_DIR, "test-plan.md")
+2 -1
View File
@@ -31,7 +31,8 @@ RULES:
- Proceed without asking for permission
- Use the notepad at .omo/notepads/{PLAN_NAME}/ to record learnings
- Do not stop until all tasks are complete
- If blocked, document the blocker and move to the next task`
- If a task is blocked by missing external input, unavailable credentials, access limits, or a decision only the user can make, you MUST edit the plan file in this turn and change that task's checkbox from \`- [ ]\` to \`- [~]\` before moving on
- A text-only explanation of a blocker is NOT progress. The \`- [~]\` checkbox edit is mandatory and must happen via a real file-editing tool call`
export const BOULDER_COMPLETE_PROMPT = `<system-reminder>
BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked.
+5
View File
@@ -22,6 +22,7 @@ import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
import { isOmoPath } from "./omo-path"
import { resolvePreferredSessionId, resolveTaskContext } from "./task-context"
import { extractSessionIdFromMetadata, extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
import { didToolMakeProgress, isTangibleProgressTool, recordToolProgress } from "./tool-progress"
import {
buildCompletionGate,
buildFinalWaveApprovalReminder,
@@ -142,6 +143,10 @@ export function createToolExecuteAfterHandler(input: {
return
}
if (toolInput.sessionID && isTangibleProgressTool(toolInput.tool) && didToolMakeProgress(toolOutput)) {
recordToolProgress(getState(toolInput.sessionID))
}
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from "bun:test"
import {
recordToolProgress,
resetStallStateForPlanChange,
shouldAbortForNoToolProgress,
updateNoToolProgressIterations,
markContinuationInjectedAwaitingToolProgress,
markContinuationStalled,
MAX_BOULDER_CONTINUATION_NO_TOOL_PROGRESS,
} from "./tool-progress"
import type { SessionState } from "./types"
function emptyState(): SessionState {
return { promptFailureCount: 0 }
}
describe("#given a fresh session state", () => {
describe("#when resetStallStateForPlanChange is called the first time", () => {
test("#then it records the active plan path without touching counters", () => {
// given
const state = emptyState()
// when
resetStallStateForPlanChange(state, "/plans/a.md")
// then
expect(state.activeContinuationPlanPath).toBe("/plans/a.md")
expect(state.iterationsSinceLastToolProgress).toBeUndefined()
expect(state.awaitingToolProgressAfterContinuation).toBeUndefined()
expect(state.stalledContinuationReason).toBeUndefined()
})
})
})
describe("#given a session that already accumulated no-tool-progress for plan A", () => {
describe("#when the active plan switches to plan B before the stall threshold is hit", () => {
test("#then iterations and awaiting state reset so plan B gets a fresh budget", () => {
// given - plan A starts and the agent racks up 2 no-progress iterations without stalling yet
const state = emptyState()
resetStallStateForPlanChange(state, "/plans/a.md")
markContinuationInjectedAwaitingToolProgress(state)
updateNoToolProgressIterations(state)
markContinuationInjectedAwaitingToolProgress(state)
updateNoToolProgressIterations(state)
expect(state.iterationsSinceLastToolProgress).toBe(2)
// when - active plan switches to plan B
resetStallStateForPlanChange(state, "/plans/b.md")
// then - plan B inherits a clean counter and is NOT one idle away from a false stall
expect(state.activeContinuationPlanPath).toBe("/plans/b.md")
expect(state.iterationsSinceLastToolProgress).toBe(0)
expect(state.awaitingToolProgressAfterContinuation).toBe(false)
expect(shouldAbortForNoToolProgress(state)).toBe(false)
})
})
describe("#when the active plan stays the same", () => {
test("#then counters are preserved across the reset call", () => {
// given
const state = emptyState()
resetStallStateForPlanChange(state, "/plans/a.md")
markContinuationInjectedAwaitingToolProgress(state)
updateNoToolProgressIterations(state)
// when
resetStallStateForPlanChange(state, "/plans/a.md")
// then
expect(state.iterationsSinceLastToolProgress).toBe(1)
expect(state.activeContinuationPlanPath).toBe("/plans/a.md")
})
})
})
describe("#given a session that already stalled on plan A", () => {
describe("#when the active plan switches to a different plan B", () => {
test("#then both the stall state and the in-progress counter clear for the new plan", () => {
// given - plan A reached the stall threshold and got marked stalled
const state = emptyState()
resetStallStateForPlanChange(state, "/plans/a.md")
for (let i = 0; i < MAX_BOULDER_CONTINUATION_NO_TOOL_PROGRESS; i += 1) {
markContinuationInjectedAwaitingToolProgress(state)
updateNoToolProgressIterations(state)
}
markContinuationStalled(state, "a", "/plans/a.md")
expect(shouldAbortForNoToolProgress(state)).toBe(true)
expect(state.stalledContinuationReason).toBeDefined()
// when
resetStallStateForPlanChange(state, "/plans/b.md")
// then
expect(state.activeContinuationPlanPath).toBe("/plans/b.md")
expect(state.stalledContinuationReason).toBeUndefined()
expect(state.stalledContinuationPlanPath).toBeUndefined()
expect(state.iterationsSinceLastToolProgress).toBe(0)
expect(state.awaitingToolProgressAfterContinuation).toBe(false)
expect(shouldAbortForNoToolProgress(state)).toBe(false)
})
})
})
describe("#given a session running on plan A with tool progress", () => {
describe("#when recordToolProgress fires", () => {
test("#then counters clear but the activeContinuationPlanPath is preserved", () => {
// given
const state = emptyState()
resetStallStateForPlanChange(state, "/plans/a.md")
markContinuationInjectedAwaitingToolProgress(state)
updateNoToolProgressIterations(state)
// when
recordToolProgress(state, 1000)
// then
expect(state.iterationsSinceLastToolProgress).toBe(0)
expect(state.awaitingToolProgressAfterContinuation).toBe(false)
expect(state.lastToolProgressAt).toBe(1000)
expect(state.activeContinuationPlanPath).toBe("/plans/a.md")
})
})
})
+77
View File
@@ -0,0 +1,77 @@
import type { SessionState } from "./types"
const TANGIBLE_PROGRESS_TOOLS = new Set([
"bash",
"edit",
"write",
])
const FAILURE_TITLE_PATTERN = /(?:\berror\b|\bfailed\b|\bfailure\b|\bdenied\b|\brejected\b)/i
const FAILURE_OUTPUT_PATTERN = /^\s*(?:error|failed|failure|denied|rejected)\b/i
export const MAX_BOULDER_CONTINUATION_NO_TOOL_PROGRESS = 3
export type ToolProgressOutput = {
title?: string
output?: string
}
export function isTangibleProgressTool(toolName: string): boolean {
return TANGIBLE_PROGRESS_TOOLS.has(toolName.toLowerCase())
}
export function didToolMakeProgress(output: ToolProgressOutput): boolean {
const title = output.title ?? ""
const body = output.output ?? ""
return !FAILURE_TITLE_PATTERN.test(title) && !FAILURE_OUTPUT_PATTERN.test(body)
}
export function recordToolProgress(state: SessionState, now = Date.now()): void {
state.awaitingToolProgressAfterContinuation = false
state.iterationsSinceLastToolProgress = 0
state.lastToolProgressAt = now
state.stalledContinuationReason = undefined
state.stalledContinuationPlanPath = undefined
}
export function resetStallStateForPlanChange(state: SessionState, planPath: string): void {
const previousPlanPath = state.activeContinuationPlanPath
if (previousPlanPath === undefined) {
state.activeContinuationPlanPath = planPath
return
}
if (previousPlanPath === planPath) {
return
}
state.activeContinuationPlanPath = planPath
state.iterationsSinceLastToolProgress = 0
state.awaitingToolProgressAfterContinuation = false
if (state.stalledContinuationReason && state.stalledContinuationPlanPath !== planPath) {
state.stalledContinuationReason = undefined
state.stalledContinuationPlanPath = undefined
}
}
export function markContinuationInjectedAwaitingToolProgress(state: SessionState): void {
state.awaitingToolProgressAfterContinuation = true
}
export function updateNoToolProgressIterations(state: SessionState): number {
if (!state.awaitingToolProgressAfterContinuation) {
return state.iterationsSinceLastToolProgress ?? 0
}
state.awaitingToolProgressAfterContinuation = false
state.iterationsSinceLastToolProgress = (state.iterationsSinceLastToolProgress ?? 0) + 1
return state.iterationsSinceLastToolProgress
}
export function shouldAbortForNoToolProgress(state: SessionState): boolean {
return (state.iterationsSinceLastToolProgress ?? 0) >= MAX_BOULDER_CONTINUATION_NO_TOOL_PROGRESS
}
export function markContinuationStalled(state: SessionState, planName: string, planPath: string): void {
state.stalledContinuationReason = `Boulder continuation stalled for plan "${planName}": ${MAX_BOULDER_CONTINUATION_NO_TOOL_PROGRESS} consecutive continuation iterations produced no successful bash/edit/write tool progress.`
state.stalledContinuationPlanPath = planPath
}
+7
View File
@@ -49,4 +49,11 @@ export interface SessionState {
pendingFinalWaveTaskCount?: number
approvedFinalWaveTaskCount?: number
boulderCompletionNudgedAt?: Record<string, number>
awaitingToolProgressAfterContinuation?: boolean
iterationsSinceLastToolProgress?: number
lastToolProgressAt?: number
stalledContinuationReason?: string
stalledContinuationPlanPath?: string
/** The plan path the in-progress no-tool-progress counter is keyed to. Changes here reset the counter. */
activeContinuationPlanPath?: string
}