diff --git a/src/hooks/compaction-context-injector/index.test.ts b/src/hooks/compaction-context-injector/index.test.ts index ad4972f06..fc74ea00b 100644 --- a/src/hooks/compaction-context-injector/index.test.ts +++ b/src/hooks/compaction-context-injector/index.test.ts @@ -23,6 +23,23 @@ import type { BackgroundManager } from "../../features/background-agent" import { TaskHistory } from "../../features/background-agent/task-history" import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint" +type PromptAsyncInput = { + path: { id: string } + body: { + noReply?: boolean + agent?: string + model?: { providerID: string; modelID: string } + tools?: Record + parts: Array<{ + type: "text" + text: string + synthetic?: true + metadata?: { compaction_continue?: true } + }> + } + query?: { directory: string } +} + function createMockContext( messageResponses: Array }>>, promptAsyncMock = mock(async () => ({})), @@ -148,7 +165,7 @@ describe("createCompactionContextInjector", () => { describe("agent checkpoint recovery", () => { it("re-injects checkpointed agent config after compaction when latest agent is lost", async () => { //#given - const promptAsyncMock = mock(async () => ({})) + const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({})) const ctx = createMockContext( [ [ @@ -201,27 +218,22 @@ describe("createCompactionContextInjector", () => { }) //#then - expect(promptAsyncMock).toHaveBeenCalledWith({ - path: { id: "ses_checkpoint" }, - body: { - noReply: true, - agent: "atlas", - model: { providerID: "openai", modelID: "gpt-5" }, - tools: { bash: true }, - parts: [ - { - type: "text", - text: expect.stringContaining("restore checkpointed session agent configuration"), - }, - ], - }, - query: { directory: "/tmp/test" }, - }) + const recoveryCall = promptAsyncMock.mock.calls[0]?.[0] + expect(recoveryCall?.path).toEqual({ id: "ses_checkpoint" }) + expect(recoveryCall?.body.noReply).toBe(true) + expect(recoveryCall?.body.agent).toBe("atlas") + expect(recoveryCall?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5" }) + expect(recoveryCall?.body.tools).toEqual({ bash: true }) + expect(recoveryCall?.body.parts[0]?.type).toBe("text") + expect(recoveryCall?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration") + expect(recoveryCall?.body.parts[0]?.synthetic).toBe(true) + expect(recoveryCall?.body.parts[0]?.metadata).toEqual({ compaction_continue: true }) + expect(recoveryCall?.query).toEqual({ directory: "/tmp/test" }) }) it("re-injects checkpointed agent config during autocontinue before synthetic continue", async () => { //#given - const promptAsyncMock = mock(async () => ({})) + const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({})) const ctx = createMockContext( [ [ @@ -273,22 +285,17 @@ describe("createCompactionContextInjector", () => { //#then expect(restored).toBe(true) - expect(promptAsyncMock).toHaveBeenCalledWith({ - path: { id: "ses_autocontinue_checkpoint" }, - body: { - noReply: true, - agent: "atlas", - model: { providerID: "openai", modelID: "gpt-5" }, - tools: { bash: true }, - parts: [ - { - type: "text", - text: expect.stringContaining("restore checkpointed session agent configuration"), - }, - ], - }, - query: { directory: "/tmp/test" }, - }) + const recoveryCall = promptAsyncMock.mock.calls[0]?.[0] + expect(recoveryCall?.path).toEqual({ id: "ses_autocontinue_checkpoint" }) + expect(recoveryCall?.body.noReply).toBe(true) + expect(recoveryCall?.body.agent).toBe("atlas") + expect(recoveryCall?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5" }) + expect(recoveryCall?.body.tools).toEqual({ bash: true }) + expect(recoveryCall?.body.parts[0]?.type).toBe("text") + expect(recoveryCall?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration") + expect(recoveryCall?.body.parts[0]?.synthetic).toBe(true) + expect(recoveryCall?.body.parts[0]?.metadata).toEqual({ compaction_continue: true }) + expect(recoveryCall?.query).toEqual({ directory: "/tmp/test" }) }) it("clears stale checkpoint when the next compaction capture has no prompt config", async () => { @@ -314,7 +321,7 @@ describe("createCompactionContextInjector", () => { it("recovers after five consecutive assistant messages with no text", async () => { //#given - const promptAsyncMock = mock(async () => ({})) + const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({})) const ctx = createMockContext( [ [ @@ -375,15 +382,10 @@ describe("createCompactionContextInjector", () => { //#then expect(promptAsyncMock).toHaveBeenCalledTimes(1) - expect(promptAsyncMock).toHaveBeenCalledWith( - expect.objectContaining({ - path: { id: "ses_no_text_tail" }, - body: expect.objectContaining({ - noReply: true, - agent: "atlas", - }), - }), - ) + const recoveryCall = promptAsyncMock.mock.calls[0]?.[0] + expect(recoveryCall?.path).toEqual({ id: "ses_no_text_tail" }) + expect(recoveryCall?.body.noReply).toBe(true) + expect(recoveryCall?.body.agent).toBe("atlas") }) }) }) diff --git a/src/hooks/compaction-context-injector/recovery.test.ts b/src/hooks/compaction-context-injector/recovery.test.ts index 3642a40c4..0119fdfde 100644 --- a/src/hooks/compaction-context-injector/recovery.test.ts +++ b/src/hooks/compaction-context-injector/recovery.test.ts @@ -15,7 +15,12 @@ type PromptAsyncInput = { agent?: string model?: { providerID: string; modelID: string } tools?: Record - parts: Array<{ type: "text"; text: string }> + parts: Array<{ + type: "text" + text: string + synthetic?: true + metadata?: { compaction_continue?: true } + }> } query?: { directory: string } } @@ -96,46 +101,31 @@ describe("createCompactionContextInjector recovery", () => { it("re-injects after compaction when agent and model match but tools are missing", async () => { //#given const promptAsyncRecorder = createPromptAsyncRecorder() + const checkpointedPromptConfig = [ + { + info: { + role: "user", + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + tools: { bash: true }, + }, + }, + ] + const incompletePromptConfig = [ + { + info: { + role: "user", + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + }, + }, + ] const ctx = createMockContext( [ - [ - { - info: { - role: "user", - agent: "atlas", - model: { providerID: "openai", modelID: "gpt-5" }, - tools: { bash: true }, - }, - }, - ], - [ - { - info: { - role: "user", - agent: "atlas", - model: { providerID: "openai", modelID: "gpt-5" }, - }, - }, - ], - [ - { - info: { - role: "user", - agent: "atlas", - model: { providerID: "openai", modelID: "gpt-5" }, - }, - }, - ], - [ - { - info: { - role: "user", - agent: "atlas", - model: { providerID: "openai", modelID: "gpt-5" }, - tools: { bash: true }, - }, - }, - ], + checkpointedPromptConfig, + incompletePromptConfig, + incompletePromptConfig, + checkpointedPromptConfig, ], promptAsyncRecorder.promptAsync, ) @@ -157,6 +147,55 @@ describe("createCompactionContextInjector recovery", () => { expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true }) }) + it("marks the recovery prompt as synthetic compaction continuation", async () => { + //#given + const promptAsyncRecorder = createPromptAsyncRecorder() + const incompletePromptConfig = [ + { + info: { + role: "user", + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + }, + }, + ] + const recoveredPromptConfig = [ + { + info: { + role: "user", + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + tools: { bash: true }, + }, + }, + ] + const ctx = createMockContext( + [ + recoveredPromptConfig, + incompletePromptConfig, + incompletePromptConfig, + recoveredPromptConfig, + ], + promptAsyncRecorder.promptAsync, + ) + const injector = createCompactionContextInjector({ ctx }) + + //#when + await injector.capture("ses_synthetic_recovery") + await injector.event({ + event: { + type: "session.compacted", + properties: { sessionID: "ses_synthetic_recovery" }, + }, + }) + + //#then + expect(promptAsyncRecorder.calls.length).toBe(1) + const recoveryPart = promptAsyncRecorder.calls[0]?.body.parts[0] + expect(recoveryPart?.synthetic).toBe(true) + expect(recoveryPart?.metadata).toEqual({ compaction_continue: true }) + }) + it("retries recovery when the recovered prompt config still mismatches expected model or tools", async () => { //#given const promptAsyncRecorder = createPromptAsyncRecorder() diff --git a/src/hooks/compaction-context-injector/recovery.ts b/src/hooks/compaction-context-injector/recovery.ts index ab8331e44..ed69bf0e0 100644 --- a/src/hooks/compaction-context-injector/recovery.ts +++ b/src/hooks/compaction-context-injector/recovery.ts @@ -5,7 +5,7 @@ import { import { getCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" -import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker" +import { createInternalAgentContinuationTextPart } from "../../shared/internal-initiator-marker" import { log } from "../../shared/logger" import { setSessionModel } from "../../shared/session-model-state" import { setSessionTools } from "../../shared/session-tools-store" @@ -88,7 +88,7 @@ export function createRecoveryLogic( agent: launchAgent ?? expectedPromptConfig.agent, ...(model ? { model } : {}), ...(tools ? { tools } : {}), - parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)], + parts: [createInternalAgentContinuationTextPart(AGENT_RECOVERY_PROMPT)], }, query: { directory: ctx.directory }, }) diff --git a/src/hooks/ralph-loop/iteration-commit-ownership.test.ts b/src/hooks/ralph-loop/iteration-commit-ownership.test.ts new file mode 100644 index 000000000..5d8050c69 --- /dev/null +++ b/src/hooks/ralph-loop/iteration-commit-ownership.test.ts @@ -0,0 +1,77 @@ +/// + +import { describe, expect, test } from "bun:test" +import { createRalphLoopEventHandler } from "./ralph-loop-event-handler" +import type { IterationCommitExpectation, RalphLoopState } from "./types" + +describe("ralph-loop iteration commit ownership", () => { + test("#given reset strategy creates a new session #when dispatch commits #then CAS expects the new owner", async () => { + // given + const commitExpectations: IterationCommitExpectation[] = [] + let state: RalphLoopState | null = { + active: true, + iteration: 1, + max_iterations: 5, + completion_promise: "DONE", + started_at: new Date().toISOString(), + prompt: "Keep working", + session_id: "session-old", + strategy: "reset", + } + const handler = createRalphLoopEventHandler({ + directory: "/tmp/ralph-loop-iteration-commit-ownership", + client: { + session: { + messages: async () => ({ data: [] }), + create: async () => ({ data: { id: "session-new" } }), + promptAsync: async () => ({}), + }, + tui: { + showToast: async () => ({}), + selectSession: async () => ({}), + }, + }, + } as never, { + directory: "/tmp/ralph-loop-iteration-commit-ownership", + apiTimeoutMs: 5000, + idleSettleMs: 0, + getTranscriptPath: () => undefined, + loopState: { + getState: () => state, + clear: () => { + state = null + return true + }, + setSessionID: (sessionID: string) => { + if (!state) return null + state = { ...state, session_id: sessionID } + return state + }, + incrementIteration: (expected?: IterationCommitExpectation) => { + if (expected) { + commitExpectations.push(expected) + } + if (!state) return null + state = { ...state, iteration: state.iteration + 1 } + return state + }, + markVerificationPending: () => state, + setVerificationSessionID: () => state, + restartAfterFailedVerification: () => state, + clearVerificationState: () => state, + }, + }) + + // when + await handler({ + event: { type: "session.idle", properties: { sessionID: "session-old" } }, + }) + + // then + expect(commitExpectations).toEqual([ + { iteration: 1, sessionID: "session-new" }, + ]) + expect(state?.iteration).toBe(2) + expect(state?.session_id).toBe("session-new") + }) +}) diff --git a/src/hooks/ralph-loop/iteration-continuation.ts b/src/hooks/ralph-loop/iteration-continuation.ts index 00c5c878b..fd3d2741d 100644 --- a/src/hooks/ralph-loop/iteration-continuation.ts +++ b/src/hooks/ralph-loop/iteration-continuation.ts @@ -16,7 +16,7 @@ type ContinuationOptions = { } export type ContinuationResult = - | { status: "dispatched" } + | { status: "dispatched"; sessionID: string } | { status: "session_creation_rejected" } | { status: "dispatch_rejected"; error: unknown } @@ -61,10 +61,10 @@ export async function continueIteration( previousSessionID: options.previousSessionID, newSessionID, }) - return { status: "dispatched" } + return { status: "dispatch_rejected", error: "state commit failed after reset dispatch" } } - return { status: "dispatched" } + return { status: "dispatched", sessionID: newSessionID } } try { @@ -81,5 +81,5 @@ export async function continueIteration( return { status: "dispatch_rejected", error } } - return { status: "dispatched" } + return { status: "dispatched", sessionID: options.previousSessionID } } diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts index 7ff1ea01d..bd7fffcf0 100644 --- a/src/hooks/ralph-loop/loop-state-controller.ts +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -1,4 +1,4 @@ -import type { RalphLoopOptions, RalphLoopState } from "./types" +import type { IterationCommitExpectation, RalphLoopOptions, RalphLoopState } from "./types" import { DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS, @@ -86,8 +86,8 @@ export function createLoopStateController(options: { return clearState(directory, stateDir) }, - incrementIteration(): RalphLoopState | null { - return incrementIteration(directory, stateDir) + incrementIteration(expected?: IterationCommitExpectation): RalphLoopState | null { + return incrementIteration(directory, stateDir, expected) }, setSessionID(sessionID: string): RalphLoopState | null { diff --git a/src/hooks/ralph-loop/pending-verification-handler.ts b/src/hooks/ralph-loop/pending-verification-handler.ts index 1976ec9aa..78f970550 100644 --- a/src/hooks/ralph-loop/pending-verification-handler.ts +++ b/src/hooks/ralph-loop/pending-verification-handler.ts @@ -5,6 +5,7 @@ import { extractOracleSessionID, isOracleVerified } from "./oracle-verification- import type { RalphLoopState } from "./types" import { handleFailedVerification } from "./verification-failure-handler" import { withTimeout } from "./with-timeout" +import type { IterationCommitExpectation } from "./types" type OpenCodeSessionMessage = { info?: { role?: string } @@ -83,7 +84,7 @@ async function detectOracleVerificationFromParentSession( type LoopStateController = { restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null - incrementIteration: () => RalphLoopState | null + incrementIteration: (expected?: IterationCommitExpectation) => RalphLoopState | null clear: () => boolean setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null } diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index cd27c117e..0e832a9ef 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { isSessionActive } from "../shared/session-idle-settle" -import type { RalphLoopOptions, RalphLoopState } from "./types" +import type { IterationCommitExpectation, RalphLoopOptions, RalphLoopState } from "./types" import { HOOK_NAME } from "./constants" import { handleDetectedCompletion } from "./completion-handler" import { @@ -18,7 +18,7 @@ const RAPID_IDLE_DEDUP_MS = 500 type LoopStateController = { getState: () => RalphLoopState | null clear: () => boolean - incrementIteration: () => RalphLoopState | null + incrementIteration: (expected?: IterationCommitExpectation) => RalphLoopState | null setSessionID: (sessionID: string) => RalphLoopState | null markVerificationPending: (sessionID: string) => RalphLoopState | null setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null @@ -377,11 +377,21 @@ export function createRalphLoopEventHandler( return } - const committed = options.loopState.incrementIteration() + const committed = options.loopState.incrementIteration({ + iteration: stateBeforeCommit.iteration, + sessionID: result.sessionID, + }) if (committed) { showIterationToast(ctx, committed) } else { log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed`, { sessionID }) + options.loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: "Dispatch succeeded but iteration commit failed", + variant: "warning", + duration: 5000, + }) } return } @@ -532,12 +542,22 @@ export function createRalphLoopEventHandler( return } - const committed = options.loopState.incrementIteration() + const committed = options.loopState.incrementIteration({ + iteration: stateBeforeCommit.iteration, + sessionID: result.sessionID, + }) if (committed) { showIterationToast(ctx, committed) runtimeErrorRetriedSessions.set(sessionID, committed.iteration) } else { log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed after runtime error`, { sessionID }) + options.loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: "Dispatch succeeded but iteration commit failed", + variant: "warning", + duration: 5000, + }) } return } diff --git a/src/hooks/ralph-loop/storage.ts b/src/hooks/ralph-loop/storage.ts index 309151a43..f5ca06fe2 100644 --- a/src/hooks/ralph-loop/storage.ts +++ b/src/hooks/ralph-loop/storage.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs" import { dirname, join } from "node:path" import { parseFrontmatter } from "../../shared/frontmatter" -import type { RalphLoopState } from "./types" +import type { IterationCommitExpectation, RalphLoopState } from "./types" import { DEFAULT_STATE_FILE, DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS } from "./constants" export function getStateFilePath(directory: string, customPath?: string): string { @@ -151,10 +151,17 @@ export function clearState(directory: string, customPath?: string): boolean { export function incrementIteration( directory: string, - customPath?: string + customPath?: string, + expected?: IterationCommitExpectation, ): RalphLoopState | null { const state = readState(directory, customPath) if (!state) return null + if ( + expected + && (state.iteration !== expected.iteration || state.session_id !== expected.sessionID) + ) { + return null + } state.iteration += 1 if (writeState(directory, state, customPath)) { diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts index af864ccbc..8c0106b05 100644 --- a/src/hooks/ralph-loop/types.ts +++ b/src/hooks/ralph-loop/types.ts @@ -17,6 +17,11 @@ export interface RalphLoopState { strategy?: "reset" | "continue" } +export interface IterationCommitExpectation { + iteration: number + sessionID: string +} + export interface RalphLoopOptions { config?: RalphLoopConfig getTranscriptPath?: (sessionId: string) => string diff --git a/src/hooks/ralph-loop/verification-failure-handler.ts b/src/hooks/ralph-loop/verification-failure-handler.ts index a14ccc846..760ea950c 100644 --- a/src/hooks/ralph-loop/verification-failure-handler.ts +++ b/src/hooks/ralph-loop/verification-failure-handler.ts @@ -3,14 +3,14 @@ import { log } from "../../shared/logger" import { buildVerificationFailurePrompt } from "./continuation-prompt-builder" import { HOOK_NAME } from "./constants" import { injectContinuationPrompt } from "./continuation-prompt-injector" -import type { RalphLoopState } from "./types" +import type { IterationCommitExpectation, RalphLoopState } from "./types" type LoopStateController = { clearVerificationState: ( sessionID: string, messageCountAtStart?: number, ) => RalphLoopState | null - incrementIteration: () => RalphLoopState | null + incrementIteration: (expected?: IterationCommitExpectation) => RalphLoopState | null clear: () => boolean }