Merge pull request #4132 from code-yeongyu/fix/3446-atlas-runaway-loop
fix(atlas): stop runaway boulder continuation loops
This commit is contained in:
+2
-1
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user