From 7b2c2529fe40ee63334c9187ccd0c7a10ca973dc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 14:52:40 +0900 Subject: [PATCH] fix: enforce continuation-aware completion gating --- src/cli/run/completion-continuation.test.ts | 138 ++++++++++++++++++++ src/cli/run/completion.ts | 21 +++ src/cli/run/continuation-state.ts | 34 +++++ 3 files changed, 193 insertions(+) create mode 100644 src/cli/run/completion-continuation.test.ts create mode 100644 src/cli/run/continuation-state.ts diff --git a/src/cli/run/completion-continuation.test.ts b/src/cli/run/completion-continuation.test.ts new file mode 100644 index 00000000..afb58b85 --- /dev/null +++ b/src/cli/run/completion-continuation.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, mock, spyOn, afterEach } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import type { RunContext } from "./types" +import { writeState as writeRalphLoopState } from "../../hooks/ralph-loop/storage" + +const testDirs: string[] = [] + +afterEach(() => { + while (testDirs.length > 0) { + const dir = testDirs.pop() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + } +}) + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "omo-run-continuation-")) + testDirs.push(dir) + return dir +} + +function createMockContext(directory: string): RunContext { + return { + client: { + session: { + todo: mock(() => Promise.resolve({ data: [] })), + children: mock(() => Promise.resolve({ data: [] })), + status: mock(() => Promise.resolve({ data: {} })), + }, + } as unknown as RunContext["client"], + sessionID: "test-session", + directory, + abortController: new AbortController(), + } +} + +function writeBoulderStateFile(directory: string, activePlanPath: string, sessionIDs: string[]): void { + const sisyphusDir = join(directory, ".sisyphus") + mkdirSync(sisyphusDir, { recursive: true }) + writeFileSync( + join(sisyphusDir, "boulder.json"), + JSON.stringify({ + active_plan: activePlanPath, + started_at: new Date().toISOString(), + session_ids: sessionIDs, + plan_name: "test-plan", + agent: "atlas", + }), + "utf-8", + ) +} + +describe("checkCompletionConditions continuation coverage", () => { + it("returns false when active boulder continuation exists for this session", async () => { + // given + spyOn(console, "log").mockImplementation(() => {}) + const directory = createTempDir() + const planPath = join(directory, ".sisyphus", "plans", "active-plan.md") + mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true }) + writeFileSync(planPath, "- [ ] incomplete task\n", "utf-8") + writeBoulderStateFile(directory, planPath, ["test-session"]) + const ctx = createMockContext(directory) + const { checkCompletionConditions } = await import("./completion") + + // when + const result = await checkCompletionConditions(ctx) + + // then + expect(result).toBe(false) + }) + + it("returns true when boulder exists but is complete", async () => { + // given + spyOn(console, "log").mockImplementation(() => {}) + const directory = createTempDir() + const planPath = join(directory, ".sisyphus", "plans", "done-plan.md") + mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true }) + writeFileSync(planPath, "- [x] completed task\n", "utf-8") + writeBoulderStateFile(directory, planPath, ["test-session"]) + const ctx = createMockContext(directory) + const { checkCompletionConditions } = await import("./completion") + + // when + const result = await checkCompletionConditions(ctx) + + // then + expect(result).toBe(true) + }) + + it("returns false when active ralph-loop continuation exists for this session", async () => { + // given + spyOn(console, "log").mockImplementation(() => {}) + const directory = createTempDir() + writeRalphLoopState(directory, { + active: true, + iteration: 2, + max_iterations: 10, + completion_promise: "DONE", + started_at: new Date().toISOString(), + prompt: "keep going", + session_id: "test-session", + }) + const ctx = createMockContext(directory) + const { checkCompletionConditions } = await import("./completion") + + // when + const result = await checkCompletionConditions(ctx) + + // then + expect(result).toBe(false) + }) + + it("returns true when active ralph-loop is bound to another session", async () => { + // given + spyOn(console, "log").mockImplementation(() => {}) + const directory = createTempDir() + writeRalphLoopState(directory, { + active: true, + iteration: 2, + max_iterations: 10, + completion_promise: "DONE", + started_at: new Date().toISOString(), + prompt: "keep going", + session_id: "other-session", + }) + const ctx = createMockContext(directory) + const { checkCompletionConditions } = await import("./completion") + + // when + const result = await checkCompletionConditions(ctx) + + // then + expect(result).toBe(true) + }) +}) diff --git a/src/cli/run/completion.ts b/src/cli/run/completion.ts index c2807f4e..69c3c19d 100644 --- a/src/cli/run/completion.ts +++ b/src/cli/run/completion.ts @@ -1,6 +1,7 @@ import pc from "picocolors" import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" import { normalizeSDKResponse } from "../../shared" +import { getContinuationState } from "./continuation-state" export async function checkCompletionConditions(ctx: RunContext): Promise { try { @@ -12,6 +13,10 @@ export async function checkCompletionConditions(ctx: RunContext): Promise { const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID }, diff --git a/src/cli/run/continuation-state.ts b/src/cli/run/continuation-state.ts new file mode 100644 index 00000000..bf58ed59 --- /dev/null +++ b/src/cli/run/continuation-state.ts @@ -0,0 +1,34 @@ +import { getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { readState as readRalphLoopState } from "../../hooks/ralph-loop/storage" + +export interface ContinuationState { + hasActiveBoulder: boolean + hasActiveRalphLoop: boolean +} + +export function getContinuationState(directory: string, sessionID: string): ContinuationState { + return { + hasActiveBoulder: hasActiveBoulderContinuation(directory, sessionID), + hasActiveRalphLoop: hasActiveRalphLoopContinuation(directory, sessionID), + } +} + +function hasActiveBoulderContinuation(directory: string, sessionID: string): boolean { + const boulder = readBoulderState(directory) + if (!boulder) return false + if (!boulder.session_ids.includes(sessionID)) return false + + const progress = getPlanProgress(boulder.active_plan) + return !progress.isComplete +} + +function hasActiveRalphLoopContinuation(directory: string, sessionID: string): boolean { + const state = readRalphLoopState(directory) + if (!state || !state.active) return false + + if (state.session_id && state.session_id !== sessionID) { + return false + } + + return true +}