Fix #3629: resolve boulder progress from worktree plan
This commit is contained in:
@@ -105,6 +105,41 @@ describe("checkCompletionConditions continuation coverage", () => {
|
|||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("returns true when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||||
|
// given
|
||||||
|
spyOn(console, "log").mockImplementation(() => {})
|
||||||
|
const directory = createTempDir()
|
||||||
|
const mainPlanPath = join(directory, ".sisyphus", "plans", "done-in-worktree-plan.md")
|
||||||
|
const worktreeDirectory = createTempDir()
|
||||||
|
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "done-in-worktree-plan.md")
|
||||||
|
mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true })
|
||||||
|
mkdirSync(join(worktreeDirectory, ".sisyphus", "plans"), { recursive: true })
|
||||||
|
writeFileSync(mainPlanPath, "- [ ] stale main repo task\n", "utf-8")
|
||||||
|
writeFileSync(worktreePlanPath, "- [x] completed worktree task\n", "utf-8")
|
||||||
|
const sisyphusDir = join(directory, ".sisyphus")
|
||||||
|
mkdirSync(sisyphusDir, { recursive: true })
|
||||||
|
writeFileSync(
|
||||||
|
join(sisyphusDir, "boulder.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
active_plan: mainPlanPath,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
session_ids: ["test-session"],
|
||||||
|
plan_name: "done-in-worktree-plan",
|
||||||
|
agent: "atlas",
|
||||||
|
worktree_path: worktreeDirectory,
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
const ctx = createMockContext(directory)
|
||||||
|
const { checkCompletionConditions } = await import("./completion")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await checkCompletionConditions(ctx)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it("returns false when current session is an appended descendant of an active boulder session with unchecked plan items", async () => {
|
it("returns false when current session is an appended descendant of an active boulder session with unchecked plan items", async () => {
|
||||||
// given
|
// given
|
||||||
spyOn(console, "log").mockImplementation(() => {})
|
spyOn(console, "log").mockImplementation(() => {})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import {
|
import {
|
||||||
getActiveContinuationMarkerReason,
|
getActiveContinuationMarkerReason,
|
||||||
@@ -45,7 +45,7 @@ async function hasActiveBoulderContinuation(
|
|||||||
const boulder = readBoulderState(directory)
|
const boulder = readBoulderState(directory)
|
||||||
if (!boulder) return false
|
if (!boulder) return false
|
||||||
|
|
||||||
const progress = getPlanProgress(boulder.active_plan)
|
const progress = getPlanProgress(resolveBoulderPlanPath(directory, boulder))
|
||||||
if (progress.isComplete) return false
|
if (progress.isComplete) return false
|
||||||
if (!client) return false
|
if (!client) return false
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
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 { tmpdir } from "node:os"
|
||||||
import {
|
import {
|
||||||
readBoulderState,
|
readBoulderState,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createBoulderState,
|
createBoulderState,
|
||||||
findPrometheusPlans,
|
findPrometheusPlans,
|
||||||
getTaskSessionState,
|
getTaskSessionState,
|
||||||
|
resolveBoulderPlanPath,
|
||||||
upsertTaskSessionState,
|
upsertTaskSessionState,
|
||||||
} from "./storage"
|
} from "./storage"
|
||||||
import type { BoulderState } from "./types"
|
import type { BoulderState } from "./types"
|
||||||
@@ -778,4 +779,46 @@ describe("boulder-state", () => {
|
|||||||
expect(state.agent).toBeUndefined()
|
expect(state.agent).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("resolveBoulderPlanPath", () => {
|
||||||
|
test("should prefer the mirrored worktree plan when it exists", () => {
|
||||||
|
// given
|
||||||
|
const planPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-plan.md")
|
||||||
|
const worktreeDir = join(tmpdir(), `boulder-state-worktree-${Date.now()}`)
|
||||||
|
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-plan.md")
|
||||||
|
mkdirSync(dirname(planPath), { recursive: true })
|
||||||
|
mkdirSync(dirname(worktreePlanPath), { recursive: true })
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Main repo task\n")
|
||||||
|
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
const resolvedPath = resolveBoulderPlanPath(TEST_DIR, {
|
||||||
|
active_plan: planPath,
|
||||||
|
worktree_path: worktreeDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(resolvedPath).toBe(worktreePlanPath)
|
||||||
|
} finally {
|
||||||
|
rmSync(worktreeDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should fall back to the tracked plan when the mirrored worktree plan is missing", () => {
|
||||||
|
// given
|
||||||
|
const planPath = join(TEST_DIR, ".sisyphus", "plans", "fallback-plan.md")
|
||||||
|
mkdirSync(dirname(planPath), { recursive: true })
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Main repo task\n")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const resolvedPath = resolveBoulderPlanPath(TEST_DIR, {
|
||||||
|
active_plan: planPath,
|
||||||
|
worktree_path: join(tmpdir(), `missing-worktree-${Date.now()}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(resolvedPath).toBe(planPath)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
|
||||||
import { dirname, join, basename } from "node:path"
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||||
import type { BoulderState, PlanProgress, TaskSessionState } from "./types"
|
import type { BoulderState, PlanProgress, TaskSessionState } from "./types"
|
||||||
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
|
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
|
||||||
|
|
||||||
@@ -15,6 +15,39 @@ export function getBoulderFilePath(directory: string): string {
|
|||||||
return join(directory, BOULDER_DIR, BOULDER_FILE)
|
return join(directory, BOULDER_DIR, BOULDER_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTrackedPath(baseDirectory: string, trackedPath: string): string {
|
||||||
|
return isAbsolute(trackedPath)
|
||||||
|
? resolve(trackedPath)
|
||||||
|
: resolve(baseDirectory, trackedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBoulderPlanPath(
|
||||||
|
directory: string,
|
||||||
|
state: Pick<BoulderState, "active_plan" | "worktree_path">,
|
||||||
|
): string {
|
||||||
|
const absolutePlanPath = resolveTrackedPath(directory, state.active_plan)
|
||||||
|
const worktreePath = state.worktree_path?.trim()
|
||||||
|
if (!worktreePath) {
|
||||||
|
return absolutePlanPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteDirectory = resolve(directory)
|
||||||
|
const relativePlanPath = relative(absoluteDirectory, absolutePlanPath)
|
||||||
|
if (
|
||||||
|
relativePlanPath.length === 0
|
||||||
|
|| relativePlanPath.startsWith("..")
|
||||||
|
|| isAbsolute(relativePlanPath)
|
||||||
|
) {
|
||||||
|
return absolutePlanPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteWorktreePath = resolveTrackedPath(directory, worktreePath)
|
||||||
|
const worktreePlanPath = resolve(absoluteWorktreePath, relativePlanPath)
|
||||||
|
return existsSync(worktreePlanPath)
|
||||||
|
? worktreePlanPath
|
||||||
|
: absolutePlanPath
|
||||||
|
}
|
||||||
|
|
||||||
export function readBoulderState(directory: string): BoulderState | null {
|
export function readBoulderState(directory: string): BoulderState | null {
|
||||||
const filePath = getBoulderFilePath(directory)
|
const filePath = getBoulderFilePath(directory)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
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 { log } from "../../shared/logger"
|
||||||
import { HOOK_NAME } from "./hook-name"
|
import { HOOK_NAME } from "./hook-name"
|
||||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||||
@@ -40,7 +40,7 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
|||||||
|
|
||||||
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
|
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
|
||||||
pendingTaskRef,
|
pendingTaskRef,
|
||||||
boulderState.active_plan,
|
resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (currentTask && !shouldSkipTaskSessionUpdate) {
|
if (currentTask && !shouldSkipTaskSessionUpdate) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getTaskSessionState,
|
getTaskSessionState,
|
||||||
readBoulderState,
|
readBoulderState,
|
||||||
readCurrentTopLevelTask,
|
readCurrentTopLevelTask,
|
||||||
|
resolveBoulderPlanPath,
|
||||||
} from "../../features/boulder-state"
|
} from "../../features/boulder-state"
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { getLastAgentFromSession } from "./session-last-agent"
|
import { getLastAgentFromSession } from "./session-last-agent"
|
||||||
@@ -52,8 +53,12 @@ async function injectContinuation(input: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentBoulder = readBoulderState(input.ctx.directory)
|
const currentBoulder = readBoulderState(input.ctx.directory)
|
||||||
|
const currentPlanPath = currentBoulder
|
||||||
|
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
|
||||||
|
: null
|
||||||
const currentTask = currentBoulder
|
const currentTask = currentBoulder
|
||||||
? readCurrentTopLevelTask(currentBoulder.active_plan)
|
&& currentPlanPath
|
||||||
|
? readCurrentTopLevelTask(currentPlanPath)
|
||||||
: null
|
: null
|
||||||
const preferredTaskSession = currentTask
|
const preferredTaskSession = currentTask
|
||||||
? getTaskSessionState(input.ctx.directory, currentTask.key)
|
? getTaskSessionState(input.ctx.directory, currentTask.key)
|
||||||
@@ -163,7 +168,7 @@ function scheduleRetry(input: {
|
|||||||
if (!currentBoulder) return
|
if (!currentBoulder) return
|
||||||
if (!currentBoulder.session_ids?.includes(sessionID)) 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 (currentProgress.isComplete) return
|
||||||
if (options?.isContinuationStopped?.(sessionID)) return
|
if (options?.isContinuationStopped?.(sessionID)) return
|
||||||
const canContinueSession = await canContinueTrackedBoulderSession({
|
const canContinueSession = await canContinueTrackedBoulderSession({
|
||||||
|
|||||||
@@ -1494,6 +1494,43 @@ session_id: ses_untrusted_999
|
|||||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
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 () => {
|
test("should skip when abort error occurred before idle", async () => {
|
||||||
// given - boulder state with incomplete plan
|
// given - boulder state with incomplete plan
|
||||||
const planPath = join(TEST_DIR, "test-plan.md")
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||||
@@ -96,4 +96,39 @@ describe("resolveActiveBoulderSession", () => {
|
|||||||
expect(result?.progress.isComplete).toBe(false)
|
expect(result?.progress.isComplete).toBe(false)
|
||||||
expect(result?.boulderState.session_ids).toContain("ses_appended")
|
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 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"
|
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||||
|
|
||||||
export async function resolveActiveBoulderSession(input: {
|
export async function resolveActiveBoulderSession(input: {
|
||||||
@@ -20,7 +20,7 @@ export async function resolveActiveBoulderSession(input: {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = getPlanProgress(boulderState.active_plan)
|
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
|
||||||
if (progress.isComplete) {
|
if (progress.isComplete) {
|
||||||
return { boulderState, progress, appendedSession: false }
|
return { boulderState, progress, appendedSession: false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getPlanProgress,
|
getPlanProgress,
|
||||||
getTaskSessionState,
|
getTaskSessionState,
|
||||||
readBoulderState,
|
readBoulderState,
|
||||||
|
resolveBoulderPlanPath,
|
||||||
upsertTaskSessionState,
|
upsertTaskSessionState,
|
||||||
} from "../../features/boulder-state"
|
} from "../../features/boulder-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
@@ -98,12 +99,13 @@ export function createToolExecuteAfterHandler(input: {
|
|||||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||||
|
|
||||||
if (boulderState) {
|
if (boulderState) {
|
||||||
const progress = getPlanProgress(boulderState.active_plan)
|
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
const {
|
const {
|
||||||
currentTask,
|
currentTask,
|
||||||
shouldSkipTaskSessionUpdate,
|
shouldSkipTaskSessionUpdate,
|
||||||
shouldIgnoreCurrentSessionId,
|
shouldIgnoreCurrentSessionId,
|
||||||
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
|
} = resolveTaskContext(pendingTaskRef, planPath)
|
||||||
const trackedTaskSession = currentTask
|
const trackedTaskSession = currentTask
|
||||||
? getTaskSessionState(ctx.directory, currentTask.key)
|
? getTaskSessionState(ctx.directory, currentTask.key)
|
||||||
: null
|
: null
|
||||||
@@ -136,7 +138,7 @@ export function createToolExecuteAfterHandler(input: {
|
|||||||
const originalResponse = toolOutput.output
|
const originalResponse = toolOutput.output
|
||||||
const shouldPauseForApproval = sessionState
|
const shouldPauseForApproval = sessionState
|
||||||
? shouldPauseForFinalWaveApproval({
|
? shouldPauseForFinalWaveApproval({
|
||||||
planPath: boulderState.active_plan,
|
planPath,
|
||||||
taskOutput: originalResponse,
|
taskOutput: originalResponse,
|
||||||
sessionState,
|
sessionState,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { log } from "../../shared/logger"
|
|||||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
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 { HOOK_NAME } from "./hook-name"
|
||||||
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
|
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
|
||||||
import { isSisyphusPath } from "./sisyphus-path"
|
import { isSisyphusPath } from "./sisyphus-path"
|
||||||
@@ -60,7 +60,7 @@ export function createToolExecuteBeforeHandler(input: {
|
|||||||
} else {
|
} else {
|
||||||
const boulderState = readBoulderState(ctx.directory)
|
const boulderState = readBoulderState(ctx.directory)
|
||||||
const currentTask = boulderState
|
const currentTask = boulderState
|
||||||
? readCurrentTopLevelTask(boulderState.active_plan)
|
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
|
||||||
: null
|
: null
|
||||||
if (currentTask) {
|
if (currentTask) {
|
||||||
const task = {
|
const task = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getPlanName,
|
getPlanName,
|
||||||
getPlanProgress,
|
getPlanProgress,
|
||||||
readBoulderState,
|
readBoulderState,
|
||||||
|
resolveBoulderPlanPath,
|
||||||
writeBoulderState,
|
writeBoulderState,
|
||||||
} from "../../features/boulder-state"
|
} from "../../features/boulder-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
@@ -150,7 +151,8 @@ function buildExistingSessionContext(params: {
|
|||||||
directory: string
|
directory: string
|
||||||
}): string {
|
}): string {
|
||||||
const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params
|
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) {
|
if (progress.isComplete) {
|
||||||
return `
|
return `
|
||||||
## Previous Work Complete
|
## Previous Work Complete
|
||||||
@@ -186,7 +188,7 @@ Looking for new plans...`
|
|||||||
|
|
||||||
**Status**: RESUMING existing work
|
**Status**: RESUMING existing work
|
||||||
**Plan**: ${existingState.plan_name}
|
**Plan**: ${existingState.plan_name}
|
||||||
**Path**: ${existingState.active_plan}
|
**Path**: ${planPath}
|
||||||
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
||||||
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
||||||
**Started**: ${existingState.started_at}
|
**Started**: ${existingState.started_at}
|
||||||
@@ -197,11 +199,16 @@ Read the plan file and continue from the first unchecked task.`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldDiscoverPlans(
|
function shouldDiscoverPlans(
|
||||||
|
directory: string,
|
||||||
existingState: ReturnType<typeof readBoulderState>,
|
existingState: ReturnType<typeof readBoulderState>,
|
||||||
explicitPlanName: string | null,
|
explicitPlanName: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (!existingState && !explicitPlanName)
|
return (!existingState && !explicitPlanName)
|
||||||
|| (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|
|| (
|
||||||
|
existingState !== null
|
||||||
|
&& !explicitPlanName
|
||||||
|
&& getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlanDiscoveryContext(params: {
|
function buildPlanDiscoveryContext(params: {
|
||||||
@@ -303,7 +310,7 @@ export function buildStartWorkContextInfo(params: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldDiscoverPlans(existingState, explicitPlanName)) {
|
if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) {
|
||||||
return buildPlanDiscoveryContext({
|
return buildPlanDiscoveryContext({
|
||||||
contextInfo,
|
contextInfo,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
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 { tmpdir } from "node:os"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { createStartWorkHook } from "./index"
|
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).toContain("subagent")
|
||||||
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user