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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user