fix(atlas): restrict idle-event session append to boulder-owned subagent sessions only
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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 type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||||
import { HOOK_NAME } from "./hook-name"
|
import { HOOK_NAME } from "./hook-name"
|
||||||
import type { AtlasHookOptions, SessionState } from "./types"
|
import type { AtlasHookOptions, SessionState } from "./types"
|
||||||
@@ -18,14 +19,15 @@ function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions
|
|||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveActiveBoulderSession(input: {
|
async function resolveActiveBoulderSession(input: {
|
||||||
|
client: PluginInput["client"]
|
||||||
directory: string
|
directory: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
}): {
|
}): Promise<{
|
||||||
boulderState: BoulderState
|
boulderState: BoulderState
|
||||||
progress: PlanProgress
|
progress: PlanProgress
|
||||||
appendedSession: boolean
|
appendedSession: boolean
|
||||||
} | null {
|
} | null> {
|
||||||
const boulderState = readBoulderState(input.directory)
|
const boulderState = readBoulderState(input.directory)
|
||||||
if (!boulderState) {
|
if (!boulderState) {
|
||||||
return null
|
return null
|
||||||
@@ -44,6 +46,15 @@ function resolveActiveBoulderSession(input: {
|
|||||||
return null
|
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)
|
const updatedBoulderState = appendSessionId(input.directory, input.sessionID)
|
||||||
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
|
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
|
||||||
return null
|
return null
|
||||||
@@ -136,7 +147,8 @@ export async function handleAtlasSessionIdle(input: {
|
|||||||
|
|
||||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||||
|
|
||||||
const activeBoulderSession = resolveActiveBoulderSession({
|
const activeBoulderSession = await resolveActiveBoulderSession({
|
||||||
|
client: ctx.client,
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ describe("atlas hook", () => {
|
|||||||
directory: TEST_DIR,
|
directory: TEST_DIR,
|
||||||
client: {
|
client: {
|
||||||
session: {
|
session: {
|
||||||
|
get: async () => ({ data: { parentID: "main-session-123" } }),
|
||||||
prompt: promptMock,
|
prompt: promptMock,
|
||||||
promptAsync: promptMock,
|
promptAsync: promptMock,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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