From 18af3d36179fd3418081b8f64e774930a7c38190 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:37:19 +0900 Subject: [PATCH] feat(hooks/atlas): use getWorkForSession in boulder lookups and session tracking --- .../background-launch-session-tracking.ts | 51 ++++++++--- .../resolve-active-boulder-session.test.ts | 73 +++++++++++++++ .../atlas/resolve-active-boulder-session.ts | 39 ++++++-- ...ol-execute-after-background-launch.test.ts | 88 +++++++++++++++++++ 4 files changed, 234 insertions(+), 17 deletions(-) diff --git a/src/hooks/atlas/background-launch-session-tracking.ts b/src/hooks/atlas/background-launch-session-tracking.ts index 4fcb68864..57a3e351c 100644 --- a/src/hooks/atlas/background-launch-session-tracking.ts +++ b/src/hooks/atlas/background-launch-session-tracking.ts @@ -1,5 +1,14 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state" +import { + appendSessionId, + appendSessionIdForWork, + getWorkForSession, + type BoulderState, + resolveBoulderPlanPath, + resolveBoulderPlanPathForWork, + upsertTaskSessionState, + upsertTaskSessionStateForWork, +} from "../../features/boulder-state" import { log } from "../../shared/logger" import { HOOK_NAME } from "./hook-name" import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id" @@ -19,8 +28,9 @@ export async function syncBackgroundLaunchSessionTracking(input: { return } + const trackedWork = getWorkForSession(ctx.directory, toolInput.sessionID) const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output) - const lineageSessionIDs = boulderState.session_ids + const lineageSessionIDs = trackedWork?.session_ids ?? boulderState.session_ids const subagentSessionId = await validateSubagentSessionId({ client: ctx.client, sessionID: extractedSessionId, @@ -36,22 +46,39 @@ export async function syncBackgroundLaunchSessionTracking(input: { return } - appendSessionId(ctx.directory, trackedSessionId, "appended") + if (trackedWork) { + appendSessionIdForWork(ctx.directory, trackedWork.work_id, trackedSessionId, "appended") + } else { + appendSessionId(ctx.directory, trackedSessionId, "appended") + } const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext( pendingTaskRef, - resolveBoulderPlanPath(ctx.directory, boulderState), + trackedWork + ? resolveBoulderPlanPathForWork(ctx.directory, trackedWork) + : resolveBoulderPlanPath(ctx.directory, boulderState), ) if (currentTask && !shouldSkipTaskSessionUpdate) { - upsertTaskSessionState(ctx.directory, { - taskKey: currentTask.key, - taskLabel: currentTask.label, - taskTitle: currentTask.title, - sessionId: trackedSessionId, - agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, - category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, - }) + if (trackedWork) { + upsertTaskSessionStateForWork(ctx.directory, trackedWork.work_id, { + taskKey: currentTask.key, + taskLabel: currentTask.label, + taskTitle: currentTask.title, + sessionId: trackedSessionId, + agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, + category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, + }) + } else { + upsertTaskSessionState(ctx.directory, { + taskKey: currentTask.key, + taskLabel: currentTask.label, + taskTitle: currentTask.title, + sessionId: trackedSessionId, + agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, + category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, + }) + } } log(`[${HOOK_NAME}] Background launch session tracked`, { diff --git a/src/hooks/atlas/resolve-active-boulder-session.test.ts b/src/hooks/atlas/resolve-active-boulder-session.test.ts index 7a300a517..85b20ecba 100644 --- a/src/hooks/atlas/resolve-active-boulder-session.test.ts +++ b/src/hooks/atlas/resolve-active-boulder-session.test.ts @@ -131,4 +131,77 @@ describe("resolveActiveBoulderSession", () => { rmSync(worktreeDirectory, { recursive: true, force: true }) } }) + + test("uses work resolved by session id when works map is present", async () => { + // given + const legacyPlanPath = join(testDirectory, "legacy-plan.md") + const workAPlanPath = join(testDirectory, "work-a-plan.md") + const workBPlanPath = join(testDirectory, "work-b-plan.md") + writeFileSync(legacyPlanPath, "# Plan\n- [ ] Legacy\n", "utf-8") + writeFileSync(workAPlanPath, "# Plan\n- [ ] Work A\n", "utf-8") + writeFileSync(workBPlanPath, "# Plan\n- [x] Work B\n", "utf-8") + + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-a", + active_plan: legacyPlanPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_legacy"], + plan_name: "legacy-plan", + works: { + "work-a": { + work_id: "work-a", + active_plan: workAPlanPath, + plan_name: "work-a-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_work_a"], + status: "active", + }, + "work-b": { + work_id: "work-b", + active_plan: workBPlanPath, + plan_name: "work-b-plan", + started_at: "2026-01-02T11:00:00Z", + session_ids: ["ses_work_b"], + status: "active", + }, + }, + }) + + // when + const result = await resolveActiveBoulderSession({ + client: { session: { get: async () => ({ data: {} }) } } as never, + directory: testDirectory, + sessionID: "ses_work_b", + }) + + // then + expect(result).not.toBeNull() + expect(result?.boulderState.active_plan).toBe(workBPlanPath) + expect(result?.progress.isComplete).toBe(true) + }) + + test("falls back to top-level mirror when works map is missing", async () => { + // given + const legacyPlanPath = join(testDirectory, "legacy-only-plan.md") + writeFileSync(legacyPlanPath, "# Plan\n- [ ] Task 1\n", "utf-8") + writeBoulderState(testDirectory, { + active_plan: legacyPlanPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_legacy_only"], + plan_name: "legacy-only-plan", + }) + + // when + const result = await resolveActiveBoulderSession({ + client: { session: { get: async () => ({ data: {} }) } } as never, + directory: testDirectory, + sessionID: "ses_legacy_only", + }) + + // then + expect(result).not.toBeNull() + expect(result?.boulderState.active_plan).toBe(legacyPlanPath) + expect(result?.progress.isComplete).toBe(false) + }) }) diff --git a/src/hooks/atlas/resolve-active-boulder-session.ts b/src/hooks/atlas/resolve-active-boulder-session.ts index 7cf23e7ba..85a4bb583 100644 --- a/src/hooks/atlas/resolve-active-boulder-session.ts +++ b/src/hooks/atlas/resolve-active-boulder-session.ts @@ -1,5 +1,11 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state" +import { + getPlanProgress, + getWorkForSession, + readBoulderState, + resolveBoulderPlanPath, + resolveBoulderPlanPathForWork, +} from "../../features/boulder-state" import type { BoulderState, PlanProgress } from "../../features/boulder-state" export async function resolveActiveBoulderSession(input: { @@ -16,14 +22,37 @@ export async function resolveActiveBoulderSession(input: { return null } - if (!boulderState.session_ids.includes(input.sessionID)) { + const sessionWork = getWorkForSession(input.directory, input.sessionID) + if (!sessionWork && !boulderState.session_ids.includes(input.sessionID)) { return null } - const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState)) + const nextBoulderState: BoulderState = sessionWork + ? { + ...boulderState, + active_plan: sessionWork.active_plan, + plan_name: sessionWork.plan_name, + status: sessionWork.status, + started_at: sessionWork.started_at, + ended_at: sessionWork.ended_at, + elapsed_ms: sessionWork.elapsed_ms, + updated_at: sessionWork.updated_at, + session_ids: [...sessionWork.session_ids], + session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {}, + agent: sessionWork.agent, + worktree_path: sessionWork.worktree_path, + task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {}, + } + : boulderState + + const progress = getPlanProgress( + sessionWork + ? resolveBoulderPlanPathForWork(input.directory, sessionWork) + : resolveBoulderPlanPath(input.directory, nextBoulderState), + ) if (progress.isComplete) { - return { boulderState, progress, appendedSession: false } + return { boulderState: nextBoulderState, progress, appendedSession: false } } - return { boulderState, progress, appendedSession: false } + return { boulderState: nextBoulderState, progress, appendedSession: false } } diff --git a/src/hooks/atlas/tool-execute-after-background-launch.test.ts b/src/hooks/atlas/tool-execute-after-background-launch.test.ts index f51320e2e..1a7d55894 100644 --- a/src/hooks/atlas/tool-execute-after-background-launch.test.ts +++ b/src/hooks/atlas/tool-execute-after-background-launch.test.ts @@ -424,6 +424,94 @@ describe("createToolExecuteAfterHandler background launch detection", () => { expect(readBoulderState(testDirectory)?.session_ids).not.toContain(sessionID) expect(readBoulderState(testDirectory)?.session_ids).not.toContain(childSessionID) }) + + it("#then it should append launched child to the session-resolved work", async () => { + const parentSessionID = "ses_parent_for_work" + const childSessionID = "ses_child_for_work" + const planPathA = join(testDirectory, "background-launch-work-a.md") + const planPathB = join(testDirectory, "background-launch-work-b.md") + const project = createProject() + const client = { + session: { + get: async () => createSessionGetResult(undefined), + }, + } as unknown as PluginInput["client"] + + spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( + createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined), + ) as never) + + writeFileSync(planPathA, "# Plan\n\n## TODOs\n- [ ] 1. Work A\n") + writeFileSync(planPathB, "# Plan\n\n## TODOs\n- [ ] 1. Work B\n") + + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-a", + active_plan: planPathA, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_unrelated_active"], + plan_name: "background-launch-work-a", + works: { + "work-a": { + work_id: "work-a", + active_plan: planPathA, + plan_name: "background-launch-work-a", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_unrelated_active"], + status: "active", + }, + "work-b": { + work_id: "work-b", + active_plan: planPathB, + plan_name: "background-launch-work-b", + started_at: "2026-01-02T10:05:00Z", + session_ids: [parentSessionID], + status: "active", + }, + }, + }) + + const pendingFilePaths = new Map() + const pendingTaskRefs = new Map() + const ctx = { + client, + project, + directory: testDirectory, + worktree: testDirectory, + serverUrl: new URL("https://example.com"), + $: Bun.$, + } satisfies PluginInput + const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }) + const afterHandler = createToolExecuteAfterHandler({ + ctx, + pendingFilePaths, + pendingTaskRefs, + autoCommit: true, + getState: () => ({ promptFailureCount: 0 }), + }) + + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-bg-work" }, + { args: { prompt: "Work B" } }, + ) + + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-bg-work" }, + { + title: "Sisyphus Task", + output: "Background task launched.\n\nBackground Task ID: bg_work\n\n\nsession_id: ses_child_for_work\n", + metadata: { + sessionId: childSessionID, + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + + const boulderState = readBoulderState(testDirectory) + expect(boulderState?.works?.["work-b"]?.session_ids).toContain(childSessionID) + expect(boulderState?.works?.["work-a"]?.session_ids).not.toContain(childSessionID) + }) }) }) })