From 8adf6a2c478eab6ee854bdef067fc8d1bd9583bf Mon Sep 17 00:00:00 2001 From: HaD0Yun <102889891+HaD0Yun@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:14:17 +0900 Subject: [PATCH] fix(atlas): tighten session reuse metadata parsing --- src/hooks/atlas/index.test.ts | 58 +++++++++++++++++++++ src/hooks/atlas/subagent-session-id.test.ts | 15 ++++++ src/hooks/atlas/subagent-session-id.ts | 11 ++-- src/hooks/atlas/tool-execute-after.ts | 8 +-- 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 8ec696558..917d0079e 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -33,6 +33,8 @@ mock.module("../../shared/opencode-storage-detection", () => ({ })) const { createAtlasHook } = await import("./index") +const { createToolExecuteAfterHandler } = await import("./tool-execute-after") +const { createToolExecuteBeforeHandler } = await import("./tool-execute-before") const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector") describe("atlas hook", () => { @@ -410,6 +412,62 @@ describe("atlas hook", () => { cleanupMessageStorage(sessionID) }) + test("should clean pending task refs when a task returns background launch output", async () => { + // given - direct handlers with shared pending maps + const sessionID = "session-bg-launch-cleanup-test" + setupMessageStorage(sessionID, "atlas") + + const planPath = join(TEST_DIR, "background-cleanup-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [ ] 1. Implement auth flow +`) + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "background-cleanup-plan", + }) + + const pendingFilePaths = new Map() + const pendingTaskRefs = new Map() + const beforeHandler = createToolExecuteBeforeHandler({ + ctx: createMockPluginInput(), + pendingFilePaths, + pendingTaskRefs, + }) + const afterHandler = createToolExecuteAfterHandler({ + ctx: createMockPluginInput(), + pendingFilePaths, + pendingTaskRefs, + autoCommit: true, + getState: () => ({ promptFailureCount: 0 }), + }) + + // when - the task is captured before execution + await beforeHandler( + { tool: "task", sessionID, callID: "call-bg-launch" }, + { args: { prompt: "Implement auth flow" } } + ) + expect(pendingTaskRefs.size).toBe(1) + + // and the task returns a background launch result + await afterHandler( + { tool: "task", sessionID, callID: "call-bg-launch" }, + { + title: "Sisyphus Task", + output: "Background task launched.\n\nSession ID: ses_bg_12345", + metadata: {}, + } + ) + + // then - the pending task ref is still cleaned up + expect(pendingTaskRefs.size).toBe(0) + + cleanupMessageStorage(sessionID) + }) + test("should persist preferred subagent session for the current top-level task", async () => { // given - boulder state with a current top-level task, Atlas caller const sessionID = "session-task-session-track-test" diff --git a/src/hooks/atlas/subagent-session-id.test.ts b/src/hooks/atlas/subagent-session-id.test.ts index 8d4416c9d..5973784c9 100644 --- a/src/hooks/atlas/subagent-session-id.test.ts +++ b/src/hooks/atlas/subagent-session-id.test.ts @@ -50,4 +50,19 @@ session_id: ses_real_metadata_456 // then expect(result).toBe("ses_real_metadata_456") }) + + test("does not let task_metadata parsing bleed into incidental body text after the closing tag", () => { + // given + const output = ` +session_id: ses_real_metadata_456 + + +debug log: session_id: ses_wrong_body_789` + + // when + const result = extractSessionIdFromOutput(output) + + // then + expect(result).toBe("ses_real_metadata_456") + }) }) diff --git a/src/hooks/atlas/subagent-session-id.ts b/src/hooks/atlas/subagent-session-id.ts index 9494d5a33..d4c5d8709 100644 --- a/src/hooks/atlas/subagent-session-id.ts +++ b/src/hooks/atlas/subagent-session-id.ts @@ -1,8 +1,11 @@ export function extractSessionIdFromOutput(output: string): string | undefined { - const taskMetadataMatches = [...output.matchAll(/[\s\S]*?session_id:\s*(ses_[a-zA-Z0-9_]+)[\s\S]*?<\/task_metadata>/gi)] - const lastTaskMetadataMatch = taskMetadataMatches.at(-1) - if (lastTaskMetadataMatch) { - return lastTaskMetadataMatch[1] + const taskMetadataBlocks = [...output.matchAll(/([\s\S]*?)<\/task_metadata>/gi)] + const lastTaskMetadataBlock = taskMetadataBlocks.at(-1)?.[1] + if (lastTaskMetadataBlock) { + const taskMetadataSessionMatch = lastTaskMetadataBlock.match(/session_id:\s*(ses_[a-zA-Z0-9_]+)/i) + if (taskMetadataSessionMatch) { + return taskMetadataSessionMatch[1] + } } const explicitSessionMatches = [...output.matchAll(/Session ID:\s*(ses_[a-zA-Z0-9_]+)/g)] diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index ed23ce011..a598e92d3 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -71,6 +71,10 @@ export function createToolExecuteAfterHandler(input: { } const outputStr = toolOutput.output && typeof toolOutput.output === "string" ? toolOutput.output : "" + const pendingTaskRef = toolInput.callID ? pendingTaskRefs.get(toolInput.callID) : undefined + if (toolInput.callID) { + pendingTaskRefs.delete(toolInput.callID) + } const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued") if (isBackgroundLaunch) { return @@ -80,10 +84,6 @@ export function createToolExecuteAfterHandler(input: { const gitStats = collectGitDiffStats(ctx.directory) const fileChanges = formatFileChanges(gitStats) const subagentSessionId = extractSessionIdFromOutput(toolOutput.output) - const pendingTaskRef = toolInput.callID ? pendingTaskRefs.get(toolInput.callID) : undefined - if (toolInput.callID) { - pendingTaskRefs.delete(toolInput.callID) - } const boulderState = readBoulderState(ctx.directory) if (boulderState) {