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:
@@ -302,6 +302,77 @@ describe("ralph-loop", () => {
|
||||
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", () => {
|
||||
// #given - hook with config
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
|
||||
@@ -64,6 +64,7 @@ export function createRalphLoopHook(
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||
const checkSessionExists = options?.checkSessionExists
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
@@ -199,6 +200,24 @@ export function createRalphLoopHook(
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,4 +14,5 @@ export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
getTranscriptPath?: (sessionId: string) => string
|
||||
apiTimeout?: number
|
||||
checkSessionExists?: (sessionId: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
createLookAt,
|
||||
createSkillTool,
|
||||
createSkillMcpTool,
|
||||
sessionExists,
|
||||
interactive_bash,
|
||||
getTmuxPath,
|
||||
} from "./tools";
|
||||
@@ -146,7 +147,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: null;
|
||||
|
||||
const ralphLoop = isHookEnabled("ralph-loop")
|
||||
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
|
||||
? createRalphLoopHook(ctx, {
|
||||
config: pluginConfig.ralph_loop,
|
||||
checkSessionExists: async (sessionId) => sessionExists(sessionId),
|
||||
})
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
session_info,
|
||||
} from "./session-manager"
|
||||
|
||||
export { sessionExists } from "./session-manager/storage"
|
||||
|
||||
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||
export { createSkillTool } from "./skill"
|
||||
export { getTmuxPath } from "./interactive-bash/utils"
|
||||
|
||||
Reference in New Issue
Block a user