From f980e256dd2ec4720262d41fe2f1c84cbc56df01 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 7 Feb 2026 13:12:41 +0900 Subject: [PATCH] fix: boulder continuation now respects /stop-continuation guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add isContinuationStopped check to atlas hook's session.idle handler so boulder continuation stops when user runs /stop-continuation. Previously, todo continuation and session recovery checked the guard, but boulder continuation did not — causing work to resume after stop. Fixes #1575 --- src/hooks/atlas/index.test.ts | 89 +++++++++++++++++++++++------------ src/hooks/atlas/index.ts | 6 +++ src/index.ts | 6 ++- 3 files changed, 71 insertions(+), 30 deletions(-) diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 993984f1..502d8d41 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -755,40 +755,71 @@ describe("atlas hook", () => { expect(mockInput._promptMock).not.toHaveBeenCalled() }) - test("should skip when background tasks are running", async () => { - // given - boulder state with incomplete plan - const planPath = join(TEST_DIR, "test-plan.md") - writeFileSync(planPath, "# Plan\n- [ ] Task 1") + test("should skip when background tasks are running", async () => { + // given - boulder state with incomplete plan + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1") - const state: BoulderState = { - active_plan: planPath, - started_at: "2026-01-02T10:00:00Z", - session_ids: [MAIN_SESSION_ID], - plan_name: "test-plan", - } - writeBoulderState(TEST_DIR, state) + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + } + writeBoulderState(TEST_DIR, state) - const mockBackgroundManager = { - getTasksByParentSession: () => [{ status: "running" }], - } + const mockBackgroundManager = { + getTasksByParentSession: () => [{ status: "running" }], + } - const mockInput = createMockPluginInput() - const hook = createAtlasHook(mockInput, { - directory: TEST_DIR, - backgroundManager: mockBackgroundManager as any, - }) + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput, { + directory: TEST_DIR, + backgroundManager: mockBackgroundManager as any, + }) - // when - await hook.handler({ - event: { - type: "session.idle", - properties: { sessionID: MAIN_SESSION_ID }, - }, - }) + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) - // then - should not call prompt - expect(mockInput._promptMock).not.toHaveBeenCalled() - }) + // then - should not call prompt + expect(mockInput._promptMock).not.toHaveBeenCalled() + }) + + test("should skip when continuation is stopped via isContinuationStopped", async () => { + // given - boulder state with incomplete plan + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + } + writeBoulderState(TEST_DIR, state) + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput, { + directory: TEST_DIR, + isContinuationStopped: (sessionID: string) => sessionID === MAIN_SESSION_ID, + }) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then - should not call prompt because continuation is stopped + expect(mockInput._promptMock).not.toHaveBeenCalled() + }) test("should clear abort state on message.updated", async () => { // given - boulder with incomplete plan diff --git a/src/hooks/atlas/index.ts b/src/hooks/atlas/index.ts index 4878a9af..e3ab910a 100644 --- a/src/hooks/atlas/index.ts +++ b/src/hooks/atlas/index.ts @@ -399,6 +399,7 @@ const CONTINUATION_COOLDOWN_MS = 5000 export interface AtlasHookOptions { directory: string backgroundManager?: BackgroundManager + isContinuationStopped?: (sessionID: string) => boolean } function isAbortError(error: unknown): boolean { @@ -573,6 +574,11 @@ export function createAtlasHook( return } + if (options?.isContinuationStopped?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) + return + } + const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase() const lastAgent = getLastAgentFromSession(sessionID) if (!lastAgent || lastAgent !== requiredAgent) { diff --git a/src/index.ts b/src/index.ts index aa0c6e73..04da17ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -335,7 +335,11 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ); const atlasHook = isHookEnabled("atlas") - ? safeCreateHook("atlas", () => createAtlasHook(ctx, { directory: ctx.directory, backgroundManager }), { enabled: safeHookEnabled }) + ? safeCreateHook("atlas", () => createAtlasHook(ctx, { + directory: ctx.directory, + backgroundManager, + isContinuationStopped: (sessionID: string) => stopContinuationGuard?.isStopped(sessionID) ?? false, + }), { enabled: safeHookEnabled }) : null; initTaskToastManager(ctx.client);