Merge pull request #2640 from HaD0Yun/had0yun/atlas-task-session-reuse

feat(atlas): persist preferred task session reuse
This commit is contained in:
YeonGyu-Kim
2026-03-18 18:37:16 +09:00
committed by GitHub
17 changed files with 1102 additions and 30 deletions
+4 -3
View File
@@ -2,11 +2,12 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createAtlasEventHandler } from "./event-handler"
import { createToolExecuteAfterHandler } from "./tool-execute-after"
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
import type { AtlasHookOptions, SessionState } from "./types"
import type { AtlasHookOptions, PendingTaskRef, SessionState } from "./types"
export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
const sessions = new Map<string, SessionState>()
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
const autoCommit = options?.autoCommit ?? true
function getState(sessionID: string): SessionState {
@@ -20,7 +21,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
return {
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, autoCommit, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
}
}
@@ -15,6 +15,8 @@ export async function injectBoulderContinuation(input: {
total: number
agent?: string
worktreePath?: string
preferredTaskSessionId?: string
preferredTaskTitle?: string
backgroundManager?: BackgroundManager
sessionState: SessionState
}): Promise<void> {
@@ -26,6 +28,8 @@ export async function injectBoulderContinuation(input: {
total,
agent,
worktreePath,
preferredTaskSessionId,
preferredTaskTitle,
backgroundManager,
sessionState,
} = input
@@ -40,9 +44,13 @@ export async function injectBoulderContinuation(input: {
}
const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : ""
const preferredSessionContext = preferredTaskSessionId
? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]`
: ""
const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext +
worktreeContext
try {
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createOpencodeClient } from "@opencode-ai/sdk"
import type { AssistantMessage } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
import type { BoulderState } from "../../features/boulder-state"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
@@ -52,6 +52,23 @@ describe("Atlas final-wave approval gate regressions", () => {
response: new Response(),
}))
Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => {
const parentID = path.id === "ses_nested_scope_review"
? "atlas-nested-final-wave-session"
: path.id.startsWith("ses_parallel_review_")
? "atlas-parallel-final-wave-session"
: "main-session-123"
return {
data: {
id: path.id,
parentID,
} as Session,
request: new Request(`http://localhost/session/${path.id}`),
response: new Response(),
}
})
return {
directory: testDirectory,
project: {} as AtlasHookContext["project"],
@@ -60,10 +60,18 @@ describe("Atlas final verification approval gate", () => {
}
})
Reflect.set(client.session, "get", async () => {
Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => {
const parentID = path.id === "ses_final_wave_review"
? "atlas-final-wave-session"
: path.id === "ses_feature_task"
? "atlas-non-final-session"
: "main-session-123"
return {
data: { parentID: "main-session-123" } as Session,
request: new Request("http://localhost/session/main-session-123"),
data: {
id: path.id,
parentID,
} as Session,
request: new Request(`http://localhost/session/${path.id}`),
response: new Response(),
}
})
+16 -1
View File
@@ -1,5 +1,10 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import {
getPlanProgress,
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
@@ -31,6 +36,14 @@ async function injectContinuation(input: {
input.sessionState.lastContinuationInjectedAt = Date.now()
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const currentTask = currentBoulder
? readCurrentTopLevelTask(currentBoulder.active_plan)
: null
const preferredTaskSession = currentTask
? getTaskSessionState(input.ctx.directory, currentTask.key)
: null
await injectBoulderContinuation({
ctx: input.ctx,
sessionID: input.sessionID,
@@ -39,6 +52,8 @@ async function injectContinuation(input: {
total: input.progress.total,
agent: input.agent,
worktreePath: input.worktreePath,
preferredTaskSessionId: preferredTaskSession?.session_id,
preferredTaskTitle: preferredTaskSession?.task_title,
backgroundManager: input.options?.backgroundManager,
sessionState: input.sessionState,
})
+468 -5
View File
@@ -10,6 +10,7 @@ import {
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
import type { PendingTaskRef } from "./types"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
@@ -33,25 +34,40 @@ 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", () => {
let TEST_DIR: string
let SISYPHUS_DIR: string
function createMockPluginInput(overrides?: { promptMock?: ReturnType<typeof mock> }) {
function createMockPluginInput(overrides?: {
promptMock?: ReturnType<typeof mock>
sessionGetMock?: ReturnType<typeof mock>
}) {
const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve())
const sessionGetMock = overrides?.sessionGetMock ?? mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
parentID: path.id.startsWith("ses_") ? "session-1" : "main-session-123",
},
}))
return {
directory: TEST_DIR,
client: {
session: {
get: async () => ({ data: { parentID: "main-session-123" } }),
get: sessionGetMock,
prompt: promptMock,
promptAsync: promptMock,
},
},
_promptMock: promptMock,
} as unknown as Parameters<typeof createAtlasHook>[0] & { _promptMock: ReturnType<typeof mock> }
_sessionGetMock: sessionGetMock,
} as unknown as Parameters<typeof createAtlasHook>[0] & {
_promptMock: ReturnType<typeof mock>
_sessionGetMock: ReturnType<typeof mock>
}
}
function setupMessageStorage(sessionID: string, agent: string): void {
@@ -404,12 +420,417 @@ describe("atlas hook", () => {
// then - should include verification instructions
expect(output.output).toContain("LYING")
expect(output.output).toContain("PHASE 1")
expect(output.output).toContain("PHASE 2")
expect(output.output).toContain("PHASE 1")
expect(output.output).toContain("PHASE 2")
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<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
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"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "task-session-plan.md")
writeFileSync(planPath, `# Plan
## TODOs
- [ ] 1. Implement auth flow
- [ ] nested acceptance checkbox
`)
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "task-session-plan",
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed successfully
<task_metadata>
session_id: ses_auth_flow_123
</task_metadata>`,
metadata: {
agent: "sisyphus-junior",
category: "deep",
},
}
// when
await hook["tool.execute.after"](
{ tool: "task", sessionID },
output
)
// then
const updatedState = readBoulderState(TEST_DIR)
expect(updatedState?.task_sessions?.["todo:1"]?.session_id).toBe("ses_auth_flow_123")
expect(updatedState?.task_sessions?.["todo:1"]?.task_title).toBe("Implement auth flow")
expect(updatedState?.task_sessions?.["todo:1"]?.agent).toBe("sisyphus-junior")
expect(updatedState?.task_sessions?.["todo:1"]?.category).toBe("deep")
cleanupMessageStorage(sessionID)
})
test("should preserve the delegated task key even after the plan advances to the next task", async () => {
// given - Atlas caller starts task 1, then the plan advances before task output is processed
const sessionID = "session-stable-task-key-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "stable-task-key-plan.md")
writeFileSync(planPath, `# Plan
## TODOs
- [ ] 1. Implement auth flow
- [ ] 2. Add API validation
`)
writeBoulderState(TEST_DIR, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "stable-task-key-plan",
})
const hook = createAtlasHook(createMockPluginInput())
// when - Atlas delegates task 1
await hook["tool.execute.before"](
{ tool: "task", sessionID, callID: "call-task-1" },
{ args: { prompt: "Implement auth flow" } }
)
// and the plan is advanced before the task output is processed
writeFileSync(planPath, `# Plan
## TODOs
- [x] 1. Implement auth flow
- [ ] 2. Add API validation
`)
await hook["tool.execute.after"](
{ tool: "task", sessionID, callID: "call-task-1" },
{
title: "Sisyphus Task",
output: `Task completed successfully
<task_metadata>
session_id: ses_auth_flow_123
</task_metadata>`,
metadata: {
agent: "sisyphus-junior",
category: "deep",
},
}
)
// then - the completed task session is still recorded against task 1, not task 2
const updatedState = readBoulderState(TEST_DIR)
expect(updatedState?.task_sessions?.["todo:1"]?.session_id).toBe("ses_auth_flow_123")
expect(updatedState?.task_sessions?.["todo:2"]).toBeUndefined()
cleanupMessageStorage(sessionID)
})
test("should not overwrite the current task mapping when task() explicitly resumes an older session", async () => {
// given - current plan is on task 2, but Atlas explicitly resumes an older session for a previous task
const sessionID = "session-cross-task-resume-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "cross-task-resume-plan.md")
writeFileSync(planPath, `# Plan
## TODOs
- [x] 1. Implement auth flow
- [ ] 2. Add API validation
`)
writeBoulderState(TEST_DIR, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "cross-task-resume-plan",
})
const hook = createAtlasHook(createMockPluginInput())
// when - Atlas resumes an explicit prior session
await hook["tool.execute.before"](
{ tool: "task", sessionID, callID: "call-resume-old-task" },
{ args: { prompt: "Follow up on previous task", session_id: "ses_old_task_111" } }
)
const output = {
title: "Sisyphus Task",
output: `Task continued successfully
<task_metadata>
session_id: ses_old_task_111
</task_metadata>`,
metadata: {
agent: "sisyphus-junior",
category: "deep",
},
}
await hook["tool.execute.after"](
{ tool: "task", sessionID, callID: "call-resume-old-task" },
output
)
// then - Atlas does not poison task 2's preferred session mapping
const updatedState = readBoulderState(TEST_DIR)
expect(updatedState?.task_sessions?.["todo:2"]).toBeUndefined()
expect(output.output).not.toContain('task(session_id="ses_old_task_111"')
cleanupMessageStorage(sessionID)
})
test("should not reuse an explicitly resumed session id in completion reminders", async () => {
// given - current plan is on task 2 with an existing tracked session
const sessionID = "session-explicit-resume-reminder-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "explicit-resume-reminder-plan.md")
writeFileSync(planPath, `# Plan
## TODOs
- [x] 1. Implement auth flow
- [ ] 2. Add API validation
`)
writeBoulderState(TEST_DIR, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "explicit-resume-reminder-plan",
task_sessions: {
"todo:2": {
task_key: "todo:2",
task_label: "2",
task_title: "Add API validation",
session_id: "ses_tracked_current_task",
updated_at: "2026-01-02T10:00:00Z",
},
},
})
const hook = createAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task continued successfully
<task_metadata>
session_id: ses_old_task_111
</task_metadata>`,
metadata: {},
}
// when
await hook["tool.execute.before"](
{ tool: "task", sessionID, callID: "call-explicit-resume-reminder" },
{ args: { prompt: "Follow up on previous task", session_id: "ses_old_task_111" } }
)
await hook["tool.execute.after"](
{ tool: "task", sessionID, callID: "call-explicit-resume-reminder" },
output
)
// then
expect(output.output).not.toContain('task(session_id="ses_old_task_111"')
expect(output.output).toContain("ses_tracked_current_task")
cleanupMessageStorage(sessionID)
})
test("should skip persistence when multiple in-flight task calls claim the same top-level task", async () => {
// given
const sessionID = "session-parallel-task-collision-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "parallel-task-collision-plan.md")
writeFileSync(planPath, `# Plan
## TODOs
- [ ] 1. Implement auth flow
- [ ] 2. Add API validation
`)
writeBoulderState(TEST_DIR, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "parallel-task-collision-plan",
})
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
const beforeHandler = createToolExecuteBeforeHandler({
ctx: createMockPluginInput(),
pendingFilePaths,
pendingTaskRefs,
})
const afterHandler = createToolExecuteAfterHandler({
ctx: createMockPluginInput(),
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
})
// when - two task() calls start before either one completes
await beforeHandler(
{ tool: "task", sessionID, callID: "call-task-first" },
{ args: { prompt: "Implement auth flow part 1" } }
)
await beforeHandler(
{ tool: "task", sessionID, callID: "call-task-second" },
{ args: { prompt: "Implement auth flow part 2" } }
)
const secondPendingTaskRef = pendingTaskRefs.get("call-task-second")
await afterHandler(
{ tool: "task", sessionID, callID: "call-task-second" },
{
title: "Sisyphus Task",
output: `Task completed successfully
<task_metadata>
session_id: ses_parallel_collision_222
</task_metadata>`,
metadata: {},
}
)
// then
expect(secondPendingTaskRef).toEqual({
kind: "skip",
reason: "ambiguous_task_key",
task: {
key: "todo:1",
label: "1",
title: "Implement auth flow",
},
})
const updatedState = readBoulderState(TEST_DIR)
expect(updatedState?.task_sessions?.["todo:1"]).toBeUndefined()
cleanupMessageStorage(sessionID)
})
test("should ignore extracted session ids that are outside the active boulder lineage", async () => {
// given
const sessionID = "session-untrusted-session-id-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "untrusted-session-id-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: "untrusted-session-id-plan",
})
const hook = createAtlasHook(createMockPluginInput({
sessionGetMock: mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
parentID: path.id === "ses_untrusted_999" ? "session-outside-lineage" : "main-session-123",
},
})),
}))
const output = {
title: "Sisyphus Task",
output: `Task completed successfully
<task_metadata>
session_id: ses_untrusted_999
</task_metadata>`,
metadata: {},
}
// when
await hook["tool.execute.after"](
{ tool: "task", sessionID },
output
)
// then
const updatedState = readBoulderState(TEST_DIR)
expect(updatedState?.task_sessions?.["todo:1"]).toBeUndefined()
expect(output.output).not.toContain('task(session_id="ses_untrusted_999"')
expect(output.output).toContain('task(session_id="<session_id>"')
cleanupMessageStorage(sessionID)
})
describe("completion gate output ordering", () => {
const COMPLETION_GATE_SESSION = "completion-gate-order-test"
@@ -1147,6 +1568,48 @@ describe("atlas hook", () => {
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should include preferred reuse session in continuation prompt for current top-level task", async () => {
// given - boulder state with tracked preferred session
const planPath = join(TEST_DIR, "preferred-session-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: [MAIN_SESSION_ID],
plan_name: "preferred-session-plan",
task_sessions: {
"todo:1": {
task_key: "todo:1",
task_label: "1",
task_title: "Implement auth flow",
session_id: "ses_auth_flow_123",
updated_at: "2026-01-02T10:00:00Z",
},
},
})
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.parts[0].text).toContain("Preferred reuse session for current top-level plan task")
expect(callArgs.body.parts[0].text).toContain("ses_auth_flow_123")
})
test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => {
// given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work)
const planPath = join(TEST_DIR, "test-plan.md")
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import { extractSessionIdFromOutput } from "./subagent-session-id"
describe("extractSessionIdFromOutput", () => {
test("extracts Session ID blocks from background output", () => {
// given
const output = `Background task launched.\n\nSession ID: ses_bg_12345`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_bg_12345")
})
test("extracts session_id from task metadata blocks", () => {
// given
const output = `Task completed.\n\n<task_metadata>\nsession_id: ses_sync_12345\n</task_metadata>`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_sync_12345")
})
test("returns undefined when no session id is present", () => {
// given
const output = "Task completed without metadata"
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBeUndefined()
})
test("prefers the session id inside the trailing task_metadata block", () => {
// given
const output = `The previous attempt mentioned session_id: ses_wrong_body_123 but that was only context.
<task_metadata>
session_id: ses_real_metadata_456
</task_metadata>`
// when
const result = extractSessionIdFromOutput(output)
// 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 = `<task_metadata>
session_id: ses_real_metadata_456
</task_metadata>
debug log: session_id: ses_wrong_body_789`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_real_metadata_456")
})
})
+43 -3
View File
@@ -1,4 +1,44 @@
export function extractSessionIdFromOutput(output: string): string {
const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/)
return match?.[1] ?? "<session_id>"
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
import { HOOK_NAME } from "./hook-name"
export function extractSessionIdFromOutput(output: string): string | undefined {
const taskMetadataBlocks = [...output.matchAll(/<task_metadata>([\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)]
return explicitSessionMatches.at(-1)?.[1]
}
export async function validateSubagentSessionId(input: {
client: PluginInput["client"]
sessionID?: string
lineageSessionIDs: string[]
}): Promise<string | undefined> {
if (!input.sessionID || input.lineageSessionIDs.length === 0) {
return undefined
}
const belongsToLineage = await isSessionInBoulderLineage({
client: input.client,
sessionID: input.sessionID,
boulderSessionIDs: input.lineageSessionIDs,
})
if (!belongsToLineage) {
log(`[${HOOK_NAME}] Ignoring extracted session id outside active lineage`, {
sessionID: input.sessionID,
lineageSessionIDs: input.lineageSessionIDs,
})
return undefined
}
return input.sessionID
}
+110 -10
View File
@@ -1,5 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state"
import {
appendSessionId,
getPlanProgress,
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
upsertTaskSessionState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { isCallerOrchestrator } from "../../shared/session-utils"
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
@@ -7,7 +14,7 @@ import { shouldPauseForFinalWaveApproval } from "./final-wave-approval-gate"
import { HOOK_NAME } from "./hook-name"
import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import { extractSessionIdFromOutput } from "./subagent-session-id"
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
import {
buildCompletionGate,
buildFinalWaveApprovalReminder,
@@ -15,16 +22,60 @@ import {
buildStandaloneVerificationReminder,
} from "./verification-reminders"
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
import type { SessionState } from "./types"
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
import type { PendingTaskRef, SessionState } from "./types"
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput, TrackedTopLevelTaskRef } from "./types"
function resolvePreferredSessionId(currentSessionId?: string, trackedSessionId?: string): string {
return currentSessionId ?? trackedSessionId ?? "<session_id>"
}
function resolveTaskContext(
pendingTaskRef: PendingTaskRef | undefined,
planPath: string,
): {
currentTask: TrackedTopLevelTaskRef | null
shouldSkipTaskSessionUpdate: boolean
shouldIgnoreCurrentSessionId: boolean
} {
if (!pendingTaskRef) {
return {
currentTask: readCurrentTopLevelTask(planPath),
shouldSkipTaskSessionUpdate: false,
shouldIgnoreCurrentSessionId: false,
}
}
if (pendingTaskRef.kind === "track") {
return {
currentTask: pendingTaskRef.task,
shouldSkipTaskSessionUpdate: false,
shouldIgnoreCurrentSessionId: false,
}
}
if (pendingTaskRef.reason === "explicit_resume") {
return {
currentTask: readCurrentTopLevelTask(planPath),
shouldSkipTaskSessionUpdate: true,
shouldIgnoreCurrentSessionId: true,
}
}
return {
currentTask: pendingTaskRef.task,
shouldSkipTaskSessionUpdate: true,
shouldIgnoreCurrentSessionId: true,
}
}
export function createToolExecuteAfterHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
autoCommit: boolean
getState: (sessionID: string) => SessionState
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
const { ctx, pendingFilePaths, autoCommit, getState } = input
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
return async (toolInput, toolOutput): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!toolOutput) {
@@ -59,6 +110,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
@@ -67,11 +122,19 @@ export function createToolExecuteAfterHandler(input: {
if (toolOutput.output && typeof toolOutput.output === "string") {
const gitStats = collectGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const subagentSessionId = extractSessionIdFromOutput(toolOutput.output)
const extractedSessionId = extractSessionIdFromOutput(toolOutput.output)
const boulderState = readBoulderState(ctx.directory)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
const {
currentTask,
shouldSkipTaskSessionUpdate,
shouldIgnoreCurrentSessionId,
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
const trackedTaskSession = currentTask
? getTaskSessionState(ctx.directory, currentTask.key)
: null
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
if (toolInput.sessionID && !boulderState.session_ids?.includes(toolInput.sessionID)) {
@@ -82,6 +145,31 @@ export function createToolExecuteAfterHandler(input: {
})
}
const lineageSessionIDs = toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)
? [...boulderState.session_ids, toolInput.sessionID]
: boulderState.session_ids
const subagentSessionId = await validateSubagentSessionId({
client: ctx.client,
sessionID: extractedSessionId,
lineageSessionIDs,
})
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: subagentSessionId,
agent: toolOutput.metadata?.agent as string | undefined,
category: toolOutput.metadata?.category as string | undefined,
})
}
const preferredSessionId = resolvePreferredSessionId(
shouldIgnoreCurrentSessionId ? undefined : subagentSessionId,
trackedTaskSession?.session_id,
)
// Preserve original subagent response - critical for debugging failed tasks
const originalResponse = toolOutput.output
const shouldPauseForApproval = sessionState
@@ -102,11 +190,11 @@ export function createToolExecuteAfterHandler(input: {
}
const leadReminder = shouldPauseForApproval
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, subagentSessionId)
: buildCompletionGate(boulderState.plan_name, subagentSessionId)
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
const followupReminder = shouldPauseForApproval
? null
: buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId, autoCommit, false)
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
toolOutput.output = `
<system-reminder>
@@ -132,10 +220,22 @@ ${
plan: boulderState.plan_name,
progress: `${progress.completed}/${progress.total}`,
fileCount: gitStats.length,
preferredSessionId,
waitingForFinalWaveApproval: shouldPauseForApproval,
})
} else {
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
const lineageSessionIDs = toolInput.sessionID ? [toolInput.sessionID] : []
const subagentSessionId = await validateSubagentSessionId({
client: ctx.client,
sessionID: extractedSessionId,
lineageSessionIDs,
})
const preferredSessionId = pendingTaskRef?.kind === "skip"
? undefined
: subagentSessionId
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(
resolvePreferredSessionId(preferredSessionId),
)}\n</system-reminder>`
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
sessionID: toolInput.sessionID,
+48 -1
View File
@@ -2,19 +2,26 @@ import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { isCallerOrchestrator } from "../../shared/session-utils"
import type { PluginInput } from "@opencode-ai/plugin"
import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state"
import { HOOK_NAME } from "./hook-name"
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types"
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
export function createToolExecuteBeforeHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
const { ctx, pendingFilePaths } = input
const { ctx, pendingFilePaths, pendingTaskRefs } = input
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
pendingTaskRefs.set(callID, { kind: "track", task })
}
return async (toolInput, toolOutput): Promise<void> => {
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
@@ -43,6 +50,46 @@ export function createToolExecuteBeforeHandler(input: {
// Check task - inject single-task directive
if (toolInput.tool === "task") {
if (toolInput.callID) {
const requestedSessionId = toolOutput.args.session_id as string | undefined
if (requestedSessionId) {
pendingTaskRefs.set(toolInput.callID, {
kind: "skip",
reason: "explicit_resume",
})
} else {
const boulderState = readBoulderState(ctx.directory)
const currentTask = boulderState
? readCurrentTopLevelTask(boulderState.active_plan)
: null
if (currentTask) {
const task = {
key: currentTask.key,
label: currentTask.label,
title: currentTask.title,
}
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
))
if (hasExistingClaim) {
pendingTaskRefs.set(toolInput.callID, {
kind: "skip",
reason: "ambiguous_task_key",
task,
})
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
sessionID: toolInput.sessionID,
callID: toolInput.callID,
taskKey: task.key,
})
} else {
trackTask(toolInput.callID, task)
}
}
}
}
const prompt = toolOutput.args.prompt as string | undefined
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
toolOutput.args.prompt = `<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>\n` + prompt
+8
View File
@@ -1,5 +1,6 @@
import type { AgentOverrides } from "../../config"
import type { BackgroundManager } from "../../features/background-agent"
import type { TopLevelTaskRef } from "../../features/boulder-state"
export type ModelInfo = { providerID: string; modelID: string }
@@ -25,6 +26,13 @@ export interface ToolExecuteAfterOutput {
metadata: Record<string, unknown>
}
export type TrackedTopLevelTaskRef = Pick<TopLevelTaskRef, "key" | "label" | "title">
export type PendingTaskRef =
| { kind: "track"; task: TrackedTopLevelTaskRef }
| { kind: "skip"; reason: "explicit_resume" }
| { kind: "skip"; reason: "ambiguous_task_key"; task: TrackedTopLevelTaskRef }
export interface SessionState {
lastEventWasAbortError?: boolean
lastContinuationInjectedAt?: number
+14 -2
View File
@@ -1,5 +1,14 @@
import { VERIFICATION_REMINDER } from "./system-reminder-templates"
function buildReuseHint(sessionId: string): string {
return `
**PREFERRED REUSE SESSION FOR THE CURRENT TOP-LEVEL PLAN TASK**
- Reuse \`${sessionId}\` first if verification fails or the result needs follow-up.
- Start a fresh subagent session only when reuse is unavailable or would cross task boundaries.
`
}
export function buildCompletionGate(planName: string, sessionId: string): string {
return `
**COMPLETION GATE — DO NOT PROCEED UNTIL THIS IS DONE**
@@ -25,7 +34,8 @@ task(session_id="${sessionId}", prompt="fix: checkbox not recorded correctly")
**Your completion is NOT tracked until the checkbox is marked in the plan file.**
**VERIFICATION_REMINDER**`
**VERIFICATION_REMINDER**
${buildReuseHint(sessionId)}`
}
function buildVerificationReminder(sessionId: string): string {
@@ -38,7 +48,9 @@ ${VERIFICATION_REMINDER}
**If ANY verification fails, use this immediately:**
\`\`\`
task(session_id="${sessionId}", prompt="fix: [describe the specific failure]")
\`\`\``
\`\`\`
${buildReuseHint(sessionId)}`
}
export function buildOrchestratorReminder(