Fix #3629: resolve boulder progress from worktree plan

This commit is contained in:
lucasyounger
2026-04-25 16:34:34 +08:00
parent 48b0cfeaf5
commit 828c2634bd
13 changed files with 252 additions and 21 deletions
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, type BoulderState, upsertTaskSessionState } from "../../features/boulder-state"
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
@@ -40,7 +40,7 @@ export async function syncBackgroundLaunchSessionTracking(input: {
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
pendingTaskRef,
boulderState.active_plan,
resolveBoulderPlanPath(ctx.directory, boulderState),
)
if (currentTask && !shouldSkipTaskSessionUpdate) {
+7 -2
View File
@@ -4,6 +4,7 @@ import {
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
resolveBoulderPlanPath,
} from "../../features/boulder-state"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getLastAgentFromSession } from "./session-last-agent"
@@ -52,8 +53,12 @@ async function injectContinuation(input: {
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const currentPlanPath = currentBoulder
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
: null
const currentTask = currentBoulder
? readCurrentTopLevelTask(currentBoulder.active_plan)
&& currentPlanPath
? readCurrentTopLevelTask(currentPlanPath)
: null
const preferredTaskSession = currentTask
? getTaskSessionState(input.ctx.directory, currentTask.key)
@@ -163,7 +168,7 @@ function scheduleRetry(input: {
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
const canContinueSession = await canContinueTrackedBoulderSession({
+37
View File
@@ -1494,6 +1494,43 @@ session_id: ses_untrusted_999
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
// given
const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md")
const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-complete-plan.md")
mkdirSync(join(TEST_DIR, ".sisyphus", "plans"), { recursive: true })
mkdirSync(join(worktreeDir, ".sisyphus", "plans"), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
writeBoulderState(TEST_DIR, {
active_plan: mainPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "worktree-complete-plan",
worktree_path: worktreeDir,
})
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
try {
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then
expect(mockInput._promptMock).not.toHaveBeenCalled()
} finally {
rmSync(worktreeDir, { recursive: true, force: true })
}
})
test("should skip when abort error occurred before idle", async () => {
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { randomUUID } from "node:crypto"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
@@ -96,4 +96,39 @@ describe("resolveActiveBoulderSession", () => {
expect(result?.progress.isComplete).toBe(false)
expect(result?.boulderState.session_ids).toContain("ses_appended")
})
test("returns complete progress when a mirrored worktree plan is complete", async () => {
// given
const mainPlanPath = join(testDirectory, ".sisyphus", "plans", "worktree-plan.md")
const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "worktree-plan.md")
mkdirSync(dirname(mainPlanPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n", "utf-8")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n", "utf-8")
writeBoulderState(testDirectory, {
active_plan: mainPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_tracked"],
session_origins: { ses_tracked: "direct" },
plan_name: "worktree-plan",
worktree_path: worktreeDirectory,
})
try {
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_tracked",
})
// then
expect(result).not.toBeNull()
expect(result?.progress.isComplete).toBe(true)
expect(result?.progress.completed).toBe(1)
} finally {
rmSync(worktreeDirectory, { recursive: true, force: true })
}
})
})
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
export async function resolveActiveBoulderSession(input: {
@@ -20,7 +20,7 @@ export async function resolveActiveBoulderSession(input: {
return null
}
const progress = getPlanProgress(boulderState.active_plan)
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
if (progress.isComplete) {
return { boulderState, progress, appendedSession: false }
}
+5 -3
View File
@@ -4,6 +4,7 @@ import {
getPlanProgress,
getTaskSessionState,
readBoulderState,
resolveBoulderPlanPath,
upsertTaskSessionState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
@@ -98,12 +99,13 @@ export function createToolExecuteAfterHandler(input: {
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
const progress = getPlanProgress(planPath)
const {
currentTask,
shouldSkipTaskSessionUpdate,
shouldIgnoreCurrentSessionId,
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
} = resolveTaskContext(pendingTaskRef, planPath)
const trackedTaskSession = currentTask
? getTaskSessionState(ctx.directory, currentTask.key)
: null
@@ -136,7 +138,7 @@ export function createToolExecuteAfterHandler(input: {
const originalResponse = toolOutput.output
const shouldPauseForApproval = sessionState
? shouldPauseForFinalWaveApproval({
planPath: boulderState.active_plan,
planPath,
taskOutput: originalResponse,
sessionState,
})
+2 -2
View File
@@ -2,7 +2,7 @@ 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 { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } 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"
@@ -60,7 +60,7 @@ export function createToolExecuteBeforeHandler(input: {
} else {
const boulderState = readBoulderState(ctx.directory)
const currentTask = boulderState
? readCurrentTopLevelTask(boulderState.active_plan)
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
: null
if (currentTask) {
const task = {
+11 -4
View File
@@ -7,6 +7,7 @@ import {
getPlanName,
getPlanProgress,
readBoulderState,
resolveBoulderPlanPath,
writeBoulderState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
@@ -150,7 +151,8 @@ function buildExistingSessionContext(params: {
directory: string
}): string {
const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params
const progress = getPlanProgress(existingState.active_plan)
const planPath = resolveBoulderPlanPath(directory, existingState)
const progress = getPlanProgress(planPath)
if (progress.isComplete) {
return `
## Previous Work Complete
@@ -186,7 +188,7 @@ Looking for new plans...`
**Status**: RESUMING existing work
**Plan**: ${existingState.plan_name}
**Path**: ${existingState.active_plan}
**Path**: ${planPath}
**Progress**: ${progress.completed}/${progress.total} tasks completed
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
**Started**: ${existingState.started_at}
@@ -197,11 +199,16 @@ Read the plan file and continue from the first unchecked task.`
}
function shouldDiscoverPlans(
directory: string,
existingState: ReturnType<typeof readBoulderState>,
explicitPlanName: string | null,
): boolean {
return (!existingState && !explicitPlanName)
|| (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|| (
existingState !== null
&& !explicitPlanName
&& getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete
)
}
function buildPlanDiscoveryContext(params: {
@@ -303,7 +310,7 @@ export function buildStartWorkContextInfo(params: {
})
}
if (shouldDiscoverPlans(existingState, explicitPlanName)) {
if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) {
return buildPlanDiscoveryContext({
contextInfo,
sessionId,
+35 -1
View File
@@ -2,7 +2,7 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { createStartWorkHook } from "./index"
@@ -1013,5 +1013,39 @@ You are starting a Sisyphus work session.
expect(output.parts[0].text).toContain("subagent")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
test("should show worktree plan progress and path when the mirrored plan exists", async () => {
// given
const mainPlanPath = join(testDir, ".sisyphus", "plans", "resume-worktree-plan.md")
const worktreeDir = join(testDir, "..", `resume-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "resume-worktree-plan.md")
mkdirSync(dirname(mainPlanPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task 1\n- [ ] Worktree task 2\n")
writeBoulderState(testDir, {
active_plan: mainPlanPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "resume-worktree-plan",
worktree_path: worktreeDir,
})
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: createStartWorkPrompt() }],
}
try {
// when
await hook["chat.message"]({ sessionID: "session-worktree-progress" }, output)
// then
expect(output.parts[0].text).toContain(worktreePlanPath)
expect(output.parts[0].text).toContain("1/2 tasks completed")
} finally {
rmSync(worktreeDir, { recursive: true, force: true })
}
})
})
})