fix(ralph-loop): guard compaction continuation ownership
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user