fix(ralph-loop): clear orphaned state when original session no longer exists (#446)

* fix(ralph-loop): clear orphaned state when original session no longer exists

When a session with an active ralph-loop terminates abnormally (abort, window close),
the state file remains with active: true. Previously, when a new session started,
the hook would skip the orphaned state without cleaning it up.

This fix adds session existence validation:
- Before skipping a loop owned by a different session, check if that session still exists
- If the original session no longer exists, clear the orphan state and log
- If the original session still exists, skip as before (it's another active session's loop)

Changes:
- Add checkSessionExists option to RalphLoopOptions for dependency injection
- Wire up sessionExists from session-manager as the default implementation
- Add tests for orphan state cleanup and active session preservation

* fix(ralph-loop): add error handling around checkSessionExists call

Wraps the async checkSessionExists call in try/catch for consistency
with other async operations in this file. If the check throws, logs
the error and falls back to the original behavior (not clearing state).

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
Victor Jaepyo Jo
2026-01-03 14:11:59 +09:00
committed by GitHub
parent 00b8f622d5
commit 95645effd7
5 changed files with 98 additions and 1 deletions

View File

@@ -302,6 +302,77 @@ describe("ralph-loop", () => {
expect(promptCalls.length).toBe(0) expect(promptCalls.length).toBe(0)
}) })
test("should clear orphaned state when original session no longer exists", async () => {
// #given - state file exists from a previous session that no longer exists
const state: RalphLoopState = {
active: true,
iteration: 3,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Build something",
session_id: "orphaned-session-999", // This session no longer exists
}
writeState(TEST_DIR, state)
// Mock sessionExists to return false for the orphaned session
const hook = createRalphLoopHook(createMockPluginInput(), {
checkSessionExists: async (sessionID: string) => {
// Orphaned session doesn't exist, current session does
return sessionID !== "orphaned-session-999"
},
})
// #when - a new session goes idle (different from the orphaned session in state)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "new-session-456" },
},
})
// #then - orphaned state should be cleared
expect(hook.getState()).toBeNull()
// #then - no continuation injected (state was cleared, not resumed)
expect(promptCalls.length).toBe(0)
})
test("should NOT clear state when original session still exists (different active session)", async () => {
// #given - state file exists from a session that still exists
const state: RalphLoopState = {
active: true,
iteration: 2,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Build something",
session_id: "active-session-123", // This session still exists
}
writeState(TEST_DIR, state)
// Mock sessionExists to return true for the active session
const hook = createRalphLoopHook(createMockPluginInput(), {
checkSessionExists: async (sessionID: string) => {
// Original session still exists
return sessionID === "active-session-123" || sessionID === "new-session-456"
},
})
// #when - a different session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "new-session-456" },
},
})
// #then - state should NOT be cleared (original session still active)
expect(hook.getState()).not.toBeNull()
expect(hook.getState()?.session_id).toBe("active-session-123")
// #then - no continuation injected (it's a different session's loop)
expect(promptCalls.length).toBe(0)
})
test("should use default config values", () => { test("should use default config values", () => {
// #given - hook with config // #given - hook with config
const hook = createRalphLoopHook(createMockPluginInput(), { const hook = createRalphLoopHook(createMockPluginInput(), {

View File

@@ -64,6 +64,7 @@ export function createRalphLoopHook(
const stateDir = config?.state_dir const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
const checkSessionExists = options?.checkSessionExists
function getSessionState(sessionID: string): SessionState { function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID) let state = sessions.get(sessionID)
@@ -199,6 +200,24 @@ export function createRalphLoopHook(
} }
if (state.session_id && state.session_id !== sessionID) { if (state.session_id && state.session_id !== sessionID) {
if (checkSessionExists) {
try {
const originalSessionExists = await checkSessionExists(state.session_id)
if (!originalSessionExists) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
orphanedSessionId: state.session_id,
currentSessionId: sessionID,
})
return
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to check session existence`, {
sessionId: state.session_id,
error: String(err),
})
}
}
return return
} }

View File

@@ -14,4 +14,5 @@ export interface RalphLoopOptions {
config?: RalphLoopConfig config?: RalphLoopConfig
getTranscriptPath?: (sessionId: string) => string getTranscriptPath?: (sessionId: string) => string
apiTimeout?: number apiTimeout?: number
checkSessionExists?: (sessionId: string) => Promise<boolean>
} }

View File

@@ -48,6 +48,7 @@ import {
createLookAt, createLookAt,
createSkillTool, createSkillTool,
createSkillMcpTool, createSkillMcpTool,
sessionExists,
interactive_bash, interactive_bash,
getTmuxPath, getTmuxPath,
} from "./tools"; } from "./tools";
@@ -146,7 +147,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
: null; : null;
const ralphLoop = isHookEnabled("ralph-loop") const ralphLoop = isHookEnabled("ralph-loop")
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop }) ? createRalphLoopHook(ctx, {
config: pluginConfig.ralph_loop,
checkSessionExists: async (sessionId) => sessionExists(sessionId),
})
: null; : null;
const autoSlashCommand = isHookEnabled("auto-slash-command") const autoSlashCommand = isHookEnabled("auto-slash-command")

View File

@@ -28,6 +28,8 @@ import {
session_info, session_info,
} from "./session-manager" } from "./session-manager"
export { sessionExists } from "./session-manager/storage"
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash" export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
export { createSkillTool } from "./skill" export { createSkillTool } from "./skill"
export { getTmuxPath } from "./interactive-bash/utils" export { getTmuxPath } from "./interactive-bash/utils"