fix(ralph-loop): guard compaction continuation ownership
This commit is contained in:
@@ -23,6 +23,23 @@ import type { BackgroundManager } from "../../features/background-agent"
|
|||||||
import { TaskHistory } from "../../features/background-agent/task-history"
|
import { TaskHistory } from "../../features/background-agent/task-history"
|
||||||
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
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<string, boolean | "allow" | "deny" | "ask">
|
||||||
|
parts: Array<{
|
||||||
|
type: "text"
|
||||||
|
text: string
|
||||||
|
synthetic?: true
|
||||||
|
metadata?: { compaction_continue?: true }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
query?: { directory: string }
|
||||||
|
}
|
||||||
|
|
||||||
function createMockContext(
|
function createMockContext(
|
||||||
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
|
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
|
||||||
promptAsyncMock = mock(async () => ({})),
|
promptAsyncMock = mock(async () => ({})),
|
||||||
@@ -148,7 +165,7 @@ describe("createCompactionContextInjector", () => {
|
|||||||
describe("agent checkpoint recovery", () => {
|
describe("agent checkpoint recovery", () => {
|
||||||
it("re-injects checkpointed agent config after compaction when latest agent is lost", async () => {
|
it("re-injects checkpointed agent config after compaction when latest agent is lost", async () => {
|
||||||
//#given
|
//#given
|
||||||
const promptAsyncMock = mock(async () => ({}))
|
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
|
||||||
const ctx = createMockContext(
|
const ctx = createMockContext(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
@@ -201,27 +218,22 @@ describe("createCompactionContextInjector", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(promptAsyncMock).toHaveBeenCalledWith({
|
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
|
||||||
path: { id: "ses_checkpoint" },
|
expect(recoveryCall?.path).toEqual({ id: "ses_checkpoint" })
|
||||||
body: {
|
expect(recoveryCall?.body.noReply).toBe(true)
|
||||||
noReply: true,
|
expect(recoveryCall?.body.agent).toBe("atlas")
|
||||||
agent: "atlas",
|
expect(recoveryCall?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
|
||||||
model: { providerID: "openai", modelID: "gpt-5" },
|
expect(recoveryCall?.body.tools).toEqual({ bash: true })
|
||||||
tools: { bash: true },
|
expect(recoveryCall?.body.parts[0]?.type).toBe("text")
|
||||||
parts: [
|
expect(recoveryCall?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration")
|
||||||
{
|
expect(recoveryCall?.body.parts[0]?.synthetic).toBe(true)
|
||||||
type: "text",
|
expect(recoveryCall?.body.parts[0]?.metadata).toEqual({ compaction_continue: true })
|
||||||
text: expect.stringContaining("restore checkpointed session agent configuration"),
|
expect(recoveryCall?.query).toEqual({ directory: "/tmp/test" })
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
query: { directory: "/tmp/test" },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("re-injects checkpointed agent config during autocontinue before synthetic continue", async () => {
|
it("re-injects checkpointed agent config during autocontinue before synthetic continue", async () => {
|
||||||
//#given
|
//#given
|
||||||
const promptAsyncMock = mock(async () => ({}))
|
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
|
||||||
const ctx = createMockContext(
|
const ctx = createMockContext(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
@@ -273,22 +285,17 @@ describe("createCompactionContextInjector", () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(restored).toBe(true)
|
expect(restored).toBe(true)
|
||||||
expect(promptAsyncMock).toHaveBeenCalledWith({
|
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
|
||||||
path: { id: "ses_autocontinue_checkpoint" },
|
expect(recoveryCall?.path).toEqual({ id: "ses_autocontinue_checkpoint" })
|
||||||
body: {
|
expect(recoveryCall?.body.noReply).toBe(true)
|
||||||
noReply: true,
|
expect(recoveryCall?.body.agent).toBe("atlas")
|
||||||
agent: "atlas",
|
expect(recoveryCall?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
|
||||||
model: { providerID: "openai", modelID: "gpt-5" },
|
expect(recoveryCall?.body.tools).toEqual({ bash: true })
|
||||||
tools: { bash: true },
|
expect(recoveryCall?.body.parts[0]?.type).toBe("text")
|
||||||
parts: [
|
expect(recoveryCall?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration")
|
||||||
{
|
expect(recoveryCall?.body.parts[0]?.synthetic).toBe(true)
|
||||||
type: "text",
|
expect(recoveryCall?.body.parts[0]?.metadata).toEqual({ compaction_continue: true })
|
||||||
text: expect.stringContaining("restore checkpointed session agent configuration"),
|
expect(recoveryCall?.query).toEqual({ directory: "/tmp/test" })
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
query: { directory: "/tmp/test" },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("clears stale checkpoint when the next compaction capture has no prompt config", async () => {
|
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 () => {
|
it("recovers after five consecutive assistant messages with no text", async () => {
|
||||||
//#given
|
//#given
|
||||||
const promptAsyncMock = mock(async () => ({}))
|
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
|
||||||
const ctx = createMockContext(
|
const ctx = createMockContext(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
@@ -375,15 +382,10 @@ describe("createCompactionContextInjector", () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||||
expect(promptAsyncMock).toHaveBeenCalledWith(
|
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
|
||||||
expect.objectContaining({
|
expect(recoveryCall?.path).toEqual({ id: "ses_no_text_tail" })
|
||||||
path: { id: "ses_no_text_tail" },
|
expect(recoveryCall?.body.noReply).toBe(true)
|
||||||
body: expect.objectContaining({
|
expect(recoveryCall?.body.agent).toBe("atlas")
|
||||||
noReply: true,
|
|
||||||
agent: "atlas",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ type PromptAsyncInput = {
|
|||||||
agent?: string
|
agent?: string
|
||||||
model?: { providerID: string; modelID: string }
|
model?: { providerID: string; modelID: string }
|
||||||
tools?: Record<string, boolean>
|
tools?: Record<string, boolean>
|
||||||
parts: Array<{ type: "text"; text: string }>
|
parts: Array<{
|
||||||
|
type: "text"
|
||||||
|
text: string
|
||||||
|
synthetic?: true
|
||||||
|
metadata?: { compaction_continue?: true }
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
query?: { directory: string }
|
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 () => {
|
it("re-injects after compaction when agent and model match but tools are missing", async () => {
|
||||||
//#given
|
//#given
|
||||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
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(
|
const ctx = createMockContext(
|
||||||
[
|
[
|
||||||
[
|
checkpointedPromptConfig,
|
||||||
{
|
incompletePromptConfig,
|
||||||
info: {
|
incompletePromptConfig,
|
||||||
role: "user",
|
checkpointedPromptConfig,
|
||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
promptAsyncRecorder.promptAsync,
|
promptAsyncRecorder.promptAsync,
|
||||||
)
|
)
|
||||||
@@ -157,6 +147,55 @@ describe("createCompactionContextInjector recovery", () => {
|
|||||||
expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true })
|
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 () => {
|
it("retries recovery when the recovered prompt config still mismatches expected model or tools", async () => {
|
||||||
//#given
|
//#given
|
||||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getCompactionAgentConfigCheckpoint,
|
getCompactionAgentConfigCheckpoint,
|
||||||
} from "../../shared/compaction-agent-config-checkpoint"
|
} 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 { log } from "../../shared/logger"
|
||||||
import { setSessionModel } from "../../shared/session-model-state"
|
import { setSessionModel } from "../../shared/session-model-state"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
@@ -88,7 +88,7 @@ export function createRecoveryLogic(
|
|||||||
agent: launchAgent ?? expectedPromptConfig.agent,
|
agent: launchAgent ?? expectedPromptConfig.agent,
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(tools ? { tools } : {}),
|
...(tools ? { tools } : {}),
|
||||||
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
|
parts: [createInternalAgentContinuationTextPart(AGENT_RECOVERY_PROMPT)],
|
||||||
},
|
},
|
||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 =
|
export type ContinuationResult =
|
||||||
| { status: "dispatched" }
|
| { status: "dispatched"; sessionID: string }
|
||||||
| { status: "session_creation_rejected" }
|
| { status: "session_creation_rejected" }
|
||||||
| { status: "dispatch_rejected"; error: unknown }
|
| { status: "dispatch_rejected"; error: unknown }
|
||||||
|
|
||||||
@@ -61,10 +61,10 @@ export async function continueIteration(
|
|||||||
previousSessionID: options.previousSessionID,
|
previousSessionID: options.previousSessionID,
|
||||||
newSessionID,
|
newSessionID,
|
||||||
})
|
})
|
||||||
return { status: "dispatched" }
|
return { status: "dispatch_rejected", error: "state commit failed after reset dispatch" }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: "dispatched" }
|
return { status: "dispatched", sessionID: newSessionID }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -81,5 +81,5 @@ export async function continueIteration(
|
|||||||
return { status: "dispatch_rejected", error }
|
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 {
|
import {
|
||||||
DEFAULT_COMPLETION_PROMISE,
|
DEFAULT_COMPLETION_PROMISE,
|
||||||
DEFAULT_MAX_ITERATIONS,
|
DEFAULT_MAX_ITERATIONS,
|
||||||
@@ -86,8 +86,8 @@ export function createLoopStateController(options: {
|
|||||||
return clearState(directory, stateDir)
|
return clearState(directory, stateDir)
|
||||||
},
|
},
|
||||||
|
|
||||||
incrementIteration(): RalphLoopState | null {
|
incrementIteration(expected?: IterationCommitExpectation): RalphLoopState | null {
|
||||||
return incrementIteration(directory, stateDir)
|
return incrementIteration(directory, stateDir, expected)
|
||||||
},
|
},
|
||||||
|
|
||||||
setSessionID(sessionID: string): RalphLoopState | null {
|
setSessionID(sessionID: string): RalphLoopState | null {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { extractOracleSessionID, isOracleVerified } from "./oracle-verification-
|
|||||||
import type { RalphLoopState } from "./types"
|
import type { RalphLoopState } from "./types"
|
||||||
import { handleFailedVerification } from "./verification-failure-handler"
|
import { handleFailedVerification } from "./verification-failure-handler"
|
||||||
import { withTimeout } from "./with-timeout"
|
import { withTimeout } from "./with-timeout"
|
||||||
|
import type { IterationCommitExpectation } from "./types"
|
||||||
|
|
||||||
type OpenCodeSessionMessage = {
|
type OpenCodeSessionMessage = {
|
||||||
info?: { role?: string }
|
info?: { role?: string }
|
||||||
@@ -83,7 +84,7 @@ async function detectOracleVerificationFromParentSession(
|
|||||||
type LoopStateController = {
|
type LoopStateController = {
|
||||||
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||||
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||||
incrementIteration: () => RalphLoopState | null
|
incrementIteration: (expected?: IterationCommitExpectation) => RalphLoopState | null
|
||||||
clear: () => boolean
|
clear: () => boolean
|
||||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { isSessionActive } from "../shared/session-idle-settle"
|
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 { HOOK_NAME } from "./constants"
|
||||||
import { handleDetectedCompletion } from "./completion-handler"
|
import { handleDetectedCompletion } from "./completion-handler"
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,7 @@ const RAPID_IDLE_DEDUP_MS = 500
|
|||||||
type LoopStateController = {
|
type LoopStateController = {
|
||||||
getState: () => RalphLoopState | null
|
getState: () => RalphLoopState | null
|
||||||
clear: () => boolean
|
clear: () => boolean
|
||||||
incrementIteration: () => RalphLoopState | null
|
incrementIteration: (expected?: IterationCommitExpectation) => RalphLoopState | null
|
||||||
setSessionID: (sessionID: string) => RalphLoopState | null
|
setSessionID: (sessionID: string) => RalphLoopState | null
|
||||||
markVerificationPending: (sessionID: string) => RalphLoopState | null
|
markVerificationPending: (sessionID: string) => RalphLoopState | null
|
||||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
||||||
@@ -377,11 +377,21 @@ export function createRalphLoopEventHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const committed = options.loopState.incrementIteration()
|
const committed = options.loopState.incrementIteration({
|
||||||
|
iteration: stateBeforeCommit.iteration,
|
||||||
|
sessionID: result.sessionID,
|
||||||
|
})
|
||||||
if (committed) {
|
if (committed) {
|
||||||
showIterationToast(ctx, committed)
|
showIterationToast(ctx, committed)
|
||||||
} else {
|
} else {
|
||||||
log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed`, { sessionID })
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -532,12 +542,22 @@ export function createRalphLoopEventHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const committed = options.loopState.incrementIteration()
|
const committed = options.loopState.incrementIteration({
|
||||||
|
iteration: stateBeforeCommit.iteration,
|
||||||
|
sessionID: result.sessionID,
|
||||||
|
})
|
||||||
if (committed) {
|
if (committed) {
|
||||||
showIterationToast(ctx, committed)
|
showIterationToast(ctx, committed)
|
||||||
runtimeErrorRetriedSessions.set(sessionID, committed.iteration)
|
runtimeErrorRetriedSessions.set(sessionID, committed.iteration)
|
||||||
} else {
|
} else {
|
||||||
log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed after runtime error`, { sessionID })
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"
|
||||||
import { dirname, join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
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"
|
import { DEFAULT_STATE_FILE, DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS } from "./constants"
|
||||||
|
|
||||||
export function getStateFilePath(directory: string, customPath?: string): string {
|
export function getStateFilePath(directory: string, customPath?: string): string {
|
||||||
@@ -151,10 +151,17 @@ export function clearState(directory: string, customPath?: string): boolean {
|
|||||||
|
|
||||||
export function incrementIteration(
|
export function incrementIteration(
|
||||||
directory: string,
|
directory: string,
|
||||||
customPath?: string
|
customPath?: string,
|
||||||
|
expected?: IterationCommitExpectation,
|
||||||
): RalphLoopState | null {
|
): RalphLoopState | null {
|
||||||
const state = readState(directory, customPath)
|
const state = readState(directory, customPath)
|
||||||
if (!state) return null
|
if (!state) return null
|
||||||
|
if (
|
||||||
|
expected
|
||||||
|
&& (state.iteration !== expected.iteration || state.session_id !== expected.sessionID)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
state.iteration += 1
|
state.iteration += 1
|
||||||
if (writeState(directory, state, customPath)) {
|
if (writeState(directory, state, customPath)) {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export interface RalphLoopState {
|
|||||||
strategy?: "reset" | "continue"
|
strategy?: "reset" | "continue"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IterationCommitExpectation {
|
||||||
|
iteration: number
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface RalphLoopOptions {
|
export interface RalphLoopOptions {
|
||||||
config?: RalphLoopConfig
|
config?: RalphLoopConfig
|
||||||
getTranscriptPath?: (sessionId: string) => string
|
getTranscriptPath?: (sessionId: string) => string
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { log } from "../../shared/logger"
|
|||||||
import { buildVerificationFailurePrompt } from "./continuation-prompt-builder"
|
import { buildVerificationFailurePrompt } from "./continuation-prompt-builder"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||||
import type { RalphLoopState } from "./types"
|
import type { IterationCommitExpectation, RalphLoopState } from "./types"
|
||||||
|
|
||||||
type LoopStateController = {
|
type LoopStateController = {
|
||||||
clearVerificationState: (
|
clearVerificationState: (
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
messageCountAtStart?: number,
|
messageCountAtStart?: number,
|
||||||
) => RalphLoopState | null
|
) => RalphLoopState | null
|
||||||
incrementIteration: () => RalphLoopState | null
|
incrementIteration: (expected?: IterationCommitExpectation) => RalphLoopState | null
|
||||||
clear: () => boolean
|
clear: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user