fix(atlas): restrict idle-event session append to boulder-owned subagent sessions only
This commit is contained in:
44
src/hooks/atlas/boulder-session-lineage.ts
Normal file
44
src/hooks/atlas/boulder-session-lineage.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
|
||||
export async function isSessionInBoulderLineage(input: {
|
||||
client: PluginInput["client"]
|
||||
sessionID: string
|
||||
boulderSessionIDs: string[]
|
||||
}): Promise<boolean> {
|
||||
const visitedSessionIDs = new Set<string>()
|
||||
let currentSessionID = input.sessionID
|
||||
|
||||
while (!visitedSessionIDs.has(currentSessionID)) {
|
||||
visitedSessionIDs.add(currentSessionID)
|
||||
|
||||
const sessionResult = await input.client.session
|
||||
.get({ path: { id: currentSessionID } })
|
||||
.catch((error: unknown) => {
|
||||
log(`[${HOOK_NAME}] Failed to resolve session lineage`, {
|
||||
sessionID: input.sessionID,
|
||||
currentSessionID,
|
||||
error,
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
if (!sessionResult || sessionResult.error) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parentSessionID = sessionResult.data?.parentID
|
||||
if (!parentSessionID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (input.boulderSessionIDs.includes(parentSessionID)) {
|
||||
return true
|
||||
}
|
||||
|
||||
currentSessionID = parentSessionID
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
122
src/hooks/atlas/idle-event-lineage.test.ts
Normal file
122
src/hooks/atlas/idle-event-lineage.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, it } from "bun:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
describe("atlas hook idle-event session lineage", () => {
|
||||
const MAIN_SESSION_ID = "main-session-123"
|
||||
|
||||
let testDirectory = ""
|
||||
let promptCalls: Array<unknown> = []
|
||||
|
||||
function writeIncompleteBoulder(): 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",
|
||||
}
|
||||
|
||||
writeBoulderState(testDirectory, state)
|
||||
}
|
||||
|
||||
function createHook(parentSessionIDs?: Record<string, string | undefined>) {
|
||||
return createAtlasHook({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
get: async (input: { path: { id: string } }) => ({
|
||||
data: {
|
||||
parentID: parentSessionIDs?.[input.path.id],
|
||||
},
|
||||
}),
|
||||
messages: async () => ({ data: [] }),
|
||||
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-idle-lineage-${randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
|
||||
promptCalls = []
|
||||
clearBoulderState(testDirectory)
|
||||
_resetForTesting()
|
||||
subagentSessions.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBoulderState(testDirectory)
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
it("does not append unrelated subagent sessions during idle", async () => {
|
||||
const unrelatedSubagentSessionID = "subagent-session-unrelated"
|
||||
const unrelatedParentSessionID = "unrelated-parent-session"
|
||||
|
||||
writeIncompleteBoulder()
|
||||
subagentSessions.add(unrelatedSubagentSessionID)
|
||||
|
||||
const hook = createHook({
|
||||
[unrelatedSubagentSessionID]: unrelatedParentSessionID,
|
||||
})
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: unrelatedSubagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(unrelatedSubagentSessionID), false)
|
||||
assert.equal(promptCalls.length, 0)
|
||||
})
|
||||
|
||||
it("appends boulder-owned subagent sessions during idle when lineage reaches tracked session", async () => {
|
||||
const subagentSessionID = "subagent-session-456"
|
||||
const intermediateParentSessionID = "subagent-parent-789"
|
||||
|
||||
writeIncompleteBoulder()
|
||||
subagentSessions.add(subagentSessionID)
|
||||
|
||||
const hook = createHook({
|
||||
[subagentSessionID]: intermediateParentSessionID,
|
||||
[intermediateParentSessionID]: MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
|
||||
assert.equal(promptCalls.length, 1)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { appendSessionId, getPlanProgress, readBoulderState } from "../../featur
|
||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
@@ -18,14 +19,15 @@ function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions
|
||||
: false
|
||||
}
|
||||
|
||||
function resolveActiveBoulderSession(input: {
|
||||
async function resolveActiveBoulderSession(input: {
|
||||
client: PluginInput["client"]
|
||||
directory: string
|
||||
sessionID: string
|
||||
}): {
|
||||
}): Promise<{
|
||||
boulderState: BoulderState
|
||||
progress: PlanProgress
|
||||
appendedSession: boolean
|
||||
} | null {
|
||||
} | null> {
|
||||
const boulderState = readBoulderState(input.directory)
|
||||
if (!boulderState) {
|
||||
return null
|
||||
@@ -44,6 +46,15 @@ function resolveActiveBoulderSession(input: {
|
||||
return null
|
||||
}
|
||||
|
||||
const belongsToActiveBoulder = await isSessionInBoulderLineage({
|
||||
client: input.client,
|
||||
sessionID: input.sessionID,
|
||||
boulderSessionIDs: boulderState.session_ids,
|
||||
})
|
||||
if (!belongsToActiveBoulder) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedBoulderState = appendSessionId(input.directory, input.sessionID)
|
||||
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
|
||||
return null
|
||||
@@ -136,7 +147,8 @@ export async function handleAtlasSessionIdle(input: {
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
const activeBoulderSession = resolveActiveBoulderSession({
|
||||
const activeBoulderSession = await resolveActiveBoulderSession({
|
||||
client: ctx.client,
|
||||
directory: ctx.directory,
|
||||
sessionID,
|
||||
})
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("atlas hook", () => {
|
||||
directory: TEST_DIR,
|
||||
client: {
|
||||
session: {
|
||||
get: async () => ({ data: { parentID: "main-session-123" } }),
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
},
|
||||
|
||||
9
src/hooks/atlas/tsconfig.json
Normal file
9
src/hooks/atlas/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
Reference in New Issue
Block a user