merge(dev): resolve latest sync-task conflict for delegated fallback PR
Sync the PR branch with the latest dev branch and resolve the remaining conflict in sync-task.test.ts while preserving both the new upstream poll-recovery coverage and this branch's delegated bootstrap cleanup and isolation coverage. Re-verified the affected delegated fallback suites and typecheck after the merge resolution. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -8,6 +8,7 @@ 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 pendingPlanSnapshots = new Map<string, string>()
|
||||
const autoCommit = options?.autoCommit ?? true
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
@@ -25,12 +26,14 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
isCallerOrchestrator: options?.isCallerOrchestrator,
|
||||
}),
|
||||
"tool.execute.after": createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
autoCommit,
|
||||
getState,
|
||||
isCallerOrchestrator: options?.isCallerOrchestrator,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import {
|
||||
appendSessionId,
|
||||
appendSessionIdForWork,
|
||||
getWorkForSession,
|
||||
type BoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
upsertTaskSessionState,
|
||||
upsertTaskSessionStateForWork,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||
@@ -19,8 +28,13 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof toolInput.sessionID !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const trackedWork = getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
const lineageSessionIDs = boulderState.session_ids
|
||||
const lineageSessionIDs = trackedWork?.session_ids ?? boulderState.session_ids
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
@@ -36,22 +50,39 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
return
|
||||
}
|
||||
|
||||
appendSessionId(ctx.directory, trackedSessionId, "appended")
|
||||
if (trackedWork) {
|
||||
appendSessionIdForWork(ctx.directory, trackedWork.work_id, trackedSessionId, "appended")
|
||||
} else {
|
||||
appendSessionId(ctx.directory, trackedSessionId, "appended")
|
||||
}
|
||||
|
||||
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
|
||||
pendingTaskRef,
|
||||
resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
trackedWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, trackedWork)
|
||||
: resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
)
|
||||
|
||||
if (currentTask && !shouldSkipTaskSessionUpdate) {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (trackedWork) {
|
||||
upsertTaskSessionStateForWork(ctx.directory, trackedWork.work_id, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
} else {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Background launch session tracked`, {
|
||||
@@ -81,17 +112,3 @@ async function resolveFallbackTrackedSessionId(input: {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSessionOrigin(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
): Promise<"direct" | "appended"> {
|
||||
try {
|
||||
const session = await ctx.client.session.get({ path: { id: sessionID } })
|
||||
return typeof session.data?.parentID === "string" && session.data.parentID.length > 0
|
||||
? "appended"
|
||||
: "direct"
|
||||
} catch {
|
||||
return "appended"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
describe("atlas hook idle-event complete boulder", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
clearBoulderState(testDirectory)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBoulderState(testDirectory)
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("marks work completed with ended_at and elapsed_ms when progress is complete", async () => {
|
||||
// given
|
||||
const sessionID = "ses_complete"
|
||||
const planPath = join(testDirectory, "complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Done\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-complete",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00.000Z",
|
||||
session_ids: [sessionID],
|
||||
plan_name: "complete-plan",
|
||||
works: {
|
||||
"work-complete": {
|
||||
work_id: "work-complete",
|
||||
active_plan: planPath,
|
||||
plan_name: "complete-plan",
|
||||
started_at: "2026-01-02T10:00:00.000Z",
|
||||
session_ids: [sessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createAtlasHook({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
get: async () => ({ data: { id: sessionID } }),
|
||||
messages: async () => ({ data: [] }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const work = readBoulderState(testDirectory)?.works?.["work-complete"]
|
||||
expect(work?.status).toBe("completed")
|
||||
expect(work?.ended_at).toBeString()
|
||||
expect((work?.elapsed_ms ?? 0) > 0).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
|
||||
import { handleAtlasSessionIdle } from "./idle-event"
|
||||
import type { SessionState } from "./types"
|
||||
|
||||
describe("handleAtlasSessionIdle completion nudge", () => {
|
||||
const SESSION_ID = "session-main-1"
|
||||
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
_resetForTesting()
|
||||
registerAgentName("atlas")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
it("injects BOULDER COMPLETE prompt once per work with substituted elapsed and task breakdown", async () => {
|
||||
// given
|
||||
const planPath = join(testDirectory, "plan.md")
|
||||
writeFileSync(planPath, "## TODOs\n- [x] 1. Parse input\n- [x] 2. Save output\n")
|
||||
|
||||
const boulder = createBoulderState(planPath, SESSION_ID, "atlas")
|
||||
const workId = boulder.active_work_id
|
||||
if (!workId) {
|
||||
throw new Error("Expected active_work_id")
|
||||
}
|
||||
|
||||
const work = boulder.works?.[workId]
|
||||
if (!work) {
|
||||
throw new Error("Expected active work")
|
||||
}
|
||||
|
||||
work.elapsed_ms = 65_000
|
||||
boulder.elapsed_ms = 65_000
|
||||
work.task_sessions = {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Save output",
|
||||
session_id: "sub-2",
|
||||
elapsed_ms: 4_000,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
"todo:1": {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Parse input",
|
||||
session_id: "sub-1",
|
||||
elapsed_ms: 61_000,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
boulder.task_sessions = work.task_sessions
|
||||
|
||||
writeBoulderState(testDirectory, boulder)
|
||||
|
||||
const promptRequests: Array<{ body?: { parts?: Array<{ text?: string }> } }> = []
|
||||
const promptAsyncMock = mock(async (request: { body?: { parts?: Array<{ text?: string }> } }) => {
|
||||
promptRequests.push(request)
|
||||
return { data: {} }
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
|
||||
const sessionStateById = new Map<string, SessionState>()
|
||||
const getState = (sessionId: string): SessionState => {
|
||||
let state = sessionStateById.get(sessionId)
|
||||
if (!state) {
|
||||
state = { promptFailureCount: 0 }
|
||||
sessionStateById.set(sessionId, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// when
|
||||
await handleAtlasSessionIdle({
|
||||
ctx,
|
||||
sessionID: SESSION_ID,
|
||||
getState,
|
||||
})
|
||||
|
||||
await handleAtlasSessionIdle({
|
||||
ctx,
|
||||
sessionID: SESSION_ID,
|
||||
getState,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const promptText = promptRequests[0]?.body?.parts?.[0]?.text ?? ""
|
||||
expect(promptText).toContain("BOULDER COMPLETE")
|
||||
expect(promptText).toContain("Total elapsed: 1m 5s")
|
||||
expect(promptText).toContain("- 1 Parse input: 1m 1s")
|
||||
expect(promptText).toContain("- 2 Save output: 4s")
|
||||
expect(promptText).not.toContain("{ELAPSED_HUMAN}")
|
||||
|
||||
const persistedState = getState(SESSION_ID)
|
||||
expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber()
|
||||
expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed")
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,29 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
completeBoulder,
|
||||
formatDurationHuman,
|
||||
getPlanProgress,
|
||||
getWorkForSession,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
resolveBoulderPlanPath,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
getSessionAgent,
|
||||
isAgentRegistered,
|
||||
resolveRegisteredAgentName,
|
||||
} from "../../features/claude-code-session-state"
|
||||
import { getLastAgentFromSession } from "./session-last-agent"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
import { createInternalAgentTextPart } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
@@ -22,6 +31,11 @@ const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
|
||||
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
||||
|
||||
function getTaskLabelSortValue(taskLabel: string): number {
|
||||
const parsed = Number.parseInt(taskLabel.replace(/[^0-9]/g, ""), 10)
|
||||
return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed
|
||||
}
|
||||
|
||||
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
|
||||
const backgroundManager = options?.backgroundManager
|
||||
return backgroundManager
|
||||
@@ -205,6 +219,7 @@ export async function handleAtlasSessionIdle(input: {
|
||||
sessionID: string
|
||||
}): Promise<void> {
|
||||
const { ctx, options, getState, sessionID } = input
|
||||
const sessionState = getState(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
@@ -220,6 +235,68 @@ export async function handleAtlasSessionIdle(input: {
|
||||
|
||||
const { boulderState, progress, appendedSession } = activeBoulderSession
|
||||
if (progress.isComplete) {
|
||||
const work = getWorkForSession(ctx.directory, sessionID)
|
||||
if (work) {
|
||||
completeBoulder(ctx.directory, work.work_id)
|
||||
} else {
|
||||
completeBoulder(ctx.directory, boulderState.active_work_id)
|
||||
}
|
||||
|
||||
if (!work || work.status === "abandoned") {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.boulderCompletionNudgedAt?.[work.work_id]) {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
const elapsedMilliseconds = work.elapsed_ms ?? (Date.now() - new Date(work.started_at).getTime())
|
||||
const elapsedHuman = formatDurationHuman(elapsedMilliseconds)
|
||||
|
||||
const taskBreakdown = Object.values(work.task_sessions ?? {})
|
||||
.sort((left, right) => {
|
||||
const leftSortValue = getTaskLabelSortValue(left.task_label)
|
||||
const rightSortValue = getTaskLabelSortValue(right.task_label)
|
||||
if (leftSortValue !== rightSortValue) {
|
||||
return leftSortValue - rightSortValue
|
||||
}
|
||||
|
||||
return left.task_label.localeCompare(right.task_label)
|
||||
})
|
||||
.map((task) => {
|
||||
if (typeof task.elapsed_ms === "number") {
|
||||
return `- ${task.task_label} ${task.task_title}: ${formatDurationHuman(task.elapsed_ms)}`
|
||||
}
|
||||
|
||||
return `- ${task.task_label} ${task.task_title}: (no timing)`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
const prompt = BOULDER_COMPLETE_PROMPT
|
||||
.replace(/{PLAN_NAME}/g, work.plan_name)
|
||||
.replace(/{ELAPSED_HUMAN}/g, elapsedHuman)
|
||||
.replace(/{TASK_BREAKDOWN}/g, taskBreakdown.length > 0 ? taskBreakdown : "- (no task timings)")
|
||||
|
||||
const atlasAgent = resolveRegisteredAgentName(
|
||||
boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
||||
)
|
||||
if (atlasAgent && isAgentRegistered(atlasAgent)) {
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: atlasAgent,
|
||||
parts: [createInternalAgentTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
sessionState.boulderCompletionNudgedAt = {
|
||||
...(sessionState.boulderCompletionNudgedAt ?? {}),
|
||||
[work.work_id]: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
@@ -246,7 +323,6 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionState = getState(sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
if (sessionState.waitingForFinalWaveApproval) {
|
||||
|
||||
@@ -1490,7 +1490,7 @@ session_id: ses_untrusted_999
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should not inject when boulder plan is complete", async () => {
|
||||
test("should inject completion nudge when boulder plan is complete", async () => {
|
||||
// given - boulder state with complete plan
|
||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
|
||||
@@ -1514,11 +1514,11 @@ session_id: ses_untrusted_999
|
||||
},
|
||||
})
|
||||
|
||||
// then - should not call prompt
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
// then
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||
test("should inject completion nudge when 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()}`)
|
||||
@@ -1549,7 +1549,7 @@ session_id: ses_untrusted_999
|
||||
})
|
||||
|
||||
// then
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -131,4 +131,77 @@ describe("resolveActiveBoulderSession", () => {
|
||||
rmSync(worktreeDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("uses work resolved by session id when works map is present", async () => {
|
||||
// given
|
||||
const legacyPlanPath = join(testDirectory, "legacy-plan.md")
|
||||
const workAPlanPath = join(testDirectory, "work-a-plan.md")
|
||||
const workBPlanPath = join(testDirectory, "work-b-plan.md")
|
||||
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Legacy\n", "utf-8")
|
||||
writeFileSync(workAPlanPath, "# Plan\n- [ ] Work A\n", "utf-8")
|
||||
writeFileSync(workBPlanPath, "# Plan\n- [x] Work B\n", "utf-8")
|
||||
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-a",
|
||||
active_plan: legacyPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_legacy"],
|
||||
plan_name: "legacy-plan",
|
||||
works: {
|
||||
"work-a": {
|
||||
work_id: "work-a",
|
||||
active_plan: workAPlanPath,
|
||||
plan_name: "work-a-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_work_a"],
|
||||
status: "active",
|
||||
},
|
||||
"work-b": {
|
||||
work_id: "work-b",
|
||||
active_plan: workBPlanPath,
|
||||
plan_name: "work-b-plan",
|
||||
started_at: "2026-01-02T11:00:00Z",
|
||||
session_ids: ["ses_work_b"],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveActiveBoulderSession({
|
||||
client: { session: { get: async () => ({ data: {} }) } } as never,
|
||||
directory: testDirectory,
|
||||
sessionID: "ses_work_b",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.boulderState.active_plan).toBe(workBPlanPath)
|
||||
expect(result?.progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to top-level mirror when works map is missing", async () => {
|
||||
// given
|
||||
const legacyPlanPath = join(testDirectory, "legacy-only-plan.md")
|
||||
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Task 1\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
active_plan: legacyPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_legacy_only"],
|
||||
plan_name: "legacy-only-plan",
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveActiveBoulderSession({
|
||||
client: { session: { get: async () => ({ data: {} }) } } as never,
|
||||
directory: testDirectory,
|
||||
sessionID: "ses_legacy_only",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.boulderState.active_plan).toBe(legacyPlanPath)
|
||||
expect(result?.progress.isComplete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import {
|
||||
getPlanProgress,
|
||||
getWorkForSession,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||
|
||||
export async function resolveActiveBoulderSession(input: {
|
||||
@@ -16,14 +22,37 @@ export async function resolveActiveBoulderSession(input: {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!boulderState.session_ids.includes(input.sessionID)) {
|
||||
const sessionWork = getWorkForSession(input.directory, input.sessionID)
|
||||
if (!sessionWork && !boulderState.session_ids.includes(input.sessionID)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
|
||||
const nextBoulderState: BoulderState = sessionWork
|
||||
? {
|
||||
...boulderState,
|
||||
active_plan: sessionWork.active_plan,
|
||||
plan_name: sessionWork.plan_name,
|
||||
status: sessionWork.status,
|
||||
started_at: sessionWork.started_at,
|
||||
ended_at: sessionWork.ended_at,
|
||||
elapsed_ms: sessionWork.elapsed_ms,
|
||||
updated_at: sessionWork.updated_at,
|
||||
session_ids: [...sessionWork.session_ids],
|
||||
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
|
||||
agent: sessionWork.agent,
|
||||
worktree_path: sessionWork.worktree_path,
|
||||
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
|
||||
}
|
||||
: boulderState
|
||||
|
||||
const progress = getPlanProgress(
|
||||
sessionWork
|
||||
? resolveBoulderPlanPathForWork(input.directory, sessionWork)
|
||||
: resolveBoulderPlanPath(input.directory, nextBoulderState),
|
||||
)
|
||||
if (progress.isComplete) {
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
return { boulderState: nextBoulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
return { boulderState: nextBoulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
BOULDER_COMPLETE_PROMPT,
|
||||
BOULDER_CONTINUATION_PROMPT,
|
||||
SINGLE_TASK_DIRECTIVE,
|
||||
VERIFICATION_REMINDER,
|
||||
@@ -47,6 +48,14 @@ describe("VERIFICATION_REMINDER", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BOULDER_COMPLETE_PROMPT", () => {
|
||||
it("contains the required placeholders", () => {
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{PLAN_NAME}")
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{ELAPSED_HUMAN}")
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{TASK_BREAKDOWN}")
|
||||
})
|
||||
})
|
||||
|
||||
describe("VERIFICATION_REMINDER_GEMINI", () => {
|
||||
it("contains node_modules exclusion pathspec in git diff command", () => {
|
||||
expect(VERIFICATION_REMINDER_GEMINI).toContain(":!node_modules")
|
||||
|
||||
@@ -33,6 +33,17 @@ RULES:
|
||||
- Do not stop until all tasks are complete
|
||||
- If blocked, document the blocker and move to the next task`
|
||||
|
||||
export const BOULDER_COMPLETE_PROMPT = `<system-reminder>
|
||||
BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked.
|
||||
|
||||
Total elapsed: {ELAPSED_HUMAN}
|
||||
|
||||
Per-task breakdown:
|
||||
{TASK_BREAKDOWN}
|
||||
|
||||
Per your <boulder_completion_response> instructions, print the final ORCHESTRATION COMPLETE summary in your next turn. This nudge fires at most once.
|
||||
</system-reminder>`
|
||||
|
||||
export const VERIFICATION_REMINDER = `**THE SUBAGENT JUST CLAIMED THIS TASK IS DONE. THEY ARE PROBABLY LYING.**
|
||||
|
||||
Subagents say "done" when code has errors, tests pass trivially, logic is wrong,
|
||||
|
||||
@@ -424,6 +424,94 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(sessionID)
|
||||
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(childSessionID)
|
||||
})
|
||||
|
||||
it("#then it should append launched child to the session-resolved work", async () => {
|
||||
const parentSessionID = "ses_parent_for_work"
|
||||
const childSessionID = "ses_child_for_work"
|
||||
const planPathA = join(testDirectory, "background-launch-work-a.md")
|
||||
const planPathB = join(testDirectory, "background-launch-work-b.md")
|
||||
const project = createProject()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => createSessionGetResult(undefined),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined),
|
||||
) as never)
|
||||
|
||||
writeFileSync(planPathA, "# Plan\n\n## TODOs\n- [ ] 1. Work A\n")
|
||||
writeFileSync(planPathB, "# Plan\n\n## TODOs\n- [ ] 1. Work B\n")
|
||||
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-a",
|
||||
active_plan: planPathA,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_unrelated_active"],
|
||||
plan_name: "background-launch-work-a",
|
||||
works: {
|
||||
"work-a": {
|
||||
work_id: "work-a",
|
||||
active_plan: planPathA,
|
||||
plan_name: "background-launch-work-a",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_unrelated_active"],
|
||||
status: "active",
|
||||
},
|
||||
"work-b": {
|
||||
work_id: "work-b",
|
||||
active_plan: planPathB,
|
||||
plan_name: "background-launch-work-b",
|
||||
started_at: "2026-01-02T10:05:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map()
|
||||
const ctx = {
|
||||
client,
|
||||
project,
|
||||
directory: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: Bun.$,
|
||||
} satisfies PluginInput
|
||||
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
|
||||
const afterHandler = createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
|
||||
{ args: { prompt: "Work B" } },
|
||||
)
|
||||
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Background task launched.\n\nBackground Task ID: bg_work\n\n<task_metadata>\nsession_id: ses_child_for_work\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const boulderState = readBoulderState(testDirectory)
|
||||
expect(boulderState?.works?.["work-b"]?.session_ids).toContain(childSessionID)
|
||||
expect(boulderState?.works?.["work-a"]?.session_ids).not.toContain(childSessionID)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Project } from "@opencode-ai/sdk"
|
||||
import { readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
|
||||
|
||||
const isCallerOrchestratorMock = mock(async () => true)
|
||||
const collectGitDiffStatsMock = mock(() => ({
|
||||
filesChanged: 0,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/session-utils", () => ({
|
||||
isCallerOrchestrator: isCallerOrchestratorMock,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/git-worktree", () => ({
|
||||
collectGitDiffStats: collectGitDiffStatsMock,
|
||||
formatFileChanges: mock(() => "No file changes"),
|
||||
}))
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
|
||||
|
||||
type SessionGetInput = { path: { id: string } }
|
||||
type SessionGetResult = {
|
||||
data: { parentID: string | undefined }
|
||||
error?: undefined
|
||||
request: Request
|
||||
response: Response
|
||||
}
|
||||
|
||||
describe("createToolExecuteAfterHandler task timers", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-task-timers-${crypto.randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
isCallerOrchestratorMock.mockClear()
|
||||
collectGitDiffStatsMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (testDirectory && existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function createProject(): Project {
|
||||
return {
|
||||
id: "project-1",
|
||||
worktree: testDirectory,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionGetResult(parentID: string | undefined): SessionGetResult {
|
||||
return {
|
||||
data: { parentID },
|
||||
error: undefined,
|
||||
request: new Request("https://example.com/session"),
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as SessionGetResult
|
||||
}
|
||||
|
||||
function createHandlers(parentSessionIDs?: Record<string, string | undefined>) {
|
||||
const project = createProject()
|
||||
const client = {
|
||||
session: {
|
||||
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
|
||||
},
|
||||
} as PluginInput["client"]
|
||||
|
||||
if (parentSessionIDs) {
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(parentSessionIDs[input?.path?.id ?? ""]),
|
||||
) as never)
|
||||
}
|
||||
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map()
|
||||
const pendingPlanSnapshots = new Map<string, string>()
|
||||
const ctx = {
|
||||
client,
|
||||
project,
|
||||
directory: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: Bun.$,
|
||||
} satisfies PluginInput
|
||||
|
||||
return {
|
||||
beforeHandler: createToolExecuteBeforeHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
}),
|
||||
afterHandler: createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
it("starts task timer for todo:1 when delegated task session is tracked", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent"
|
||||
const childSessionID = "ses_child"
|
||||
const planPath = join(testDirectory, "task-timer-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\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-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-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-timer-1" },
|
||||
{ args: { prompt: "Implement auth flow" } },
|
||||
)
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.started_at).toBeString()
|
||||
expect(taskSession?.status).toBe("running")
|
||||
expect(taskSession?.session_id).toBe(childSessionID)
|
||||
})
|
||||
|
||||
it("ends task timer when todo:1 checkbox transitions to checked", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_2"
|
||||
const childSessionID = "ses_child_2"
|
||||
const planPath = join(testDirectory, "task-timer-complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\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-complete-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-complete-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-timer-2" },
|
||||
{ args: { prompt: "Implement auth flow" } },
|
||||
)
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_2\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.ended_at).toBeString()
|
||||
expect(taskSession?.status).toBe("completed")
|
||||
expect(typeof taskSession?.elapsed_ms).toBe("number")
|
||||
})
|
||||
|
||||
it("ends task timer when plan checkbox flips to checked via edit tool", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_3"
|
||||
const planDirectory = join(testDirectory, ".sisyphus", "plans")
|
||||
mkdirSync(planDirectory, { recursive: true })
|
||||
const planPath = join(planDirectory, "task-timer-edit-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\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-edit-plan",
|
||||
task_sessions: {
|
||||
"todo:1": {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Implement auth flow",
|
||||
session_id: "ses_child_3",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
status: "running",
|
||||
updated_at: "2026-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-edit-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
task_sessions: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers()
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
|
||||
{ args: { filePath: planPath, oldString: "- [ ] 1. Implement auth flow", newString: "- [x] 1. Implement auth flow" } },
|
||||
)
|
||||
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
|
||||
{
|
||||
title: "Edit",
|
||||
output: "Updated file",
|
||||
metadata: {
|
||||
filePath: planPath,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.ended_at).toBeString()
|
||||
expect(taskSession?.status).toBe("completed")
|
||||
expect(typeof taskSession?.elapsed_ms).toBe("number")
|
||||
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<task_metadata>\nsession_id: ses_child_parallel_2\n</task_metadata>",
|
||||
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<task_metadata>\nsession_id: ses_child_parallel_3\n</task_metadata>",
|
||||
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<task_metadata>\nsession_id: ses_child_fallback\n</task_metadata>",
|
||||
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")
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
endTaskTimer,
|
||||
getWorkForSession,
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
startTaskTimer,
|
||||
upsertTaskSessionState,
|
||||
} from "../../features/boulder-state"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import { syncBackgroundLaunchSessionTracking } from "./background-launch-session-tracking"
|
||||
@@ -26,15 +32,105 @@ import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
import type { PendingTaskRef, SessionState } from "./types"
|
||||
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
|
||||
|
||||
function isTrackedTaskChecked(planPath: string, taskKey: string): boolean {
|
||||
if (!existsSync(planPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [section, label] = taskKey.split(":")
|
||||
if (!section || !label) {
|
||||
return false
|
||||
}
|
||||
|
||||
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const matcher = section === "todo"
|
||||
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel}\\.\\s+`, "m")
|
||||
: section === "final-wave"
|
||||
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel.toUpperCase()}\\.\\s+`, "m")
|
||||
: null
|
||||
if (!matcher) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
return matcher.test(content)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
|
||||
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
|
||||
|
||||
function parseCheckedTopLevelTaskKeys(planContent: string): Set<string> {
|
||||
const checkedKeys = new Set<string>()
|
||||
const lines = planContent.split(/\r?\n/)
|
||||
let section: "todo" | "final-wave" | "other" = "other"
|
||||
|
||||
for (const line of lines) {
|
||||
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
|
||||
section = TODO_HEADING_PATTERN.test(line)
|
||||
? "todo"
|
||||
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||
? "final-wave"
|
||||
: "other"
|
||||
continue
|
||||
}
|
||||
|
||||
if (section !== "todo" && section !== "final-wave") {
|
||||
continue
|
||||
}
|
||||
|
||||
const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN)
|
||||
if (!checkedMatch || checkedMatch[1].length > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const taskBody = checkedMatch[2].trim()
|
||||
if (section === "todo") {
|
||||
const taskMatch = taskBody.match(TODO_TASK_PATTERN)
|
||||
if (taskMatch?.[1]) {
|
||||
checkedKeys.add(`todo:${taskMatch[1]}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const taskMatch = taskBody.match(FINAL_WAVE_TASK_PATTERN)
|
||||
if (taskMatch?.[1]) {
|
||||
checkedKeys.add(`final-wave:${taskMatch[1].toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
|
||||
return checkedKeys
|
||||
}
|
||||
|
||||
function readCheckedTaskKeysFromPlan(planPath: string): Set<string> {
|
||||
if (!existsSync(planPath)) {
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
try {
|
||||
return parseCheckedTopLevelTaskKeys(readFileSync(planPath, "utf-8"))
|
||||
} catch {
|
||||
return new Set<string>()
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolExecuteAfterHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
pendingPlanSnapshots?: Map<string, string>
|
||||
autoCommit: boolean
|
||||
getState: (sessionID: string) => SessionState
|
||||
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
|
||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input
|
||||
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
|
||||
return async (toolInput, toolOutput): Promise<void> => {
|
||||
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
||||
@@ -48,12 +144,33 @@ export function createToolExecuteAfterHandler(input: {
|
||||
|
||||
if (isWriteOrEditToolName(toolInput.tool)) {
|
||||
let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined
|
||||
const planSnapshot = toolInput.callID && pendingPlanSnapshots
|
||||
? pendingPlanSnapshots.get(toolInput.callID)
|
||||
: undefined
|
||||
if (toolInput.callID) {
|
||||
pendingFilePaths.delete(toolInput.callID)
|
||||
pendingPlanSnapshots?.delete(toolInput.callID)
|
||||
}
|
||||
if (!filePath) {
|
||||
filePath = toolOutput.metadata?.filePath as string | undefined
|
||||
}
|
||||
|
||||
if (filePath && toolInput.sessionID) {
|
||||
const sessionWork = getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
if (sessionWork) {
|
||||
const planPath = resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
if (resolve(filePath) === resolve(planPath) && planSnapshot !== undefined) {
|
||||
const beforeCheckedKeys = parseCheckedTopLevelTaskKeys(planSnapshot)
|
||||
const afterCheckedKeys = readCheckedTaskKeysFromPlan(planPath)
|
||||
for (const taskKey of afterCheckedKeys) {
|
||||
if (!beforeCheckedKeys.has(taskKey)) {
|
||||
endTaskTimer(ctx.directory, sessionWork.work_id, taskKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER
|
||||
log(`[${HOOK_NAME}] Direct work reminder appended`, {
|
||||
@@ -100,7 +217,29 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
if (boulderState) {
|
||||
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||
const sessionWork = toolInput.sessionID
|
||||
? getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
: null
|
||||
const planPath = sessionWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
: resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||
const workScopedBoulderState = sessionWork
|
||||
? {
|
||||
...boulderState,
|
||||
active_plan: sessionWork.active_plan,
|
||||
plan_name: sessionWork.plan_name,
|
||||
status: sessionWork.status,
|
||||
started_at: sessionWork.started_at,
|
||||
ended_at: sessionWork.ended_at,
|
||||
elapsed_ms: sessionWork.elapsed_ms,
|
||||
updated_at: sessionWork.updated_at,
|
||||
session_ids: [...sessionWork.session_ids],
|
||||
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
|
||||
agent: sessionWork.agent,
|
||||
worktree_path: sessionWork.worktree_path,
|
||||
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
|
||||
}
|
||||
: boulderState
|
||||
const progress = getPlanProgress(planPath)
|
||||
const {
|
||||
currentTask,
|
||||
@@ -112,7 +251,7 @@ export function createToolExecuteAfterHandler(input: {
|
||||
: null
|
||||
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
|
||||
|
||||
const lineageSessionIDs = boulderState.session_ids
|
||||
const lineageSessionIDs = sessionWork?.session_ids ?? boulderState.session_ids
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
@@ -120,14 +259,28 @@ export function createToolExecuteAfterHandler(input: {
|
||||
})
|
||||
|
||||
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (sessionWork) {
|
||||
startTaskTimer(ctx.directory, sessionWork.work_id, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (isTrackedTaskChecked(planPath, currentTask.key)) {
|
||||
endTaskTimer(ctx.directory, sessionWork.work_id, currentTask.key)
|
||||
}
|
||||
} else {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const preferredSessionId = resolvePreferredSessionId(
|
||||
@@ -155,11 +308,11 @@ export function createToolExecuteAfterHandler(input: {
|
||||
}
|
||||
|
||||
const leadReminder = shouldPauseForApproval
|
||||
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
|
||||
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
|
||||
? buildFinalWaveApprovalReminder(workScopedBoulderState.plan_name, progress, preferredSessionId)
|
||||
: buildCompletionGate(workScopedBoulderState.plan_name, preferredSessionId)
|
||||
const followupReminder = shouldPauseForApproval
|
||||
? null
|
||||
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
|
||||
: buildOrchestratorReminder(workScopedBoulderState.plan_name, progress, preferredSessionId, autoCommit, false)
|
||||
|
||||
toolOutput.output = `
|
||||
<system-reminder>
|
||||
@@ -181,8 +334,8 @@ ${
|
||||
? ""
|
||||
: `<system-reminder>\n${followupReminder}\n</system-reminder>`
|
||||
}`
|
||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||
plan: boulderState.plan_name,
|
||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||
plan: workScopedBoulderState.plan_name,
|
||||
progress: `${progress.completed}/${progress.total}`,
|
||||
fileCount: gitStats.length,
|
||||
preferredSessionId,
|
||||
|
||||
@@ -2,23 +2,69 @@ 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, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { getWorkForSession, readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } 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"
|
||||
|
||||
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<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
pendingPlanSnapshots?: Map<string, string>
|
||||
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
|
||||
}): (
|
||||
toolInput: { tool: string; sessionID?: string; callID?: string },
|
||||
toolOutput: { args: Record<string, unknown>; message?: string }
|
||||
) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs } = input
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots } = input
|
||||
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
|
||||
|
||||
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
|
||||
@@ -34,11 +80,35 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
// Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes
|
||||
if (isWriteOrEditToolName(toolInput.tool)) {
|
||||
const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
// Store filePath for use in tool.execute.after
|
||||
if (toolInput.callID) {
|
||||
pendingFilePaths.set(toolInput.callID, filePath)
|
||||
if (!filePath || !toolInput.callID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store filePath for use in tool.execute.after
|
||||
pendingFilePaths.set(toolInput.callID, filePath)
|
||||
|
||||
const sessionID = toolInput.sessionID
|
||||
const sessionWork = sessionID
|
||||
? getWorkForSession(ctx.directory, sessionID)
|
||||
: null
|
||||
const state = sessionWork ? null : readBoulderState(ctx.directory)
|
||||
const planPath = sessionWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
: state
|
||||
? resolveBoulderPlanPath(ctx.directory, state)
|
||||
: null
|
||||
|
||||
if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) {
|
||||
try {
|
||||
if (existsSync(planPath)) {
|
||||
pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8"))
|
||||
}
|
||||
} catch {
|
||||
pendingPlanSnapshots.delete(toolInput.callID)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSisyphusPath(filePath)) {
|
||||
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
|
||||
toolOutput.message = (toolOutput.message || "") + warning
|
||||
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
|
||||
@@ -60,33 +130,48 @@ 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 task = {
|
||||
key: currentTask.key,
|
||||
label: currentTask.label,
|
||||
title: currentTask.title,
|
||||
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: resolvedTask.key,
|
||||
label: resolvedTask.label,
|
||||
title: resolvedTask.title,
|
||||
}
|
||||
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
|
||||
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
|
||||
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key
|
||||
))
|
||||
|
||||
if (hasExistingClaim) {
|
||||
pendingTaskRefs.set(toolInput.callID, {
|
||||
kind: "skip",
|
||||
reason: "ambiguous_task_key",
|
||||
task,
|
||||
task: trackedTask,
|
||||
})
|
||||
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
callID: toolInput.callID,
|
||||
taskKey: task.key,
|
||||
taskKey: trackedTask.key,
|
||||
})
|
||||
} else {
|
||||
trackTask(toolInput.callID, task)
|
||||
trackTask(toolInput.callID, trackedTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,5 @@ export interface SessionState {
|
||||
waitingForFinalWaveApproval?: boolean
|
||||
pendingFinalWaveTaskCount?: number
|
||||
approvedFinalWaveTaskCount?: number
|
||||
boulderCompletionNudgedAt?: Record<string, number>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit", "hashline_edit"]
|
||||
|
||||
export function isWriteOrEditToolName(toolName: string): boolean {
|
||||
return WRITE_EDIT_TOOLS.includes(toolName)
|
||||
|
||||
@@ -235,6 +235,45 @@ describe("context-window-monitor", () => {
|
||||
expect(output.output).toContain("context remaining")
|
||||
})
|
||||
|
||||
// #given only a compaction agent summary message update is seen
|
||||
// #when tool.execute.after checks context usage
|
||||
// #then stale pre-compaction tokens should not create a context reminder
|
||||
it("should ignore compaction-agent message updates when caching context usage", async () => {
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_compaction_agent_context"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
agent: "compaction",
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.output).toBe("original")
|
||||
expect(ctx.client.session.messages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// #given session is deleted
|
||||
// #when session.deleted event fires
|
||||
// #then cached data should be cleaned up
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveActualContextLimit,
|
||||
type ContextLimitModelCacheState,
|
||||
} from "../shared/context-limit-resolver"
|
||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
||||
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
@@ -94,6 +95,7 @@ export function createContextWindowMonitorHook(
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as {
|
||||
agent?: unknown
|
||||
role?: string
|
||||
sessionID?: string
|
||||
providerID?: string
|
||||
@@ -103,6 +105,7 @@ export function createContextWindowMonitorHook(
|
||||
} | undefined
|
||||
|
||||
if (!info || info.role !== "assistant" || !info.finish) return
|
||||
if (isCompactionAgent(info.agent)) return
|
||||
if (!info.sessionID || !info.providerID || !info.tokens) return
|
||||
|
||||
tokenCache.set(info.sessionID, {
|
||||
|
||||
@@ -55,7 +55,9 @@ function setupImmediateTimeouts(): () => void {
|
||||
|
||||
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
|
||||
callback(...args)
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>
|
||||
const timeoutID = originalSetTimeout(() => undefined, 0)
|
||||
originalClearTimeout(timeoutID)
|
||||
return timeoutID
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = (() => {}) as typeof clearTimeout
|
||||
@@ -637,6 +639,78 @@ describe("preemptive-compaction", () => {
|
||||
Date.now = originalNow
|
||||
})
|
||||
|
||||
// #given compaction already succeeded for a session
|
||||
// #when the compaction agent emits its summary message update
|
||||
// #then it should not clear the compaction guard or trigger a duplicate summary
|
||||
it("should ignore compaction-agent message updates after successful compaction", async () => {
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||
const sessionID = "ses_compaction_agent_update"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 170000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
{ title: "", output: "test", metadata: null }
|
||||
)
|
||||
|
||||
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
|
||||
|
||||
const originalNow = Date.now
|
||||
try {
|
||||
Date.now = () => originalNow() + 61_000
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
agent: "compaction",
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 170000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_2" },
|
||||
{ title: "", output: "test", metadata: null }
|
||||
)
|
||||
|
||||
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
Date.now = originalNow
|
||||
}
|
||||
})
|
||||
|
||||
// #given modelContextLimitsCache has model-specific limit (256k)
|
||||
// #when tokens are above default 78% of 200k but below 78% of 256k
|
||||
// #then should NOT trigger compaction
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
|
||||
|
||||
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
||||
@@ -70,6 +71,7 @@ export function createPreemptiveCompactionHook(
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as {
|
||||
id?: string
|
||||
agent?: unknown
|
||||
role?: string
|
||||
sessionID?: string
|
||||
providerID?: string
|
||||
@@ -80,6 +82,7 @@ export function createPreemptiveCompactionHook(
|
||||
} | undefined
|
||||
|
||||
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
|
||||
if (isCompactionAgent(info.agent)) return
|
||||
|
||||
if (info.providerID && info.tokens) {
|
||||
tokenCache.set(info.sessionID, {
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/// <reference types="bun-types" />
|
||||
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 { createRalphLoopHook } from "./index"
|
||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||
import { clearState, writeState } from "./storage"
|
||||
import { handleFailedVerification } from "./verification-failure-handler"
|
||||
|
||||
describe("ralph-loop dispatch failure invariants", () => {
|
||||
const testDirectory = join(tmpdir(), `ralph-loop-dispatch-failure-${Date.now()}`)
|
||||
let promptCalls: Array<{ sessionID: string; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string; variant: string }>
|
||||
let messagesCalls: Array<{ sessionID: string }>
|
||||
let createSessionCalls: Array<{ parentID: string }>
|
||||
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
messagesCalls = []
|
||||
createSessionCalls = []
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
clearState(testDirectory)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearState(testDirectory)
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("#given idle path #when promptAsync throws #then no state or toast advance", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async () => {
|
||||
throw new Error("simulated dispatch failure")
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
create: async () => ({ data: { id: "new-session-id" } }),
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
})
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
|
||||
// when
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false)
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given error retry path #when promptAsync throws #then no state or toast advance", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async () => {
|
||||
throw new Error("simulated dispatch failure")
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
create: async () => ({ data: { id: "new-session-id" } }),
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
})
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
|
||||
// when
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "RuntimeError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false)
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given verification-failure path #when promptAsync throws #then iteration not advanced", async () => {
|
||||
// given
|
||||
const parentTranscriptPath = join(testDirectory, "transcript-parent.jsonl")
|
||||
const oracleTranscriptPath = join(testDirectory, "transcript-oracle.jsonl")
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
if (options.path.id === "session-123") {
|
||||
return { data: [{}, {}, {}] }
|
||||
}
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: { body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
if (options.body.parts[0]?.text.includes("Verification failed")) {
|
||||
throw new Error("simulated dispatch failure")
|
||||
}
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
create: async () => ({ data: { id: "new-session-id" } }),
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never, {
|
||||
getTranscriptPath: (sessionID): string => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath,
|
||||
})
|
||||
|
||||
hook.startLoop("session-123", "Build API", { ultrawork: true })
|
||||
writeState(testDirectory, {
|
||||
...hook.getState()!,
|
||||
iteration: 2,
|
||||
verification_pending: true,
|
||||
verification_session_id: "ses-oracle",
|
||||
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
|
||||
initial_completion_promise: "DONE",
|
||||
})
|
||||
writeState(testDirectory, {
|
||||
...hook.getState()!,
|
||||
verification_session_id: "ses-oracle",
|
||||
})
|
||||
writeFileSync(
|
||||
oracleTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "verification failed" } })}\n`,
|
||||
)
|
||||
|
||||
const preRestartIteration = hook.getState()?.iteration
|
||||
|
||||
// when
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } })
|
||||
|
||||
// then
|
||||
expect(preRestartIteration).toBe(2)
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("Verification continuation rejected"))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given reset strategy #when createIterationSession returns null #then dispatch failure surfaces", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
create: async (options: { body: { parentID: string } }) => {
|
||||
createSessionCalls.push({ parentID: options.body.parentID })
|
||||
return { error: "fail", data: undefined }
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
strategy: "reset",
|
||||
})
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
|
||||
// when
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
expect(createSessionCalls).toHaveLength(1)
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given idle path #when state rebound during settle window #then no dispatch against new owner", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" })
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
create: async () => ({ data: { id: "new-session-id" } }),
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never, {
|
||||
idleSettleMs: 50,
|
||||
})
|
||||
|
||||
hook.startLoop("session-A", "Keep working", { messageCountAtStart: 0, maxIterations: 5 })
|
||||
expect(hook.getState()?.session_id).toBe("session-A")
|
||||
|
||||
// when
|
||||
const eventPromise = hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
writeState(testDirectory, { ...hook.getState()!, session_id: "session-B" })
|
||||
await eventPromise
|
||||
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
expect(hook.getState()?.session_id).toBe("session-B")
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
})
|
||||
|
||||
test("#given verification-failure path #when incrementIteration fails #then loud failure not success", async () => {
|
||||
// given
|
||||
const loopState = {
|
||||
clearVerificationState: () => ({
|
||||
active: true,
|
||||
iteration: 2,
|
||||
prompt: "Build API",
|
||||
started_at: new Date().toISOString(),
|
||||
session_id: "session-123",
|
||||
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
|
||||
message_count_at_start: 3,
|
||||
}),
|
||||
incrementIteration: () => null,
|
||||
clear: () => true,
|
||||
}
|
||||
|
||||
const result = await handleFailedVerification({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [{}, {}, {}] }),
|
||||
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" })
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never, {
|
||||
state: {
|
||||
active: true,
|
||||
iteration: 2,
|
||||
prompt: "Build API",
|
||||
started_at: new Date().toISOString(),
|
||||
session_id: "session-123",
|
||||
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
|
||||
verification_pending: true,
|
||||
verification_session_id: "ses-oracle",
|
||||
},
|
||||
directory: testDirectory,
|
||||
apiTimeoutMs: 5000,
|
||||
loopState,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false)
|
||||
expect(
|
||||
toastCalls.some(
|
||||
(toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("iteration commit failed"),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("#given reset strategy #when session.create throws #then dispatch failure surfaces", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" })
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
create: async () => {
|
||||
throw new Error("simulated network error during session.create")
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
strategy: "reset",
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -713,6 +713,53 @@ describe("ralph-loop", () => {
|
||||
expect(messagesCalls[0].sessionID).toBe("session-123")
|
||||
})
|
||||
|
||||
test("#given completion lands during continuation dispatch #when idle returns #then completion wins over iteration toast", async () => {
|
||||
// given - active loop whose completion promise appears while dispatch is in progress
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
const pluginInput = createMockPluginInput()
|
||||
Object.defineProperty(pluginInput.client.session, "promptAsync", {
|
||||
value: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
content: "Task finished <promise>DONE</promise>",
|
||||
}) + "\n",
|
||||
)
|
||||
return {}
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createRalphLoopHook(pluginInput, {
|
||||
getTranscriptPath: () => transcriptPath,
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", {
|
||||
completionPromise: "DONE",
|
||||
maxIterations: 5,
|
||||
})
|
||||
|
||||
// when - idle handler begins continuation, then completion appears before dispatch returns
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// then - loop completes without publishing a stale iteration toast
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(
|
||||
toastCalls.some((t) => t.title === "Ralph Loop" && t.message.includes("Iteration")),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("should ignore completion promise in reasoning part via session messages API", async () => {
|
||||
//#given - active loop with assistant reasoning containing completion promise
|
||||
mockSessionMessages = [
|
||||
|
||||
@@ -15,11 +15,16 @@ type ContinuationOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ContinuationResult =
|
||||
| { status: "dispatched" }
|
||||
| { status: "session_creation_rejected" }
|
||||
| { status: "dispatch_rejected"; error: unknown }
|
||||
|
||||
export async function continueIteration(
|
||||
ctx: PluginInput,
|
||||
state: RalphLoopState,
|
||||
options: ContinuationOptions,
|
||||
): Promise<void> {
|
||||
): Promise<ContinuationResult> {
|
||||
const strategy = state.strategy ?? "continue"
|
||||
const continuationPrompt = buildContinuationPrompt(state)
|
||||
|
||||
@@ -30,16 +35,20 @@ export async function continueIteration(
|
||||
options.directory,
|
||||
)
|
||||
if (!newSessionID) {
|
||||
return
|
||||
return { status: "session_creation_rejected" }
|
||||
}
|
||||
|
||||
await injectContinuationPrompt(ctx, {
|
||||
sessionID: newSessionID,
|
||||
inheritFromSessionID: options.previousSessionID,
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
try {
|
||||
await injectContinuationPrompt(ctx, {
|
||||
sessionID: newSessionID,
|
||||
inheritFromSessionID: options.previousSessionID,
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return { status: "dispatch_rejected", error }
|
||||
}
|
||||
|
||||
await selectSessionInTui(ctx.client, newSessionID)
|
||||
|
||||
@@ -49,16 +58,22 @@ export async function continueIteration(
|
||||
previousSessionID: options.previousSessionID,
|
||||
newSessionID,
|
||||
})
|
||||
return
|
||||
return { status: "dispatched" }
|
||||
}
|
||||
|
||||
return
|
||||
return { status: "dispatched" }
|
||||
}
|
||||
|
||||
await injectContinuationPrompt(ctx, {
|
||||
sessionID: options.previousSessionID,
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
try {
|
||||
await injectContinuationPrompt(ctx, {
|
||||
sessionID: options.previousSessionID,
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return { status: "dispatch_rejected", error }
|
||||
}
|
||||
|
||||
return { status: "dispatched" }
|
||||
}
|
||||
|
||||
@@ -174,5 +174,27 @@ export function createLoopStateController(options: {
|
||||
|
||||
return state
|
||||
},
|
||||
|
||||
clearVerificationState(sessionID: string, messageCountAtStart?: number): RalphLoopState | null {
|
||||
const state = readState(directory, stateDir)
|
||||
if (!state || state.session_id !== sessionID || !state.ultrawork || !state.verification_pending) {
|
||||
return null
|
||||
}
|
||||
|
||||
state.started_at = new Date().toISOString()
|
||||
state.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE
|
||||
state.verification_pending = undefined
|
||||
state.verification_attempt_id = undefined
|
||||
state.verification_session_id = undefined
|
||||
if (typeof messageCountAtStart === "number") {
|
||||
state.message_count_at_start = messageCountAtStart
|
||||
}
|
||||
|
||||
if (!writeState(directory, state, stateDir)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ async function detectOracleVerificationFromParentSession(
|
||||
|
||||
type LoopStateController = {
|
||||
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||
incrementIteration: () => RalphLoopState | null
|
||||
clear: () => boolean
|
||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type LoopStateController = {
|
||||
markVerificationPending: (sessionID: string) => RalphLoopState | null
|
||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
||||
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||
}
|
||||
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
|
||||
|
||||
@@ -81,9 +82,83 @@ function showToastBestEffort(
|
||||
try {
|
||||
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function completionDetectedForState(
|
||||
ctx: PluginInput,
|
||||
options: RalphLoopEventHandlerOptions,
|
||||
sessionID: string,
|
||||
state: RalphLoopState,
|
||||
verificationSessionID: string | undefined,
|
||||
): Promise<"transcript_file" | "session_messages_api" | null> {
|
||||
const completionSessionID = verificationSessionID ?? sessionID
|
||||
const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined
|
||||
const completionViaTranscript = completionSessionID
|
||||
? detectCompletionInTranscript(
|
||||
transcriptPath,
|
||||
state.completion_promise,
|
||||
state.started_at,
|
||||
)
|
||||
: false
|
||||
if (completionViaTranscript) return "transcript_file"
|
||||
|
||||
const completionViaApi = verificationSessionID
|
||||
? await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: verificationSessionID,
|
||||
promise: state.completion_promise,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
directory: options.directory,
|
||||
sinceMessageIndex: undefined,
|
||||
})
|
||||
: await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID,
|
||||
promise: state.completion_promise,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
directory: options.directory,
|
||||
sinceMessageIndex: state.message_count_at_start,
|
||||
})
|
||||
|
||||
return completionViaApi ? "session_messages_api" : null
|
||||
}
|
||||
|
||||
async function handleCompletionIfDetected(
|
||||
ctx: PluginInput,
|
||||
options: RalphLoopEventHandlerOptions,
|
||||
input: {
|
||||
sessionID: string
|
||||
state: RalphLoopState
|
||||
verificationSessionID: string | undefined
|
||||
runtimeErrorRetriedSessions: Map<string, number>
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const detectedVia = await completionDetectedForState(
|
||||
ctx,
|
||||
options,
|
||||
input.sessionID,
|
||||
input.state,
|
||||
input.verificationSessionID,
|
||||
)
|
||||
if (!detectedVia) return false
|
||||
|
||||
input.runtimeErrorRetriedSessions.delete(input.sessionID)
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID: input.sessionID,
|
||||
iteration: input.state.iteration,
|
||||
promise: input.state.completion_promise,
|
||||
detectedVia,
|
||||
})
|
||||
await handleDetectedCompletion(ctx, {
|
||||
sessionID: input.sessionID,
|
||||
state: input.state,
|
||||
loopState: options.loopState,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function showMaxIterationsToast(
|
||||
ctx: PluginInput,
|
||||
state: RalphLoopState,
|
||||
@@ -135,14 +210,14 @@ export function createRalphLoopEventHandler(
|
||||
|
||||
try {
|
||||
const state = options.loopState.getState()
|
||||
if (!state || !state.active) {
|
||||
return
|
||||
}
|
||||
if (!state || !state.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const verificationSessionID = state.verification_pending
|
||||
? state.verification_session_id
|
||||
@@ -172,58 +247,12 @@ export function createRalphLoopEventHandler(
|
||||
return
|
||||
}
|
||||
|
||||
const completionSessionID = verificationSessionID ?? sessionID
|
||||
const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined
|
||||
const completionViaTranscript = completionSessionID
|
||||
? detectCompletionInTranscript(
|
||||
transcriptPath,
|
||||
state.completion_promise,
|
||||
state.started_at,
|
||||
)
|
||||
: false
|
||||
const completionViaApi = completionViaTranscript
|
||||
? false
|
||||
: verificationSessionID
|
||||
? await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: verificationSessionID,
|
||||
promise: state.completion_promise,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
directory: options.directory,
|
||||
sinceMessageIndex: undefined,
|
||||
})
|
||||
: state.verification_pending
|
||||
? await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID,
|
||||
promise: state.completion_promise,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
directory: options.directory,
|
||||
sinceMessageIndex: state.message_count_at_start,
|
||||
})
|
||||
: await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID,
|
||||
promise: state.completion_promise,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
directory: options.directory,
|
||||
sinceMessageIndex: state.message_count_at_start,
|
||||
})
|
||||
|
||||
if (completionViaTranscript || completionViaApi) {
|
||||
runtimeErrorRetriedSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
detectedVia: completionViaTranscript
|
||||
? "transcript_file"
|
||||
: "session_messages_api",
|
||||
})
|
||||
await handleDetectedCompletion(ctx, {
|
||||
sessionID,
|
||||
state,
|
||||
loopState: options.loopState,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
if (await handleCompletionIfDetected(ctx, options, {
|
||||
sessionID,
|
||||
state,
|
||||
verificationSessionID,
|
||||
runtimeErrorRetriedSessions,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -272,34 +301,82 @@ export function createRalphLoopEventHandler(
|
||||
return
|
||||
}
|
||||
|
||||
const newState = options.loopState.incrementIteration()
|
||||
if (!newState) {
|
||||
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
|
||||
await sleep(options.idleSettleMs)
|
||||
const stateAfterSettle = options.loopState.getState()
|
||||
if (!stateAfterSettle || !stateAfterSettle.active) {
|
||||
return
|
||||
}
|
||||
if (stateAfterSettle.session_id !== undefined && stateAfterSettle.session_id !== sessionID) {
|
||||
log(`[${HOOK_NAME}] Skipped: state rebound during settle window`, {
|
||||
sessionID,
|
||||
currentOwner: stateAfterSettle.session_id,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (stateAfterSettle.verification_pending) {
|
||||
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
|
||||
return
|
||||
}
|
||||
if (await handleCompletionIfDetected(ctx, options, {
|
||||
sessionID,
|
||||
state: stateAfterSettle,
|
||||
verificationSessionID: undefined,
|
||||
runtimeErrorRetriedSessions,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextIteration = stateAfterSettle.iteration + 1
|
||||
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
|
||||
|
||||
log(`[${HOOK_NAME}] Continuing loop`, {
|
||||
sessionID,
|
||||
iteration: newState.iteration,
|
||||
max: newState.max_iterations,
|
||||
iteration: nextIteration,
|
||||
max: previewState.max_iterations,
|
||||
})
|
||||
|
||||
showIterationToast(ctx, newState)
|
||||
await sleep(options.idleSettleMs)
|
||||
const result = await continueIteration(ctx, previewState, {
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
||||
if (result.status === "dispatched") {
|
||||
const stateBeforeCommit = options.loopState.getState()
|
||||
if (!stateBeforeCommit || !stateBeforeCommit.active) {
|
||||
return
|
||||
}
|
||||
if (await handleCompletionIfDetected(ctx, options, {
|
||||
sessionID,
|
||||
error: String(err),
|
||||
})
|
||||
state: stateBeforeCommit,
|
||||
verificationSessionID: stateBeforeCommit.verification_pending
|
||||
? stateBeforeCommit.verification_session_id
|
||||
: undefined,
|
||||
runtimeErrorRetriedSessions,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
const committed = options.loopState.incrementIteration()
|
||||
if (committed) {
|
||||
showIterationToast(ctx, committed)
|
||||
} else {
|
||||
log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed`, { sessionID })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Dispatch failed`, { sessionID, status: result.status })
|
||||
options.loopState.clear()
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop Failed",
|
||||
message: result.status === "dispatch_rejected"
|
||||
? `Dispatch ${result.status}: ${String(result.error)}`
|
||||
: `Dispatch ${result.status}`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
} finally {
|
||||
inFlightSessions.delete(sessionID)
|
||||
@@ -335,23 +412,23 @@ export function createRalphLoopEventHandler(
|
||||
const verificationSessionID = state.verification_pending
|
||||
? state.verification_session_id
|
||||
: undefined
|
||||
const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
|
||||
const matchesVerificationSession = verificationSessionID === sessionID
|
||||
if (!matchesParentSession && !matchesVerificationSession) {
|
||||
handleErroredLoopSession(props, options.loopState)
|
||||
return
|
||||
}
|
||||
const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
|
||||
const matchesVerificationSession = verificationSessionID === sessionID
|
||||
if (!matchesParentSession && !matchesVerificationSession) {
|
||||
handleErroredLoopSession(props, options.loopState)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Retrying after runtime session error`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
error: String(error),
|
||||
})
|
||||
log(`[${HOOK_NAME}] Retrying after runtime session error`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
error: String(error),
|
||||
})
|
||||
|
||||
if (state.verification_pending) {
|
||||
await handlePendingVerification(ctx, {
|
||||
@@ -381,28 +458,77 @@ export function createRalphLoopEventHandler(
|
||||
return
|
||||
}
|
||||
|
||||
const newState = options.loopState.incrementIteration()
|
||||
if (!newState) {
|
||||
log(`[${HOOK_NAME}] Failed to increment iteration after runtime error`, { sessionID })
|
||||
await sleep(options.idleSettleMs)
|
||||
const stateAfterSettle = options.loopState.getState()
|
||||
if (!stateAfterSettle || !stateAfterSettle.active) {
|
||||
return
|
||||
}
|
||||
if (stateAfterSettle.session_id !== undefined && stateAfterSettle.session_id !== sessionID) {
|
||||
log(`[${HOOK_NAME}] Skipped: state rebound during settle window`, {
|
||||
sessionID,
|
||||
currentOwner: stateAfterSettle.session_id,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (stateAfterSettle.verification_pending) {
|
||||
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
|
||||
return
|
||||
}
|
||||
if (await handleCompletionIfDetected(ctx, options, {
|
||||
sessionID,
|
||||
state: stateAfterSettle,
|
||||
verificationSessionID: undefined,
|
||||
runtimeErrorRetriedSessions,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
showIterationToast(ctx, newState)
|
||||
await sleep(options.idleSettleMs)
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
runtimeErrorRetriedSessions.set(sessionID, newState.iteration)
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to retry after runtime error`, {
|
||||
const nextIteration = stateAfterSettle.iteration + 1
|
||||
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
|
||||
|
||||
const result = await continueIteration(ctx, previewState, {
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
|
||||
if (result.status === "dispatched") {
|
||||
const stateBeforeCommit = options.loopState.getState()
|
||||
if (!stateBeforeCommit || !stateBeforeCommit.active) {
|
||||
return
|
||||
}
|
||||
if (await handleCompletionIfDetected(ctx, options, {
|
||||
sessionID,
|
||||
error: String(err),
|
||||
})
|
||||
state: stateBeforeCommit,
|
||||
verificationSessionID: stateBeforeCommit.verification_pending
|
||||
? stateBeforeCommit.verification_session_id
|
||||
: undefined,
|
||||
runtimeErrorRetriedSessions,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
const committed = options.loopState.incrementIteration()
|
||||
if (committed) {
|
||||
showIterationToast(ctx, committed)
|
||||
runtimeErrorRetriedSessions.set(sessionID, committed.iteration)
|
||||
} else {
|
||||
log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed after runtime error`, { sessionID })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Dispatch failed after runtime error`, { sessionID, status: result.status })
|
||||
options.loopState.clear()
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop Failed",
|
||||
message: result.status === "dispatch_rejected"
|
||||
? `Dispatch ${result.status}: ${String(result.error)}`
|
||||
: `Dispatch ${result.status}`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
} finally {
|
||||
inFlightSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
@@ -7,23 +7,31 @@ export async function createIterationSession(
|
||||
parentSessionID: string,
|
||||
directory: string,
|
||||
): Promise<string | null> {
|
||||
const createResult = await ctx.client.session.create({
|
||||
body: {
|
||||
parentID: parentSessionID,
|
||||
title: "Ralph Loop Iteration",
|
||||
},
|
||||
query: { directory },
|
||||
})
|
||||
try {
|
||||
const createResult = await ctx.client.session.create({
|
||||
body: {
|
||||
parentID: parentSessionID,
|
||||
title: "Ralph Loop Iteration",
|
||||
},
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
if (createResult.error || !createResult.data?.id) {
|
||||
log("[ralph-loop] Failed to create iteration session", {
|
||||
if (createResult.error || !createResult.data?.id) {
|
||||
log("[ralph-loop] Failed to create iteration session", {
|
||||
parentSessionID,
|
||||
error: String(createResult.error ?? "No session ID returned"),
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
return createResult.data.id
|
||||
} catch (error: unknown) {
|
||||
log("[ralph-loop] session.create threw during iteration session creation", {
|
||||
parentSessionID,
|
||||
error: String(createResult.error ?? "No session ID returned"),
|
||||
error: String(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
return createResult.data.id
|
||||
}
|
||||
|
||||
export async function selectSessionInTui(
|
||||
|
||||
@@ -6,10 +6,22 @@ import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||
import type { RalphLoopState } from "./types"
|
||||
|
||||
type LoopStateController = {
|
||||
restartAfterFailedVerification: (
|
||||
clearVerificationState: (
|
||||
sessionID: string,
|
||||
messageCountAtStart?: number,
|
||||
) => RalphLoopState | null
|
||||
incrementIteration: () => RalphLoopState | null
|
||||
clear: () => boolean
|
||||
}
|
||||
|
||||
function showToastBestEffort(
|
||||
ctx: PluginInput,
|
||||
body: { title: string; message: string; variant: "warning" | "info"; duration: number },
|
||||
): void {
|
||||
try {
|
||||
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageCountFromResponse(messagesResponse: unknown): number {
|
||||
@@ -72,23 +84,53 @@ export async function handleFailedVerification(
|
||||
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
|
||||
}
|
||||
|
||||
const resumedState = loopState.restartAfterFailedVerification(
|
||||
const clearedState = loopState.clearVerificationState(
|
||||
parentSessionID,
|
||||
messageCountAtStart,
|
||||
)
|
||||
if (!resumedState) {
|
||||
if (!clearedState) {
|
||||
log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, {
|
||||
parentSessionID,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
await injectContinuationPrompt(ctx, {
|
||||
sessionID: parentSessionID,
|
||||
prompt: buildVerificationFailurePrompt(resumedState),
|
||||
directory,
|
||||
apiTimeoutMs,
|
||||
})
|
||||
const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 }
|
||||
|
||||
try {
|
||||
await injectContinuationPrompt(ctx, {
|
||||
sessionID: parentSessionID,
|
||||
prompt: buildVerificationFailurePrompt(previewState),
|
||||
directory,
|
||||
apiTimeoutMs,
|
||||
})
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, {
|
||||
parentSessionID,
|
||||
error: String(error),
|
||||
})
|
||||
loopState.clear()
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop Failed",
|
||||
message: `Verification continuation rejected: ${String(error)}`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const committed = loopState.incrementIteration()
|
||||
if (!committed) {
|
||||
log(`[${HOOK_NAME}] Failed to commit iteration after verification restart`, { parentSessionID })
|
||||
loopState.clear()
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop Failed",
|
||||
message: "Verification continuation dispatched but iteration commit failed",
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { extractAutoRetrySignal } from "./auto-retry-signal"
|
||||
|
||||
describe("extractAutoRetrySignal", () => {
|
||||
test("detects Volcano Engine 'exceeded the usage quota' signal", () => {
|
||||
//#given
|
||||
const info = {
|
||||
status: "You have exceeded the 5-hour usage quota. It will reset at 2026-05-11 01:20:12 +0800 CST.",
|
||||
}
|
||||
|
||||
//#when
|
||||
const signal = extractAutoRetrySignal(info)
|
||||
|
||||
//#then
|
||||
expect(signal).toBeDefined()
|
||||
expect(signal?.signal).toContain("exceeded")
|
||||
expect(signal?.signal).toContain("usage quota")
|
||||
})
|
||||
|
||||
test("detects standard 'quota exceeded' signal", () => {
|
||||
//#given
|
||||
const info = { message: "Quota exceeded for model gpt-4" }
|
||||
|
||||
//#when
|
||||
const signal = extractAutoRetrySignal(info)
|
||||
|
||||
//#then
|
||||
expect(signal).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns undefined for non-retryable info", () => {
|
||||
//#given
|
||||
const info = { message: "Something went wrong" }
|
||||
|
||||
//#when
|
||||
const signal = extractAutoRetrySignal(info)
|
||||
|
||||
//#then
|
||||
expect(signal).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ export interface AutoRetrySignal {
|
||||
const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
|
||||
(combined) => /retrying\s+in/i.test(combined),
|
||||
(combined) =>
|
||||
/(?:too\s+many\s+requests|quota\s+will\s+reset\s+after|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
|
||||
/(?:too\s+many\s+requests|quota\s+will\s+reset\s+after|quota\s*exceeded|exceeded.*quota|usage\s+limit|usage\s*quota|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
|
||||
]
|
||||
|
||||
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
|
||||
|
||||
@@ -27,6 +27,8 @@ export const RETRYABLE_ERROR_PATTERNS = [
|
||||
/too.?many.?requests/i,
|
||||
/quota\s+will\s+reset\s+after/i,
|
||||
/quota.?exceeded/i,
|
||||
/exceeded.*quota/i,
|
||||
/usage\s*quota/i,
|
||||
/exhausted\s+your\s+capacity/i,
|
||||
/all\s+credentials\s+for\s+model/i,
|
||||
/cool(?:ing)?\s+down/i,
|
||||
|
||||
@@ -126,6 +126,8 @@ export function classifyErrorType(error: unknown): string | undefined {
|
||||
errorName?.includes("insufficientquota") ||
|
||||
errorName?.includes("billingerror") ||
|
||||
/quota.?exceeded/i.test(message) ||
|
||||
/exceeded.*quota/i.test(message) ||
|
||||
/usage\s*quota/i.test(message) ||
|
||||
/subscription.*quota/i.test(message) ||
|
||||
/insufficient.?(?:quota|balance|funds?)/i.test(message) ||
|
||||
/billing.?(?:hard.?)?limit/i.test(message) ||
|
||||
|
||||
@@ -56,4 +56,21 @@ describe("runtime-fallback quota error regressions", () => {
|
||||
// quota errors trigger fallback to next configured model
|
||||
expect(retryable).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies Volcano Engine 'exceeded the usage quota' as quota_exceeded and retryable", () => {
|
||||
//#given
|
||||
const error = {
|
||||
name: "SessionRetry",
|
||||
message: "You have exceeded the 5-hour usage quota. It will reset at 2026-05-11 01:20:12 +0800 CST. We recommend using a different model.",
|
||||
}
|
||||
|
||||
//#when
|
||||
const errorType = classifyErrorType(error)
|
||||
const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
|
||||
|
||||
//#then
|
||||
expect(errorType).toBe("quota_exceeded")
|
||||
// Volcano Engine quota errors trigger fallback to the next model
|
||||
expect(retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { buildStartWorkContextInfo } from "./context-info-builder"
|
||||
import {
|
||||
addBoulderWork,
|
||||
createBoulderState,
|
||||
getBoulderFilePath,
|
||||
getWorkByPlanName,
|
||||
readBoulderState,
|
||||
writeBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import * as boulderState from "../../features/boulder-state"
|
||||
|
||||
describe("buildStartWorkContextInfo", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
function createPluginInput() {
|
||||
return {
|
||||
directory: testDirectory,
|
||||
} as never
|
||||
}
|
||||
|
||||
function writePlan(planName: string, content: string): string {
|
||||
const plansDirectory = join(testDirectory, ".sisyphus", "plans")
|
||||
mkdirSync(plansDirectory, { recursive: true })
|
||||
const planPath = join(plansDirectory, `${planName}.md`)
|
||||
writeFileSync(planPath, content)
|
||||
return planPath
|
||||
}
|
||||
|
||||
function readExistingState() {
|
||||
return readBoulderState(testDirectory)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `context-info-builder-${randomUUID()}`)
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("lists multiple active works and asks agent to choose resume vs new when no explicit plan", () => {
|
||||
// given
|
||||
const clearSpy = spyOn(boulderState, "clearBoulderState")
|
||||
const planAPath = writePlan("plan-alpha", "## TODOs\n- [ ] 1. Alpha")
|
||||
const planBPath = writePlan("plan-beta", "## TODOs\n- [ ] 1. Beta")
|
||||
const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a")
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
addBoulderWork(testDirectory, {
|
||||
planPath: planBPath,
|
||||
sessionId: "session-b",
|
||||
agent: "atlas",
|
||||
worktreePath: "/tmp/worktree-b",
|
||||
})
|
||||
|
||||
// when
|
||||
const contextInfo = buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: null,
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-current",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(contextInfo).toContain("plan-alpha")
|
||||
expect(contextInfo).toContain("plan-beta")
|
||||
expect(contextInfo).toContain("Use the Question tool")
|
||||
expect(clearSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("auto-resumes when exactly one active work exists and no explicit plan", () => {
|
||||
// given
|
||||
const clearSpy = spyOn(boulderState, "clearBoulderState")
|
||||
const planPath = writePlan("single-active-plan", "## TODOs\n- [ ] 1. Single task")
|
||||
const initialState = createBoulderState(planPath, "session-a", "atlas", "/tmp/worktree-single")
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
|
||||
// when
|
||||
const contextInfo = buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: null,
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-current",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(contextInfo).toContain("RESUMING existing work")
|
||||
expect(contextInfo).toContain("single-active-plan")
|
||||
expect(contextInfo).not.toContain("Use the Question tool")
|
||||
expect(clearSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("explicit plan selects matching work only and never clears boulder state", () => {
|
||||
// given
|
||||
const clearSpy = spyOn(boulderState, "clearBoulderState")
|
||||
const planAPath = writePlan("explicit-plan-a", "## TODOs\n- [ ] 1. A")
|
||||
const planBPath = writePlan("explicit-plan-b", "## TODOs\n- [ ] 1. B")
|
||||
const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a")
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
addBoulderWork(testDirectory, {
|
||||
planPath: planBPath,
|
||||
sessionId: "session-b",
|
||||
agent: "atlas",
|
||||
worktreePath: "/tmp/worktree-b",
|
||||
})
|
||||
|
||||
// when
|
||||
const contextInfo = buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: "explicit-plan-a",
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-current",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: "/tmp/worktree-a",
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(contextInfo).toContain("explicit-plan-a")
|
||||
expect(contextInfo).not.toContain("explicit-plan-b")
|
||||
expect(clearSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
const selectedWork = getWorkByPlanName(testDirectory, "explicit-plan-a", { worktreePath: "/tmp/worktree-a" })
|
||||
const nextState = readBoulderState(testDirectory)
|
||||
expect(selectedWork).not.toBeNull()
|
||||
expect(nextState?.active_work_id).toBe(selectedWork?.work_id)
|
||||
})
|
||||
|
||||
test("falls back to auto-select latest plan when no works exist", () => {
|
||||
// given
|
||||
const clearSpy = spyOn(boulderState, "clearBoulderState")
|
||||
const coldStartPlanPath = writePlan("cold-start-plan", "## TODOs\n- [ ] 1. Cold start")
|
||||
|
||||
// when
|
||||
const contextInfo = buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: null,
|
||||
existingState: null,
|
||||
sessionId: "session-current",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(contextInfo).toContain("Auto-Selected Plan")
|
||||
expect(contextInfo).toContain("cold-start-plan")
|
||||
expect(contextInfo).toContain(coldStartPlanPath)
|
||||
expect(existsSync(getBoulderFilePath(testDirectory))).toBe(true)
|
||||
expect(clearSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("keeps existing works when explicit new plan is started", () => {
|
||||
// given
|
||||
writePlan("work-a", "## TODOs\n- [ ] 1. Work A")
|
||||
const workBPath = writePlan("work-b", "## TODOs\n- [ ] 1. Work B")
|
||||
writePlan("new-plan-c", "## TODOs\n- [ ] 1. Work C")
|
||||
|
||||
const initialState = createBoulderState(
|
||||
join(testDirectory, ".sisyphus", "plans", "work-a.md"),
|
||||
"session-a",
|
||||
"atlas",
|
||||
"/tmp/worktree-a",
|
||||
)
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
|
||||
const workAId = initialState.active_work_id!
|
||||
const withSecondWork = addBoulderWork(testDirectory, {
|
||||
planPath: workBPath,
|
||||
sessionId: "session-b",
|
||||
agent: "atlas",
|
||||
worktreePath: "/tmp/worktree-b",
|
||||
})
|
||||
expect(withSecondWork).not.toBeNull()
|
||||
const workBId = Object.keys(withSecondWork!.works!).find((workId) => workId !== workAId)
|
||||
expect(workBId).toBeDefined()
|
||||
|
||||
// when
|
||||
buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: "new-plan-c",
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-c",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
const nextState = readBoulderState(testDirectory)
|
||||
const workIds = Object.keys(nextState?.works ?? {})
|
||||
expect(workIds.length).toBe(3)
|
||||
expect(workIds).toContain(workAId)
|
||||
expect(workIds).toContain(workBId!)
|
||||
const workC = getWorkByPlanName(testDirectory, "new-plan-c")
|
||||
expect(workC).not.toBeNull()
|
||||
expect(workIds).toContain(workC!.work_id)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,17 @@
|
||||
import { statSync } from "node:fs"
|
||||
import {
|
||||
appendSessionId,
|
||||
clearBoulderState,
|
||||
addBoulderWork,
|
||||
createBoulderState,
|
||||
findPrometheusPlans,
|
||||
getActiveWorks,
|
||||
getPlanName,
|
||||
getPlanProgress,
|
||||
getWorkByPlanName,
|
||||
getWorkResumeOptions,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
selectActiveWork,
|
||||
writeBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -44,19 +48,14 @@ function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||
return normalizedPartialMatch || null
|
||||
}
|
||||
|
||||
function buildAutoSelectedPlanContext(params: {
|
||||
function buildAutoSelectedPlanContextInfoOnly(params: {
|
||||
planPath: string
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
worktreePath: string | undefined
|
||||
worktreeBlock: string
|
||||
directory: string
|
||||
}): string {
|
||||
const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const { planPath, sessionId, timestamp, worktreeBlock } = params
|
||||
const progress = getPlanProgress(planPath)
|
||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(directory, newState)
|
||||
|
||||
return `
|
||||
## Auto-Selected Plan
|
||||
@@ -71,6 +70,27 @@ ${worktreeBlock}
|
||||
boulder.json has been created. Read the plan and begin execution.`
|
||||
}
|
||||
|
||||
function buildAutoSelectedPlanContextWithStateInit(params: {
|
||||
planPath: string
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
worktreePath: string | undefined
|
||||
worktreeBlock: string
|
||||
directory: string
|
||||
}): string {
|
||||
const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(directory, newState)
|
||||
|
||||
return buildAutoSelectedPlanContextInfoOnly({
|
||||
planPath,
|
||||
sessionId,
|
||||
timestamp,
|
||||
worktreeBlock,
|
||||
})
|
||||
}
|
||||
|
||||
function buildMissingPlanContext(explicitPlanName: string, allPlans: string[]): string {
|
||||
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
|
||||
if (incompletePlans.length > 0) {
|
||||
@@ -99,9 +119,73 @@ Ask the user which plan to work on.`
|
||||
No incomplete plans available. Create a new plan using the Prometheus agent.`
|
||||
}
|
||||
|
||||
function formatElapsedHuman(elapsedMs: number | undefined): string {
|
||||
if (typeof elapsedMs !== "number" || elapsedMs <= 0) {
|
||||
return "running"
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(elapsedMs / 1000)
|
||||
const seconds = totalSeconds % 60
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function buildMultipleActiveWorksContext(params: {
|
||||
resumeOptions: ReturnType<typeof getWorkResumeOptions>
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
}): string {
|
||||
const { resumeOptions, sessionId, timestamp } = params
|
||||
const optionList = resumeOptions
|
||||
.map((option, index) => `${index + 1}. ${option.plan_name} - ${option.progress.completed}/${option.progress.total} (${option.progress.total === 0 ? 0 : Math.floor((option.progress.completed / option.progress.total) * 100)}%) - elapsed: ${formatElapsedHuman(option.elapsed_ms)} - worktree: ${option.worktree_path ?? "current directory"} - sessions: ${option.session_count}`)
|
||||
.join("\n")
|
||||
|
||||
return `
|
||||
<system-reminder>
|
||||
## Multiple Active Works Found
|
||||
|
||||
Current Time: ${timestamp}
|
||||
Session ID: ${sessionId}
|
||||
|
||||
${optionList}
|
||||
|
||||
Use the Question tool to ask the user which plan to resume.
|
||||
- If the user chooses one option, run /start-work {plan-name} for that plan.
|
||||
- If the user chooses to start a new plan, proceed with cold-start auto-selection flow.
|
||||
</system-reminder>`
|
||||
}
|
||||
|
||||
function createNewWorkOrInitialize(params: {
|
||||
directory: string
|
||||
planPath: string
|
||||
sessionId: string
|
||||
activeAgent: string
|
||||
worktreePath: string | undefined
|
||||
}): void {
|
||||
const { directory, planPath, sessionId, activeAgent, worktreePath } = params
|
||||
const created = addBoulderWork(directory, {
|
||||
planPath,
|
||||
sessionId,
|
||||
agent: activeAgent,
|
||||
worktreePath,
|
||||
})
|
||||
|
||||
if (!created) {
|
||||
const initializedState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(directory, initializedState)
|
||||
}
|
||||
}
|
||||
|
||||
function buildExplicitPlanContext(params: {
|
||||
explicitPlanName: string
|
||||
existingState: ReturnType<typeof readBoulderState>
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
@@ -109,9 +193,24 @@ function buildExplicitPlanContext(params: {
|
||||
worktreeBlock: string
|
||||
directory: string
|
||||
}): string {
|
||||
const { explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const { explicitPlanName, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: sessionId })
|
||||
|
||||
const matchedWork = getWorkByPlanName(directory, explicitPlanName, { worktreePath })
|
||||
if (matchedWork) {
|
||||
const selectedState = selectActiveWork(directory, matchedWork.work_id)
|
||||
if (selectedState) {
|
||||
return buildExistingSessionContext({
|
||||
existingState: selectedState,
|
||||
sessionId,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const allPlans = findPrometheusPlans(directory)
|
||||
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
||||
if (!matchedPlan) {
|
||||
@@ -127,18 +226,19 @@ function buildExplicitPlanContext(params: {
|
||||
All ${progress.total} tasks are done. Create a new plan using the Prometheus agent.`
|
||||
}
|
||||
|
||||
if (existingState) {
|
||||
clearBoulderState(directory)
|
||||
}
|
||||
createNewWorkOrInitialize({
|
||||
directory,
|
||||
planPath: matchedPlan,
|
||||
sessionId,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
})
|
||||
|
||||
return buildAutoSelectedPlanContext({
|
||||
return buildAutoSelectedPlanContextInfoOnly({
|
||||
planPath: matchedPlan,
|
||||
sessionId,
|
||||
timestamp,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,7 +341,7 @@ function buildPlanDiscoveryContext(params: {
|
||||
}
|
||||
|
||||
if (incompletePlans.length === 1) {
|
||||
return contextInfo + buildAutoSelectedPlanContext({
|
||||
return contextInfo + buildAutoSelectedPlanContextWithStateInit({
|
||||
planPath: incompletePlans[0],
|
||||
sessionId,
|
||||
timestamp,
|
||||
@@ -287,11 +387,48 @@ export function buildStartWorkContextInfo(params: {
|
||||
}): string {
|
||||
const { ctx, explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock } = params
|
||||
|
||||
const resumeOptions = getWorkResumeOptions(ctx.directory)
|
||||
.filter((option) => option.status === "active" || option.status === "paused")
|
||||
|
||||
if (!explicitPlanName && resumeOptions.length > 1) {
|
||||
return buildMultipleActiveWorksContext({
|
||||
resumeOptions,
|
||||
sessionId,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
if (!explicitPlanName && resumeOptions.length === 1) {
|
||||
const onlyOption = resumeOptions[0]
|
||||
const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id)
|
||||
if (selectedState) {
|
||||
return buildExistingSessionContext({
|
||||
existingState: selectedState,
|
||||
sessionId,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory: ctx.directory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!explicitPlanName && resumeOptions.length === 0 && getActiveWorks(ctx.directory).length === 0) {
|
||||
return buildPlanDiscoveryContext({
|
||||
contextInfo: "",
|
||||
sessionId,
|
||||
timestamp,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory: ctx.directory,
|
||||
})
|
||||
}
|
||||
|
||||
let contextInfo = ""
|
||||
if (explicitPlanName) {
|
||||
contextInfo = buildExplicitPlanContext({
|
||||
explicitPlanName,
|
||||
existingState,
|
||||
sessionId,
|
||||
timestamp,
|
||||
activeAgent,
|
||||
|
||||
Reference in New Issue
Block a user