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/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index 4451903ae..640db5b86 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -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) { diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts index 2ad001df4..3d279e4c3 100644 --- a/src/hooks/atlas/event-handler.ts +++ b/src/hooks/atlas/event-handler.ts @@ -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 }) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 973b079ec..4cba2c831 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -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 }) diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index c7c8d9b28..1dfa0743e 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -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") diff --git a/src/hooks/atlas/system-reminder-templates.ts b/src/hooks/atlas/system-reminder-templates.ts index 5ce0dab9e..4df66990d 100644 --- a/src/hooks/atlas/system-reminder-templates.ts +++ b/src/hooks/atlas/system-reminder-templates.ts @@ -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 = ` BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked. diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 8aeef62ff..42f958a62 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -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 } 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 new file mode 100644 index 000000000..cb765c16a --- /dev/null +++ b/src/hooks/atlas/tool-progress.ts @@ -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 +} diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index 4c03d3966..cb2b1b2e4 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -49,4 +49,11 @@ export interface SessionState { pendingFinalWaveTaskCount?: number approvedFinalWaveTaskCount?: number boulderCompletionNudgedAt?: Record + 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 }