feat(atlas): add canContinueTrackedBoulderSession for lineage-aware continuation

- 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
This commit is contained in:
YeonGyu-Kim
2026-04-05 17:14:47 +09:00
parent 9199dd545f
commit cd71ced0fb
3 changed files with 332 additions and 31 deletions
+8 -7
View File
@@ -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 () => {
@@ -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<unknown> = []
function writeIncompleteBoulder(overrides: Partial<BoulderState> = {}): 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<string, string | undefined>,
messagesBySession?: Record<string, Array<{ info: { agent: string; providerID: string; modelID: string } }>>,
) {
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<typeof createAtlasHook>[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<typeof createAtlasHook>[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)
})
})
+80 -24
View File
@@ -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<boolean> {
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"))
}