From 53337ad68f005f1bf199ec0a7858bdad267b04b2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Mar 2026 12:37:21 +0900 Subject: [PATCH] fix(atlas): append idle subagent sessions to active boulder 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/idle-event.ts | 107 +++++++++++++++++----------------- src/hooks/atlas/index.test.ts | 33 +++++++++++ 2 files changed, 88 insertions(+), 52 deletions(-) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 290f00461..2e9307239 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -1,11 +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 { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state" +import type { BoulderState, PlanProgress } from "../../features/boulder-state" +import { 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 @@ -19,23 +18,42 @@ function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions : false } -function shouldSkipForAgentMismatch(input: { - isBoulderSession: boolean - sessionAgent: string | undefined - lastAgent: string | null - requiredAgent: string -}): boolean { - if (input.isBoulderSession) { - return false +function resolveActiveBoulderSession(input: { + directory: string + sessionID: string +}): { + boulderState: BoulderState + progress: PlanProgress + appendedSession: boolean +} | null { + const boulderState = readBoulderState(input.directory) + if (!boulderState) { + return null } - 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" + const progress = getPlanProgress(boulderState.active_plan) + if (progress.isComplete) { + return { boulderState, progress, appendedSession: false } + } - return !lastAgentMatchesRequired && !allowSisyphusForAtlasBoulder + if (boulderState.session_ids.includes(input.sessionID)) { + return { boulderState, progress, appendedSession: false } + } + + if (!subagentSessions.has(input.sessionID)) { + return null + } + + const updatedBoulderState = appendSessionId(input.directory, input.sessionID) + if (!updatedBoulderState?.session_ids.includes(input.sessionID)) { + return null + } + + return { + boulderState: updatedBoulderState, + progress, + appendedSession: true, + } } async function injectContinuation(input: { @@ -117,14 +135,28 @@ export async function handleAtlasSessionIdle(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 }) + const activeBoulderSession = resolveActiveBoulderSession({ + directory: ctx.directory, + sessionID, + }) + if (!activeBoulderSession) { + log(`[${HOOK_NAME}] Skipped: session not registered in active boulder`, { sessionID }) return } + const { boulderState, progress, appendedSession } = activeBoulderSession + if (progress.isComplete) { + log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) + return + } + + if (appendedSession) { + log(`[${HOOK_NAME}] Appended subagent session to boulder during idle`, { + sessionID, + plan: boulderState.plan_name, + }) + } + const sessionState = getState(sessionID) const now = Date.now() @@ -155,40 +187,11 @@ export async function handleAtlasSessionIdle(input: { 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`, { diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index b9f5c9abf..4bc9d5dc1 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -846,6 +846,39 @@ describe("atlas hook", () => { expect(mockInput._promptMock).not.toHaveBeenCalled() }) + test("should append subagent session to boulder before injecting continuation", async () => { + // given - active boulder plan with another registered session and current session tracked as subagent + const subagentSessionID = "subagent-session-456" + 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", + } + writeBoulderState(TEST_DIR, state) + subagentSessions.add(subagentSessionID) + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + // when - subagent session goes idle before parent task output appends it + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: subagentSessionID }, + }, + }) + + // then - session is registered into boulder and continuation is injected + expect(readBoulderState(TEST_DIR)?.session_ids).toContain(subagentSessionID) + expect(mockInput._promptMock).toHaveBeenCalled() + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.path.id).toBe(subagentSessionID) + }) + 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")