fix: enforce continuation-aware completion gating
This commit is contained in:
parent
47a8c3e4a9
commit
7b2c2529fe
138
src/cli/run/completion-continuation.test.ts
Normal file
138
src/cli/run/completion-continuation.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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<boolean> {
|
||||
try {
|
||||
@ -12,6 +13,10 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
|
||||
return false
|
||||
}
|
||||
|
||||
if (!areContinuationHooksIdle(ctx)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(pc.red(`[completion] API error: ${err}`))
|
||||
@ -19,6 +24,22 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function areContinuationHooksIdle(ctx: RunContext): boolean {
|
||||
const continuationState = getContinuationState(ctx.directory, ctx.sessionID)
|
||||
|
||||
if (continuationState.hasActiveBoulder) {
|
||||
console.log(pc.dim(" Waiting: boulder continuation is active"))
|
||||
return false
|
||||
}
|
||||
|
||||
if (continuationState.hasActiveRalphLoop) {
|
||||
console.log(pc.dim(" Waiting: ralph-loop continuation is active"))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
|
||||
const todosRes = await ctx.client.session.todo({
|
||||
path: { id: ctx.sessionID },
|
||||
|
||||
34
src/cli/run/continuation-state.ts
Normal file
34
src/cli/run/continuation-state.ts
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user