From cf5fe757df7472a64a5424f5a32495c2951ba18f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:28:56 +0900 Subject: [PATCH] feat(hooks/atlas): parse task_key from delegation prompt for parallel batches --- .../tool-execute-after-task-timers.test.ts | 138 ++++++++++++++++++ src/hooks/atlas/tool-execute-before.ts | 66 ++++++++- 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/src/hooks/atlas/tool-execute-after-task-timers.test.ts b/src/hooks/atlas/tool-execute-after-task-timers.test.ts index 6bda79239..c565e5c2d 100644 --- a/src/hooks/atlas/tool-execute-after-task-timers.test.ts +++ b/src/hooks/atlas/tool-execute-after-task-timers.test.ts @@ -292,4 +292,142 @@ describe("createToolExecuteAfterHandler task timers", () => { expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true) }) + it("tracks parallel delegated tasks by task label from TASK section", async () => { + // given + const parentSessionID = "ses_parent_parallel" + const planPath = join(testDirectory, "task-timer-parallel-plan.md") + writeFileSync( + planPath, + "# Plan\n\n## TODOs\n- [ ] 1. First task\n- [ ] 2. Add tests\n- [ ] 3. Write docs\n", + "utf-8", + ) + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-1", + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + plan_name: "task-timer-parallel-plan", + works: { + "work-1": { + work_id: "work-1", + active_plan: planPath, + plan_name: "task-timer-parallel-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + status: "active", + }, + }, + }) + const { beforeHandler, afterHandler } = createHandlers({ + ses_child_parallel_2: parentSessionID, + ses_child_parallel_3: parentSessionID, + }) + + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" }, + { + args: { + prompt: "## 1. TASK\n- [ ] 2. Add tests\n\n## 2. CONTEXT\n...", + }, + }, + ) + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" }, + { + args: { + prompt: "## 1. TASK\n- [ ] 3. Write docs\n\n## 2. CONTEXT\n...", + }, + }, + ) + + // when + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" }, + { + title: "Sisyphus Task", + output: "Task completed\n\nsession_id: ses_child_parallel_2\n", + metadata: { + sessionId: "ses_child_parallel_2", + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" }, + { + title: "Sisyphus Task", + output: "Task completed\n\nsession_id: ses_child_parallel_3\n", + metadata: { + sessionId: "ses_child_parallel_3", + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + + // then + const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions + expect(taskSessions?.["todo:2"]?.task_key).toBe("todo:2") + expect(taskSessions?.["todo:3"]?.task_key).toBe("todo:3") + expect(taskSessions?.["todo:1"]).toBeUndefined() + }) + + it("falls back to current top-level task when TASK section label is missing", async () => { + // given + const parentSessionID = "ses_parent_fallback" + const childSessionID = "ses_child_fallback" + const planPath = join(testDirectory, "task-timer-fallback-plan.md") + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. First task\n", "utf-8") + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-1", + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + plan_name: "task-timer-fallback-plan", + works: { + "work-1": { + work_id: "work-1", + active_plan: planPath, + plan_name: "task-timer-fallback-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + status: "active", + }, + }, + }) + const { beforeHandler, afterHandler } = createHandlers({ + [childSessionID]: parentSessionID, + }) + + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" }, + { + args: { + prompt: "No structured header in this prompt", + }, + }, + ) + + // when + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" }, + { + title: "Sisyphus Task", + output: "Task completed\n\nsession_id: ses_child_fallback\n", + metadata: { + sessionId: childSessionID, + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + + // then + const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions + expect(taskSessions?.["todo:1"]?.task_key).toBe("todo:1") + }) + }) diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index 88e31fc13..d6e4789ee 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -11,6 +11,49 @@ import { isSisyphusPath } from "./sisyphus-path" import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types" import { isWriteOrEditToolName } from "./write-edit-tool-policy" +const TASK_SECTION_HEADER_PATTERN = /^##\s*1\.\s*TASK\s*$/i +const TODO_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(\d+)\.\s+(.+)$/ +const FINAL_WAVE_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(F\d+)\.\s+(.+)$/i + +function parseTrackedTaskFromPrompt(prompt: string): TrackedTopLevelTaskRef | null { + const lines = prompt.split(/\r?\n/) + const taskHeaderIndex = lines.findIndex((line) => TASK_SECTION_HEADER_PATTERN.test(line.trim())) + if (taskHeaderIndex < 0) { + return null + } + + const startIndex = taskHeaderIndex + 1 + const endIndex = Math.min(lines.length, startIndex + 5) + for (let index = startIndex; index < endIndex; index += 1) { + const candidate = lines[index]?.trim() + if (!candidate) { + continue + } + + const finalWaveMatch = candidate.match(FINAL_WAVE_TASK_LINE_PATTERN) + if (finalWaveMatch?.[1] && finalWaveMatch[2]) { + const label = finalWaveMatch[1].toUpperCase() + return { + key: `final-wave:${label.toLowerCase()}`, + label, + title: finalWaveMatch[2].trim(), + } + } + + const todoMatch = candidate.match(TODO_TASK_LINE_PATTERN) + if (todoMatch?.[1] && todoMatch[2]) { + const label = todoMatch[1] + return { + key: `todo:${label}`, + label, + title: todoMatch[2].trim(), + } + } + } + + return null +} + export function createToolExecuteBeforeHandler(input: { ctx: PluginInput pendingFilePaths: Map @@ -84,15 +127,30 @@ export function createToolExecuteBeforeHandler(input: { reason: "explicit_resume", }) } else { + const prompt = typeof toolOutput.args.prompt === "string" ? toolOutput.args.prompt : "" + const taskFromPrompt = parseTrackedTaskFromPrompt(prompt) const boulderState = readBoulderState(ctx.directory) const currentTask = boulderState ? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState)) : null - if (currentTask) { + const resolvedTask = taskFromPrompt ?? (currentTask + ? { + key: currentTask.key, + label: currentTask.label, + title: currentTask.title, + } + : null) + if (resolvedTask) { + if (!taskFromPrompt) { + log(`[${HOOK_NAME}] TASK section parse failed; falling back to current top-level task`, { + sessionID: toolInput.sessionID, + callID: toolInput.callID, + }) + } const trackedTask = { - key: currentTask.key, - label: currentTask.label, - title: currentTask.title, + key: resolvedTask.key, + label: resolvedTask.label, + title: resolvedTask.title, } const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => ( pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key