Skip to content

Commit 95645ef

Browse files
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 <[email protected]>
1 parent 00b8f62 commit 95645ef

File tree

5 files changed

+98
-1
lines changed

5 files changed

+98
-1
lines changed

src/hooks/ralph-loop/index.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,77 @@ describe("ralph-loop", () => {
302302
expect(promptCalls.length).toBe(0)
303303
})
304304

305+
test("should clear orphaned state when original session no longer exists", async () => {
306+
// #given - state file exists from a previous session that no longer exists
307+
const state: RalphLoopState = {
308+
active: true,
309+
iteration: 3,
310+
max_iterations: 50,
311+
completion_promise: "DONE",
312+
started_at: "2025-12-30T01:00:00Z",
313+
prompt: "Build something",
314+
session_id: "orphaned-session-999", // This session no longer exists
315+
}
316+
writeState(TEST_DIR, state)
317+
318+
// Mock sessionExists to return false for the orphaned session
319+
const hook = createRalphLoopHook(createMockPluginInput(), {
320+
checkSessionExists: async (sessionID: string) => {
321+
// Orphaned session doesn't exist, current session does
322+
return sessionID !== "orphaned-session-999"
323+
},
324+
})
325+
326+
// #when - a new session goes idle (different from the orphaned session in state)
327+
await hook.event({
328+
event: {
329+
type: "session.idle",
330+
properties: { sessionID: "new-session-456" },
331+
},
332+
})
333+
334+
// #then - orphaned state should be cleared
335+
expect(hook.getState()).toBeNull()
336+
// #then - no continuation injected (state was cleared, not resumed)
337+
expect(promptCalls.length).toBe(0)
338+
})
339+
340+
test("should NOT clear state when original session still exists (different active session)", async () => {
341+
// #given - state file exists from a session that still exists
342+
const state: RalphLoopState = {
343+
active: true,
344+
iteration: 2,
345+
max_iterations: 50,
346+
completion_promise: "DONE",
347+
started_at: "2025-12-30T01:00:00Z",
348+
prompt: "Build something",
349+
session_id: "active-session-123", // This session still exists
350+
}
351+
writeState(TEST_DIR, state)
352+
353+
// Mock sessionExists to return true for the active session
354+
const hook = createRalphLoopHook(createMockPluginInput(), {
355+
checkSessionExists: async (sessionID: string) => {
356+
// Original session still exists
357+
return sessionID === "active-session-123" || sessionID === "new-session-456"
358+
},
359+
})
360+
361+
// #when - a different session goes idle
362+
await hook.event({
363+
event: {
364+
type: "session.idle",
365+
properties: { sessionID: "new-session-456" },
366+
},
367+
})
368+
369+
// #then - state should NOT be cleared (original session still active)
370+
expect(hook.getState()).not.toBeNull()
371+
expect(hook.getState()?.session_id).toBe("active-session-123")
372+
// #then - no continuation injected (it's a different session's loop)
373+
expect(promptCalls.length).toBe(0)
374+
})
375+
305376
test("should use default config values", () => {
306377
// #given - hook with config
307378
const hook = createRalphLoopHook(createMockPluginInput(), {

src/hooks/ralph-loop/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export function createRalphLoopHook(
6464
const stateDir = config?.state_dir
6565
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
6666
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
67+
const checkSessionExists = options?.checkSessionExists
6768

6869
function getSessionState(sessionID: string): SessionState {
6970
let state = sessions.get(sessionID)
@@ -199,6 +200,24 @@ export function createRalphLoopHook(
199200
}
200201

201202
if (state.session_id && state.session_id !== sessionID) {
203+
if (checkSessionExists) {
204+
try {
205+
const originalSessionExists = await checkSessionExists(state.session_id)
206+
if (!originalSessionExists) {
207+
clearState(ctx.directory, stateDir)
208+
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
209+
orphanedSessionId: state.session_id,
210+
currentSessionId: sessionID,
211+
})
212+
return
213+
}
214+
} catch (err) {
215+
log(`[${HOOK_NAME}] Failed to check session existence`, {
216+
sessionId: state.session_id,
217+
error: String(err),
218+
})
219+
}
220+
}
202221
return
203222
}
204223

src/hooks/ralph-loop/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ export interface RalphLoopOptions {
1414
config?: RalphLoopConfig
1515
getTranscriptPath?: (sessionId: string) => string
1616
apiTimeout?: number
17+
checkSessionExists?: (sessionId: string) => Promise<boolean>
1718
}

src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
createLookAt,
4949
createSkillTool,
5050
createSkillMcpTool,
51+
sessionExists,
5152
interactive_bash,
5253
getTmuxPath,
5354
} from "./tools";
@@ -146,7 +147,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
146147
: null;
147148

148149
const ralphLoop = isHookEnabled("ralph-loop")
149-
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
150+
? createRalphLoopHook(ctx, {
151+
config: pluginConfig.ralph_loop,
152+
checkSessionExists: async (sessionId) => sessionExists(sessionId),
153+
})
150154
: null;
151155

152156
const autoSlashCommand = isHookEnabled("auto-slash-command")

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
session_info,
2929
} from "./session-manager"
3030

31+
export { sessionExists } from "./session-manager/storage"
32+
3133
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
3234
export { createSkillTool } from "./skill"
3335
export { getTmuxPath } from "./interactive-bash/utils"

0 commit comments

Comments
 (0)