fix(ralph-loop): adopt OContinue patterns for better performance and abort handling (#431)

This commit is contained in:
Sisyphus
2026-01-03 09:55:12 +09:00
committed by GitHub
parent d09c994b91
commit f345101f91
3 changed files with 202 additions and 26 deletions
+44 -26
View File
@@ -53,6 +53,8 @@ export interface RalphLoopHook {
getState: () => RalphLoopState | null
}
const DEFAULT_API_TIMEOUT = 3000
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
@@ -61,6 +63,7 @@ export function createRalphLoopHook(
const config = options?.config
const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
@@ -97,32 +100,34 @@ export function createRalphLoopHook(
promise: string
): Promise<boolean> {
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const response = await Promise.race([
ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
),
])
const messages = (response as { data?: unknown[] }).data ?? []
if (!Array.isArray(messages)) return false
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
(msg) => msg.info?.role === "assistant"
)
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (!lastAssistant?.parts) return false
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
const responseText = lastAssistant.parts
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("\n")
for (const msg of messages as OpenCodeSessionMessage[]) {
if (msg.info?.role !== "assistant") continue
for (const part of msg.parts || []) {
if (part.type === "text" && part.text) {
if (pattern.test(part.text)) {
return true
}
}
}
}
return false
return pattern.test(responseText)
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) })
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
return false
}
}
@@ -197,20 +202,19 @@ export function createRalphLoopHook(
return
}
const completionDetectedViaApi = await detectCompletionInSessionMessages(
sessionID,
state.completion_promise
)
const transcriptPath = getTranscriptPath(sessionID)
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
if (completionDetectedViaApi || completionDetectedViaTranscript) {
const completionDetectedViaApi = completionDetectedViaTranscript
? false
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
if (completionDetectedViaTranscript || completionDetectedViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file",
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
})
clearState(ctx.directory, stateDir)
@@ -308,6 +312,20 @@ export function createRalphLoopHook(
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError") {
if (sessionID) {
const state = readState(ctx.directory, stateDir)
if (state?.session_id === sessionID) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
}
sessions.delete(sessionID)
}
return
}
if (sessionID) {
const sessionState = getSessionState(sessionID)
sessionState.isRecovering = true