From cd71ced0fbda56b11e59f58f3551d19df63f9e61 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 5 Apr 2026 17:14:47 +0900 Subject: [PATCH] feat(atlas): add canContinueTrackedBoulderSession for lineage-aware continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement canContinueTrackedBoulderSession helper function - Add lineage validation for appended descendant sessions - Add agent matching logic for tracked sessions - Add comprehensive tests for lineage continuation scenarios - Add persisted lineage tests for boulder state tracking 🤖 Generated with assistance of OhMyOpenCode --- src/hooks/atlas/idle-event-lineage.test.ts | 15 +- .../idle-event-persisted-lineage.test.ts | 244 ++++++++++++++++++ src/hooks/atlas/idle-event.ts | 104 ++++++-- 3 files changed, 332 insertions(+), 31 deletions(-) create mode 100644 src/hooks/atlas/idle-event-persisted-lineage.test.ts diff --git a/src/hooks/atlas/idle-event-lineage.test.ts b/src/hooks/atlas/idle-event-lineage.test.ts index 112c676b0..5beea6397 100644 --- a/src/hooks/atlas/idle-event-lineage.test.ts +++ b/src/hooks/atlas/idle-event-lineage.test.ts @@ -100,7 +100,7 @@ describe("atlas hook idle-event session lineage", () => { assert.equal(promptCalls.length, 0) }) - it("appends boulder-owned subagent sessions during idle when lineage reaches tracked session", async () => { + it("does not append lineage-only subagent sessions during idle even when lineage reaches tracked session", async () => { const subagentSessionID = "subagent-session-456" const intermediateParentSessionID = "subagent-parent-789" @@ -120,11 +120,11 @@ describe("atlas hook idle-event session lineage", () => { }, }) - assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true) - assert.equal(promptCalls.length, 1) + assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), false) + assert.equal(promptCalls.length, 0) }) - it("does not inject continuation for boulder-lineage subagent with non-matching agent", async () => { + it("does not inject continuation for lineage-only subagent with non-matching agent", async () => { const subagentSessionID = "subagent-session-agent-mismatch" writeIncompleteBoulder({ agent: "atlas" }) @@ -142,11 +142,11 @@ describe("atlas hook idle-event session lineage", () => { }, }) - assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true) + assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), false) assert.equal(promptCalls.length, 0) }) - it("injects continuation for boulder-lineage subagent with matching agent", async () => { + it("does not inject continuation for lineage-only subagent with matching agent until explicitly tracked", async () => { const subagentSessionID = "subagent-session-agent-match" writeIncompleteBoulder({ agent: "atlas" }) @@ -164,7 +164,8 @@ describe("atlas hook idle-event session lineage", () => { }, }) - assert.equal(promptCalls.length, 1) + assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), false) + assert.equal(promptCalls.length, 0) }) it("injects continuation for explicitly tracked boulder session regardless of agent", async () => { diff --git a/src/hooks/atlas/idle-event-persisted-lineage.test.ts b/src/hooks/atlas/idle-event-persisted-lineage.test.ts new file mode 100644 index 000000000..a079bf5a0 --- /dev/null +++ b/src/hooks/atlas/idle-event-persisted-lineage.test.ts @@ -0,0 +1,244 @@ +declare const require: (name: string) => any +const { afterEach, beforeEach, describe, expect, mock, test, afterAll } = require("bun:test") +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { randomUUID } from "node:crypto" + +import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" +import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state" +import type { BoulderState } from "../../features/boulder-state" + +const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-persisted-lineage-storage-${randomUUID()}`) +const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message") +const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part") + +mock.module("../../features/hook-message-injector/constants", () => ({ + OPENCODE_STORAGE: TEST_STORAGE_ROOT, + MESSAGE_STORAGE: TEST_MESSAGE_STORAGE, + PART_STORAGE: TEST_PART_STORAGE, +})) + +mock.module("../../shared/opencode-message-dir", () => ({ + getMessageDir: (sessionID: string) => { + const directory = join(TEST_MESSAGE_STORAGE, sessionID) + return existsSync(directory) ? directory : null + }, +})) + +mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, +})) + +afterAll(() => { mock.restore() }) + +const { createAtlasHook } = await import("./index") + +describe("atlas hook idle-event persisted lineage", () => { + const MAIN_SESSION_ID = "ses_main_session" + let testDirectory = "" + let promptCalls: Array = [] + + function writeIncompleteBoulder(overrides: Partial = {}): void { + const planPath = join(testDirectory, "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", + ...overrides, + } + + writeBoulderState(testDirectory, state) + } + + function createHook( + parentSessionIDs?: Record, + messagesBySession?: Record>, + ) { + return createAtlasHook({ + directory: testDirectory, + client: { + session: { + get: async (input: { path: { id: string } }) => ({ + data: { + id: input.path.id, + parentID: parentSessionIDs?.[input.path.id], + }, + }), + messages: async (input: { path: { id: string } }) => ({ data: messagesBySession?.[input.path.id] ?? [] }), + prompt: async (input: unknown) => { + promptCalls.push(input) + return { data: {} } + }, + promptAsync: async (input: unknown) => { + promptCalls.push(input) + return { data: {} } + }, + }, + }, + } as unknown as Parameters[0]) + } + + beforeEach(() => { + testDirectory = join(tmpdir(), `atlas-persisted-lineage-${randomUUID()}`) + mkdirSync(testDirectory, { recursive: true }) + promptCalls = [] + clearBoulderState(testDirectory) + _resetForTesting() + registerAgentName("atlas") + registerAgentName("sisyphus") + }) + + afterEach(() => { + clearBoulderState(testDirectory) + rmSync(testDirectory, { recursive: true, force: true }) + _resetForTesting() + }) + + test("does not inject continuation for untracked persisted descendant session without in-memory subagent state", async () => { + // given + const descendantSessionID = "ses_persisted_descendant" + writeIncompleteBoulder({ agent: "atlas" }) + + const hook = createHook( + { + [descendantSessionID]: MAIN_SESSION_ID, + }, + { + [descendantSessionID]: [ + { info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }, + ], + }, + ) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: descendantSessionID }, + }, + }) + + // then + expect(readBoulderState(testDirectory)?.session_ids).not.toContain(descendantSessionID) + expect(promptCalls.length).toBe(0) + }) + + test("does not inject continuation for persisted appended descendant with mismatched agent", async () => { + // given + const descendantSessionID = "ses_persisted_mismatch" + writeIncompleteBoulder({ + agent: "atlas", + session_ids: [MAIN_SESSION_ID, descendantSessionID], + session_origins: { + [MAIN_SESSION_ID]: "direct", + [descendantSessionID]: "appended", + }, + }) + const hook = createHook( + { + [descendantSessionID]: MAIN_SESSION_ID, + }, + { + [descendantSessionID]: [ + { info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }, + ], + }, + ) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: descendantSessionID }, + }, + }) + + // then + expect(promptCalls.length).toBe(0) + }) + + test("does not inject continuation for appended descendant when lineage cannot be proven", async () => { + // given + const descendantSessionID = "ses_unresolved_descendant" + writeIncompleteBoulder({ + agent: "atlas", + session_ids: [MAIN_SESSION_ID, descendantSessionID], + session_origins: { + [MAIN_SESSION_ID]: "direct", + [descendantSessionID]: "appended", + }, + }) + + const hook = createAtlasHook({ + directory: testDirectory, + client: { + session: { + get: async () => { + throw new Error("session lookup failed") + }, + messages: async () => ({ + data: [{ info: { agent: "atlas", providerID: "openai", modelID: "gpt-5.4" } }], + }), + prompt: async (input: unknown) => { + promptCalls.push(input) + return { data: {} } + }, + promptAsync: async (input: unknown) => { + promptCalls.push(input) + return { data: {} } + }, + }, + }, + } as unknown as Parameters[0]) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: descendantSessionID }, + }, + }) + + // then + expect(promptCalls.length).toBe(0) + }) + + test("injects continuation for directly tracked child session even when ancestor is also tracked and child agent mismatches", async () => { + // given + const descendantSessionID = "ses_direct_child_tracked" + writeIncompleteBoulder({ + agent: "atlas", + session_ids: [MAIN_SESSION_ID, descendantSessionID], + session_origins: { + [MAIN_SESSION_ID]: "direct", + [descendantSessionID]: "direct", + }, + }) + + const hook = createHook( + { + [descendantSessionID]: MAIN_SESSION_ID, + }, + { + [descendantSessionID]: [ + { info: { agent: "sisyphus-junior", providerID: "openai", modelID: "gpt-5.4" } }, + ], + }, + ) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: descendantSessionID }, + }, + }) + + // then + expect(promptCalls.length).toBe(1) + }) +}) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index c58ce0ff3..41df724bb 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -5,7 +5,9 @@ import { readBoulderState, readCurrentTopLevelTask, } from "../../features/boulder-state" -import { getSessionAgent, isAgentRegistered, subagentSessions } from "../../features/claude-code-session-state" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { getLastAgentFromSession } from "./session-last-agent" +import { isSessionInBoulderLineage } from "./boulder-session-lineage" import { getAgentConfigKey } from "../../shared/agent-display-names" import { log } from "../../shared/logger" import { injectBoulderContinuation } from "./boulder-continuation-injector" @@ -57,6 +59,25 @@ async function injectContinuation(input: { ? getTaskSessionState(input.ctx.directory, currentTask.key) : null + if (!currentBoulder) { + return + } + + const canContinueSession = await canContinueTrackedBoulderSession({ + client: input.ctx.client, + sessionID: input.sessionID, + sessionOrigin: currentBoulder.session_origins?.[input.sessionID], + boulderSessionIDs: currentBoulder.session_ids, + requiredAgent: currentBoulder.agent, + }) + if (!canContinueSession) { + log(`[${HOOK_NAME}] Skipped: tracked descendant agent does not match boulder agent`, { + sessionID: input.sessionID, + requiredAgent: currentBoulder.agent ?? "atlas", + }) + return + } + const result = await injectBoulderContinuation({ ctx: input.ctx, sessionID: input.sessionID, @@ -145,6 +166,14 @@ function scheduleRetry(input: { const currentProgress = getPlanProgress(currentBoulder.active_plan) if (currentProgress.isComplete) return if (options?.isContinuationStopped?.(sessionID)) return + const canContinueSession = await canContinueTrackedBoulderSession({ + client: ctx.client, + sessionID, + sessionOrigin: currentBoulder.session_origins?.[sessionID], + boulderSessionIDs: currentBoulder.session_ids, + requiredAgent: currentBoulder.agent, + }) + if (!canContinueSession) return if (hasRunningBackgroundTasks(sessionID, options)) { scheduleRetry({ ctx, sessionID, sessionState, options }) return @@ -196,29 +225,19 @@ export async function handleAtlasSessionIdle(input: { }) } - if (subagentSessions.has(sessionID)) { - const sessionAgent = getSessionAgent(sessionID) - const agentKey = getAgentConfigKey(sessionAgent ?? "") - const requiredAgentName = boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined) - if (!requiredAgentName || !isAgentRegistered(requiredAgentName)) { - log(`[${HOOK_NAME}] Skipped: boulder agent is unavailable for continuation`, { - sessionID, - requiredAgent: boulderState.agent ?? "unknown", - }) - return - } - const requiredAgentKey = getAgentConfigKey(requiredAgentName) - const agentMatches = - agentKey === requiredAgentKey || - (requiredAgentKey === getAgentConfigKey("atlas") && agentKey === getAgentConfigKey("sisyphus")) - if (!agentMatches) { - log(`[${HOOK_NAME}] Skipped: subagent agent does not match boulder agent`, { - sessionID, - agent: sessionAgent ?? "unknown", - requiredAgent: requiredAgentName, - }) - return - } + const canContinueSession = await canContinueTrackedBoulderSession({ + client: ctx.client, + sessionID, + sessionOrigin: boulderState.session_origins?.[sessionID], + boulderSessionIDs: boulderState.session_ids, + requiredAgent: boulderState.agent, + }) + if (!canContinueSession) { + log(`[${HOOK_NAME}] Skipped: tracked descendant agent does not match boulder agent`, { + sessionID, + requiredAgent: boulderState.agent ?? "atlas", + }) + return } const sessionState = getState(sessionID) @@ -283,3 +302,40 @@ export async function handleAtlasSessionIdle(input: { worktreePath: boulderState.worktree_path, }) } + +async function canContinueTrackedBoulderSession(input: { + client: PluginInput["client"] + sessionID: string + sessionOrigin?: "direct" | "appended" + boulderSessionIDs: string[] + requiredAgent?: string +}): Promise { + const ancestorSessionIDs = input.boulderSessionIDs.filter((trackedSessionID) => trackedSessionID !== input.sessionID) + if (ancestorSessionIDs.length === 0) { + return true + } + + const isTrackedDescendant = await isSessionInBoulderLineage({ + client: input.client, + sessionID: input.sessionID, + boulderSessionIDs: ancestorSessionIDs, + }) + if (input.sessionOrigin === "direct") { + return true + } + + if (!isTrackedDescendant) { + return false + } + + const sessionAgent = await getLastAgentFromSession(input.sessionID, input.client) + ?? getSessionAgent(input.sessionID) + if (!sessionAgent) { + return false + } + + const requiredAgentKey = getAgentConfigKey(input.requiredAgent ?? "atlas") + const sessionAgentKey = getAgentConfigKey(sessionAgent) + return sessionAgentKey === requiredAgentKey + || (requiredAgentKey === getAgentConfigKey("atlas") && sessionAgentKey === getAgentConfigKey("sisyphus")) +}