diff --git a/src/hooks/atlas/boulder-continuation-injector.test.ts b/src/hooks/atlas/boulder-continuation-injector.test.ts index 5eb6e90c1..bb63d1c25 100644 --- a/src/hooks/atlas/boulder-continuation-injector.test.ts +++ b/src/hooks/atlas/boulder-continuation-injector.test.ts @@ -31,7 +31,7 @@ describe("injectBoulderContinuation", () => { } as unknown as PluginInput // when - await injectBoulderContinuation({ + const result = await injectBoulderContinuation({ ctx, sessionID: "ses_test_123", planName: "test-plan", @@ -42,6 +42,7 @@ describe("injectBoulderContinuation", () => { }) // then + expect(result).toBe("injected") expect(promptAsyncMock).toHaveBeenCalledTimes(1) expect(promptAsyncMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -51,4 +52,73 @@ describe("injectBoulderContinuation", () => { }), ) }) + + test("#given background tasks are running #when injector checks again #then it reports skipped background tasks without mutating failure count", async () => { + // given + registerAgentName("atlas") + const promptAsyncMock = mock(async (_request: unknown) => undefined) + const messagesMock = mock(async () => ({ data: [] })) + const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 } + + const ctx = { + directory: "/tmp", + client: { + session: { + messages: messagesMock, + promptAsync: promptAsyncMock, + }, + }, + } as unknown as PluginInput + + // when + const result = await injectBoulderContinuation({ + ctx, + sessionID: "ses_test_123", + planName: "test-plan", + remaining: 1, + total: 2, + agent: "atlas", + backgroundManager: { + getTasksByParentSession: () => [{ status: "running" }], + } as unknown as Parameters[0]["backgroundManager"], + sessionState, + }) + + // then + expect(result).toBe("skipped_background_tasks") + expect(promptAsyncMock).not.toHaveBeenCalled() + expect(sessionState.promptFailureCount).toBe(2) + expect(sessionState.lastContinuationInjectedAt).toBe(123) + }) + + test("#given the continuation agent is unavailable #when injector runs #then it reports skipped agent unavailable without prompting", async () => { + // given + const promptAsyncMock = mock(async (_request: unknown) => undefined) + const messagesMock = mock(async () => ({ data: [] })) + + const ctx = { + directory: "/tmp", + client: { + session: { + messages: messagesMock, + promptAsync: promptAsyncMock, + }, + }, + } as unknown as PluginInput + + // when + const result = await injectBoulderContinuation({ + ctx, + sessionID: "ses_test_123", + planName: "test-plan", + remaining: 1, + total: 2, + agent: "missing-agent", + sessionState: { promptFailureCount: 0 }, + }) + + // then + expect(result).toBe("skipped_agent_unavailable") + expect(promptAsyncMock).not.toHaveBeenCalled() + }) }) diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index 7d64c6175..48a6f1edc 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -9,6 +9,8 @@ import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates" import { resolveRecentPromptContextForSession } from "./recent-model-resolver" import type { SessionState } from "./types" +export type BoulderContinuationResult = "injected" | "skipped_background_tasks" | "skipped_agent_unavailable" | "failed" + export async function injectBoulderContinuation(input: { ctx: PluginInput sessionID: string @@ -21,7 +23,7 @@ export async function injectBoulderContinuation(input: { preferredTaskTitle?: string backgroundManager?: BackgroundManager sessionState: SessionState -}): Promise { +}): Promise { const { ctx, sessionID, @@ -42,7 +44,7 @@ export async function injectBoulderContinuation(input: { if (hasRunningBgTasks) { log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) - return + return "skipped_background_tasks" } const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : "" @@ -61,7 +63,7 @@ export async function injectBoulderContinuation(input: { sessionID, agent: continuationAgent ?? agent ?? "unknown", }) - return + return "skipped_agent_unavailable" } try { @@ -83,6 +85,7 @@ export async function injectBoulderContinuation(input: { sessionState.promptFailureCount = 0 log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID }) + return "injected" } catch (err) { sessionState.promptFailureCount += 1 sessionState.lastFailureAt = Date.now() @@ -91,5 +94,6 @@ export async function injectBoulderContinuation(input: { error: String(err), promptFailureCount: sessionState.promptFailureCount, }) + return "failed" } } diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 05734e4a3..c58ce0ff3 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -36,7 +36,17 @@ async function injectContinuation(input: { worktreePath?: string }): Promise { const remaining = input.progress.total - input.progress.completed - input.sessionState.lastContinuationInjectedAt = Date.now() + if (input.sessionState.isInjectingContinuation) { + scheduleRetry({ + ctx: input.ctx, + sessionID: input.sessionID, + sessionState: input.sessionState, + options: input.options, + }) + return + } + + input.sessionState.isInjectingContinuation = true try { const currentBoulder = readBoulderState(input.ctx.directory) @@ -47,7 +57,7 @@ async function injectContinuation(input: { ? getTaskSessionState(input.ctx.directory, currentTask.key) : null - await injectBoulderContinuation({ + const result = await injectBoulderContinuation({ ctx: input.ctx, sessionID: input.sessionID, planName: input.planName, @@ -60,9 +70,46 @@ async function injectContinuation(input: { backgroundManager: input.options?.backgroundManager, sessionState: input.sessionState, }) + + if (result === "injected") { + if (input.sessionState.pendingRetryTimer) { + clearTimeout(input.sessionState.pendingRetryTimer) + input.sessionState.pendingRetryTimer = undefined + } + input.sessionState.lastContinuationInjectedAt = Date.now() + return + } + + if (result === "skipped_background_tasks") { + scheduleRetry({ + ctx: input.ctx, + sessionID: input.sessionID, + sessionState: input.sessionState, + options: input.options, + }) + return + } + + if (result === "failed") { + scheduleRetry({ + ctx: input.ctx, + sessionID: input.sessionID, + sessionState: input.sessionState, + options: input.options, + }) + } } catch (error) { log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error }) input.sessionState.promptFailureCount += 1 + input.sessionState.lastFailureAt = Date.now() + scheduleRetry({ + ctx: input.ctx, + sessionID: input.sessionID, + sessionState: input.sessionState, + options: input.options, + }) + } finally { + input.sessionState.isInjectingContinuation = false } } @@ -83,6 +130,14 @@ function scheduleRetry(input: { if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) return if (sessionState.waitingForFinalWaveApproval) return + const now = Date.now() + if ( + sessionState.lastContinuationInjectedAt + && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS + ) { + return + } + const currentBoulder = readBoulderState(ctx.directory) if (!currentBoulder) return if (!currentBoulder.session_ids?.includes(sessionID)) return