From 1a66b96bb8c9f62f3b8e574769821ca1d80609e8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 12:43:26 +0900 Subject: [PATCH] docs: note Atlas stalled continuation fix Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- CHANGELOG.md | 3 +- src/hooks/atlas/tool-progress.test.ts | 124 ++++++++++++++++++++++++++ src/hooks/atlas/tool-progress.ts | 23 ++++- src/hooks/atlas/types.ts | 3 + 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 src/hooks/atlas/tool-progress.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2970e64d..4cc0a82b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/hooks/atlas/tool-progress.test.ts b/src/hooks/atlas/tool-progress.test.ts new file mode 100644 index 000000000..75bfb0752 --- /dev/null +++ b/src/hooks/atlas/tool-progress.test.ts @@ -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") + }) + }) +}) diff --git a/src/hooks/atlas/tool-progress.ts b/src/hooks/atlas/tool-progress.ts index c9e48b4f6..cb765c16a 100644 --- a/src/hooks/atlas/tool-progress.ts +++ b/src/hooks/atlas/tool-progress.ts @@ -31,6 +31,26 @@ export function recordToolProgress(state: SessionState, now = Date.now()): void 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 { @@ -51,6 +71,7 @@ export function shouldAbortForNoToolProgress(state: SessionState): boolean { return (state.iterationsSinceLastToolProgress ?? 0) >= MAX_BOULDER_CONTINUATION_NO_TOOL_PROGRESS } -export function markContinuationStalled(state: SessionState, planName: string): void { +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 } diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index c623c9fea..cb2b1b2e4 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -53,4 +53,7 @@ export interface SessionState { 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 }