From d553bb75a47d0936c2cfd25cbbb85b1380e2110e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Mar 2026 10:38:13 +0900 Subject: [PATCH] Allow registered atlas boulder sessions to continue on idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/hooks/atlas/event-handler.ts | 162 +---------------------- src/hooks/atlas/idle-event.ts | 212 +++++++++++++++++++++++++++++++ src/hooks/atlas/index.test.ts | 57 +++++++-- 3 files changed, 257 insertions(+), 174 deletions(-) create mode 100644 src/hooks/atlas/idle-event.ts diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts index c88062a61..e6084c596 100644 --- a/src/hooks/atlas/event-handler.ts +++ b/src/hooks/atlas/event-handler.ts @@ -1,18 +1,10 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { getPlanProgress, readBoulderState } from "../../features/boulder-state" -import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" -import { getAgentConfigKey } from "../../shared/agent-display-names" import { HOOK_NAME } from "./hook-name" import { isAbortError } from "./is-abort-error" -import { injectBoulderContinuation } from "./boulder-continuation-injector" -import { getLastAgentFromSession } from "./session-last-agent" +import { handleAtlasSessionIdle } from "./idle-event" import type { AtlasHookOptions, SessionState } from "./types" -const CONTINUATION_COOLDOWN_MS = 5000 -const FAILURE_BACKOFF_MS = 5 * 60 * 1000 -const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000 - export function createAtlasEventHandler(input: { ctx: PluginInput options?: AtlasHookOptions @@ -39,157 +31,7 @@ export function createAtlasEventHandler(input: { if (event.type === "session.idle") { const sessionID = props?.sessionID as string | undefined if (!sessionID) return - - log(`[${HOOK_NAME}] session.idle`, { sessionID }) - - // Read boulder state FIRST to check if this session is part of an active boulder - const boulderState = readBoulderState(ctx.directory) - const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false - - const isBackgroundTaskSession = subagentSessions.has(sessionID) - - // Allow continuation only if: session is in boulder's session_ids OR is a background task - if (!isBackgroundTaskSession && !isBoulderSession) { - log(`[${HOOK_NAME}] Skipped: not boulder or background task session`, { sessionID }) - return - } - - const state = getState(sessionID) - const now = Date.now() - - if (state.lastEventWasAbortError) { - state.lastEventWasAbortError = false - log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID }) - return - } - - if (state.promptFailureCount >= 2) { - const timeSinceLastFailure = state.lastFailureAt !== undefined ? now - state.lastFailureAt : Number.POSITIVE_INFINITY - if (timeSinceLastFailure < FAILURE_BACKOFF_MS) { - log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, { - sessionID, - promptFailureCount: state.promptFailureCount, - backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure, - }) - return - } - - state.promptFailureCount = 0 - state.lastFailureAt = undefined - } - - const backgroundManager = options?.backgroundManager - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } - - if (!boulderState) { - log(`[${HOOK_NAME}] No active boulder`, { sessionID }) - return - } - - if (options?.isContinuationStopped?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) - return - } - - const sessionAgent = getSessionAgent(sessionID) - const lastAgent = await getLastAgentFromSession(sessionID, ctx.client) - const effectiveAgent = sessionAgent ?? lastAgent - const lastAgentKey = getAgentConfigKey(effectiveAgent ?? "") - const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas") - const lastAgentMatchesRequired = lastAgentKey === requiredAgent - const boulderAgentDefaultsToAtlas = requiredAgent === "atlas" - const lastAgentIsSisyphus = lastAgentKey === "sisyphus" - const allowSisyphusForAtlasBoulder = boulderAgentDefaultsToAtlas && lastAgentIsSisyphus - const agentMatches = lastAgentMatchesRequired || allowSisyphusForAtlasBoulder - if (!agentMatches) { - log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, { - sessionID, - lastAgent: effectiveAgent ?? "unknown", - requiredAgent, - }) - return - } - - const progress = getPlanProgress(boulderState.active_plan) - if (progress.isComplete) { - log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) - return - } - - if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { - if (!state.pendingRetryTimer) { - state.pendingRetryTimer = setTimeout(async () => { - state.pendingRetryTimer = undefined - - if (state.promptFailureCount >= 2) return - - const currentBoulder = readBoulderState(ctx.directory) - if (!currentBoulder) return - if (!currentBoulder.session_ids?.includes(sessionID)) return - - const currentProgress = getPlanProgress(currentBoulder.active_plan) - if (currentProgress.isComplete) return - - if (options?.isContinuationStopped?.(sessionID)) return - - const hasBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running") - : false - if (hasBgTasks) return - - state.lastContinuationInjectedAt = Date.now() - const currentRemaining = currentProgress.total - currentProgress.completed - try { - await injectBoulderContinuation({ - ctx, - sessionID, - planName: currentBoulder.plan_name, - remaining: currentRemaining, - total: currentProgress.total, - agent: currentBoulder.agent, - worktreePath: currentBoulder.worktree_path, - backgroundManager, - sessionState: state, - }) - } catch (err) { - log(`[${HOOK_NAME}] Delayed retry failed`, { sessionID, error: err }) - state.promptFailureCount++ - } - }, RETRY_DELAY_MS) - } - log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { - sessionID, - cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt), - pendingRetry: !!state.pendingRetryTimer, - }) - return - } - - state.lastContinuationInjectedAt = now - const remaining = progress.total - progress.completed - try { - await injectBoulderContinuation({ - ctx, - sessionID, - planName: boulderState.plan_name, - remaining, - total: progress.total, - agent: boulderState.agent, - worktreePath: boulderState.worktree_path, - backgroundManager, - sessionState: state, - }) - } catch (err) { - log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID, error: err }) - state.promptFailureCount++ - } + await handleAtlasSessionIdle({ ctx, options, getState, sessionID }) return } diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts new file mode 100644 index 000000000..290f00461 --- /dev/null +++ b/src/hooks/atlas/idle-event.ts @@ -0,0 +1,212 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state" +import { log } from "../../shared/logger" +import { getAgentConfigKey } from "../../shared/agent-display-names" +import { injectBoulderContinuation } from "./boulder-continuation-injector" +import { HOOK_NAME } from "./hook-name" +import { getLastAgentFromSession } from "./session-last-agent" +import type { AtlasHookOptions, SessionState } from "./types" + +const CONTINUATION_COOLDOWN_MS = 5000 +const FAILURE_BACKOFF_MS = 5 * 60 * 1000 +const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000 + +function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean { + const backgroundManager = options?.backgroundManager + return backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running") + : false +} + +function shouldSkipForAgentMismatch(input: { + isBoulderSession: boolean + sessionAgent: string | undefined + lastAgent: string | null + requiredAgent: string +}): boolean { + if (input.isBoulderSession) { + return false + } + + const effectiveAgent = input.sessionAgent ?? input.lastAgent ?? "" + const lastAgentKey = getAgentConfigKey(effectiveAgent) + const requiredAgentKey = getAgentConfigKey(input.requiredAgent) + const lastAgentMatchesRequired = lastAgentKey === requiredAgentKey + const allowSisyphusForAtlasBoulder = requiredAgentKey === "atlas" && lastAgentKey === "sisyphus" + + return !lastAgentMatchesRequired && !allowSisyphusForAtlasBoulder +} + +async function injectContinuation(input: { + ctx: PluginInput + sessionID: string + sessionState: SessionState + options?: AtlasHookOptions + planName: string + progress: { total: number; completed: number } + agent?: string + worktreePath?: string +}): Promise { + const remaining = input.progress.total - input.progress.completed + input.sessionState.lastContinuationInjectedAt = Date.now() + + try { + await injectBoulderContinuation({ + ctx: input.ctx, + sessionID: input.sessionID, + planName: input.planName, + remaining, + total: input.progress.total, + agent: input.agent, + worktreePath: input.worktreePath, + backgroundManager: input.options?.backgroundManager, + sessionState: input.sessionState, + }) + } catch (error) { + log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error }) + input.sessionState.promptFailureCount += 1 + } +} + +function scheduleRetry(input: { + ctx: PluginInput + sessionID: string + sessionState: SessionState + options?: AtlasHookOptions +}): void { + const { ctx, sessionID, sessionState, options } = input + if (sessionState.pendingRetryTimer) { + return + } + + sessionState.pendingRetryTimer = setTimeout(async () => { + sessionState.pendingRetryTimer = undefined + + if (sessionState.promptFailureCount >= 2) return + + const currentBoulder = readBoulderState(ctx.directory) + if (!currentBoulder) return + if (!currentBoulder.session_ids?.includes(sessionID)) return + + const currentProgress = getPlanProgress(currentBoulder.active_plan) + if (currentProgress.isComplete) return + if (options?.isContinuationStopped?.(sessionID)) return + if (hasRunningBackgroundTasks(sessionID, options)) return + + await injectContinuation({ + ctx, + sessionID, + sessionState, + options, + planName: currentBoulder.plan_name, + progress: currentProgress, + agent: currentBoulder.agent, + worktreePath: currentBoulder.worktree_path, + }) + }, RETRY_DELAY_MS) +} + +export async function handleAtlasSessionIdle(input: { + ctx: PluginInput + options?: AtlasHookOptions + getState: (sessionID: string) => SessionState + sessionID: string +}): Promise { + const { ctx, options, getState, sessionID } = input + + log(`[${HOOK_NAME}] session.idle`, { sessionID }) + + const boulderState = readBoulderState(ctx.directory) + const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false + const isBackgroundTaskSession = subagentSessions.has(sessionID) + if (!isBackgroundTaskSession && !isBoulderSession) { + log(`[${HOOK_NAME}] Skipped: not boulder or background task session`, { sessionID }) + return + } + + const sessionState = getState(sessionID) + const now = Date.now() + + if (sessionState.lastEventWasAbortError) { + sessionState.lastEventWasAbortError = false + log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID }) + return + } + + if (sessionState.promptFailureCount >= 2) { + const timeSinceLastFailure = + sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY + if (timeSinceLastFailure < FAILURE_BACKOFF_MS) { + log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, { + sessionID, + promptFailureCount: sessionState.promptFailureCount, + backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure, + }) + return + } + + sessionState.promptFailureCount = 0 + sessionState.lastFailureAt = undefined + } + + if (hasRunningBackgroundTasks(sessionID, options)) { + log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) + return + } + + if (!boulderState) { + log(`[${HOOK_NAME}] No active boulder`, { sessionID }) + return + } + + if (options?.isContinuationStopped?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) + return + } + + const sessionAgent = getSessionAgent(sessionID) + const lastAgent = await getLastAgentFromSession(sessionID, ctx.client) + if ( + shouldSkipForAgentMismatch({ + isBoulderSession, + sessionAgent, + lastAgent, + requiredAgent: boulderState.agent ?? "atlas", + }) + ) { + log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, { + sessionID, + lastAgent: sessionAgent ?? lastAgent ?? "unknown", + requiredAgent: boulderState.agent ?? "atlas", + }) + return + } + + const progress = getPlanProgress(boulderState.active_plan) + if (progress.isComplete) { + log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) + return + } + + if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { + scheduleRetry({ ctx, sessionID, sessionState, options }) + log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { + sessionID, + cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - sessionState.lastContinuationInjectedAt), + pendingRetry: !!sessionState.pendingRetryTimer, + }) + return + } + + await injectContinuation({ + ctx, + sessionID, + sessionState, + options, + planName: boulderState.plan_name, + progress, + agent: boulderState.agent, + worktreePath: boulderState.worktree_path, + }) +} diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index cd01eb71a..b9f5c9abf 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -846,6 +846,38 @@ describe("atlas hook", () => { expect(mockInput._promptMock).not.toHaveBeenCalled() }) + test("should inject when registered boulder session has incomplete tasks even if last agent differs", async () => { + cleanupMessageStorage(MAIN_SESSION_ID) + setupMessageStorage(MAIN_SESSION_ID, "hephaestus") + + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + agent: "atlas", + } + writeBoulderState(TEST_DIR, state) + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + expect(mockInput._promptMock).toHaveBeenCalled() + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.path.id).toBe(MAIN_SESSION_ID) + expect(callArgs.body.parts[0].text).toContain("2 remaining") + }) + test("should not inject when boulder plan is complete", async () => { // given - boulder state with complete plan const planPath = join(TEST_DIR, "complete-plan.md") @@ -1083,10 +1115,9 @@ describe("atlas hook", () => { expect(mockInput._promptMock).toHaveBeenCalled() }) - test("should not inject when last agent is non-sisyphus and does not match boulder agent", async () => { - // given - boulder explicitly set to atlas, last agent is hephaestus (unrelated agent) - const planPath = join(TEST_DIR, "test-plan.md") - writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + test("should inject when registered atlas boulder session last agent does not match", async () => { + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") const state: BoulderState = { active_plan: planPath, @@ -1103,17 +1134,15 @@ describe("atlas hook", () => { const mockInput = createMockPluginInput() const hook = createAtlasHook(mockInput) - // when - await hook.handler({ - event: { - type: "session.idle", - properties: { sessionID: MAIN_SESSION_ID }, - }, - }) + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) - // then - should NOT call prompt because hephaestus does not match atlas or sisyphus - expect(mockInput._promptMock).not.toHaveBeenCalled() - }) + expect(mockInput._promptMock).toHaveBeenCalled() + }) test("should inject when last agent matches boulder agent even if non-Atlas", async () => { // given - boulder state expects sisyphus and last agent is sisyphus