From 8236d7d6b864654c1500ee5adb349efec8838ecb Mon Sep 17 00:00:00 2001 From: ShishaBoyTJ Date: Thu, 7 May 2026 13:01:33 +0900 Subject: [PATCH 01/73] fix: support cmux tmux compatibility --- docs/reference/features.md | 2 + script/run-ci-tests.ts | 1 + src/shared/tmux/runner.test.ts | 59 ++++++++++++++- src/shared/tmux/runner.ts | 16 +++-- .../tmux-path-resolver.test.ts | 72 +++++++++++++++++++ .../interactive-bash/tmux-path-resolver.ts | 57 +++++++++++++-- 6 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 src/tools/interactive-bash/tmux-path-resolver.test.ts diff --git a/docs/reference/features.md b/docs/reference/features.md index a1b8ba6e2..eaafd7e22 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -92,6 +92,8 @@ When running inside tmux: - Auto-cleanup when agents complete - **Stable agent ordering**: core-agent tab cycling is deterministic via injected runtime order field (Sisyphus: 1, Hephaestus: 2, Prometheus: 3, Atlas: 4) +When running inside cmux (`cmux omo`), the same pane integration is routed through cmux's tmux compatibility command. OMO detects the cmux environment from `CMUX_SOCKET_PATH` or a cmux-provided `TMUX` value, so `tmux.enabled` can create cmux panes even when a real `tmux` binary is not installed. + Customize agent models, prompts, and permissions in `oh-my-opencode.jsonc`. ### Team Mode (experimental, OFF by default) diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts index e23cb41ac..cae400858 100644 --- a/script/run-ci-tests.ts +++ b/script/run-ci-tests.ts @@ -23,6 +23,7 @@ const ALWAYS_ISOLATED_TEST_FILES = [ "src/openclaw/__tests__/reply-listener-discord.test.ts", "src/tools/background-task/create-background-output.blocking.test.ts", "src/tools/background-task/tools.test.ts", + "src/tools/interactive-bash/tmux-path-resolver.test.ts", "src/tools/task/task-list.test.ts", ] as const diff --git a/src/shared/tmux/runner.test.ts b/src/shared/tmux/runner.test.ts index 9832c99b2..d0edeac5e 100644 --- a/src/shared/tmux/runner.test.ts +++ b/src/shared/tmux/runner.test.ts @@ -1,6 +1,6 @@ /// -import { afterAll, describe, expect, test } from "bun:test" +import { afterAll, beforeEach, describe, expect, test } from "bun:test" import { randomUUID } from "node:crypto" import fs from "node:fs/promises" import os from "node:os" @@ -9,6 +9,9 @@ import path from "node:path" import { runTmuxCommand } from "./runner" const temporaryDirectories: string[] = [] +const originalCmuxSocketPath = process.env.CMUX_SOCKET_PATH +const originalTmux = process.env.TMUX +const originalPath = process.env.PATH async function createTemporaryDirectory(): Promise { const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-runner-")) @@ -21,7 +24,39 @@ async function readInvocationCount(counterFilePath: string): Promise { return Number.parseInt(count, 10) } +async function createFakeCmux(directoryPath: string, argsFilePath: string): Promise { + const cmuxPath = path.join(directoryPath, "cmux") + const script = [ + "#!/bin/sh", + "printf '%s\\n' \"$@\" > \"$1.args\"", + "printf '%s\\n' '%42'", + ].join("\n") + await fs.writeFile(cmuxPath, script.replace("$1.args", argsFilePath), "utf8") + await fs.chmod(cmuxPath, 0o755) + return cmuxPath +} + +beforeEach(() => { + delete process.env.CMUX_SOCKET_PATH + delete process.env.TMUX + process.env.PATH = originalPath +}) + afterAll(async () => { + if (originalCmuxSocketPath === undefined) { + delete process.env.CMUX_SOCKET_PATH + } else { + process.env.CMUX_SOCKET_PATH = originalCmuxSocketPath + } + + if (originalTmux === undefined) { + delete process.env.TMUX + } else { + process.env.TMUX = originalTmux + } + + process.env.PATH = originalPath + for (const directoryPath of temporaryDirectories) { await fs.rm(directoryPath, { recursive: true, force: true }) } @@ -124,4 +159,26 @@ describe("runTmuxCommand", () => { expect(success).toBe(true) expect(output).toBe("%9") }) + + test("#given cmux environment #when run #then delegates through cmux tmux compatibility command", async () => { + // given + const temporaryDirectory = await createTemporaryDirectory() + const argsFilePath = path.join(temporaryDirectory, "cmux.args") + const cmuxPath = await createFakeCmux(temporaryDirectory, argsFilePath) + process.env.CMUX_SOCKET_PATH = path.join(temporaryDirectory, "cmux.sock") + process.env.PATH = `${temporaryDirectory}${path.delimiter}${originalPath ?? ""}` + + // when + const result = await runTmuxCommand(cmuxPath, ["display-message", "-p", "#{pane_id}"]) + + // then + expect(result).toEqual({ + success: true, + output: "%42", + stdout: "%42", + stderr: "", + exitCode: 0, + }) + await expect(fs.readFile(argsFilePath, "utf8")).resolves.toBe("__tmux-compat\ndisplay-message\n-p\n#{pane_id}\n") + }) }) diff --git a/src/shared/tmux/runner.ts b/src/shared/tmux/runner.ts index 5ad86395c..b61995fbc 100644 --- a/src/shared/tmux/runner.ts +++ b/src/shared/tmux/runner.ts @@ -36,13 +36,19 @@ function isTerminalTmuxError(stderr: string): boolean { * `cmux __tmux-compat` so they become native cmux splits instead of * failing because there is no real tmux server running. */ -function resolveTmuxExecutable(tmuxPath: string): string[] { - const inCmux = Boolean(process.env.CMUX_SOCKET_PATH) || +function isCmuxCompatEnvironment(): boolean { + return Boolean(process.env.CMUX_SOCKET_PATH) || process.env.TMUX?.includes("cmuxterm") === true - if (inCmux) { - return ["cmux", "__tmux-compat"] +} + +function resolveTmuxExecutable(tmuxPath: string): string[] { + if (!isCmuxCompatEnvironment()) { + return [tmuxPath] } - return [tmuxPath] + + const executableName = tmuxPath.split(/[\\/]/).pop() + const cmuxExecutable = executableName === "cmux" ? tmuxPath : "cmux" + return [cmuxExecutable, "__tmux-compat"] } async function runTmuxCommandOnce(tmuxPath: string, args: Array, timeoutMs?: number): Promise { diff --git a/src/tools/interactive-bash/tmux-path-resolver.test.ts b/src/tools/interactive-bash/tmux-path-resolver.test.ts new file mode 100644 index 000000000..be0247095 --- /dev/null +++ b/src/tools/interactive-bash/tmux-path-resolver.test.ts @@ -0,0 +1,72 @@ +/// + +import { afterAll, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { getTmuxPath, resetTmuxPathCacheForTesting } from "./tmux-path-resolver" + +const temporaryDirectories: string[] = [] +const originalCmuxSocketPath = process.env.CMUX_SOCKET_PATH +const originalTmux = process.env.TMUX +const originalPath = process.env.PATH + +async function createTemporaryDirectory(): Promise { + const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-path-resolver-")) + temporaryDirectories.push(directoryPath) + return directoryPath +} + +async function createExecutable(directoryPath: string, name: string, script: string): Promise { + const executablePath = path.join(directoryPath, name) + await fs.writeFile(executablePath, script, "utf8") + await fs.chmod(executablePath, 0o755) + return executablePath +} + +beforeEach(() => { + resetTmuxPathCacheForTesting() + delete process.env.CMUX_SOCKET_PATH + delete process.env.TMUX + process.env.PATH = originalPath +}) + +afterAll(async () => { + resetTmuxPathCacheForTesting() + + if (originalCmuxSocketPath === undefined) { + delete process.env.CMUX_SOCKET_PATH + } else { + process.env.CMUX_SOCKET_PATH = originalCmuxSocketPath + } + + if (originalTmux === undefined) { + delete process.env.TMUX + } else { + process.env.TMUX = originalTmux + } + + process.env.PATH = originalPath + + for (const directoryPath of temporaryDirectories) { + await fs.rm(directoryPath, { recursive: true, force: true }) + } +}) + +describe("getTmuxPath", () => { + test("#given cmux environment #when cmux is available #then returns cmux without requiring a real tmux binary", async () => { + // given + const temporaryDirectory = await createTemporaryDirectory() + const cmuxPath = await createExecutable(temporaryDirectory, "cmux", "#!/bin/sh\nexit 0\n") + await createExecutable(temporaryDirectory, "tmux", "#!/bin/sh\nexit 1\n") + process.env.CMUX_SOCKET_PATH = path.join(temporaryDirectory, "cmux.sock") + process.env.PATH = `${temporaryDirectory}${path.delimiter}${originalPath ?? ""}` + + // when + const resolvedPath = await getTmuxPath() + + // then + expect(path.basename(resolvedPath ?? "")).toBe(path.basename(cmuxPath)) + }) +}) diff --git a/src/tools/interactive-bash/tmux-path-resolver.ts b/src/tools/interactive-bash/tmux-path-resolver.ts index 1187fdef0..0562caa0d 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.ts @@ -2,13 +2,24 @@ import { spawn } from "../../shared/bun-spawn-shim" let tmuxPath: string | null = null let initPromise: Promise | null = null +let tmuxPathEnvironmentKey: "cmux" | "tmux" | null = null -async function findTmuxPath(): Promise { +function isCmuxCompatEnvironment(): boolean { + return Boolean(process.env.CMUX_SOCKET_PATH) || + process.env.TMUX?.includes("cmuxterm") === true +} + +function getEnvironmentKey(): "cmux" | "tmux" { + return isCmuxCompatEnvironment() ? "cmux" : "tmux" +} + +async function findCommandPath(command: string): Promise { const isWindows = process.platform === "win32" const cmd = isWindows ? "where" : "which" try { - const proc = spawn([cmd, "tmux"], { + const proc = spawn([cmd, command], { + env: process.env, stdout: "pipe", stderr: "pipe", }) @@ -25,7 +36,21 @@ async function findTmuxPath(): Promise { return null } + return path + } catch { + return null + } +} + +async function findVerifiedTmuxPath(): Promise { + const path = await findCommandPath("tmux") + if (!path) { + return null + } + + try { const verifyProc = spawn([path, "-V"], { + env: process.env, stdout: "pipe", stderr: "pipe", }) @@ -41,18 +66,34 @@ async function findTmuxPath(): Promise { } } +async function findTmuxPath(): Promise { + if (isCmuxCompatEnvironment()) { + const cmuxPath = await findCommandPath("cmux") + if (cmuxPath) { + return cmuxPath + } + } + + return findVerifiedTmuxPath() +} + export async function getTmuxPath(): Promise { - if (tmuxPath !== null) { + const environmentKey = getEnvironmentKey() + if (tmuxPath !== null && tmuxPathEnvironmentKey === environmentKey) { return tmuxPath } - if (initPromise) { + if (initPromise && tmuxPathEnvironmentKey === environmentKey) { return initPromise } + tmuxPathEnvironmentKey = environmentKey + const promiseEnvironmentKey = environmentKey initPromise = (async () => { const path = await findTmuxPath() - tmuxPath = path + if (tmuxPathEnvironmentKey === promiseEnvironmentKey) { + tmuxPath = path + } return path })() @@ -63,6 +104,12 @@ export function getCachedTmuxPath(): string | null { return tmuxPath } +export function resetTmuxPathCacheForTesting(): void { + tmuxPath = null + initPromise = null + tmuxPathEnvironmentKey = null +} + export function startBackgroundCheck(): void { if (!initPromise) { initPromise = getTmuxPath() From f45452ae6ca966869e95c6f259bb317ee147a50b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:53:28 +0900 Subject: [PATCH 02/73] fix(config): use r+ mode for fsync on Windows to prevent migration retry loop openSync with read-only mode fails fsync on Windows because FlushFileBuffers requires write-permission FD. This caused atomic writes to fail silently, leaving migrated config unwritten and triggering repeated migration + .bak. generation on every startup. Same root cause as PR #3644 (#3643). Hyperplan disappear is a secondary symptom of plugin load instability. Fixes #3877 --- src/shared/write-file-atomically.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/write-file-atomically.ts b/src/shared/write-file-atomically.ts index 09ce5b7d5..7f6544761 100644 --- a/src/shared/write-file-atomically.ts +++ b/src/shared/write-file-atomically.ts @@ -16,7 +16,7 @@ export function writeFileAtomically( ): void { const tempPath = `${filePath}.tmp` writeFileSync(tempPath, content, "utf-8") - const tempFileDescriptor = openSync(tempPath, "r") + const tempFileDescriptor = openSync(tempPath, "r+") try { tolerantFsyncSync(tempFileDescriptor, `writeFileAtomically:${filePath}`, deps.fsyncSync) } finally { From a196d84c2ed2c0133ce475f06835a347a4f4c93b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:54:01 +0900 Subject: [PATCH 03/73] fix(plugin): allow real session.idle after synthetic idle within dedup window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When session.status(idle) is converted to synthetic session.idle and recorded in recentAnyIdles, a real session.idle arriving within 500ms was being dropped by the dedup logic. recentSyntheticIdles was cleared but recentAnyIdles persisted, causing TODO-DIAG to red-alert with 'no todossession.idle event'. Fix: when real session.idle arrives, also clear recentAnyIdles entry so dedup does not drop it. Test renamed and expected dispatchCalls updated 1 → 2. Fixes #2667 --- src/plugin/event.test.ts | 6 ++++-- src/plugin/event.ts | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 49ed2c031..8b10a5950 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -335,7 +335,7 @@ describe("createEventHandler - idle deduplication", () => { expect(spawnTmuxPane).toHaveBeenCalledTimes(1) }) - it("dedups real-idle-after-synthetic-idle within 500ms", async () => { + it("does NOT dedup real-idle-after-synthetic-idle within 500ms", async () => { //#given const dispatchCalls: EventInput[] = [] const eventHandler = createIdleTrackingEventHandler(dispatchCalls) @@ -359,9 +359,11 @@ describe("createEventHandler - idle deduplication", () => { })) //#then - expect(dispatchCalls).toHaveLength(1) + expect(dispatchCalls).toHaveLength(2) expect(dispatchCalls[0]?.event.type).toBe("session.idle") expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) + expect(dispatchCalls[1]?.event.type).toBe("session.idle") + expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) }) it("dedups back-to-back real session.idle events for the same sessionID within 500ms", async () => { diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 265244f29..e8bb49a7d 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -415,6 +415,9 @@ export function createEventHandler(args: { const emittedAt = recentSyntheticIdles.get(sessionID); if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) { recentSyntheticIdles.delete(sessionID); + // Let real idle events through even when a synthetic idle fired moments earlier. + // OpenCode diagnostics expect a concrete session.idle event signal. + recentAnyIdles.delete(sessionID); } recentRealIdles.set(sessionID, now); if (!shouldDispatchIdleEvent(sessionID, now)) { From a064e1367622cf2429e98de6a3f1087000bb46de Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:55:06 +0900 Subject: [PATCH 04/73] fix(delegate-task): recover sync results on abort race --- .../delegate-task/sync-continuation.test.ts | 118 +++++++++++++++++- src/tools/delegate-task/sync-continuation.ts | 20 ++- .../delegate-task/sync-session-poller.test.ts | 31 +++++ .../delegate-task/sync-session-poller.ts | 31 +++-- src/tools/delegate-task/sync-task.test.ts | 57 ++++++++- src/tools/delegate-task/sync-task.ts | 30 +++++ 6 files changed, 272 insertions(+), 15 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index 9f2a690ef..b33b7f721 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -186,6 +186,121 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) + test("recovers from pollSyncSession error when result already exists", async () => { + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const deps = { + pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + task_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + messageID: "parent-message", + }, deps) + + //#then + expect(result).toContain("Task continued and completed in") + expect(result).toContain("Recovered result") + expect(removeTaskCalls.length).toBe(1) + expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") + }) + + test("returns poll error when recovery fetch has no result", async () => { + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const deps = { + pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + task_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + messageID: "parent-message", + }, deps) + + //#then + expect(result).toBe("Task aborted.\n\nSession ID: ses_test_12345678") + expect(removeTaskCalls.length).toBe(1) + expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") + }) + test("removes toast on successful completion", async () => { //#given - mock successful completion with messages growing after anchor const mockClient = { @@ -306,7 +421,8 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { //#then - removeTask should be called at least once (poller and finally may both call it) expect(removeTaskCalls.length).toBeGreaterThanOrEqual(1) expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") - expect(result).toContain("Task aborted") + expect(result).toContain("Task continued and completed in") + expect(result).toContain("Result") }) test("no crash when toastManager is null", async () => { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index add3afd0d..36679981f 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -162,7 +162,25 @@ export async function executeSyncContinuation( anchorMessageCount, }, syncPollTimeoutMs) if (pollError) { - return pollError + const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) + if (!recoveredResult.ok) { + return pollError + } + + const duration = formatDuration(startTime) + + return `Task continued and completed in ${duration}. + +--- + +${recoveredResult.textContent || "(No text output)"} + +${buildTaskMetadataBlock({ + sessionId: continuationID, + taskId: continuationID, + agent: resumeAgent, + category: args.category, + })}` } const result = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) diff --git a/src/tools/delegate-task/sync-session-poller.test.ts b/src/tools/delegate-task/sync-session-poller.test.ts index 004fa0cb8..552b543bc 100644 --- a/src/tools/delegate-task/sync-session-poller.test.ts +++ b/src/tools/delegate-task/sync-session-poller.test.ts @@ -421,6 +421,37 @@ describe("pollSyncSession", () => { expect(result).toContain("ses_abort") expect(abortCount).toBe(1) }) + + test("retries final message fetch on abort before returning aborted", async () => { + // given: abort signal set and message fetch keeps failing + const { pollSyncSession } = require("./sync-session-poller") + let abortCount = 0 + let messageCallCount = 0 + const mockClient = { + session: { + abort: async () => { + abortCount++ + }, + messages: async () => { + messageCallCount++ + throw new Error("temporary fetch failure") + }, + status: async () => ({ data: {} }), + }, + } + + const result = await pollSyncSession(createMockCtx(true), mockClient, { + sessionID: "ses_abort_retry", + agentToUse: "test-agent", + toastManager: { removeTask: () => {} }, + taskId: "task_123", + }) + + // then + expect(result).toContain("Task aborted") + expect(messageCallCount).toBe(3) + expect(abortCount).toBe(1) + }) }) describe("timeout handling", () => { diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 9d69e4157..5c3d5e5b9 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -105,19 +105,32 @@ export async function pollSyncSession( } if (ctx.abort?.aborted) { - try { - const messages = await fetchSessionMessages(client, input.sessionID) + let finalMessages: SessionMessage[] | null = null + const abortFetchAttempts = 3 + for (let attempt = 1; attempt <= abortFetchAttempts; attempt++) { + try { + finalMessages = await fetchSessionMessages(client, input.sessionID) + break + } catch (error) { + log("[task] Final messages fetch failed after abort, retrying", { + sessionID: input.sessionID, + attempt, + maxAttempts: abortFetchAttempts, + error: String(error), + }) + if (attempt < abortFetchAttempts) { + await wait(syncTiming.POLL_INTERVAL_MS) + } + } + } + + if (finalMessages) { const hasNewMessages = - input.anchorMessageCount === undefined || messages.length > input.anchorMessageCount - if (hasNewMessages && isSessionComplete(messages)) { + input.anchorMessageCount === undefined || finalMessages.length > input.anchorMessageCount + if (hasNewMessages && isSessionComplete(finalMessages)) { log("[task] Abort detected after session already completed", { sessionID: input.sessionID }) return null } - } catch (error) { - log("[task] Final messages fetch failed after abort, continuing with abort", { - sessionID: input.sessionID, - error: String(error), - }) } log("[task] Aborted by user", { sessionID: input.sessionID }) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index bd346b8f0..11d2c4177 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -173,7 +173,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(rollback).toHaveBeenCalledTimes(1) }) - test("cleans up toast and subagentSessions when pollSyncSession returns error", async () => { + test("recovers from pollSyncSession error when result already exists", async () => { const mockClient = { session: { create: async () => ({ data: { id: "ses_test_12345678" } }), @@ -185,7 +185,7 @@ describe("executeSyncTask - cleanup on error paths", () => { const deps = { createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), sendSyncPrompt: async () => null, - pollSyncSession: async () => "Poll error", + pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), } @@ -215,14 +215,63 @@ describe("executeSyncTask - cleanup on error paths", () => { sessionID: "parent-session", }, "test-agent", undefined, undefined, undefined, undefined, deps) - //#then - should return error and cleanup resources - expect(result).toBe("Poll error") + //#then - should recover via fetchSyncResult and cleanup resources + expect(result).toContain("Task completed in") + expect(result).toContain("Result") expect(removeTaskCalls.length).toBe(1) expect(removeTaskCalls[0]).toBe("sync_ses_test") expect(deleteCalls.length).toBe(1) expect(deleteCalls[0]).toBe("ses_test_12345678") }) + test("returns poll error when recovery fetch has no result", async () => { + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => "Poll error", + fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then + expect(result).toBe("Poll error") + expect(removeTaskCalls.length).toBe(1) + expect(deleteCalls.length).toBe(1) + }) + test("#given fallback chain set #when sendSyncPrompt fails #then retries with next model", async () => { //#given const mockClient = { diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 5601247b7..762146442 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -15,6 +15,11 @@ import { resolveMetadataModel } from "./resolve-metadata-model" import { shouldRetryError } from "../../shared/model-error-classifier" import type { ModelFallbackState } from "../../hooks/model-fallback/hook" +function shouldAttemptPollErrorRecovery(pollError: string): boolean { + const normalized = pollError.toLowerCase() + return normalized.includes("aborted") || normalized.includes("abort") +} + export async function executeSyncTask( args: DelegateTaskArgs, ctx: ToolContextWithMetadata, @@ -213,6 +218,31 @@ export async function executeSyncTask( taskId, }, syncPollTimeoutMs) if (pollError) { + if (shouldAttemptPollErrorRecovery(pollError)) { + const recoveredResult = await deps.fetchSyncResult(client, activeSessionID) + if (recoveredResult.ok) { + const duration = formatDuration(startTime) + + const actualModelStr = effectiveCategoryModel + ? `${effectiveCategoryModel.providerID}/${effectiveCategoryModel.modelID}` + : undefined + const parentModelStr = parentContext.model + ? `${parentContext.model.providerID}/${parentContext.model.modelID}` + : undefined + let modelRoutingNote = "" + if (actualModelStr && parentModelStr && actualModelStr !== parentModelStr) { + modelRoutingNote = `\n⚠️ Model fallback used: requested ${parentModelStr}, executed ${actualModelStr}` + } + + return `Task completed in ${duration}.\n\n---\n\n${recoveredResult.textContent || "(No text output)"}${modelRoutingNote}\n\n${buildTaskMetadataBlock({ + sessionId: activeSessionID, + taskId: activeSessionID, + agent: agentToUse, + category: args.category, + })}` + } + } + const nextFallbackModel = shouldRetryError({ message: pollError }) ? getNextSyncFallbackModel(activeSessionID, fallbackState) : null From dbaea82b739f3f268d155cc6721cedb5c93bfac3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:56:28 +0900 Subject: [PATCH 05/73] fix(background-agent): retain completed tasks via archive fallback after cleanup MessageAbortedError/worker shutdown could race with scheduled removeTask, leaving background_output's manager.getTask returning 'Task not found' even though the task had completed cleanly. Fix: add completedTaskArchive (max 500, FIFO eviction). On removeTask, archive non-running/pending tasks with sessionId. getTask falls back to archive on active-map miss. addTask clears stale archive entries on re-registration. Fixes #3895 --- src/features/background-agent/manager.test.ts | 27 +++++++++++++++++++ src/features/background-agent/manager.ts | 25 ++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 5f622466b..9470d12a7 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -5991,6 +5991,33 @@ describe("BackgroundManager regression fixes - resume and aborted notification", manager.shutdown() }) + + test("should keep completed task retrievable after scheduled removal", () => { + //#given + const manager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-archive-regression", + sessionId: "session-archive-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "archive regression", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + getTaskMap(manager).set(task.id, task) + + //#when + ;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task) + + //#then + expect(getTaskMap(manager).has(task.id)).toBe(false) + expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId) + + manager.shutdown() + }) }) describe("BackgroundManager - tool permission spread order", () => { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 85df81f1a..a5c988a7b 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -188,6 +188,7 @@ export interface SubagentSessionCreatedEvent { export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise const MAX_TASK_REMOVAL_RESCHEDULES = 6 +const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 500 export interface BackgroundManagerConfig { pluginContext: PluginInput @@ -222,6 +223,7 @@ export class BackgroundManager { private queuesByKey: Map = new Map() private processingKeys: Set = new Set() private completionTimers: Map> = new Map() + private completedTaskArchive: Map = new Map() private completedTaskSummaries: Map = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() @@ -347,6 +349,7 @@ export class BackgroundManager { } private addTask(task: BackgroundTask): void { + this.completedTaskArchive.delete(task.id) this.tasks.set(task.id, task) if (!task.parentSessionId) { return @@ -358,10 +361,30 @@ export class BackgroundManager { } private removeTask(task: BackgroundTask): void { + this.archiveCompletedTask(task) this.tasks.delete(task.id) this.removeTaskFromParentIndex(task.id, task.parentSessionId) } + private archiveCompletedTask(task: BackgroundTask): void { + if (!task.sessionId) { + return + } + if (task.status === "running" || task.status === "pending") { + return + } + + this.completedTaskArchive.set(task.id, task) + if (this.completedTaskArchive.size <= MAX_COMPLETED_TASK_ARCHIVE_SIZE) { + return + } + + const oldestTaskID = this.completedTaskArchive.keys().next().value + if (typeof oldestTaskID === "string") { + this.completedTaskArchive.delete(oldestTaskID) + } + } + private updateTaskParent(task: BackgroundTask, parentSessionID: string): void { if (task.parentSessionId === parentSessionID) { return @@ -830,7 +853,7 @@ The fallback retry session is now created and can be inspected directly. } getTask(id: string): BackgroundTask | undefined { - return this.tasks.get(id) + return this.tasks.get(id) ?? this.completedTaskArchive.get(id) } getTasksByParentSession(sessionID: string): BackgroundTask[] { From 279f0d150f234b4102f07c9e75a17acb97d60dcc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 15:03:01 +0900 Subject: [PATCH 06/73] fix(chat-params): guard non-positive max output tokens --- src/plugin/chat-params.test.ts | 32 +++++++++++++++++++ src/plugin/chat-params.ts | 11 +++++-- .../model-settings-compatibility.test.ts | 12 +++++++ src/shared/model-settings-compatibility.ts | 4 +++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/plugin/chat-params.test.ts b/src/plugin/chat-params.test.ts index 5886b7204..de7acb5df 100644 --- a/src/plugin/chat-params.test.ts +++ b/src/plugin/chat-params.test.ts @@ -253,4 +253,36 @@ describe("createChatParamsHandler", () => { options: {}, }) }) + + test("falls back to default maxOutputTokens when stored and compatibility tokens are non-positive", async () => { + //#given + setSessionPromptParams("ses_chat_params", { + maxOutputTokens: 0, + }) + + const handler = createChatParamsHandler({ + anthropicEffort: null, + }) + + const input = { + sessionID: "ses_chat_params", + agent: { name: "oracle" }, + model: { providerID: "custom-provider", modelID: "custom-model" }, + provider: { id: "custom-provider" }, + message: {}, + } + + const output: ChatParamsOutput = { + topP: 1, + topK: 1, + maxOutputTokens: 0, + options: {}, + } + + //#when + await handler(input, output) + + //#then + expect(output.maxOutputTokens).toBe(4096) + }) }) diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts index 41e4a0200..ac665b62b 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -96,7 +96,10 @@ export function createChatParamsHandler(args: { if (storedPromptParams.topP !== undefined) { output.topP = storedPromptParams.topP } - if (storedPromptParams.maxOutputTokens !== undefined) { + if ( + typeof storedPromptParams.maxOutputTokens === "number" && + storedPromptParams.maxOutputTokens > 0 + ) { (output as Record).maxOutputTokens = storedPromptParams.maxOutputTokens } if (storedPromptParams.options) { @@ -162,10 +165,12 @@ export function createChatParamsHandler(args: { } if ("maxTokens" in compatibility) { - if (compatibility.maxTokens !== undefined) { + if (compatibility.maxTokens !== undefined && compatibility.maxTokens > 0) { output.maxOutputTokens = compatibility.maxTokens } else { - delete output.maxOutputTokens + const capabilitiesLimit = capabilities?.maxOutputTokens + output.maxOutputTokens = + typeof capabilitiesLimit === "number" && capabilitiesLimit > 0 ? capabilitiesLimit : 4096 } } diff --git a/src/shared/model-settings-compatibility.test.ts b/src/shared/model-settings-compatibility.test.ts index 725b2a54c..a653f7259 100644 --- a/src/shared/model-settings-compatibility.test.ts +++ b/src/shared/model-settings-compatibility.test.ts @@ -553,6 +553,18 @@ describe("resolveCompatibleModelSettings", () => { expect(result.changes).toEqual([]) }) + test("#given desired.maxTokens is 0 #then maxTokens is dropped", () => { + const result = resolveCompatibleModelSettings({ + providerID: "openai", + modelID: "gpt-5.4", + desired: { maxTokens: 0 }, + capabilities: { maxOutputTokens: 128_000 }, + }) + + expect(result.maxTokens).toBeUndefined() + expect(result.changes).toEqual([]) + }) + // Passthrough: undefined desired values produce no changes test("no-op when desired settings are empty", () => { const result = resolveCompatibleModelSettings({ diff --git a/src/shared/model-settings-compatibility.ts b/src/shared/model-settings-compatibility.ts index c2354038e..4e9145192 100644 --- a/src/shared/model-settings-compatibility.ts +++ b/src/shared/model-settings-compatibility.ts @@ -162,6 +162,10 @@ export function resolveCompatibleModelSettings( } let maxTokens = input.desired.maxTokens + if (maxTokens !== undefined && maxTokens <= 0) { + maxTokens = undefined + } + if ( maxTokens !== undefined && input.capabilities?.maxOutputTokens !== undefined && From 6fd1ec3ea8f107abb0190c1dbd14a02584145dd4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 15:05:31 +0900 Subject: [PATCH 07/73] test(background-agent): align cancel cleanup assertions with archive fallback --- src/features/background-agent/cancel-task-cleanup.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/background-agent/cancel-task-cleanup.test.ts b/src/features/background-agent/cancel-task-cleanup.test.ts index d8e43f95b..19bb03351 100644 --- a/src/features/background-agent/cancel-task-cleanup.test.ts +++ b/src/features/background-agent/cancel-task-cleanup.test.ts @@ -107,7 +107,8 @@ describe("BackgroundManager.cancelTask cleanup", () => { expect(cancelled).toBe(true) expect(getPendingByParent(manager).get(task.parentSessionId)).toBeUndefined() runScheduledCleanup(manager, task.id) - expect(manager.getTask(task.id)).toBeUndefined() + expect(getTaskMap(manager).has(task.id)).toBe(false) + expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId) }) test("#given a running task #when cancelTask called with skipNotification=false #then task is also eventually removed", async () => { @@ -131,7 +132,8 @@ describe("BackgroundManager.cancelTask cleanup", () => { // then expect(cancelled).toBe(true) runScheduledCleanup(manager, task.id) - expect(manager.getTask(task.id)).toBeUndefined() + expect(getTaskMap(manager).has(task.id)).toBe(false) + expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId) }) test("#given a running task #when cancelTask called with skipNotification=true #then concurrency slot is freed and pending tasks can start", async () => { From 1c7881ec09893dc57f4e02aae3ead64ec932bca0 Mon Sep 17 00:00:00 2001 From: wenghuayang863 <381421746@qq.com> Date: Mon, 11 May 2026 00:43:58 +0800 Subject: [PATCH 08/73] fix(runtime-fallback): match Volcano Engine 'exceeded the usage quota' errors Volcano Engine sends quota exceeded errors with the words in reverse order: 'You have exceeded the 5-hour usage quota'. The existing patterns required 'quota' to precede 'exceeded', so they never matched. - Add /exceeded.*quota/i and /usage.?quota/i to RETRYABLE_ERROR_PATTERNS - Add exceeded.*quota and usage\s*quota to AUTO_RETRY_PATTERNS - Add regression tests for both detection paths Fixes: runtime-fallback not triggering on Volcano Engine quota errors --- .../auto-retry-signal.test.ts | 41 +++++++++++++++++++ .../runtime-fallback/auto-retry-signal.ts | 2 +- src/hooks/runtime-fallback/constants.ts | 2 + .../quota-error-classifier.regression.test.ts | 15 +++++++ 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/hooks/runtime-fallback/auto-retry-signal.test.ts diff --git a/src/hooks/runtime-fallback/auto-retry-signal.test.ts b/src/hooks/runtime-fallback/auto-retry-signal.test.ts new file mode 100644 index 000000000..e734b309b --- /dev/null +++ b/src/hooks/runtime-fallback/auto-retry-signal.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" + +import { extractAutoRetrySignal } from "./auto-retry-signal" + +describe("extractAutoRetrySignal", () => { + test("detects Volcano Engine 'exceeded the usage quota' signal", () => { + //#given + const info = { + status: "You have exceeded the 5-hour usage quota. It will reset at 2026-05-11 01:20:12 +0800 CST.", + } + + //#when + const signal = extractAutoRetrySignal(info) + + //#then + expect(signal).toBeDefined() + expect(signal?.signal).toContain("exceeded") + }) + + test("detects standard 'quota exceeded' signal", () => { + //#given + const info = { message: "Quota exceeded for model gpt-4" } + + //#when + const signal = extractAutoRetrySignal(info) + + //#then + expect(signal).toBeDefined() + }) + + test("returns undefined for non-retryable info", () => { + //#given + const info = { message: "Something went wrong" } + + //#when + const signal = extractAutoRetrySignal(info) + + //#then + expect(signal).toBeUndefined() + }) +}) diff --git a/src/hooks/runtime-fallback/auto-retry-signal.ts b/src/hooks/runtime-fallback/auto-retry-signal.ts index 1d33edbee..9e2e9ab67 100644 --- a/src/hooks/runtime-fallback/auto-retry-signal.ts +++ b/src/hooks/runtime-fallback/auto-retry-signal.ts @@ -5,7 +5,7 @@ export interface AutoRetrySignal { const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [ (combined) => /retrying\s+in/i.test(combined), (combined) => - /(?:too\s+many\s+requests|quota\s+will\s+reset\s+after|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined), + /(?:too\s+many\s+requests|quota\s+will\s+reset\s+after|quota\s*exceeded|exceeded.*quota|usage\s+limit|usage\s*quota|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined), ] export function extractAutoRetrySignal(info: Record | undefined): AutoRetrySignal | undefined { diff --git a/src/hooks/runtime-fallback/constants.ts b/src/hooks/runtime-fallback/constants.ts index 19a7cad56..e835b4099 100644 --- a/src/hooks/runtime-fallback/constants.ts +++ b/src/hooks/runtime-fallback/constants.ts @@ -27,6 +27,8 @@ export const RETRYABLE_ERROR_PATTERNS = [ /too.?many.?requests/i, /quota\s+will\s+reset\s+after/i, /quota.?exceeded/i, + /exceeded.*quota/i, + /usage.?quota/i, /exhausted\s+your\s+capacity/i, /all\s+credentials\s+for\s+model/i, /cool(?:ing)?\s+down/i, diff --git a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts index 1979ddc30..737db208c 100644 --- a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts +++ b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts @@ -56,4 +56,19 @@ describe("runtime-fallback quota error regressions", () => { // quota errors trigger fallback to next configured model expect(retryable).toBe(true) }) + + test("classifies Volcano Engine 'exceeded the usage quota' as retryable", () => { + //#given + const error = { + name: "SessionRetry", + message: "You have exceeded the 5-hour usage quota. It will reset at 2026-05-11 01:20:12 +0800 CST. We recommend using a different model.", + } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + // Volcano Engine quota errors trigger fallback to the next model + expect(retryable).toBe(true) + }) }) From f3f72fc96f2b0a1f789eff12187b4b50cdeb9116 Mon Sep 17 00:00:00 2001 From: wenghuayang863 <381421746@qq.com> Date: Mon, 11 May 2026 01:03:02 +0800 Subject: [PATCH 09/73] fix(runtime-fallback): also classify Volcano Engine errors as quota_exceeded - Add /exceeded.*quota/i and /usage\s*quota/i to classifyErrorType quota block - Align /usage.?quota/i -> /usage\s*quota/i in RETRYABLE_ERROR_PATTERNS for consistency - Strengthen auto-retry-signal test assertion - Add classifyErrorType assertion to Volcano Engine regression test Ensures Volcano Engine errors are both retryable AND logged as errorType: quota_exceeded. --- src/hooks/runtime-fallback/auto-retry-signal.test.ts | 1 + src/hooks/runtime-fallback/constants.ts | 2 +- src/hooks/runtime-fallback/error-classifier.ts | 2 ++ .../quota-error-classifier.regression.test.ts | 4 +++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/hooks/runtime-fallback/auto-retry-signal.test.ts b/src/hooks/runtime-fallback/auto-retry-signal.test.ts index e734b309b..e485fbd6c 100644 --- a/src/hooks/runtime-fallback/auto-retry-signal.test.ts +++ b/src/hooks/runtime-fallback/auto-retry-signal.test.ts @@ -15,6 +15,7 @@ describe("extractAutoRetrySignal", () => { //#then expect(signal).toBeDefined() expect(signal?.signal).toContain("exceeded") + expect(signal?.signal).toContain("usage quota") }) test("detects standard 'quota exceeded' signal", () => { diff --git a/src/hooks/runtime-fallback/constants.ts b/src/hooks/runtime-fallback/constants.ts index e835b4099..f407ffea0 100644 --- a/src/hooks/runtime-fallback/constants.ts +++ b/src/hooks/runtime-fallback/constants.ts @@ -28,7 +28,7 @@ export const RETRYABLE_ERROR_PATTERNS = [ /quota\s+will\s+reset\s+after/i, /quota.?exceeded/i, /exceeded.*quota/i, - /usage.?quota/i, + /usage\s*quota/i, /exhausted\s+your\s+capacity/i, /all\s+credentials\s+for\s+model/i, /cool(?:ing)?\s+down/i, diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 614023f1c..3bb46454c 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -126,6 +126,8 @@ export function classifyErrorType(error: unknown): string | undefined { errorName?.includes("insufficientquota") || errorName?.includes("billingerror") || /quota.?exceeded/i.test(message) || + /exceeded.*quota/i.test(message) || + /usage\s*quota/i.test(message) || /subscription.*quota/i.test(message) || /insufficient.?(?:quota|balance|funds?)/i.test(message) || /billing.?(?:hard.?)?limit/i.test(message) || diff --git a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts index 737db208c..5878e8f2a 100644 --- a/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts +++ b/src/hooks/runtime-fallback/quota-error-classifier.regression.test.ts @@ -57,7 +57,7 @@ describe("runtime-fallback quota error regressions", () => { expect(retryable).toBe(true) }) - test("classifies Volcano Engine 'exceeded the usage quota' as retryable", () => { + test("classifies Volcano Engine 'exceeded the usage quota' as quota_exceeded and retryable", () => { //#given const error = { name: "SessionRetry", @@ -65,9 +65,11 @@ describe("runtime-fallback quota error regressions", () => { } //#when + const errorType = classifyErrorType(error) const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) //#then + expect(errorType).toBe("quota_exceeded") // Volcano Engine quota errors trigger fallback to the next model expect(retryable).toBe(true) }) From 124bbb14c6551bad443a3b71fd2459b087acfe7b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 19:58:57 +0000 Subject: [PATCH 10/73] @masterkain has signed the CLA in code-yeongyu/oh-my-openagent#3930 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index 3e4ec0333..fb45989a4 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -3247,6 +3247,14 @@ "created_at": "2026-05-10T17:02:45Z", "repoId": 1108837393, "pullRequestNo": 3929 + }, + { + "name": "masterkain", + "id": 12844, + "comment_id": 4416207088, + "created_at": "2026-05-10T19:58:46Z", + "repoId": 1108837393, + "pullRequestNo": 3930 } ] } \ No newline at end of file From 299275094f50a0759651538173c3a835ddae23ee Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Sun, 10 May 2026 17:33:36 -0600 Subject: [PATCH 11/73] Revert "fix(tool-execute-after): cap excessively long tool output to prevent TUI flooding (fixes #3586)" --- src/plugin/tool-execute-after.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 7dabc7545..1fc78f5f7 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -182,14 +182,5 @@ export function createToolExecuteAfterHandler(args: { } await runToolExecuteAfterHooks() - - // Cap excessively long error outputs that would flood the TUI with raw - // stack traces or framework internals. Normal outputs are handled by the - // tool-output-truncator hook for specific tools; this catch-all only fires - // for outputs that still exceed a safe display length after all hooks. - const MAX_ERROR_OUTPUT_CHARS = 3000 - if (typeof output.output === "string" && output.output.length > MAX_ERROR_OUTPUT_CHARS) { - output.output = output.output.slice(0, MAX_ERROR_OUTPUT_CHARS) + "\n\n...(output truncated for display)" - } } } From 7464d7e00531fc2eaf548d74e1e15a28602b0e3f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 15:07:23 +0900 Subject: [PATCH 12/73] fix(installer): timeout opencode --version probe to avoid Desktop binary hang When the binary resolved as 'opencode' on PATH is the OpenCode Desktop GUI (not the CLI), it does not respond to --version with prompt exit. proc.exited then waits forever, freezing the installer at 'Checking OpenCode installation'. Fix: race proc.exited against OPENCODE_VERSION_CHECK_TIMEOUT_MS=1500. On timeout, proc.kill() and treat the binary as failed so the next candidate is tried. Success requires both timedExitCode === 0 and proc.exitCode === 0. Fixes #3766 --- .../config-manager/opencode-binary.test.ts | 50 ++++++++++++++++++- src/cli/config-manager/opencode-binary.ts | 35 +++++++++++-- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index b298c1027..fb64de7ed 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -9,17 +9,19 @@ type OpenCodeBinaryModule = typeof import("./opencode-binary") type CreateProcOptions = { exitCode?: number | null + exited?: Promise output?: { stdout?: string; stderr?: string } + kill?: (signal?: NodeJS.Signals) => void } function createProc(options: CreateProcOptions = {}): ReturnType { const exitCode = options.exitCode ?? 0 return { - exited: Promise.resolve(exitCode), + exited: options.exited ?? Promise.resolve(exitCode), exitCode, stdout: options.output?.stdout !== undefined ? new Blob([options.output.stdout]).stream() : undefined, stderr: options.output?.stderr !== undefined ? new Blob([options.output.stderr]).stream() : undefined, - kill: () => {}, + kill: options.kill ?? (() => {}), } satisfies ReturnType } @@ -71,6 +73,50 @@ describe("getOpenCodeVersion (installer)", () => { }) }) + describe("#given timeout path #when getOpenCodeVersion #then sends SIGTERM and SIGKILL and returns null without hanging", () => { + it("bounds process lifetime on hung --version", async () => { + const killCalls: Array = [] + spawnSpy.mockReturnValue( + createProc({ + exited: new Promise(() => {}), + output: { stdout: "" }, + kill: (signal?: NodeJS.Signals) => { + killCalls.push(signal) + }, + }), + ) + + const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 1 as unknown as ReturnType + }) + + const result = await getOpenCodeVersion() + + expect(result).toBe(null) + expect(killCalls).toEqual(["SIGTERM", "SIGKILL", "SIGTERM", "SIGKILL"]) + + setTimeoutSpy.mockRestore() + }) + }) + + describe("#given quick successful exit #when getOpenCodeVersion #then clears the watchdog timer", () => { + it("avoids timer leak after success", async () => { + spawnSpy.mockReturnValue(createProc({ output: { stdout: "1.14.33\n" } })) + + const clearTimeoutSpy = spyOn(globalThis, "clearTimeout") + + const result = await getOpenCodeVersion() + + expect(result).toBe("1.14.33") + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1) + + clearTimeoutSpy.mockRestore() + }) + }) + describe("#given no opencode binary on PATH #when getOpenCodeVersion #then returns null", () => { it("all candidate spawns throw", async () => { spawnSpy.mockImplementation(() => { diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts index d5256a0b0..4c64205cf 100644 --- a/src/cli/config-manager/opencode-binary.ts +++ b/src/cli/config-manager/opencode-binary.ts @@ -4,6 +4,8 @@ import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" import { initConfigContext } from "./config-context" const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const +const OPENCODE_VERSION_CHECK_TIMEOUT_MS = 1500 +const OPENCODE_VERSION_KILL_GRACE_MS = 200 interface OpenCodeBinaryResult { binary: OpenCodeBinaryType @@ -17,9 +19,36 @@ async function findOpenCodeBinaryWithVersion(): Promise | null = null + const timedExitCode = await Promise.race([ + proc.exited, + new Promise((resolve) => { + killTimer = setTimeout(() => { + proc.kill("SIGTERM") + setTimeout(() => { + proc.kill("SIGKILL") + }, OPENCODE_VERSION_KILL_GRACE_MS) + resolve(1) + }, OPENCODE_VERSION_CHECK_TIMEOUT_MS) + }), + ]) + + if (killTimer) { + clearTimeout(killTimer) + } + + const output = await Promise.race([ + outputPromise, + new Promise((resolve) => { + setTimeout(() => { + resolve("") + }, OPENCODE_VERSION_KILL_GRACE_MS) + }), + ]) + + if (timedExitCode === 0 && proc.exitCode === 0) { const version = extractSemverFromOutput(output) ?? output.trim() initConfigContext(binary, version) return { binary, version } From 2168040ac68fe812fb784c90dd41f1a5c95ae3dd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:45:34 +0900 Subject: [PATCH 13/73] fix(plugin): scope synthetic-idle dedup bypass to matching marker Only clear recentAnyIdles when the stored marker matches the synthetic idle timestamp for the same session, preventing accidental clobbering of newer idle markers. Add a regression test to verify other sessions keep their dedup state during this bypass path. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++ src/plugin/event.ts | 5 ++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 8b10a5950..a400118e6 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -366,6 +366,72 @@ describe("createEventHandler - idle deduplication", () => { expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) }) + it("keeps other session dedup state untouched when bypassing synthetic-idle for current session", async () => { + //#given + const originalDateNow = Date.now + let currentNow = 30_000 + Date.now = () => currentNow + const dispatchedSessionIds: string[] = [] + const eventHandler = createIdleDedupSpyEventHandler({ + onEvent: () => {}, + sessionNotification: async (input: EventInput) => { + if (input.event.type !== "session.idle") { + return + } + const props = input.event.properties as { sessionID?: string } | undefined + if (props?.sessionID) { + dispatchedSessionIds.push(props.sessionID) + } + }, + }) + + try { + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.status", + properties: { + sessionID: "ses_a", + status: { type: "idle" }, + }, + }, + })) + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_b", + }, + }, + })) + + currentNow += 100 + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_a", + }, + }, + })) + + currentNow += 100 + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_b", + }, + }, + })) + + //#then + expect(dispatchedSessionIds).toEqual(["ses_a", "ses_b", "ses_a"]) + } finally { + Date.now = originalDateNow + } + }) + it("dedups back-to-back real session.idle events for the same sessionID within 500ms", async () => { //#given const originalDateNow = Date.now diff --git a/src/plugin/event.ts b/src/plugin/event.ts index e8bb49a7d..2b94fcadf 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -417,7 +417,10 @@ export function createEventHandler(args: { recentSyntheticIdles.delete(sessionID); // Let real idle events through even when a synthetic idle fired moments earlier. // OpenCode diagnostics expect a concrete session.idle event signal. - recentAnyIdles.delete(sessionID); + const lastAnyIdleAt = recentAnyIdles.get(sessionID); + if (lastAnyIdleAt === emittedAt) { + recentAnyIdles.delete(sessionID); + } } recentRealIdles.set(sessionID, now); if (!shouldDispatchIdleEvent(sessionID, now)) { From 65913023c53d9f888718d0ac014ab8ff068c551d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:46:14 +0900 Subject: [PATCH 14/73] fix(delegate-task): narrow abort recovery to canonical abort errors --- src/tools/delegate-task/sync-task.test.ts | 57 +++++++++++++++++++++-- src/tools/delegate-task/sync-task.ts | 21 ++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 11d2c4177..03e2d500a 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -173,7 +173,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(rollback).toHaveBeenCalledTimes(1) }) - test("recovers from pollSyncSession error when result already exists", async () => { + test("recovers from MessageAbortedError poll error when result already exists", async () => { const mockClient = { session: { create: async () => ({ data: { id: "ses_test_12345678" } }), @@ -185,7 +185,7 @@ describe("executeSyncTask - cleanup on error paths", () => { const deps = { createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), sendSyncPrompt: async () => null, - pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + pollSyncSession: async () => "MessageAbortedError: aborted by user", fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), } @@ -210,7 +210,7 @@ describe("executeSyncTask - cleanup on error paths", () => { command: null, } - //#when - executeSyncTask with pollSyncSession failing + //#when - executeSyncTask with MessageAbortedError poll error const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { sessionID: "parent-session", }, "test-agent", undefined, undefined, undefined, undefined, deps) @@ -224,6 +224,57 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(deleteCalls[0]).toBe("ses_test_12345678") }) + test("does not recover from non-abort poll error containing abort-like words", async () => { + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + let fetchSyncResultCalled = false + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => "Task aborted: subagent exceeded 5 assistant turns without completing", + fetchSyncResult: async () => { + fetchSyncResultCalled = true + return { ok: true as const, textContent: "unexpected" } + }, + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then + expect(result).toBe("Task aborted: subagent exceeded 5 assistant turns without completing") + expect(fetchSyncResultCalled).toBe(false) + }) + test("returns poll error when recovery fetch has no result", async () => { const mockClient = { session: { diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 762146442..58b52a07b 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -16,8 +16,25 @@ import { shouldRetryError } from "../../shared/model-error-classifier" import type { ModelFallbackState } from "../../hooks/model-fallback/hook" function shouldAttemptPollErrorRecovery(pollError: string): boolean { - const normalized = pollError.toLowerCase() - return normalized.includes("aborted") || normalized.includes("abort") + const trimmed = pollError.trim() + + if (trimmed.length === 0) { + return false + } + + if (/\bMessageAbortedError\b/u.test(trimmed)) { + return true + } + + if (/\bDOMException\b/u.test(trimmed) && /\bAbortError\b/u.test(trimmed)) { + return true + } + + if (/\bAbortError\b/u.test(trimmed) && !/\bTask aborted\b/u.test(trimmed)) { + return true + } + + return false } export async function executeSyncTask( From a4ce0b63b6aa8ea30c8c9f87ab03804febf5b7e4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:47:39 +0900 Subject: [PATCH 15/73] fix(background-agent): redact archived prompts and cap fallback archive Store only sanitized completed-task snapshots in archive to avoid retaining sensitive prompts after cleanup. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 36 ++++++++++++++++++- src/features/background-agent/manager.ts | 19 ++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 9470d12a7..fc46a1c5d 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -6014,7 +6014,41 @@ describe("BackgroundManager regression fixes - resume and aborted notification", //#then expect(getTaskMap(manager).has(task.id)).toBe(false) - expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId) + const archivedTask = manager.getTask(task.id) + expect(archivedTask?.sessionId).toBe(task.sessionId) + expect(archivedTask?.prompt).toBe("[redacted]") + + manager.shutdown() + }) + + test("should cap completed task archive size at 100 entries", () => { + //#given + const manager = createBackgroundManager() + + //#when + for (let index = 0; index < 120; index += 1) { + const task: BackgroundTask = { + id: `task-archive-${index}`, + sessionId: `session-archive-${index}`, + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "archive cap regression", + prompt: `sensitive-${index}`, + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + ;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task) + } + + //#then + const archive = cast>(Reflect.get(manager, "completedTaskArchive")) + expect(archive.size).toBe(100) + expect(archive.has("task-archive-0")).toBe(false) + expect(archive.has("task-archive-19")).toBe(false) + expect(archive.has("task-archive-20")).toBe(true) + expect(archive.has("task-archive-119")).toBe(true) manager.shutdown() }) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index a5c988a7b..47739930a 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -188,7 +188,7 @@ export interface SubagentSessionCreatedEvent { export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise const MAX_TASK_REMOVAL_RESCHEDULES = 6 -const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 500 +const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 100 export interface BackgroundManagerConfig { pluginContext: PluginInput @@ -374,7 +374,22 @@ export class BackgroundManager { return } - this.completedTaskArchive.set(task.id, task) + const archivedTask: BackgroundTask = { + id: task.id, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + description: task.description, + prompt: "[redacted]", + agent: task.agent, + sessionId: task.sessionId, + status: task.status, + completedAt: task.completedAt, + model: task.model, + error: task.error, + category: task.category, + } + + this.completedTaskArchive.set(task.id, archivedTask) if (this.completedTaskArchive.size <= MAX_COMPLETED_TASK_ARCHIVE_SIZE) { return } From 2f8ce576dccdc1e1b28ef22da936200652daa8e4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:47:49 +0900 Subject: [PATCH 16/73] fix(chat-params): use conservative token fallback for invalid limits Avoid inflating non-positive maxOutputTokens values to model capability maxima by always falling back to a safe fixed budget. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/chat-params.test.ts | 46 +++++++++++++++++++++++++++++++--- src/plugin/chat-params.ts | 16 +++++++++--- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/plugin/chat-params.test.ts b/src/plugin/chat-params.test.ts index de7acb5df..736e36d21 100644 --- a/src/plugin/chat-params.test.ts +++ b/src/plugin/chat-params.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path" import { createChatParamsHandler, type ChatParamsOutput } from "./chat-params" import * as dataPathModule from "../shared/data-path" -import { writeProviderModelsCache } from "../shared" +import * as sharedModule from "../shared" import { clearSessionPromptParams, getSessionPromptParams, @@ -21,13 +21,13 @@ describe("createChatParamsHandler", () => { getCacheDirSpy = spyOn(dataPathModule, "getOmoOpenCodeCacheDir").mockReturnValue( join(tempCacheRoot, "oh-my-opencode"), ) - writeProviderModelsCache({ connected: [], models: {} }) + sharedModule.writeProviderModelsCache({ connected: [], models: {} }) }) afterEach(() => { clearSessionPromptParams("ses_chat_params") clearSessionPromptParams("ses_chat_params_temperature") - writeProviderModelsCache({ connected: [], models: {} }) + sharedModule.writeProviderModelsCache({ connected: [], models: {} }) getCacheDirSpy?.mockRestore() if (tempCacheRoot) { rmSync(tempCacheRoot, { recursive: true, force: true }) @@ -101,7 +101,7 @@ describe("createChatParamsHandler", () => { test("applies stored prompt params for the session", async () => { //#given - writeProviderModelsCache({ + sharedModule.writeProviderModelsCache({ connected: ["openai"], models: { openai: [ @@ -256,6 +256,7 @@ describe("createChatParamsHandler", () => { test("falls back to default maxOutputTokens when stored and compatibility tokens are non-positive", async () => { //#given + const logSpy = spyOn(sharedModule, "log").mockImplementation(() => undefined) setSessionPromptParams("ses_chat_params", { maxOutputTokens: 0, }) @@ -282,6 +283,43 @@ describe("createChatParamsHandler", () => { //#when await handler(input, output) + //#then + expect(output.maxOutputTokens).toBe(4096) + expect(logSpy).toHaveBeenCalledWith( + "[plugin] maxOutputTokens=0 is non-positive; using safe fallback 4096", + ) + + logSpy.mockRestore() + }) + + test("uses safe fallback instead of model max when stored maxOutputTokens is non-positive", async () => { + //#given + setSessionPromptParams("ses_chat_params", { + maxOutputTokens: -1, + }) + + const handler = createChatParamsHandler({ + anthropicEffort: null, + }) + + const input = { + sessionID: "ses_chat_params", + agent: { name: "oracle" }, + model: { providerID: "openai", modelID: "gpt-5.4" }, + provider: { id: "openai" }, + message: {}, + } + + const output: ChatParamsOutput = { + topP: 1, + topK: 1, + maxOutputTokens: -1, + options: {}, + } + + //#when + await handler(input, output) + //#then expect(output.maxOutputTokens).toBe(4096) }) diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts index ac665b62b..26f35d03d 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -1,5 +1,7 @@ import { getSessionPromptParams } from "../shared/session-prompt-params-state" -import { getModelCapabilities, resolveCompatibleModelSettings } from "../shared" +import { getModelCapabilities, log, resolveCompatibleModelSettings } from "../shared" + +const SAFE_MAX_OUTPUT_TOKENS_FALLBACK = 4096 export type ChatParamsInput = { sessionID: string @@ -168,9 +170,15 @@ export function createChatParamsHandler(args: { if (compatibility.maxTokens !== undefined && compatibility.maxTokens > 0) { output.maxOutputTokens = compatibility.maxTokens } else { - const capabilitiesLimit = capabilities?.maxOutputTokens - output.maxOutputTokens = - typeof capabilitiesLimit === "number" && capabilitiesLimit > 0 ? capabilitiesLimit : 4096 + const originalMaxOutputTokens = typeof output.maxOutputTokens === "number" + ? output.maxOutputTokens + : compatibility.maxTokens + output.maxOutputTokens = SAFE_MAX_OUTPUT_TOKENS_FALLBACK + if (typeof originalMaxOutputTokens === "number" && originalMaxOutputTokens <= 0) { + log( + `[plugin] maxOutputTokens=${originalMaxOutputTokens} is non-positive; using safe fallback ${SAFE_MAX_OUTPUT_TOKENS_FALLBACK}`, + ) + } } } From 84073897c6bccb3a8d4235bec96bc874b18861b7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:50:59 +0900 Subject: [PATCH 17/73] test(installer): stabilize timeout signal escalation assertion --- src/cli/config-manager/opencode-binary.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index fb64de7ed..ec7b05f35 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -86,17 +86,18 @@ describe("getOpenCodeVersion (installer)", () => { }), ) - const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((handler: TimerHandler) => { + const immediateSetTimeout = ((handler: TimerHandler) => { if (typeof handler === "function") { handler() } return 1 as unknown as ReturnType - }) + }) as unknown as typeof globalThis.setTimeout + const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) const result = await getOpenCodeVersion() expect(result).toBe(null) - expect(killCalls).toEqual(["SIGTERM", "SIGKILL", "SIGTERM", "SIGKILL"]) + expect(killCalls).toEqual(["SIGTERM", "SIGKILL"]) setTimeoutSpy.mockRestore() }) From 468bf25dcbfcfa6500b3a58a13bfcc4cf98e0097 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:53:21 +0900 Subject: [PATCH 18/73] fix(delegate-task): gate continuation recovery to canonical abort errors --- .../delegate-task/sync-continuation.test.ts | 13 +++++----- src/tools/delegate-task/sync-continuation.ts | 26 ++++++++++++++++++- src/tools/delegate-task/sync-task.test.ts | 14 +++++++--- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index b33b7f721..0ddf5e7ed 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -186,7 +186,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) - test("recovers from pollSyncSession error when result already exists", async () => { + test("recovers from MessageAbortedError poll error when result already exists", async () => { const mockClient = { session: { messages: async () => ({ @@ -208,7 +208,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { const { executeSyncContinuation } = require("./sync-continuation") const deps = { - pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + pollSyncSession: async () => "MessageAbortedError: aborted by user", fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), } @@ -244,7 +244,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) - test("returns poll error when recovery fetch has no result", async () => { + test("returns MessageAbortedError poll error when recovery fetch has no result", async () => { const mockClient = { session: { messages: async () => ({ @@ -266,7 +266,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { const { executeSyncContinuation } = require("./sync-continuation") const deps = { - pollSyncSession: async () => "Task aborted.\n\nSession ID: ses_test_12345678", + pollSyncSession: async () => "MessageAbortedError: aborted by user", fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), } @@ -296,7 +296,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { }, deps) //#then - expect(result).toBe("Task aborted.\n\nSession ID: ses_test_12345678") + expect(result).toBe("MessageAbortedError: aborted by user") expect(removeTaskCalls.length).toBe(1) expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) @@ -421,8 +421,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { //#then - removeTask should be called at least once (poller and finally may both call it) expect(removeTaskCalls.length).toBeGreaterThanOrEqual(1) expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") - expect(result).toContain("Task continued and completed in") - expect(result).toContain("Result") + expect(result).toBe("Task aborted.\n\nSession ID: ses_test_12345678") }) test("no crash when toastManager is null", async () => { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 36679981f..6e600616d 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -24,6 +24,28 @@ type ResumeContext = { anchorMessageCount?: number } +function shouldAttemptPollErrorRecovery(pollError: string): boolean { + const trimmed = pollError.trim() + + if (trimmed.length === 0) { + return false + } + + if (/\bMessageAbortedError\b/u.test(trimmed)) { + return true + } + + if (/\bDOMException\b/u.test(trimmed) && /\bAbortError\b/u.test(trimmed)) { + return true + } + + if (/\bAbortError\b/u.test(trimmed) && !/\bTask aborted\b/u.test(trimmed)) { + return true + } + + return false +} + async function resolveResumeContext( client: ExecutorContext["client"], continuationID: string @@ -161,7 +183,7 @@ export async function executeSyncContinuation( taskId, anchorMessageCount, }, syncPollTimeoutMs) - if (pollError) { + if (pollError && shouldAttemptPollErrorRecovery(pollError)) { const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) if (!recoveredResult.ok) { return pollError @@ -181,6 +203,8 @@ ${buildTaskMetadataBlock({ agent: resumeAgent, category: args.category, })}` + } else if (pollError) { + return pollError } const result = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 03e2d500a..0143885dd 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -275,7 +275,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(fetchSyncResultCalled).toBe(false) }) - test("returns poll error when recovery fetch has no result", async () => { + test("returns abort poll error when recovery fetch has no result", async () => { const mockClient = { session: { create: async () => ({ data: { id: "ses_test_12345678" } }), @@ -284,11 +284,16 @@ describe("executeSyncTask - cleanup on error paths", () => { const { executeSyncTask } = require("./sync-task") + let fetchSyncResultCalled = false + const deps = { createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), sendSyncPrompt: async () => null, - pollSyncSession: async () => "Poll error", - fetchSyncResult: async () => ({ ok: false as const, error: "No assistant response found" }), + pollSyncSession: async () => "MessageAbortedError: aborted by user", + fetchSyncResult: async () => { + fetchSyncResultCalled = true + return { ok: false as const, error: "No assistant response found" } + }, } const mockCtx = { @@ -318,7 +323,8 @@ describe("executeSyncTask - cleanup on error paths", () => { }, "test-agent", undefined, undefined, undefined, undefined, deps) //#then - expect(result).toBe("Poll error") + expect(result).toBe("MessageAbortedError: aborted by user") + expect(fetchSyncResultCalled).toBe(true) expect(removeTaskCalls.length).toBe(1) expect(deleteCalls.length).toBe(1) }) From eb6605a87f7fd69a8d7975b993901cf4335f1c76 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:53:44 +0900 Subject: [PATCH 19/73] fix(background-agent): preserve non-sensitive timing in archived tasks Carry queuedAt and startedAt in sanitized archive snapshots so post-cleanup task output duration remains accurate without retaining prompt content. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 1 + src/features/background-agent/manager.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index fc46a1c5d..6c92398ad 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -6017,6 +6017,7 @@ describe("BackgroundManager regression fixes - resume and aborted notification", const archivedTask = manager.getTask(task.id) expect(archivedTask?.sessionId).toBe(task.sessionId) expect(archivedTask?.prompt).toBe("[redacted]") + expect(archivedTask?.startedAt).toEqual(task.startedAt) manager.shutdown() }) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 47739930a..540c65d58 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -383,6 +383,8 @@ export class BackgroundManager { agent: task.agent, sessionId: task.sessionId, status: task.status, + queuedAt: task.queuedAt, + startedAt: task.startedAt, completedAt: task.completedAt, model: task.model, error: task.error, From 64f9a7071200b0bcd01ae4c60c218ef052cf9e5f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:54:46 +0900 Subject: [PATCH 20/73] fix(config): use r+ when fsyncing migrated temp file Apply the Windows-safe open mode in migrateLegacyPluginEntry and add a regression test to assert the temp fd is opened with r+ before fsync. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/migrate-legacy-plugin-entry.ts | 2 +- .../migrate-legacy-plugin-entry.test.ts | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/shared/migrate-legacy-plugin-entry.ts b/src/shared/migrate-legacy-plugin-entry.ts index 80a015c6e..0eeb1949d 100644 --- a/src/shared/migrate-legacy-plugin-entry.ts +++ b/src/shared/migrate-legacy-plugin-entry.ts @@ -54,7 +54,7 @@ export function migrateLegacyPluginEntry(configPath: string): boolean { const tempPath = `${configPath}.tmp` writeFileSync(tempPath, updated, "utf-8") - const tempFileDescriptor = openSync(tempPath, "r") + const tempFileDescriptor = openSync(tempPath, "r+") try { fsyncSync(tempFileDescriptor) } finally { diff --git a/src/shared/zauc-mocks-migrate-legacy-plugin/migrate-legacy-plugin-entry.test.ts b/src/shared/zauc-mocks-migrate-legacy-plugin/migrate-legacy-plugin-entry.test.ts index 993e356ab..6cd489062 100644 --- a/src/shared/zauc-mocks-migrate-legacy-plugin/migrate-legacy-plugin-entry.test.ts +++ b/src/shared/zauc-mocks-migrate-legacy-plugin/migrate-legacy-plugin-entry.test.ts @@ -96,6 +96,41 @@ describe("migrateLegacyPluginEntry", () => { }) }) + describe("#given migration writes a temp file for fsync", () => { + describe("#when opening the temp file descriptor", () => { + it("#then uses r+ mode to satisfy FlushFileBuffers requirements on Windows", async () => { + const configPath = join(testDir, "opencode.json") + writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }, null, 2)) + + const fs = await import("node:fs") + const originalOpenSync = fs.openSync + const openSyncCalls: string[] = [] + + mock.module("node:fs", () => ({ + ...fs, + openSync: (path: Parameters[0], flags: Parameters[1]) => { + openSyncCalls.push(String(flags)) + return originalOpenSync(path, flags) + }, + })) + + try { + const { migrateLegacyPluginEntry } = await importFreshMigrationModule() + + const result = migrateLegacyPluginEntry(configPath) + + expect(result).toBe(true) + expect(openSyncCalls).toContain("r+") + } finally { + mock.module("node:fs", () => ({ + ...fs, + openSync: originalOpenSync, + })) + } + }) + }) + }) + describe("#given opencode.json contains pinned oh-my-opencode version", () => { describe("#when migrating the config", () => { it("#then preserves the version pin", async () => { @@ -217,4 +252,4 @@ describe("migrateLegacyPluginEntry", () => { }) }) }) -}) \ No newline at end of file +}) From e018739d2cc5af2dd841935b0a3b2dd6a2eef152 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:01:17 +0900 Subject: [PATCH 21/73] fix(delegate-task): recover aborted-operation messages in sync flows --- .../delegate-task/sync-continuation.test.ts | 56 +++++++++++++++++++ src/tools/delegate-task/sync-continuation.ts | 4 ++ src/tools/delegate-task/sync-task.test.ts | 51 ++++++++++++++++- src/tools/delegate-task/sync-task.ts | 4 ++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index 0ddf5e7ed..7d0015503 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -244,6 +244,62 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) + test("recovers from canonical aborted-operation message", async () => { + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const deps = { + pollSyncSession: async () => "The operation was aborted.", + fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + task_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + messageID: "parent-message", + }, deps) + + //#then + expect(result).toContain("Task continued and completed in") + expect(result).toContain("Recovered result") + }) + test("returns MessageAbortedError poll error when recovery fetch has no result", async () => { const mockClient = { session: { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 6e600616d..17c85f026 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -43,6 +43,10 @@ function shouldAttemptPollErrorRecovery(pollError: string): boolean { return true } + if (/^the operation was aborted\.?$/iu.test(trimmed)) { + return true + } + return false } diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 0143885dd..e1d792cfe 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -224,6 +224,53 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(deleteCalls[0]).toBe("ses_test_12345678") }) + test("recovers from canonical aborted-operation message", async () => { + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => "The operation was aborted.", + fetchSyncResult: async () => ({ ok: true as const, textContent: "Recovered result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then + expect(result).toContain("Task completed in") + expect(result).toContain("Recovered result") + }) + test("does not recover from non-abort poll error containing abort-like words", async () => { const mockClient = { session: { @@ -607,7 +654,7 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(result).toContain("Result from ses_second") expect(deleteCalls).toContain("ses_first") - const finalMetadata = metadataCalls.at(-1) + const finalMetadata = metadataCalls[metadataCalls.length - 1] expect(finalMetadata.metadata.sessionId).toBe("ses_second") expect(finalMetadata.metadata.taskId).toBe("ses_second") expect(finalMetadata.metadata.model).toEqual({ @@ -758,7 +805,7 @@ describe("executeSyncTask - cleanup on error paths", () => { }, "sisyphus-junior", initialModel, undefined, undefined, fallbackChain, deps) expect(result).toBe("Final retry failed") - const finalMetadata = metadataCalls.at(-1) + const finalMetadata = metadataCalls[metadataCalls.length - 1] expect(finalMetadata.metadata.sessionId).toBe("ses_second") expect(finalMetadata.metadata.taskId).toBe("ses_second") expect(finalMetadata.metadata.model).toEqual({ diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 58b52a07b..556ca8225 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -34,6 +34,10 @@ function shouldAttemptPollErrorRecovery(pollError: string): boolean { return true } + if (/^the operation was aborted\.?$/iu.test(trimmed)) { + return true + } + return false } From 8e47d3a16630eb913a3b6306c9476eb560a2eaaf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:10:47 +0900 Subject: [PATCH 22/73] fix(delegate-task): require anchored text for abort recovery --- .../delegate-task/sync-continuation.test.ts | 54 +++++++++++++++++++ src/tools/delegate-task/sync-continuation.ts | 3 ++ .../delegate-task/sync-result-fetcher.ts | 7 +++ 3 files changed, 64 insertions(+) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts index 7d0015503..967b30fb4 100644 --- a/src/tools/delegate-task/sync-continuation.test.ts +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -357,6 +357,60 @@ describe("executeSyncContinuation - toast cleanup error paths", () => { expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") }) + test("does not recover abort poll error when anchor cannot be established", async () => { + const mockClient = { + session: { + messages: async () => { + throw new Error("messages unavailable") + }, + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + let fetchSyncResultCalled = false + + const deps = { + pollSyncSession: async () => "The operation was aborted.", + fetchSyncResult: async () => { + fetchSyncResultCalled = true + return { ok: true as const, textContent: "Recovered result" } + }, + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + task_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + messageID: "parent-message", + }, deps) + + //#then + expect(result).toBe("The operation was aborted.") + expect(fetchSyncResultCalled).toBe(false) + }) + test("removes toast on successful completion", async () => { //#given - mock successful completion with messages growing after anchor const mockClient = { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 17c85f026..817a7e95d 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -188,6 +188,9 @@ export async function executeSyncContinuation( anchorMessageCount, }, syncPollTimeoutMs) if (pollError && shouldAttemptPollErrorRecovery(pollError)) { + if (anchorMessageCount === undefined) { + return pollError + } const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) if (!recoveredResult.ok) { return pollError diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts index f2274eae6..9631d3637 100644 --- a/src/tools/delegate-task/sync-result-fetcher.ts +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -56,5 +56,12 @@ export async function fetchSyncResult( } } + if (!textContent) { + return { + ok: false, + error: `No assistant text output found in completed response.\n\nSession ID: ${sessionID}`, + } + } + return { ok: true, textContent } } From 8b1696f5e57471455cf9fcfe7a995e3fff18e012 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:20:14 +0900 Subject: [PATCH 23/73] fix(delegate-task): prevent stale-text abort recovery --- src/tools/delegate-task/sync-continuation.ts | 4 +- .../delegate-task/sync-result-fetcher.test.ts | 61 +++++++++++++++++++ .../delegate-task/sync-result-fetcher.ts | 23 ++++++- src/tools/delegate-task/sync-task.ts | 4 +- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 817a7e95d..d2dd3b678 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -191,7 +191,9 @@ export async function executeSyncContinuation( if (anchorMessageCount === undefined) { return pollError } - const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount) + const recoveredResult = await deps.fetchSyncResult(client, continuationID, anchorMessageCount, { + strictAbortRecovery: true, + }) if (!recoveredResult.ok) { return pollError } diff --git a/src/tools/delegate-task/sync-result-fetcher.test.ts b/src/tools/delegate-task/sync-result-fetcher.test.ts index 82a41f3f6..400c066ad 100644 --- a/src/tools/delegate-task/sync-result-fetcher.test.ts +++ b/src/tools/delegate-task/sync-result-fetcher.test.ts @@ -141,4 +141,65 @@ describe("fetchSyncResult", () => { expect(result.ok).toBe(false) expect(result.error).toContain("No assistant response found") }) + + test("strict abort recovery: does not fall back to older text when latest assistant is error", async () => { + //#given + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "Older text" }], + }, + { + info: { + id: "msg_003", + role: "assistant", + time: { created: 3000 }, + error: { name: "MessageAbortedError", message: "The operation was aborted." }, + }, + parts: [], + }, + ], + }), + }, + } + + //#when + const result = await fetchSyncResult(mockClient, "ses_test", 1, { strictAbortRecovery: true }) + + //#then + expect(result.ok).toBe(false) + expect(result.error).toContain("Latest assistant message is an error") + }) + + test("strict abort recovery: requires latest assistant text output", async () => { + //#given + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "tool", toolCallId: "t1", toolName: "x", state: "output-available", input: {}, output: {} }], + }, + ], + }), + }, + } + + //#when + const result = await fetchSyncResult(mockClient, "ses_test", 0, { strictAbortRecovery: true }) + + //#then + expect(result.ok).toBe(false) + expect(result.error).toContain("No assistant text output found in latest response") + }) }) diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts index 9631d3637..f236d6724 100644 --- a/src/tools/delegate-task/sync-result-fetcher.ts +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -5,7 +5,8 @@ import { normalizeSDKResponse } from "../../shared" export async function fetchSyncResult( client: OpencodeClient, sessionID: string, - anchorMessageCount?: number + anchorMessageCount?: number, + options?: { strictAbortRecovery?: boolean } ): Promise<{ ok: true; textContent: string } | { ok: false; error: string }> { const messagesResult = await client.session.messages({ path: { id: sessionID }, @@ -44,6 +45,26 @@ export async function fetchSyncResult( return { ok: false, error: `No assistant response found.\n\nSession ID: ${sessionID}` } } + if (options?.strictAbortRecovery) { + if (lastMessage.info && "error" in lastMessage.info) { + return { + ok: false, + error: `Latest assistant message is an error; refusing abort recovery.\n\nSession ID: ${sessionID}`, + } + } + + const lastTextParts = lastMessage.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] + const lastContent = lastTextParts.map((p) => p.text ?? "").filter(Boolean).join("\n") + if (!lastContent) { + return { + ok: false, + error: `No assistant text output found in latest response.\n\nSession ID: ${sessionID}`, + } + } + + return { ok: true, textContent: lastContent } + } + // Search assistant messages (newest first) for one with text/reasoning content. // The last assistant message may only contain tool calls with no text. let textContent = "" diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 556ca8225..dcc4b31e8 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -240,7 +240,9 @@ export async function executeSyncTask( }, syncPollTimeoutMs) if (pollError) { if (shouldAttemptPollErrorRecovery(pollError)) { - const recoveredResult = await deps.fetchSyncResult(client, activeSessionID) + const recoveredResult = await deps.fetchSyncResult(client, activeSessionID, undefined, { + strictAbortRecovery: true, + }) if (recoveredResult.ok) { const duration = formatDuration(startTime) From 5d1c8718d7e6daef76fd52e04e6eca0f9513f880 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:24:42 +0900 Subject: [PATCH 24/73] fix(installer): bound outputPromise wait after kill to prevent indirect hang After SIGTERM/SIGKILL escalation, the stdout stream may not close immediately on all platforms. The unconditional await on outputPromise could then hang indefinitely, defeating the bounded process lifetime guarantee. Race outputPromise against a short follow-up timeout to ensure getOpenCodeVersion always returns within a bounded time. Refs #3766 --- .../config-manager/opencode-binary.test.ts | 41 ++++++++++++++++++- src/cli/config-manager/opencode-binary.ts | 5 ++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index ec7b05f35..99113da9a 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -10,7 +10,11 @@ type OpenCodeBinaryModule = typeof import("./opencode-binary") type CreateProcOptions = { exitCode?: number | null exited?: Promise - output?: { stdout?: string; stderr?: string } + output?: { + stdout?: string + stdoutStream?: ReadableStream + stderr?: string + } kill?: (signal?: NodeJS.Signals) => void } @@ -19,7 +23,9 @@ function createProc(options: CreateProcOptions = {}): ReturnType {}), } satisfies ReturnType @@ -103,6 +109,37 @@ describe("getOpenCodeVersion (installer)", () => { }) }) + describe("#given never-closing stdout after kill #when getOpenCodeVersion #then returns within bounded time", () => { + it("bounds outputPromise wait and returns null", async () => { + const neverClosingStdout = new ReadableStream({ + start() { + // Intentionally never closing to simulate a hung stdout stream. + }, + }) + spawnSpy.mockReturnValue( + createProc({ + exited: new Promise(() => {}), + output: { stdoutStream: neverClosingStdout }, + kill: () => {}, + }), + ) + + const immediateSetTimeout = ((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 1 as unknown as ReturnType + }) as unknown as typeof globalThis.setTimeout + const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) + + const result = await getOpenCodeVersion() + + expect(result).toBe(null) + + setTimeoutSpy.mockRestore() + }) + }) + describe("#given quick successful exit #when getOpenCodeVersion #then clears the watchdog timer", () => { it("avoids timer leak after success", async () => { spawnSpy.mockReturnValue(createProc({ output: { stdout: "1.14.33\n" } })) diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts index 4c64205cf..ff650da3c 100644 --- a/src/cli/config-manager/opencode-binary.ts +++ b/src/cli/config-manager/opencode-binary.ts @@ -6,6 +6,7 @@ import { initConfigContext } from "./config-context" const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const const OPENCODE_VERSION_CHECK_TIMEOUT_MS = 1500 const OPENCODE_VERSION_KILL_GRACE_MS = 200 +const OPENCODE_OUTPUT_WAIT_TIMEOUT_MS = 200 interface OpenCodeBinaryResult { binary: OpenCodeBinaryType @@ -44,9 +45,9 @@ async function findOpenCodeBinaryWithVersion(): Promise((resolve) => { setTimeout(() => { resolve("") - }, OPENCODE_VERSION_KILL_GRACE_MS) + }, OPENCODE_OUTPUT_WAIT_TIMEOUT_MS) }), - ]) + ]).catch(() => "") if (timedExitCode === 0 && proc.exitCode === 0) { const version = extractSemverFromOutput(output) ?? output.trim() From f983b6b19e4b378d8fa720fbcd65ac314c3b3235 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:30:33 +0900 Subject: [PATCH 25/73] fix(installer): avoid empty-version success on delayed stdout --- .../config-manager/opencode-binary.test.ts | 6 +-- src/cli/config-manager/opencode-binary.ts | 48 ++++++++++++++----- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index 99113da9a..27171db5b 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -140,8 +140,8 @@ describe("getOpenCodeVersion (installer)", () => { }) }) - describe("#given quick successful exit #when getOpenCodeVersion #then clears the watchdog timer", () => { - it("avoids timer leak after success", async () => { + describe("#given quick successful exit #when getOpenCodeVersion #then clears active timers", () => { + it("avoids timer leaks after success", async () => { spawnSpy.mockReturnValue(createProc({ output: { stdout: "1.14.33\n" } })) const clearTimeoutSpy = spyOn(globalThis, "clearTimeout") @@ -149,7 +149,7 @@ describe("getOpenCodeVersion (installer)", () => { const result = await getOpenCodeVersion() expect(result).toBe("1.14.33") - expect(clearTimeoutSpy).toHaveBeenCalledTimes(1) + expect(clearTimeoutSpy).toHaveBeenCalledTimes(2) clearTimeoutSpy.mockRestore() }) diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts index ff650da3c..79e4ee542 100644 --- a/src/cli/config-manager/opencode-binary.ts +++ b/src/cli/config-manager/opencode-binary.ts @@ -23,15 +23,16 @@ async function findOpenCodeBinaryWithVersion(): Promise | null = null - const timedExitCode = await Promise.race([ - proc.exited, - new Promise((resolve) => { + let killGraceTimer: ReturnType | null = null + const timedExitResult = await Promise.race([ + proc.exited.then((exitCode) => ({ type: "exit" as const, exitCode })), + new Promise<{ type: "timeout" }>((resolve) => { killTimer = setTimeout(() => { proc.kill("SIGTERM") - setTimeout(() => { + killGraceTimer = setTimeout(() => { proc.kill("SIGKILL") }, OPENCODE_VERSION_KILL_GRACE_MS) - resolve(1) + resolve({ type: "timeout" }) }, OPENCODE_VERSION_CHECK_TIMEOUT_MS) }), ]) @@ -40,17 +41,40 @@ async function findOpenCodeBinaryWithVersion(): Promise((resolve) => { - setTimeout(() => { - resolve("") + if (timedExitResult.type === "timeout") { + void outputPromise.catch(() => {}) + continue + } + + if (killGraceTimer) { + clearTimeout(killGraceTimer) + } + + let outputTimer: ReturnType | null = null + const outputResult = await Promise.race([ + outputPromise.then((output) => ({ type: "output" as const, output })), + new Promise<{ type: "timeout" }>((resolve) => { + outputTimer = setTimeout(() => { + resolve({ type: "timeout" }) }, OPENCODE_OUTPUT_WAIT_TIMEOUT_MS) }), - ]).catch(() => "") + ]).catch(() => ({ type: "timeout" as const })) - if (timedExitCode === 0 && proc.exitCode === 0) { + if (outputTimer) { + clearTimeout(outputTimer) + } + + if (outputResult.type !== "output") { + continue + } + + if (timedExitResult.exitCode === 0 && proc.exitCode === 0) { + const output = outputResult.output const version = extractSemverFromOutput(output) ?? output.trim() + if (version.length === 0) { + continue + } + initConfigContext(binary, version) return { binary, version } } From 9314dca06ac44f158003a5d626b4c594e302e4c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 01:07:10 +0000 Subject: [PATCH 26/73] @acamq has signed the CLA in code-yeongyu/oh-my-openagent#3644 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index fb45989a4..2e426f004 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -3255,6 +3255,14 @@ "created_at": "2026-05-10T19:58:46Z", "repoId": 1108837393, "pullRequestNo": 3930 + }, + { + "name": "iCrazeiOS", + "id": 39101269, + "comment_id": 4320391846, + "created_at": "2026-04-25T19:31:24Z", + "repoId": 1108837393, + "pullRequestNo": 3644 } ] } \ No newline at end of file From 0d19c31274af2cf3c5327a2355cbd64fe345a257 Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Sun, 10 May 2026 19:29:42 -0600 Subject: [PATCH 27/73] fix: add cmux __tmux-compat prefix to interactive_bash tool Extract isCmuxCompatEnvironment to shared module and fix interactive_bash to resolve cmux executables with the __tmux-compat prefix, matching all other tmux command paths in the codebase. --- src/shared/tmux/cmux-detect.ts | 11 +++++++++++ src/shared/tmux/index.ts | 1 + src/shared/tmux/runner.ts | 13 +------------ src/tools/interactive-bash/tmux-path-resolver.ts | 6 +----- src/tools/interactive-bash/tools.ts | 13 ++++++++++++- 5 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 src/shared/tmux/cmux-detect.ts diff --git a/src/shared/tmux/cmux-detect.ts b/src/shared/tmux/cmux-detect.ts new file mode 100644 index 000000000..202733c77 --- /dev/null +++ b/src/shared/tmux/cmux-detect.ts @@ -0,0 +1,11 @@ +/** + * Detect whether we are running inside cmux (cmux omo). + * When cmux-omo sets up the environment it injects a tmux shim and sets + * CMUX_SOCKET_PATH / TMUX. If detected, redirect tmux commands to + * `cmux __tmux-compat` so they become native cmux splits instead of + * failing because there is no real tmux server running. + */ +export function isCmuxCompatEnvironment(): boolean { + return Boolean(process.env.CMUX_SOCKET_PATH) || + process.env.TMUX?.includes("cmuxterm") === true +} diff --git a/src/shared/tmux/index.ts b/src/shared/tmux/index.ts index b523bf642..b8ef46b75 100644 --- a/src/shared/tmux/index.ts +++ b/src/shared/tmux/index.ts @@ -1,4 +1,5 @@ export * from "./types" export * from "./constants" +export * from "./cmux-detect" export * from "./runner" export * from "./tmux-utils" diff --git a/src/shared/tmux/runner.ts b/src/shared/tmux/runner.ts index b61995fbc..6bbd2cf4b 100644 --- a/src/shared/tmux/runner.ts +++ b/src/shared/tmux/runner.ts @@ -1,4 +1,5 @@ import { spawn } from "../bun-spawn-shim" +import { isCmuxCompatEnvironment } from "./cmux-detect" type RunTmuxOptions = { retry?: number @@ -29,18 +30,6 @@ function isTerminalTmuxError(stderr: string): boolean { return TERMINAL_TMUX_ERROR_PATTERN.test(stderr) } -/** - * Detect whether we are running inside cmux (cmux omo). - * When cmux-omo sets up the environment it injects a tmux shim and sets - * CMUX_SOCKET_PATH / TMUX. If detected, redirect tmux commands to - * `cmux __tmux-compat` so they become native cmux splits instead of - * failing because there is no real tmux server running. - */ -function isCmuxCompatEnvironment(): boolean { - return Boolean(process.env.CMUX_SOCKET_PATH) || - process.env.TMUX?.includes("cmuxterm") === true -} - function resolveTmuxExecutable(tmuxPath: string): string[] { if (!isCmuxCompatEnvironment()) { return [tmuxPath] diff --git a/src/tools/interactive-bash/tmux-path-resolver.ts b/src/tools/interactive-bash/tmux-path-resolver.ts index 0562caa0d..2ef2324eb 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.ts @@ -1,14 +1,10 @@ import { spawn } from "../../shared/bun-spawn-shim" +import { isCmuxCompatEnvironment } from "../../shared/tmux/cmux-detect" let tmuxPath: string | null = null let initPromise: Promise | null = null let tmuxPathEnvironmentKey: "cmux" | "tmux" | null = null -function isCmuxCompatEnvironment(): boolean { - return Boolean(process.env.CMUX_SOCKET_PATH) || - process.env.TMUX?.includes("cmuxterm") === true -} - function getEnvironmentKey(): "cmux" | "tmux" { return isCmuxCompatEnvironment() ? "cmux" : "tmux" } diff --git a/src/tools/interactive-bash/tools.ts b/src/tools/interactive-bash/tools.ts index a0795ee36..21a03cf7f 100644 --- a/src/tools/interactive-bash/tools.ts +++ b/src/tools/interactive-bash/tools.ts @@ -1,8 +1,19 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" +import { isCmuxCompatEnvironment } from "../../shared/tmux/cmux-detect" import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants" import { getCachedTmuxPath } from "./tmux-path-resolver" +function resolveTmuxExecutable(tmuxPath: string): string[] { + if (!isCmuxCompatEnvironment()) { + return [tmuxPath] + } + + const executableName = tmuxPath.split(/[\\/]/).pop() + const cmuxExecutable = executableName === "cmux" ? tmuxPath : "cmux" + return [cmuxExecutable, "__tmux-compat"] +} + /** * Quote-aware command tokenizer with escape handling * Handles single/double quotes and backslash escapes without external dependencies @@ -90,7 +101,7 @@ tmux capture-pane -p -t ${sessionName} -S -1000 The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.` } - const proc = spawnWithWindowsHide([tmuxPath, ...parts], { + const proc = spawnWithWindowsHide([...resolveTmuxExecutable(tmuxPath), ...parts], { stdout: "pipe", stderr: "pipe", }) From 5bb0b0140eba27f86c4551f3c62989a69f2511d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 03:09:37 +0000 Subject: [PATCH 28/73] @Qihao0v0 has signed the CLA in code-yeongyu/oh-my-openagent#3934 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index 2e426f004..d413c60ec 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -3263,6 +3263,14 @@ "created_at": "2026-04-25T19:31:24Z", "repoId": 1108837393, "pullRequestNo": 3644 + }, + { + "name": "Qihao0v0", + "id": 185514257, + "comment_id": 4417271273, + "created_at": "2026-05-11T03:09:24Z", + "repoId": 1108837393, + "pullRequestNo": 3934 } ] } \ No newline at end of file From ac9e375d8535f183710e11bd8ef9ac71d59cddc2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:21:37 +0900 Subject: [PATCH 29/73] fix(delegate-task): align continuation task metadata titles --- .../delegate-task/background-continuation.ts | 2 +- .../metadata-task-id-consistency.test.ts | 51 +++++++++++++++++++ .../delegate-task/oracle-gap-closure.test.ts | 2 +- src/tools/delegate-task/sync-continuation.ts | 2 +- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/tools/delegate-task/background-continuation.ts b/src/tools/delegate-task/background-continuation.ts index b090170ee..92b162ead 100644 --- a/src/tools/delegate-task/background-continuation.ts +++ b/src/tools/delegate-task/background-continuation.ts @@ -40,7 +40,7 @@ export async function executeBackgroundContinuation( const resolvedModel = resolveMetadataModel(task.model, parentContext.model) const bgContMeta = { - title: `Continue: ${args.description}`, + title: args.description, metadata: { prompt: args.prompt, agent: task.agent, diff --git a/src/tools/delegate-task/metadata-task-id-consistency.test.ts b/src/tools/delegate-task/metadata-task-id-consistency.test.ts index 69f3f5508..23ce7d64a 100644 --- a/src/tools/delegate-task/metadata-task-id-consistency.test.ts +++ b/src/tools/delegate-task/metadata-task-id-consistency.test.ts @@ -429,6 +429,57 @@ describe("taskId and backgroundTaskId metadata consistency", () => { }) }) + describe("#given stock task title metadata contract", () => { + test("#when background continuation publishes metadata #then title equals description without resume prefix", async () => { + const { executeBackgroundContinuation } = require("./background-continuation") + const ctx = makeMockCtx() + const args: DelegateTaskArgs = { + description: "continue work", prompt: "keep going", + load_skills: [], run_in_background: true, task_id: "ses_resume_title", + } + + await executeBackgroundContinuation(args, ctx, { + manager: { + resume: async () => ({ + id: "bg_resume_title", description: "continue work", agent: "explore", + status: "running", sessionId: "ses_resume_title", model: MODEL, + }), + }, + } as any, parentContext) + + const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) + expect(meta).toBeDefined() + expect(meta.title).toBe("continue work") + }) + + test("#when sync continuation publishes metadata #then title equals description without resume prefix", async () => { + const { executeSyncContinuation } = require("./sync-continuation") + const ctx = makeMockCtx() + const args: DelegateTaskArgs = { + description: "continue sync", prompt: "keep going", + load_skills: [], run_in_background: false, task_id: "ses_sync_title", + } + + await executeSyncContinuation(args, ctx, { + client: { + session: { + messages: async () => ({ + data: [{ info: { agent: "explore", model: MODEL } }], + }), + prompt: async () => ({}), + }, + }, + } as any, parentContext, { + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "done" }), + }) + + const meta = ctx.captured.find((item: any) => item.metadata?.sessionId) + expect(meta).toBeDefined() + expect(meta.title).toBe("continue sync") + }) + }) + describe("#given background_output runs", () => { test("#when publishing metadata #then backgroundTaskId is task.id not task_id", async () => { const { createBackgroundOutput } = require("../background-task/create-background-output") diff --git a/src/tools/delegate-task/oracle-gap-closure.test.ts b/src/tools/delegate-task/oracle-gap-closure.test.ts index 57d67965a..c6bbc01ff 100644 --- a/src/tools/delegate-task/oracle-gap-closure.test.ts +++ b/src/tools/delegate-task/oracle-gap-closure.test.ts @@ -171,7 +171,7 @@ describe("delegate-task Oracle gap closure", () => { //#then const published = ctx.captured.find((item) => item.metadata?.sessionId === "ses_bg_title") - expect(published?.title).toBe("Continue: new desc") + expect(published?.title).toBe("new desc") }) test("#given sync continuation receives system content #when prompt is sent #then system content reaches prompt body", async () => { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index d2dd3b678..37b22db9e 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -131,7 +131,7 @@ export async function executeSyncContinuation( : resumeModel const syncContMeta = { - title: `Continue: ${args.description}`, + title: args.description, metadata: { prompt: args.prompt, ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), From 59209f06592769cf3751446a4945c61b62336e3c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:37:20 +0900 Subject: [PATCH 30/73] fix(team-mode): track session-created team runs --- .../cleanup-team-run-resources.ts | 2 + .../team-mode/team-runtime/create.test.ts | 26 ++++++- src/features/team-mode/team-runtime/create.ts | 2 + .../team-mode/team-runtime/delete-team.ts | 2 + .../team-runtime/session-cleanup.test.ts | 57 +++++++++++++++ .../team-mode/team-runtime/session-cleanup.ts | 71 +++++++++++++++++++ .../team-runtime/session-team-run-registry.ts | 17 +++++ .../team-mode/team-runtime/shutdown.test.ts | 23 ++++++ 8 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/features/team-mode/team-runtime/session-cleanup.test.ts create mode 100644 src/features/team-mode/team-runtime/session-cleanup.ts create mode 100644 src/features/team-mode/team-runtime/session-team-run-registry.ts diff --git a/src/features/team-mode/team-runtime/cleanup-team-run-resources.ts b/src/features/team-mode/team-runtime/cleanup-team-run-resources.ts index 931339d4d..e3096f969 100644 --- a/src/features/team-mode/team-runtime/cleanup-team-run-resources.ts +++ b/src/features/team-mode/team-runtime/cleanup-team-run-resources.ts @@ -7,6 +7,7 @@ import { removeTeamLayout } from "../team-layout-tmux/layout" import { unregisterTeamSessionsByTeam } from "../team-session-registry" import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store" import type { TeamRunCreateError } from "./create" +import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry" type SpawnedMemberResource = { taskId?: string @@ -72,6 +73,7 @@ export async function cleanupTeamRunResources(args: { }) unregisterTeamSessionsByTeam(args.teamRunId) + unregisterTeamRunForSessionCleanup(args.teamRunId) return cleanupReport } diff --git a/src/features/team-mode/team-runtime/create.test.ts b/src/features/team-mode/team-runtime/create.test.ts index a45d8da2e..b14ad4f3d 100644 --- a/src/features/team-mode/team-runtime/create.test.ts +++ b/src/features/team-mode/team-runtime/create.test.ts @@ -1,6 +1,6 @@ /// -import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { access, mkdtemp, readdir, rm } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" @@ -14,6 +14,10 @@ import { BackgroundManager } from "../../background-agent/manager" import { loadRuntimeState } from "../team-state-store/store" import { clearTeamSessionRegistry, lookupTeamSession } from "../team-session-registry" import type { TeamSpec } from "../types" +import { + clearSessionTeamRunCleanupRegistry, + getSessionCreatedTeamRunIds, +} from "./session-cleanup" const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({ agentToUse: `${member.name}-agent`, @@ -92,9 +96,15 @@ describe("createTeamRun", () => { beforeEach(() => { resolveMemberMock.mockClear() clearTeamSessionRegistry() + clearSessionTeamRunCleanupRegistry() + }) + + afterEach(() => { + clearSessionTeamRunCleanupRegistry() }) afterAll(async () => { + clearSessionTeamRunCleanupRegistry() await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true }))) }) @@ -117,6 +127,19 @@ describe("createTeamRun", () => { expect((launchMock.mock.calls as Array<[LaunchInput]>).every(([input]) => input.suppressTmuxSpawn === true)).toBe(true) }) + test("#given a new team runtime #when createTeamRun succeeds #then it registers the run for session cleanup", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-cleanup-")) + temporaryDirectories.push(baseDir) + const { manager } = createManager(baseDir, async () => ({ id: "task-1", sessionId: "session-1", status: "running" } as BackgroundTask)) + + // when + const runtimeState = await createTeamRun(createSpec(1), "lead-session", createContext(baseDir, manager), createConfig(baseDir), manager) + + // then + expect(getSessionCreatedTeamRunIds()).toEqual([runtimeState.teamRunId]) + }) + test("registers a member session as soon as launch reports the real sessionId", async () => { // given const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-lineage-")) @@ -230,6 +253,7 @@ describe("createTeamRun", () => { } expect((cancelTaskMock.mock.calls as Array<[string]>).map(([taskId]) => taskId)).toEqual(["task-3", "task-2", "task-1"]) expect((await loadSingleRuntimeState(baseDir)).status).toBe("failed") + expect(getSessionCreatedTeamRunIds()).toEqual([]) }) test("removes all created worktrees when spawn fails after worktree creation", async () => { diff --git a/src/features/team-mode/team-runtime/create.ts b/src/features/team-mode/team-runtime/create.ts index 7671b03ed..8e6b707c7 100644 --- a/src/features/team-mode/team-runtime/create.ts +++ b/src/features/team-mode/team-runtime/create.ts @@ -17,6 +17,7 @@ import { buildTeammateCommunicationAddendum } from "../member-guidance" import { resolveMember } from "./resolve-member" import { shouldReuseCallerLeadSession } from "../resolve-caller-team-lead" import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions" +import { registerTeamRunForSessionCleanup } from "./session-team-run-registry" const SESSION_ID_POLL_MS = 25 @@ -129,6 +130,7 @@ export async function createTeamRun( await ensureBaseDirs(baseDir) const reusesCallerLeadSession = shouldReuseCallerLeadSession(spec, options?.callerAgentTypeId) let runtimeState = await createRuntimeState(spec, leadSessionId, await resolveSpecSource(spec, ctx, config), config) + registerTeamRunForSessionCleanup(runtimeState.teamRunId) if (reusesCallerLeadSession && spec.leadAgentId) { const callerLeadSubagentType = options?.callerAgentTypeId registerTeamSession(leadSessionId, { diff --git a/src/features/team-mode/team-runtime/delete-team.ts b/src/features/team-mode/team-runtime/delete-team.ts index bd8e8bb9e..201b44a63 100644 --- a/src/features/team-mode/team-runtime/delete-team.ts +++ b/src/features/team-mode/team-runtime/delete-team.ts @@ -9,6 +9,7 @@ import { unregisterTeamSessionsByTeam } from "../team-session-registry" import { listActiveTeams, loadRuntimeState, saveRuntimeState, transitionRuntimeState } from "../team-state-store/store" import type { RuntimeState } from "../types" import { DELETABLE_MEMBER_STATUSES, removeWorktrees } from "./shutdown-helpers" +import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry" export type DeleteTeamDeps = { canVisualize: typeof canVisualize @@ -139,6 +140,7 @@ export async function deleteTeam( await removeWorktrees([getRuntimeStateDir(resolveBaseDir(config), teamRunId)]) unregisterTeamSessionsByTeam(teamRunId) + unregisterTeamRunForSessionCleanup(teamRunId) const activeTeams = await listActiveTeams(config) sweepStaleTeamSessions(new Set(activeTeams.map((team) => team.teamRunId))).catch(() => {}) diff --git a/src/features/team-mode/team-runtime/session-cleanup.test.ts b/src/features/team-mode/team-runtime/session-cleanup.test.ts new file mode 100644 index 000000000..ae745f14e --- /dev/null +++ b/src/features/team-mode/team-runtime/session-cleanup.test.ts @@ -0,0 +1,57 @@ +/// + +import { afterEach, describe, expect, mock, test } from "bun:test" + +import { TeamModeConfigSchema } from "../../../config/schema/team-mode" +import type { BackgroundManager } from "../../background-agent/manager" +import type { TmuxSessionManager } from "../../tmux-subagent/manager" +import type { deleteTeam } from "./delete-team" +import { + cleanupSessionTeamRuns, + clearSessionTeamRunCleanupRegistry, + getSessionCreatedTeamRunIds, + registerTeamRunForSessionCleanup, +} from "./session-cleanup" + +describe("session team cleanup", () => { + afterEach(() => { + clearSessionTeamRunCleanupRegistry() + mock.restore() + }) + + test("#given team runs created in this process #when session cleanup runs #then it force deletes them with the tmux visualizer manager", async () => { + // given + const config = TeamModeConfigSchema.parse({ enabled: true, tmux_visualization: true }) + const tmuxMgr = { getServerUrl: () => "http://127.0.0.1:4096" } as TmuxSessionManager + const bgMgr = { cancelTask: mock(async () => true) } as BackgroundManager + const deleteTeamMock = mock(async () => ({ + removedLayout: true, + removedWorktrees: [], + })) as typeof deleteTeam + + registerTeamRunForSessionCleanup("team-run-a") + registerTeamRunForSessionCleanup("team-run-b") + + // when + const report = await cleanupSessionTeamRuns({ + config, + tmuxMgr, + bgMgr, + deps: { + deleteTeam: deleteTeamMock, + log: mock(() => {}), + }, + }) + + // then + expect(deleteTeamMock).toHaveBeenCalledTimes(2) + expect(deleteTeamMock).toHaveBeenNthCalledWith(1, "team-run-a", config, tmuxMgr, bgMgr, { force: true }) + expect(deleteTeamMock).toHaveBeenNthCalledWith(2, "team-run-b", config, tmuxMgr, bgMgr, { force: true }) + expect(report).toEqual({ + cleanedTeamRunIds: ["team-run-a", "team-run-b"], + removedLayoutTeamRunIds: ["team-run-a", "team-run-b"], + errors: [], + }) + expect(getSessionCreatedTeamRunIds()).toEqual([]) + }) +}) diff --git a/src/features/team-mode/team-runtime/session-cleanup.ts b/src/features/team-mode/team-runtime/session-cleanup.ts new file mode 100644 index 000000000..9250c17c1 --- /dev/null +++ b/src/features/team-mode/team-runtime/session-cleanup.ts @@ -0,0 +1,71 @@ +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import { log } from "../../../shared/logger" +import type { BackgroundManager } from "../../background-agent/manager" +import type { TmuxSessionManager } from "../../tmux-subagent/manager" +import { deleteTeam } from "./delete-team" +import { + getSessionCreatedTeamRunIds, + unregisterTeamRunForSessionCleanup, +} from "./session-team-run-registry" + +export { + clearSessionTeamRunCleanupRegistry, + getSessionCreatedTeamRunIds, + registerTeamRunForSessionCleanup, + unregisterTeamRunForSessionCleanup, +} from "./session-team-run-registry" + +export type SessionTeamCleanupReport = { + cleanedTeamRunIds: string[] + removedLayoutTeamRunIds: string[] + errors: string[] +} + +export type SessionTeamCleanupDeps = { + deleteTeam: typeof deleteTeam + log: typeof log +} + +const defaultSessionTeamCleanupDeps: SessionTeamCleanupDeps = { + deleteTeam, + log, +} + +function normalizeError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +export async function cleanupSessionTeamRuns(args: { + config: TeamModeConfig + tmuxMgr?: TmuxSessionManager + bgMgr?: BackgroundManager + deps?: SessionTeamCleanupDeps +}): Promise { + const deps = args.deps ?? defaultSessionTeamCleanupDeps + const report: SessionTeamCleanupReport = { + cleanedTeamRunIds: [], + removedLayoutTeamRunIds: [], + errors: [], + } + + for (const teamRunId of getSessionCreatedTeamRunIds()) { + try { + const result = await deps.deleteTeam(teamRunId, args.config, args.tmuxMgr, args.bgMgr, { force: true }) + report.cleanedTeamRunIds.push(teamRunId) + if (result.removedLayout) { + report.removedLayoutTeamRunIds.push(teamRunId) + } + } catch (error) { + const normalizedError = normalizeError(error) + report.errors.push(`${teamRunId}: ${normalizedError.message}`) + deps.log("session team cleanup failed", { + teamRunId, + error: normalizedError.message, + }) + } finally { + unregisterTeamRunForSessionCleanup(teamRunId) + } + } + + return report +} diff --git a/src/features/team-mode/team-runtime/session-team-run-registry.ts b/src/features/team-mode/team-runtime/session-team-run-registry.ts new file mode 100644 index 000000000..24ab4a48f --- /dev/null +++ b/src/features/team-mode/team-runtime/session-team-run-registry.ts @@ -0,0 +1,17 @@ +const sessionCreatedTeamRunIds = new Set() + +export function registerTeamRunForSessionCleanup(teamRunId: string): void { + sessionCreatedTeamRunIds.add(teamRunId) +} + +export function unregisterTeamRunForSessionCleanup(teamRunId: string): void { + sessionCreatedTeamRunIds.delete(teamRunId) +} + +export function getSessionCreatedTeamRunIds(): string[] { + return Array.from(sessionCreatedTeamRunIds) +} + +export function clearSessionTeamRunCleanupRegistry(): void { + sessionCreatedTeamRunIds.clear() +} diff --git a/src/features/team-mode/team-runtime/shutdown.test.ts b/src/features/team-mode/team-runtime/shutdown.test.ts index 89682fa8a..88e95f112 100644 --- a/src/features/team-mode/team-runtime/shutdown.test.ts +++ b/src/features/team-mode/team-runtime/shutdown.test.ts @@ -15,6 +15,11 @@ import { readInboxMessages, updateMemberStatuses, } from "./shutdown-test-fixtures" +import { + clearSessionTeamRunCleanupRegistry, + getSessionCreatedTeamRunIds, + registerTeamRunForSessionCleanup, +} from "./session-cleanup" const { approveShutdown, deleteTeam, rejectShutdown, requestShutdownOfMember } = await import("./shutdown") @@ -25,6 +30,7 @@ describe("team-runtime shutdown", () => { await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => { await rm(directoryPath, { recursive: true, force: true }) })) + clearSessionTeamRunCleanupRegistry() mock.restore() }) @@ -161,6 +167,23 @@ describe("team-runtime shutdown", () => { ) }) + test("#given a team run is tracked for session cleanup #when deleteTeam succeeds #then it unregisters the run", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + registerTeamRunForSessionCleanup(fixture.teamRunId) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "shutdown_approved", + "member-b": "shutdown_approved", + }) + + // when + await deleteTeam(fixture.teamRunId, fixture.config) + + // then + expect(getSessionCreatedTeamRunIds()).toEqual([]) + }) + test("deletes team even with active members when force=true", async () => { // given const fixture = await createFixture() From 917398b7198e608e359cd5b029181868cb0d8e03 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:37:54 +0900 Subject: [PATCH 31/73] fix(team-mode): cleanup team runs on shutdown --- src/create-managers.test.ts | 47 ++++++++++++++++++++++++++++++++++++- src/create-managers.ts | 24 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/create-managers.test.ts b/src/create-managers.test.ts index 8dc0d1d3e..075080606 100644 --- a/src/create-managers.test.ts +++ b/src/create-managers.test.ts @@ -8,11 +8,23 @@ import { createManagers } from "./create-managers" import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch" import { createModelCacheState } from "./plugin-state" +type CleanupRegistration = { + shutdown: () => void | Promise +} + +type CleanupSessionTeamRunsFn = typeof import("./features/team-mode/team-runtime/session-cleanup").cleanupSessionTeamRuns + const markServerRunningInProcess = mock(() => {}) let backgroundManagerOptions: { onSubagentSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise } | null = null const trackedPaneBySession = new Map() +const registeredCleanupManagers: CleanupRegistration[] = [] +const cleanupSessionTeamRunsMock = mock(async () => ({ + cleanedTeamRunIds: [], + removedLayoutTeamRunIds: [], + errors: [], +})) class MockBackgroundManager { constructor(config: { @@ -51,7 +63,9 @@ function initTaskToastManager(): ReturnType } -function registerManagerForCleanup(): void {} +function registerManagerForCleanup(manager: CleanupRegistration): void { + registeredCleanupManagers.push(manager) +} function createDeps(): NonNullable[0]["deps"]> { return { @@ -60,6 +74,7 @@ function createDeps(): NonNullable[0]["deps"]> TmuxSessionManagerClass: MockTmuxSessionManager as typeof import("./features/tmux-subagent").TmuxSessionManager, initTaskToastManagerFn: initTaskToastManager, registerManagerForCleanupFn: registerManagerForCleanup, + cleanupSessionTeamRunsFn: cleanupSessionTeamRunsMock as CleanupSessionTeamRunsFn, createConfigHandlerFn: createConfigHandler, markServerRunningInProcessFn: markServerRunningInProcess, } @@ -122,6 +137,8 @@ describe("createManagers", () => { dispatchOpenClawEvent.mockReset() backgroundManagerOptions = null trackedPaneBySession.clear() + registeredCleanupManagers.length = 0 + cleanupSessionTeamRunsMock.mockClear() }) afterEach(() => { @@ -193,4 +210,32 @@ describe("createManagers", () => { }, }) }) + + it("#given team mode is enabled #when process cleanup runs #then session team runs are cleaned with tmux visualization dependencies", async () => { + const args = { + ctx: createContext("/tmp/project"), + pluginConfig: OhMyOpenCodeConfigSchema.parse({ + team_mode: { + enabled: true, + tmux_visualization: true, + }, + }), + tmuxConfig: createTmuxConfig(true), + modelCacheState: createModelCacheState(), + backgroundNotificationHookEnabled: false, + deps: createDeps(), + } + + createManagers(args) + + await registeredCleanupManagers[0]?.shutdown() + + expect(cleanupSessionTeamRunsMock).toHaveBeenCalledTimes(1) + const cleanupArgs = cleanupSessionTeamRunsMock.mock.calls[0]?.[0] + expect(cleanupArgs).toMatchObject({ + config: args.pluginConfig.team_mode, + }) + expect(cleanupArgs?.tmuxMgr).toBeInstanceOf(MockTmuxSessionManager) + expect(cleanupArgs?.bgMgr).toBeInstanceOf(MockBackgroundManager) + }) }) diff --git a/src/create-managers.ts b/src/create-managers.ts index c4fcc9837..842f6cfe3 100644 --- a/src/create-managers.ts +++ b/src/create-managers.ts @@ -5,6 +5,7 @@ import type { PluginContext, TmuxConfig } from "./plugin/types" import type { SubagentSessionCreatedEvent } from "./features/background-agent" import { BackgroundManager } from "./features/background-agent" import { SkillMcpManager } from "./features/skill-mcp-manager" +import { cleanupSessionTeamRuns } from "./features/team-mode/team-runtime/session-cleanup" import { createModelFallbackControllerAccessor } from "./hooks/model-fallback" import { initTaskToastManager } from "./features/task-toast-manager" import { TmuxSessionManager } from "./features/tmux-subagent" @@ -21,6 +22,7 @@ type CreateManagersDeps = { TmuxSessionManagerClass: typeof TmuxSessionManager initTaskToastManagerFn: typeof initTaskToastManager registerManagerForCleanupFn: typeof registerManagerForCleanup + cleanupSessionTeamRunsFn: typeof cleanupSessionTeamRuns createConfigHandlerFn: typeof createConfigHandler markServerRunningInProcessFn: typeof markServerRunningInProcess } @@ -31,6 +33,7 @@ const defaultCreateManagersDeps: CreateManagersDeps = { TmuxSessionManagerClass: TmuxSessionManager, initTaskToastManagerFn: initTaskToastManager, registerManagerForCleanupFn: registerManagerForCleanup, + cleanupSessionTeamRunsFn: cleanupSessionTeamRuns, createConfigHandlerFn: createConfigHandler, markServerRunningInProcessFn: markServerRunningInProcess, } @@ -59,16 +62,32 @@ export function createManagers(args: { } const tmuxSessionManager = new deps.TmuxSessionManagerClass(ctx, tmuxConfig) const modelFallbackControllerAccessor = createModelFallbackControllerAccessor() + let backgroundManager: BackgroundManager | undefined + + const cleanupTeamModeRuns = async (): Promise => { + if (!pluginConfig.team_mode?.enabled) return + const report = await deps.cleanupSessionTeamRunsFn({ + config: pluginConfig.team_mode, + tmuxMgr: tmuxSessionManager, + bgMgr: backgroundManager, + }) + if (report.cleanedTeamRunIds.length > 0 || report.errors.length > 0) { + log("[create-managers] team-mode session cleanup complete", report) + } + } deps.registerManagerForCleanupFn({ shutdown: async () => { + await cleanupTeamModeRuns().catch((error) => { + log("[create-managers] team-mode cleanup error during process shutdown:", error) + }) await tmuxSessionManager.cleanup().catch((error) => { log("[create-managers] tmux cleanup error during process shutdown:", error) }) }, }) - const backgroundManager = new deps.BackgroundManagerClass({ + backgroundManager = new deps.BackgroundManagerClass({ pluginContext: ctx, config: pluginConfig.background_task, tmuxConfig, @@ -105,6 +124,9 @@ export function createManagers(args: { log("[create-managers] onSubagentSessionCreated callback completed") }, onShutdown: async () => { + await cleanupTeamModeRuns().catch((error) => { + log("[create-managers] team-mode cleanup error during shutdown:", error) + }) await tmuxSessionManager.cleanup().catch((error) => { log("[create-managers] tmux cleanup error during shutdown:", error) }) From a5cc4984335e2763e8f9acd18c940dc3bd7e6c60 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:38:04 +0900 Subject: [PATCH 32/73] fix(background-agent): retry deferred parent wake --- src/features/background-agent/manager.ts | 42 ++++++++++++++++++- .../task-completion-cleanup.test.ts | 31 +++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 540c65d58..071a33cd9 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -106,6 +106,8 @@ const BACKGROUND_PARENT_WAKE_PROMPT = ` A background task notification was already added to this session. Continue from that notification. ` +const PENDING_PARENT_WAKE_RETRY_MS = 1_000 + interface MessagePartInfo { id?: string sessionID?: string @@ -228,6 +230,7 @@ export class BackgroundManager { private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() private pendingParentWakes: Map = new Map() + private pendingParentWakeTimers: Map> = new Map() private observedOutputSessions: Set = new Set() private observedIncompleteTodosBySession: Map = new Map() private rootDescendantCounts: Map @@ -2233,6 +2236,7 @@ The task was re-queued on a fallback model after a retryable failure. }) if (shouldDeferReply) { this.pendingParentWakes.set(task.parentSessionId, parentPromptContext) + this.schedulePendingParentWakeFlush(task.parentSessionId) } log("[background-agent] Sent notification to parent session:", { taskId: task.id, @@ -2296,17 +2300,23 @@ The task was re-queued on a fallback model after a retryable failure. private async flushPendingParentWake(sessionID: string): Promise { const wakeContext = this.pendingParentWakes.get(sessionID) - if (!wakeContext) return + if (!wakeContext) { + this.clearPendingParentWakeTimer(sessionID) + return + } if (await this.isSessionActive(sessionID)) { + this.schedulePendingParentWakeFlush(sessionID) return } this.pendingParentWakes.delete(sessionID) + this.clearPendingParentWakeTimer(sessionID) await settleAfterSessionIdle() if (await this.isSessionActive(sessionID)) { this.pendingParentWakes.set(sessionID, wakeContext) + this.schedulePendingParentWakeFlush(sessionID) return } @@ -2325,6 +2335,31 @@ The task was re-queued on a fallback model after a retryable failure. } } + private schedulePendingParentWakeFlush(sessionID: string): void { + if (this.pendingParentWakeTimers.has(sessionID)) { + return + } + + const timer = setTimeout(() => { + this.pendingParentWakeTimers.delete(sessionID) + void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { + log("[background-agent] Failed to retry pending parent wake:", { sessionID, error }) + }) + }, PENDING_PARENT_WAKE_RETRY_MS) + + this.pendingParentWakeTimers.set(sessionID, timer) + } + + private clearPendingParentWakeTimer(sessionID: string): void { + const timer = this.pendingParentWakeTimers.get(sessionID) + if (!timer) { + return + } + + clearTimeout(timer) + this.pendingParentWakeTimers.delete(sessionID) + } + private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void { pruneStaleTasksAndNotifications({ tasks: this.tasks, @@ -2638,6 +2673,11 @@ The task was re-queued on a fallback model after a retryable failure. } this.idleDeferralTimers.clear() + for (const timer of this.pendingParentWakeTimers.values()) { + clearTimeout(timer) + } + this.pendingParentWakeTimers.clear() + for (const sessionID of trackedSessionIDs) { subagentSessions.delete(sessionID) SessionCategoryRegistry.remove(sessionID) diff --git a/src/features/background-agent/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index f8cdf51a5..881b35d1d 100644 --- a/src/features/background-agent/task-completion-cleanup.test.ts +++ b/src/features/background-agent/task-completion-cleanup.test.ts @@ -155,6 +155,10 @@ function waitForDeferredWake(): Promise { return new Promise((resolve) => setTimeout(resolve, 180)) } +function waitForDeferredWakeRetry(): Promise { + return new Promise((resolve) => setTimeout(resolve, 1_180)) +} + function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType { const timer = getCompletionTimers(manager).get(taskID) expect(timer).toBeDefined() @@ -208,7 +212,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { }) }) - describe("#given 2 tasks for same parent and both completed", () => { + describe("#given background tasks for same parent", () => { test("#when the second completion notification is sent #then ALL BACKGROUND TASKS COMPLETE notification still works correctly", async () => { // given const { manager, promptAsyncCalls } = createManager(true) @@ -290,6 +294,31 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY") expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE") }) + + test("#when a single background task finishes during a stale busy parent status #then wake prompt is sent after the parent becomes idle", async () => { + // given + const sessionStatuses: Record = { + "parent-1": { type: "busy" }, + } + const { manager, promptAsyncCalls } = createManager(true, sessionStatuses) + managerUnderTest = manager + const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) + getTasks(manager).set(task.id, task) + getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) + + // when + await notifyParentSessionForTest(manager, task) + sessionStatuses["parent-1"] = { type: "idle" } + await waitForDeferredWakeRetry() + + // then + expect(promptAsyncCalls).toHaveLength(2) + expect(promptAsyncCalls[0]?.body.noReply).toBe(true) + expect(promptAsyncCalls[1]?.body.noReply).toBe(false) + const wakePayload = JSON.stringify(promptAsyncCalls[1]?.body.parts) + expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY") + expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE") + }) }) describe("#given a completed task with cleanup timer scheduled", () => { From f0857a88fb5f23b3e59fdd77bc1919514c9248ef Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:45:51 +0900 Subject: [PATCH 33/73] test(ralph-loop): add dispatch-failure invariant tests Lock the contract that durable iteration state and visible UI must only\nadvance when the continuation dispatch is semantically accepted. Adds 4\npermanent invariant tests covering the idle, session.error retry, and\nverification-failure orchestration paths, plus the reset-strategy\nsilent-null path. --- .../dispatch-failure-invariant.test.ts | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 src/hooks/ralph-loop/dispatch-failure-invariant.test.ts diff --git a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts new file mode 100644 index 000000000..174ce459a --- /dev/null +++ b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts @@ -0,0 +1,254 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createRalphLoopHook } from "./index" +import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" +import { clearState, writeState } from "./storage" + +describe("ralph-loop dispatch failure invariants", () => { + const testDirectory = join(tmpdir(), `ralph-loop-dispatch-failure-${Date.now()}`) + let promptCalls: Array<{ sessionID: string; text: string }> + let toastCalls: Array<{ title: string; message: string; variant: string }> + let messagesCalls: Array<{ sessionID: string }> + let createSessionCalls: Array<{ parentID: string }> + + beforeEach(() => { + promptCalls = [] + toastCalls = [] + messagesCalls = [] + createSessionCalls = [] + mkdirSync(testDirectory, { recursive: true }) + clearState(testDirectory) + }) + + afterEach(() => { + clearState(testDirectory) + if (existsSync(testDirectory)) { + rmSync(testDirectory, { recursive: true, force: true }) + } + }) + + test("#given idle path #when promptAsync throws #then no state or toast advance", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async () => { + throw new Error("simulated dispatch failure") + }, + prompt: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + expect(hook.getState()?.iteration).toBe(1) + + // when + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then + expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true) + }) + + test("#given error retry path #when promptAsync throws #then no state or toast advance", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async () => { + throw new Error("simulated dispatch failure") + }, + prompt: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + expect(hook.getState()?.iteration).toBe(1) + + // when + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // then + expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true) + }) + + test("#given verification-failure path #when promptAsync throws #then iteration not advanced", async () => { + // given + const parentTranscriptPath = join(testDirectory, "transcript-parent.jsonl") + const oracleTranscriptPath = join(testDirectory, "transcript-oracle.jsonl") + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + if (options.path.id === "session-123") { + return { data: [{}, {}, {}] } + } + return { data: [] } + }, + promptAsync: async (options: { body: { parts: Array<{ type: string; text: string }> } }) => { + if (options.body.parts[0]?.text.includes("Verification failed")) { + throw new Error("simulated dispatch failure") + } + return {} + }, + prompt: async () => ({}), + abort: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never, { + getTranscriptPath: (sessionID): string => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath, + }) + + hook.startLoop("session-123", "Build API", { ultrawork: true }) + writeState(testDirectory, { + ...hook.getState()!, + iteration: 2, + verification_pending: true, + verification_session_id: "ses-oracle", + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + initial_completion_promise: "DONE", + }) + writeState(testDirectory, { + ...hook.getState()!, + verification_session_id: "ses-oracle", + }) + writeFileSync( + oracleTranscriptPath, + `${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "verification failed" } })}\n`, + ) + + const preRestartIteration = hook.getState()?.iteration + + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } }) + + // then + expect(preRestartIteration).toBe(2) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("Verification continuation rejected"))).toBe(true) + }) + + test("#given reset strategy #when createIterationSession returns null #then dispatch failure surfaces", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async () => ({}), + create: async (options: { body: { parentID: string } }) => { + createSessionCalls.push({ parentID: options.body.parentID }) + return { error: "fail", data: undefined } + }, + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + strategy: "reset", + }) + expect(hook.getState()?.iteration).toBe(1) + + // when + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then + expect(hook.getState()).toBeNull() + expect(promptCalls).toHaveLength(0) + expect(createSessionCalls).toHaveLength(1) + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true) + }) +}) From d4cdeaccbecfc4604f4a1d4e91f97f9e9b5d72b5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:45:59 +0900 Subject: [PATCH 34/73] fix(ralph-loop): return typed ContinuationResult from continueIteration Replace silent returns in continueIteration with a discriminated union\n(dispatched | session_creation_rejected | dispatch_rejected). Wraps\ninjectContinuationPrompt in try/catch so reset-strategy createIterationSession\nreturning null and promptAsync rejections both surface as typed failures\nthe caller can react to. --- .../ralph-loop/iteration-continuation.ts | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/hooks/ralph-loop/iteration-continuation.ts b/src/hooks/ralph-loop/iteration-continuation.ts index be067b76c..af43955fa 100644 --- a/src/hooks/ralph-loop/iteration-continuation.ts +++ b/src/hooks/ralph-loop/iteration-continuation.ts @@ -15,11 +15,16 @@ type ContinuationOptions = { } } +export type ContinuationResult = + | { status: "dispatched" } + | { status: "session_creation_rejected" } + | { status: "dispatch_rejected"; error: unknown } + export async function continueIteration( ctx: PluginInput, state: RalphLoopState, options: ContinuationOptions, -): Promise { +): Promise { const strategy = state.strategy ?? "continue" const continuationPrompt = buildContinuationPrompt(state) @@ -30,16 +35,20 @@ export async function continueIteration( options.directory, ) if (!newSessionID) { - return + return { status: "session_creation_rejected" } } - await injectContinuationPrompt(ctx, { - sessionID: newSessionID, - inheritFromSessionID: options.previousSessionID, - prompt: continuationPrompt, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - }) + try { + await injectContinuationPrompt(ctx, { + sessionID: newSessionID, + inheritFromSessionID: options.previousSessionID, + prompt: continuationPrompt, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + }) + } catch (error: unknown) { + return { status: "dispatch_rejected", error } + } await selectSessionInTui(ctx.client, newSessionID) @@ -49,16 +58,22 @@ export async function continueIteration( previousSessionID: options.previousSessionID, newSessionID, }) - return + return { status: "dispatched" } } - return + return { status: "dispatched" } } - await injectContinuationPrompt(ctx, { - sessionID: options.previousSessionID, - prompt: continuationPrompt, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - }) + try { + await injectContinuationPrompt(ctx, { + sessionID: options.previousSessionID, + prompt: continuationPrompt, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + }) + } catch (error: unknown) { + return { status: "dispatch_rejected", error } + } + + return { status: "dispatched" } } From 09c45c3acb7f339065d0b65795e821621386fc8b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:46:07 +0900 Subject: [PATCH 35/73] fix(ralph-loop): commit iteration only after continuation is dispatched Reorder the session.idle and session.error retry paths so the durable\niteration counter and the progress toast advance only when continueIteration\nreturns dispatched. On dispatch_rejected or session_creation_rejected,\nclear the loop state and emit a loud failure toast instead of silently\nlogging while the loop appears to make progress.\n\nAdds an explicit settle-window state check so a session.deleted firing\nduring the idleSettleMs sleep no longer feeds dispatch against a cleared\nloop. Keeps idleSettleMs intact for the original idle-settle race. --- .../ralph-loop/ralph-loop-event-handler.ts | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 3f20ccf34..3af7c1a3a 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -19,6 +19,7 @@ type LoopStateController = { markVerificationPending: (sessionID: string) => RalphLoopState | null setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null + clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null } type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController } @@ -272,34 +273,48 @@ export function createRalphLoopEventHandler( return } - const newState = options.loopState.incrementIteration() - if (!newState) { - log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) + await sleep(options.idleSettleMs) + const stateAfterSettle = options.loopState.getState() + if (!stateAfterSettle || !stateAfterSettle.active) { return } + const nextIteration = stateAfterSettle.iteration + 1 + const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration } + log(`[${HOOK_NAME}] Continuing loop`, { sessionID, - iteration: newState.iteration, - max: newState.max_iterations, + iteration: nextIteration, + max: previewState.max_iterations, }) - showIterationToast(ctx, newState) - await sleep(options.idleSettleMs) + const result = await continueIteration(ctx, previewState, { + previousSessionID: sessionID, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + loopState: options.loopState, + }) - try { - await continueIteration(ctx, newState, { - previousSessionID: sessionID, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - loopState: options.loopState, - }) - } catch (err) { - log(`[${HOOK_NAME}] Failed to inject continuation`, { - sessionID, - error: String(err), - }) + if (result.status === "dispatched") { + const committed = options.loopState.incrementIteration() + if (committed) { + showIterationToast(ctx, committed) + } else { + log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed`, { sessionID }) + } + return } + + log(`[${HOOK_NAME}] Dispatch failed`, { sessionID, status: result.status }) + options.loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: result.status === "dispatch_rejected" + ? `Dispatch ${result.status}: ${String(result.error)}` + : `Dispatch ${result.status}`, + variant: "warning", + duration: 5000, + }) return } finally { inFlightSessions.delete(sessionID) @@ -381,28 +396,43 @@ export function createRalphLoopEventHandler( return } - const newState = options.loopState.incrementIteration() - if (!newState) { - log(`[${HOOK_NAME}] Failed to increment iteration after runtime error`, { sessionID }) + await sleep(options.idleSettleMs) + const stateAfterSettle = options.loopState.getState() + if (!stateAfterSettle || !stateAfterSettle.active) { return } - showIterationToast(ctx, newState) - await sleep(options.idleSettleMs) - try { - await continueIteration(ctx, newState, { - previousSessionID: sessionID, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - loopState: options.loopState, - }) - runtimeErrorRetriedSessions.set(sessionID, newState.iteration) - } catch (err) { - log(`[${HOOK_NAME}] Failed to retry after runtime error`, { - sessionID, - error: String(err), - }) + const nextIteration = stateAfterSettle.iteration + 1 + const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration } + + const result = await continueIteration(ctx, previewState, { + previousSessionID: sessionID, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + loopState: options.loopState, + }) + + if (result.status === "dispatched") { + const committed = options.loopState.incrementIteration() + if (committed) { + showIterationToast(ctx, committed) + runtimeErrorRetriedSessions.set(sessionID, committed.iteration) + } else { + log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed after runtime error`, { sessionID }) + } + return } + + log(`[${HOOK_NAME}] Dispatch failed after runtime error`, { sessionID, status: result.status }) + options.loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: result.status === "dispatch_rejected" + ? `Dispatch ${result.status}: ${String(result.error)}` + : `Dispatch ${result.status}`, + variant: "warning", + duration: 5000, + }) } finally { inFlightSessions.delete(sessionID) } From caaae191556d2740a508f7da1a7826c87afd2b31 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:46:35 +0900 Subject: [PATCH 36/73] fix(ralph-loop): commit iteration only after verification continuation dispatches Split the verification-failure restart into clearVerificationState (clears\nthe verification flags so we cleanly transition back to the main loop)\nfollowed by injectContinuationPrompt, with incrementIteration only on\nsuccessful injection. On rejection: clear the loop state and emit a loud\nwarning toast. Mirrors the dispatch-before-commit contract enforced for\nthe idle and session.error paths. --- src/hooks/ralph-loop/loop-state-controller.ts | 22 ++++++++ .../pending-verification-handler.ts | 3 ++ .../verification-failure-handler.ts | 52 +++++++++++++++---- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts index 2a455412a..3679a3dab 100644 --- a/src/hooks/ralph-loop/loop-state-controller.ts +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -174,5 +174,27 @@ export function createLoopStateController(options: { return state }, + + clearVerificationState(sessionID: string, messageCountAtStart?: number): RalphLoopState | null { + const state = readState(directory, stateDir) + if (!state || state.session_id !== sessionID || !state.ultrawork || !state.verification_pending) { + return null + } + + state.started_at = new Date().toISOString() + state.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE + state.verification_pending = undefined + state.verification_attempt_id = undefined + state.verification_session_id = undefined + if (typeof messageCountAtStart === "number") { + state.message_count_at_start = messageCountAtStart + } + + if (!writeState(directory, state, stateDir)) { + return null + } + + return state + }, } } diff --git a/src/hooks/ralph-loop/pending-verification-handler.ts b/src/hooks/ralph-loop/pending-verification-handler.ts index 420a2f935..1976ec9aa 100644 --- a/src/hooks/ralph-loop/pending-verification-handler.ts +++ b/src/hooks/ralph-loop/pending-verification-handler.ts @@ -82,6 +82,9 @@ async function detectOracleVerificationFromParentSession( type LoopStateController = { restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null + clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null + incrementIteration: () => RalphLoopState | null + clear: () => boolean setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null } diff --git a/src/hooks/ralph-loop/verification-failure-handler.ts b/src/hooks/ralph-loop/verification-failure-handler.ts index f6ea8f522..6b8e2d2cb 100644 --- a/src/hooks/ralph-loop/verification-failure-handler.ts +++ b/src/hooks/ralph-loop/verification-failure-handler.ts @@ -6,10 +6,22 @@ import { injectContinuationPrompt } from "./continuation-prompt-injector" import type { RalphLoopState } from "./types" type LoopStateController = { - restartAfterFailedVerification: ( + clearVerificationState: ( sessionID: string, messageCountAtStart?: number, ) => RalphLoopState | null + incrementIteration: () => RalphLoopState | null + clear: () => boolean +} + +function showToastBestEffort( + ctx: PluginInput, + body: { title: string; message: string; variant: "warning" | "info"; duration: number }, +): void { + try { + void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {}) + } catch { + } } function getMessageCountFromResponse(messagesResponse: unknown): number { @@ -72,23 +84,45 @@ export async function handleFailedVerification( ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {}) } - const resumedState = loopState.restartAfterFailedVerification( + const clearedState = loopState.clearVerificationState( parentSessionID, messageCountAtStart, ) - if (!resumedState) { + if (!clearedState) { log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, { parentSessionID, }) return false } - await injectContinuationPrompt(ctx, { - sessionID: parentSessionID, - prompt: buildVerificationFailurePrompt(resumedState), - directory, - apiTimeoutMs, - }) + const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 } + + try { + await injectContinuationPrompt(ctx, { + sessionID: parentSessionID, + prompt: buildVerificationFailurePrompt(previewState), + directory, + apiTimeoutMs, + }) + } catch (error) { + log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, { + parentSessionID, + error: String(error), + }) + loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: `Verification continuation rejected: ${String(error)}`, + variant: "warning", + duration: 5000, + }) + return false + } + + const committed = loopState.incrementIteration() + if (!committed) { + log(`[${HOOK_NAME}] Failed to commit iteration after verification restart`, { parentSessionID }) + } await ctx.client.tui?.showToast?.({ body: { From 954aa1f12107af81b0764729a65fe6d6d28e8684 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:54:32 +0900 Subject: [PATCH 37/73] fix(compaction): isolate preservation hook failures --- src/index.compacting.test.ts | 130 +++++++++++------- ...x.compaction-model-agnostic.static.test.ts | 22 +-- src/index.ts | 33 +---- src/plugin/session-compacting.ts | 110 +++++++++++++++ 4 files changed, 210 insertions(+), 85 deletions(-) create mode 100644 src/plugin/session-compacting.ts diff --git a/src/index.compacting.test.ts b/src/index.compacting.test.ts index 2a83e4cfe..2a96cde5a 100644 --- a/src/index.compacting.test.ts +++ b/src/index.compacting.test.ts @@ -1,46 +1,9 @@ import { describe, expect, it, mock } from "bun:test" -function createCompactingHandler(hooks: { - compactionContextInjector?: { - capture: (sessionID: string) => Promise - inject: (sessionID: string) => string - } - compactionTodoPreserver?: { capture: (sessionID: string) => Promise } - claudeCodeHooks?: { - "experimental.session.compacting"?: ( - input: { sessionID: string }, - output: { context: string[] }, - ) => Promise - } -}) { - return async ( - input: { sessionID: string }, - output: { context: string[] }, - ): Promise => { - await hooks.compactionContextInjector?.capture(input.sessionID) - await hooks.compactionTodoPreserver?.capture(input.sessionID) - await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.( - input, - output, - ) - if (hooks.compactionContextInjector) { - output.context.push(hooks.compactionContextInjector.inject(input.sessionID)) - } - } -} - -function createCompactionAutocontinueHandler(hooks: { - compactionContextInjector?: { restore: (sessionID: string) => Promise } - compactionTodoPreserver?: { restore: (sessionID: string) => Promise } -}) { - return async ( - input: { sessionID: string }, - _output: { enabled: boolean }, - ): Promise => { - await hooks.compactionContextInjector?.restore(input.sessionID) - await hooks.compactionTodoPreserver?.restore(input.sessionID) - } -} +import { + createCompactionAutocontinueHandler, + createSessionCompactingHandler, +} from "./plugin/session-compacting" describe("experimental.session.compacting handler", () => { //#given all three hooks are present @@ -49,7 +12,7 @@ describe("experimental.session.compacting handler", () => { it("calls claudeCodeHooks PreCompact alongside other hooks", async () => { const callOrder: string[] = [] - const handler = createCompactingHandler({ + const handler = createSessionCompactingHandler({ compactionContextInjector: { capture: mock(async () => { callOrder.push("checkpointCapture") @@ -71,7 +34,7 @@ describe("experimental.session.compacting handler", () => { }, }) - const output = { context: [] as string[] } + const output = { context: [] as string[], prompt: undefined as string | undefined } await handler({ sessionID: "ses_test" }, output) expect(callOrder).toEqual([ @@ -87,7 +50,7 @@ describe("experimental.session.compacting handler", () => { //#when compacting handler is invoked //#then injected context from PreCompact is preserved in output it("preserves context injected by PreCompact hooks", async () => { - const handler = createCompactingHandler({ + const handler = createSessionCompactingHandler({ claudeCodeHooks: { "experimental.session.compacting": async (_input, output) => { output.context.push("precompact-injected-context") @@ -95,7 +58,7 @@ describe("experimental.session.compacting handler", () => { }, }) - const output = { context: [] as string[] } + const output = { context: [] as string[], prompt: undefined as string | undefined } await handler({ sessionID: "ses_test" }, output) expect(output.context).toContain("precompact-injected-context") @@ -109,7 +72,7 @@ describe("experimental.session.compacting handler", () => { const checkpointCaptureMock = mock(async () => {}) const contextMock = mock(() => "injected-context") - const handler = createCompactingHandler({ + const handler = createSessionCompactingHandler({ compactionContextInjector: { capture: checkpointCaptureMock, inject: contextMock, @@ -118,7 +81,7 @@ describe("experimental.session.compacting handler", () => { claudeCodeHooks: undefined, }) - const output = { context: [] as string[] } + const output = { context: [] as string[], prompt: undefined as string | undefined } await handler({ sessionID: "ses_test" }, output) expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test") @@ -133,19 +96,67 @@ describe("experimental.session.compacting handler", () => { it("does not early-return when compactionContextInjector is null", async () => { const preCompactMock = mock(async () => {}) - const handler = createCompactingHandler({ + const handler = createSessionCompactingHandler({ claudeCodeHooks: { "experimental.session.compacting": preCompactMock, }, compactionContextInjector: undefined, }) - const output = { context: [] as string[] } + const output = { context: [] as string[], prompt: undefined as string | undefined } await handler({ sessionID: "ses_test" }, output) expect(preCompactMock).toHaveBeenCalled() expect(output.context).toEqual([]) }) + + //#given a preservation hook throws while OpenCode is compacting + //#when compacting handler is invoked + //#then compaction still continues so the user does not see a failed compact + it("continues compaction when an internal preservation hook throws", async () => { + const preCompactMock = mock(async (_input, output: { context: string[] }) => { + output.context.push("precompact-context") + }) + + const handler = createSessionCompactingHandler({ + compactionContextInjector: { + capture: mock(async () => { + throw new Error("checkpoint api down") + }), + inject: mock(() => "injected-context"), + }, + compactionTodoPreserver: { + capture: mock(async () => {}), + }, + claudeCodeHooks: { + "experimental.session.compacting": preCompactMock, + }, + }) + + const output = { context: [] as string[], prompt: undefined as string | undefined } + + await expect(handler({ sessionID: "ses_test" }, output)).resolves.toBeUndefined() + expect(preCompactMock).toHaveBeenCalled() + expect(output.context).toContain("precompact-context") + }) + + //#given a PreCompact hook replaces the OpenCode compaction prompt + //#when compacting handler is invoked + //#then the prompt replacement is preserved for OpenCode + it("preserves prompt replacement from PreCompact hooks", async () => { + const handler = createSessionCompactingHandler({ + claudeCodeHooks: { + "experimental.session.compacting": mock(async (_input, output) => { + output.prompt = "custom compaction prompt" + }), + }, + }) + + const output = { context: [] as string[], prompt: undefined as string | undefined } + await handler({ sessionID: "ses_prompt" }, output) + + expect(output.prompt).toBe("custom compaction prompt") + }) }) describe("experimental.compaction.autocontinue handler", () => { @@ -177,4 +188,25 @@ describe("experimental.compaction.autocontinue handler", () => { expect(callOrder).toEqual(["context", "todos:ses_autocontinue"]) expect(output.enabled).toBe(true) }) + + it("continues autocontinue restore when one restore hook throws", async () => { + //#given + const restoreMock = mock(async () => {}) + const handler = createCompactionAutocontinueHandler({ + compactionContextInjector: { + restore: mock(async () => { + throw new Error("checkpoint restore failed") + }), + }, + compactionTodoPreserver: { restore: restoreMock }, + }) + const output = { enabled: true } + + //#when + await expect(handler({ sessionID: "ses_autocontinue" }, output)).resolves.toBeUndefined() + + //#then + expect(restoreMock).toHaveBeenCalledWith("ses_autocontinue") + expect(output.enabled).toBe(true) + }) }) diff --git a/src/index.compaction-model-agnostic.static.test.ts b/src/index.compaction-model-agnostic.static.test.ts index 91326dd28..91b427ba6 100644 --- a/src/index.compaction-model-agnostic.static.test.ts +++ b/src/index.compaction-model-agnostic.static.test.ts @@ -5,32 +5,34 @@ describe("experimental.session.compacting", () => { test("does not hardcode a model and uses output.context", () => { //#given const indexUrl = new URL("./index.ts", import.meta.url) + const compactionUrl = new URL("./plugin/session-compacting.ts", import.meta.url) const content = readFileSync(indexUrl, "utf-8") - const hookIndex = content.indexOf('"experimental.session.compacting"') + const compactionContent = readFileSync(compactionUrl, "utf-8") //#when - const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 1200) : "" + const hookIndex = content.indexOf("createSessionCompactingHandler") //#then expect(hookIndex).toBeGreaterThanOrEqual(0) - expect(content.includes('modelID: "claude-opus-4-7"')).toBe(false) - expect(hookSlice.includes("output.context.push")).toBe(true) - expect(hookSlice.includes("providerID:")).toBe(false) - expect(hookSlice.includes("modelID:")).toBe(false) + expect(`${content}\n${compactionContent}`.includes('modelID: "claude-opus-4-7"')).toBe(false) + expect(compactionContent.includes("output.context.push")).toBe(true) + expect(compactionContent.includes("providerID:")).toBe(false) + expect(compactionContent.includes("modelID:")).toBe(false) }) test("registers autocontinue restores before OpenCode synthetic continue", () => { //#given const indexUrl = new URL("./index.ts", import.meta.url) + const compactionUrl = new URL("./plugin/session-compacting.ts", import.meta.url) const content = readFileSync(indexUrl, "utf-8") - const hookIndex = content.lastIndexOf('"experimental.compaction.autocontinue"') + const compactionContent = readFileSync(compactionUrl, "utf-8") //#when - const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 500) : "" + const hookIndex = content.indexOf("createCompactionAutocontinueHandler") //#then expect(hookIndex).toBeGreaterThanOrEqual(0) - expect(hookSlice.includes("compactionContextInjector?.restore")).toBe(true) - expect(hookSlice.includes("compactionTodoPreserver?.restore")).toBe(true) + expect(compactionContent.includes("compactionContextInjector?.restore")).toBe(true) + expect(compactionContent.includes("compactionTodoPreserver?.restore")).toBe(true) }) }) diff --git a/src/index.ts b/src/index.ts index 88e6150a5..808141b32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,11 @@ import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "./create-runt import { createTools } from "./create-tools" import { initializeOpenClaw } from "./openclaw" import { createPluginInterface } from "./plugin-interface" +import { + createCompactionAutocontinueHandler, + createSessionCompactingHandler, + type CompactionAutocontinueHook, +} from "./plugin/session-compacting" import { loadPluginConfig } from "./plugin-config" import { createModelCacheState } from "./plugin-state" @@ -18,11 +23,6 @@ import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shi import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" -type CompactionAutocontinueHook = ( - input: { sessionID: string }, - output: { enabled: boolean }, -) => Promise - type HooksWithCompactionAutocontinue = Hooks & { "experimental.compaction.autocontinue"?: CompactionAutocontinueHook } @@ -117,28 +117,9 @@ const serverPlugin: Plugin = async (input, _options): Promise => { const pluginHooks: HooksWithCompactionAutocontinue = { ...pluginInterface, - "experimental.session.compacting": async ( - compactingInput: { sessionID: string }, - output: { context: string[] }, - ): Promise => { - await hooks.compactionContextInjector?.capture(compactingInput.sessionID) - await hooks.compactionTodoPreserver?.capture(compactingInput.sessionID) - await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.( - compactingInput, - output, - ) - if (hooks.compactionContextInjector) { - output.context.push(hooks.compactionContextInjector.inject(compactingInput.sessionID)) - } - }, + "experimental.session.compacting": createSessionCompactingHandler(hooks), - "experimental.compaction.autocontinue": async ( - autocontinueInput: { sessionID: string }, - _output: { enabled: boolean }, - ): Promise => { - await hooks.compactionContextInjector?.restore(autocontinueInput.sessionID) - await hooks.compactionTodoPreserver?.restore(autocontinueInput.sessionID) - }, + "experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks), } return pluginHooks diff --git a/src/plugin/session-compacting.ts b/src/plugin/session-compacting.ts new file mode 100644 index 000000000..940b029a2 --- /dev/null +++ b/src/plugin/session-compacting.ts @@ -0,0 +1,110 @@ +import type { Hooks } from "@opencode-ai/plugin" + +import { log } from "../shared/logger" + +type SessionCompactingHook = NonNullable +type SessionCompactingInput = Parameters[0] +type SessionCompactingOutput = Parameters[1] + +export type CompactionAutocontinueInput = { + sessionID: string + agent?: string + model?: unknown + provider?: unknown + message?: unknown + overflow?: boolean +} + +export type CompactionAutocontinueOutput = { + enabled: boolean +} + +export type CompactionAutocontinueHook = ( + input: CompactionAutocontinueInput, + output: CompactionAutocontinueOutput, +) => Promise + +type CompactionHookDependencies = { + compactionContextInjector?: { + capture?: (sessionID: string) => Promise + inject?: (sessionID: string) => string + restore?: (sessionID: string) => Promise + } | null + compactionTodoPreserver?: { + capture?: (sessionID: string) => Promise + restore?: (sessionID: string) => Promise + } | null + claudeCodeHooks?: { + "experimental.session.compacting"?: SessionCompactingHook + } | null +} + +async function runCompactionStep( + hook: string, + sessionID: string, + action: () => Promise | void, +): Promise { + try { + await action() + } catch (error) { + log("[session-compacting] hook execution failed", { + hook, + sessionID, + error: String(error), + }) + } +} + +export function createSessionCompactingHandler( + hooks: CompactionHookDependencies, +): SessionCompactingHook { + return async ( + input: SessionCompactingInput, + output: SessionCompactingOutput, + ): Promise => { + await runCompactionStep("compactionContextInjector.capture", input.sessionID, async () => { + const capture = hooks.compactionContextInjector?.capture + if (capture) { + await capture(input.sessionID) + } + }) + await runCompactionStep("compactionTodoPreserver.capture", input.sessionID, async () => { + const capture = hooks.compactionTodoPreserver?.capture + if (capture) { + await capture(input.sessionID) + } + }) + await runCompactionStep("claudeCodeHooks.experimental.session.compacting", input.sessionID, async () => { + await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(input, output) + }) + await runCompactionStep("compactionContextInjector.inject", input.sessionID, () => { + const inject = hooks.compactionContextInjector?.inject + const context = inject ? inject(input.sessionID) : undefined + if (context) { + output.context.push(context) + } + }) + } +} + +export function createCompactionAutocontinueHandler( + hooks: CompactionHookDependencies, +): CompactionAutocontinueHook { + return async ( + input: CompactionAutocontinueInput, + _output: CompactionAutocontinueOutput, + ): Promise => { + await runCompactionStep("compactionContextInjector.restore", input.sessionID, async () => { + const restore = hooks.compactionContextInjector?.restore + if (restore) { + await restore(input.sessionID) + } + }) + await runCompactionStep("compactionTodoPreserver.restore", input.sessionID, async () => { + const restore = hooks.compactionTodoPreserver?.restore + if (restore) { + await restore(input.sessionID) + } + }) + } +} From 35cab4db10bfc469958df3d1510d661af88bd78a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:02:07 +0900 Subject: [PATCH 38/73] test(ralph-loop): cover ownership race, verification commit failure, session.create throw Three additional invariant tests addressing the gaps surfaced by Cubic and the post-implementation review: - idle path must not dispatch when state ownership changes during the idleSettleMs window - verification-failure path must treat incrementIteration failure as a loud failure, not a success - reset strategy must surface session.create rejections as session_creation_rejected even when the SDK throws instead of returning an error envelope --- .../dispatch-failure-invariant.test.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts index 174ce459a..096a01aad 100644 --- a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts +++ b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path" import { createRalphLoopHook } from "./index" import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" import { clearState, writeState } from "./storage" +import { handleFailedVerification } from "./verification-failure-handler" describe("ralph-loop dispatch failure invariants", () => { const testDirectory = join(tmpdir(), `ralph-loop-dispatch-failure-${Date.now()}`) @@ -251,4 +252,161 @@ describe("ralph-loop dispatch failure invariants", () => { expect(createSessionCalls).toHaveLength(1) expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true) }) + + test("#given idle path #when state rebound during settle window #then no dispatch against new owner", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async () => ({ data: [] }), + promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" }) + return {} + }, + prompt: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never, { + idleSettleMs: 50, + }) + + hook.startLoop("session-A", "Keep working", { messageCountAtStart: 0, maxIterations: 5 }) + expect(hook.getState()?.session_id).toBe("session-A") + + // when + const eventPromise = hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-A" } }, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + writeState(testDirectory, { ...hook.getState()!, session_id: "session-B" }) + await eventPromise + + // then + expect(promptCalls).toHaveLength(0) + expect(hook.getState()?.session_id).toBe("session-B") + expect(hook.getState()?.iteration).toBe(1) + }) + + test("#given verification-failure path #when incrementIteration fails #then loud failure not success", async () => { + // given + const loopState = { + clearVerificationState: () => ({ + active: true, + iteration: 2, + prompt: "Build API", + started_at: new Date().toISOString(), + session_id: "session-123", + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + message_count_at_start: 3, + }), + incrementIteration: () => null, + clear: () => true, + } + + const result = await handleFailedVerification({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async () => ({ data: [{}, {}, {}] }), + promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" }) + return {} + }, + abort: async () => ({}), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never, { + state: { + active: true, + iteration: 2, + prompt: "Build API", + started_at: new Date().toISOString(), + session_id: "session-123", + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + verification_pending: true, + verification_session_id: "ses-oracle", + }, + directory: testDirectory, + apiTimeoutMs: 5000, + loopState, + }) + + // then + expect(result).toBe(false) + expect(promptCalls).toHaveLength(1) + expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false) + expect( + toastCalls.some( + (toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("iteration commit failed"), + ), + ).toBe(true) + }) + + test("#given reset strategy #when session.create throws #then dispatch failure surfaces", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async () => ({ data: [] }), + promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" }) + return {} + }, + prompt: async () => ({}), + create: async () => { + throw new Error("simulated network error during session.create") + }, + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + strategy: "reset", + }) + + // when + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then + expect(hook.getState()).toBeNull() + expect(promptCalls).toHaveLength(0) + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true) + }) }) From 2e36a92c25458572d12d2da3382e9223962a88c2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:02:16 +0900 Subject: [PATCH 39/73] fix(ralph-loop): revalidate ownership and harden commit/session-creation failures Three correctness fixes on top of the dispatch-before-commit invariant: - ralph-loop-event-handler.ts: after idleSettleMs, also require state ownership and non-verification-pending to match the event source before dispatching. Applied to both the session.idle and session.error retry paths. - verification-failure-handler.ts: if incrementIteration fails after a successful continuation injection, clear the loop state and emit a warning toast instead of returning success. - session-reset-strategy.ts: catch thrown session.create errors so they route through the typed session_creation_rejected path instead of surfacing as an unhandled rejection. --- .../ralph-loop/ralph-loop-event-handler.ts | 22 +++++++++++++ .../ralph-loop/session-reset-strategy.ts | 32 ++++++++++++------- .../verification-failure-handler.ts | 8 +++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 3af7c1a3a..167454b58 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -278,6 +278,17 @@ export function createRalphLoopEventHandler( if (!stateAfterSettle || !stateAfterSettle.active) { return } + if (stateAfterSettle.session_id !== undefined && stateAfterSettle.session_id !== sessionID) { + log(`[${HOOK_NAME}] Skipped: state rebound during settle window`, { + sessionID, + currentOwner: stateAfterSettle.session_id, + }) + return + } + if (stateAfterSettle.verification_pending) { + log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID }) + return + } const nextIteration = stateAfterSettle.iteration + 1 const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration } @@ -401,6 +412,17 @@ export function createRalphLoopEventHandler( if (!stateAfterSettle || !stateAfterSettle.active) { return } + if (stateAfterSettle.session_id !== undefined && stateAfterSettle.session_id !== sessionID) { + log(`[${HOOK_NAME}] Skipped: state rebound during settle window`, { + sessionID, + currentOwner: stateAfterSettle.session_id, + }) + return + } + if (stateAfterSettle.verification_pending) { + log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID }) + return + } const nextIteration = stateAfterSettle.iteration + 1 const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration } diff --git a/src/hooks/ralph-loop/session-reset-strategy.ts b/src/hooks/ralph-loop/session-reset-strategy.ts index d6854727d..bf8d3b5af 100644 --- a/src/hooks/ralph-loop/session-reset-strategy.ts +++ b/src/hooks/ralph-loop/session-reset-strategy.ts @@ -7,23 +7,31 @@ export async function createIterationSession( parentSessionID: string, directory: string, ): Promise { - const createResult = await ctx.client.session.create({ - body: { - parentID: parentSessionID, - title: "Ralph Loop Iteration", - }, - query: { directory }, - }) + try { + const createResult = await ctx.client.session.create({ + body: { + parentID: parentSessionID, + title: "Ralph Loop Iteration", + }, + query: { directory }, + }) - if (createResult.error || !createResult.data?.id) { - log("[ralph-loop] Failed to create iteration session", { + if (createResult.error || !createResult.data?.id) { + log("[ralph-loop] Failed to create iteration session", { + parentSessionID, + error: String(createResult.error ?? "No session ID returned"), + }) + return null + } + + return createResult.data.id + } catch (error: unknown) { + log("[ralph-loop] session.create threw during iteration session creation", { parentSessionID, - error: String(createResult.error ?? "No session ID returned"), + error: String(error), }) return null } - - return createResult.data.id } export async function selectSessionInTui( diff --git a/src/hooks/ralph-loop/verification-failure-handler.ts b/src/hooks/ralph-loop/verification-failure-handler.ts index 6b8e2d2cb..89917f033 100644 --- a/src/hooks/ralph-loop/verification-failure-handler.ts +++ b/src/hooks/ralph-loop/verification-failure-handler.ts @@ -122,6 +122,14 @@ export async function handleFailedVerification( const committed = loopState.incrementIteration() if (!committed) { log(`[${HOOK_NAME}] Failed to commit iteration after verification restart`, { parentSessionID }) + loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: "Verification continuation dispatched but iteration commit failed", + variant: "warning", + duration: 5000, + }) + return false } await ctx.client.tui?.showToast?.({ From 0fd918ea80d2fbd9bb59d4c774decd41e8eb1890 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:08:50 +0900 Subject: [PATCH 40/73] fix(delegate-task): preserve native task metadata --- src/plugin/tool-execute-after.test.ts | 30 +++++++++++++++++++++++++++ src/plugin/tool-execute-after.ts | 3 ++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/plugin/tool-execute-after.test.ts b/src/plugin/tool-execute-after.test.ts index 7c8e9d87c..f6b55eea2 100644 --- a/src/plugin/tool-execute-after.test.ts +++ b/src/plugin/tool-execute-after.test.ts @@ -92,4 +92,34 @@ describe("createToolExecuteAfterHandler", () => { expect(output.title).toBe("stored title") expect(output.metadata).toEqual({ sessionId: "ses_native", agent: "hephaestus" }) }) + + it("#given native session linkage without model #when stored metadata exists #then required task metadata is preserved", async () => { + // given + const model = { providerID: "openai", modelID: "gpt-5.5" } + storeToolMetadata("ses_parent", "call_model", { + title: "stored title", + metadata: { sessionId: "ses_stored", agent: "oracle", model }, + }) + + const handler = createToolExecuteAfterHandler({ + ctx: {} as never, + hooks: {} as never, + }) + + const output = { + title: "result", + output: "original output", + metadata: { sessionId: "ses_native", agent: "hephaestus" }, + } + + // when + await handler( + { tool: "task", sessionID: "ses_parent", callID: "call_model" }, + output + ) + + // then + expect(output.title).toBe("stored title") + expect(output.metadata).toEqual({ sessionId: "ses_native", agent: "hephaestus", model }) + }) }) diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 1fc78f5f7..7cfeb65b4 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -59,12 +59,13 @@ export function createToolExecuteAfterHandler(args: { } if (stored.metadata) { if (nativeSessionId) { - log("[tool-execute-after] Native output metadata already includes session linkage; skipping stored metadata overwrite", { + log("[tool-execute-after] Native output metadata already includes session linkage; preserving native metadata precedence", { tool: input.tool, sessionID: input.sessionID, callID: input.callID ?? input.callId ?? input.call_id, nativeSessionId, }) + output.metadata = { ...stored.metadata, ...output.metadata } } else { output.metadata = { ...output.metadata, ...stored.metadata } } From 246e0dca8043e3a5b96328bfe70b564cf1fe2083 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:24:28 +0900 Subject: [PATCH 41/73] feat(boulder-state): add BoulderWorkState and timing fields to types Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/boulder-state/types.test.ts | 78 ++++++++++++++++++++++++ src/features/boulder-state/types.ts | 46 ++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 src/features/boulder-state/types.test.ts diff --git a/src/features/boulder-state/types.test.ts b/src/features/boulder-state/types.test.ts new file mode 100644 index 000000000..15d2ea10c --- /dev/null +++ b/src/features/boulder-state/types.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import type { + BoulderSessionOrigin, + BoulderState, + BoulderTaskStatus, + BoulderWorkResumeOption, + BoulderWorkState, + BoulderWorkStatus, + PlanProgress, + TaskSessionState, +} from "./types" + +describe("boulder-state types", () => { + test("keeps legacy BoulderState assignable while allowing v2 fields", () => { + // given + const legacyState: BoulderState = { + active_plan: "/tmp/plan.md", + started_at: "2026-01-01T00:00:00.000Z", + session_ids: ["ses_1"], + plan_name: "plan", + } + + // when + const hasLegacyShape = legacyState.active_plan.length > 0 + + // then + expect(hasLegacyShape).toBe(true) + }) + + test("supports multi-work and timer fields", () => { + // given + const taskStatus: BoulderTaskStatus = "running" + const workStatus: BoulderWorkStatus = "active" + const origin: BoulderSessionOrigin = "direct" + + const taskSession: TaskSessionState = { + task_key: "todo:1", + task_label: "1", + task_title: "Do work", + session_id: "ses_task", + started_at: "2026-01-01T00:00:00.000Z", + ended_at: "2026-01-01T00:00:01.000Z", + elapsed_ms: 1000, + status: taskStatus, + updated_at: "2026-01-01T00:00:01.000Z", + } + + const work: BoulderWorkState = { + work_id: "plan-abc12345", + active_plan: "/tmp/plan.md", + plan_name: "plan", + status: workStatus, + started_at: "2026-01-01T00:00:00.000Z", + session_ids: ["ses_1"], + session_origins: { ses_1: origin }, + task_sessions: { "todo:1": taskSession }, + } + + const progress: PlanProgress = { total: 2, completed: 1, isComplete: false } + const resumeOption: BoulderWorkResumeOption = { + work_id: work.work_id, + plan_name: work.plan_name, + active_plan: work.active_plan, + status: "paused", + started_at: work.started_at, + updated_at: "2026-01-01T00:00:02.000Z", + session_count: 1, + progress, + is_current_mirror: false, + } + + // when + const combined = { taskSession, work, resumeOption } + + // then + expect(combined.resumeOption.progress.total).toBe(2) + }) +}) diff --git a/src/features/boulder-state/types.ts b/src/features/boulder-state/types.ts index f41bc1bf8..15ac41ab5 100644 --- a/src/features/boulder-state/types.ts +++ b/src/features/boulder-state/types.ts @@ -6,10 +6,17 @@ */ export interface BoulderState { + schema_version?: 2 + active_work_id?: string + works?: Record /** Absolute path to the active plan file */ active_plan: string /** ISO timestamp when work started */ started_at: string + ended_at?: string + elapsed_ms?: number + status?: BoulderWorkStatus + updated_at?: string /** Session IDs that have worked on this plan */ session_ids: string[] session_origins?: Record @@ -23,6 +30,26 @@ export interface BoulderState { task_sessions?: Record } +export type BoulderSessionOrigin = "direct" | "appended" +export type BoulderWorkStatus = "active" | "completed" | "paused" | "abandoned" +export type BoulderTaskStatus = "running" | "completed" | "cancelled" + +export interface BoulderWorkState { + work_id: string + active_plan: string + plan_name: string + status?: BoulderWorkStatus + started_at: string + ended_at?: string + elapsed_ms?: number + updated_at?: string + session_ids: string[] + session_origins?: Record + agent?: string + worktree_path?: string + task_sessions?: Record +} + export interface PlanProgress { /** Total number of checkboxes */ total: number @@ -45,10 +72,29 @@ export interface TaskSessionState { agent?: string /** Category associated with the task session, when known */ category?: string + started_at?: string + ended_at?: string + elapsed_ms?: number + status?: BoulderTaskStatus /** Last update timestamp */ updated_at: string } +export interface BoulderWorkResumeOption { + work_id: string + plan_name: string + active_plan: string + worktree_path?: string + status: BoulderWorkStatus + started_at: string + updated_at: string + ended_at?: string + elapsed_ms?: number + session_count: number + progress: PlanProgress + is_current_mirror: boolean +} + export interface TopLevelTaskRef { /** Stable identifier for the current top-level plan task */ key: string From 49ff4b5f8dd03809815ed6f9649f6c8e400830b3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:25:29 +0900 Subject: [PATCH 42/73] fix(compaction): ignore compaction agent updates Fixes #3819 --- src/hooks/context-window-monitor.test.ts | 39 ++++++++++++ src/hooks/context-window-monitor.ts | 3 + src/hooks/preemptive-compaction.test.ts | 76 +++++++++++++++++++++++- src/hooks/preemptive-compaction.ts | 3 + 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/hooks/context-window-monitor.test.ts b/src/hooks/context-window-monitor.test.ts index 75453b9c5..b664717a6 100644 --- a/src/hooks/context-window-monitor.test.ts +++ b/src/hooks/context-window-monitor.test.ts @@ -235,6 +235,45 @@ describe("context-window-monitor", () => { expect(output.output).toContain("context remaining") }) + // #given only a compaction agent summary message update is seen + // #when tool.execute.after checks context usage + // #then stale pre-compaction tokens should not create a context reminder + it("should ignore compaction-agent message updates when caching context usage", async () => { + const hook = createContextWindowMonitorHook(ctx as never) + const sessionID = "ses_compaction_agent_context" + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + agent: "compaction", + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 150000, + output: 1000, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + const output = { title: "", output: "original", metadata: null } + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + output + ) + + expect(output.output).toBe("original") + expect(ctx.client.session.messages).not.toHaveBeenCalled() + }) + // #given session is deleted // #when session.deleted event fires // #then cached data should be cleaned up diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index 0f60be926..acdeee1c4 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -3,6 +3,7 @@ import { resolveActualContextLimit, type ContextLimitModelCacheState, } from "../shared/context-limit-resolver" +import { isCompactionAgent } from "../shared/compaction-marker" import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" const CONTEXT_WARNING_THRESHOLD = 0.70 @@ -94,6 +95,7 @@ export function createContextWindowMonitorHook( if (event.type === "message.updated") { const info = props?.info as { + agent?: unknown role?: string sessionID?: string providerID?: string @@ -103,6 +105,7 @@ export function createContextWindowMonitorHook( } | undefined if (!info || info.role !== "assistant" || !info.finish) return + if (isCompactionAgent(info.agent)) return if (!info.sessionID || !info.providerID || !info.tokens) return tokenCache.set(info.sessionID, { diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index 09cbf83dc..ebf90c208 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -55,7 +55,9 @@ function setupImmediateTimeouts(): () => void { globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => { callback(...args) - return 1 as unknown as ReturnType + const timeoutID = originalSetTimeout(() => undefined, 0) + originalClearTimeout(timeoutID) + return timeoutID }) as typeof setTimeout globalThis.clearTimeout = (() => {}) as typeof clearTimeout @@ -637,6 +639,78 @@ describe("preemptive-compaction", () => { Date.now = originalNow }) + // #given compaction already succeeded for a session + // #when the compaction agent emits its summary message update + // #then it should not clear the compaction guard or trigger a duplicate summary + it("should ignore compaction-agent message updates after successful compaction", async () => { + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_compaction_agent_update" + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + + const originalNow = Date.now + try { + Date.now = () => originalNow() + 61_000 + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + agent: "compaction", + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_2" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + } finally { + Date.now = originalNow + } + }) + // #given modelContextLimitsCache has model-specific limit (256k) // #when tokens are above default 78% of 200k but below 78% of 256k // #then should NOT trigger compaction diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index a8da4b91e..b1e46b689 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -1,4 +1,5 @@ import type { OhMyOpenCodeConfig } from "../config" +import { isCompactionAgent } from "../shared/compaction-marker" import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" @@ -70,6 +71,7 @@ export function createPreemptiveCompactionHook( if (event.type === "message.updated") { const info = props?.info as { id?: string + agent?: unknown role?: string sessionID?: string providerID?: string @@ -80,6 +82,7 @@ export function createPreemptiveCompactionHook( } | undefined if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return + if (isCompactionAgent(info.agent)) return if (info.providerID && info.tokens) { tokenCache.set(info.sessionID, { From 9f500743d12c805954609ee49ac49be7081fe98d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:28:58 +0900 Subject: [PATCH 43/73] feat(boulder-state): add session-aware multi-work storage helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/boulder-state/storage.test.ts | 157 +++++++ src/features/boulder-state/storage.ts | 499 ++++++++++++++++++++- 2 files changed, 651 insertions(+), 5 deletions(-) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index c424e02eb..6675b0e97 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -3,17 +3,28 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" import { tmpdir } from "node:os" import { + addBoulderWork, + appendSessionIdForWork, + getActiveWorks, + getBoulderWorks, readBoulderState, writeBoulderState, appendSessionId, clearBoulderState, + getWorkById, + getWorkByPlanName, + getWorkForSession, + getWorkResumeOptions, getPlanProgress, getPlanName, createBoulderState, findPrometheusPlans, getTaskSessionState, resolveBoulderPlanPath, + resolveBoulderPlanPathForWork, + selectActiveWork, upsertTaskSessionState, + upsertTaskSessionStateForWork, } from "./storage" import type { BoulderState } from "./types" import { readCurrentTopLevelTask } from "./top-level-task" @@ -39,6 +50,31 @@ describe("boulder-state", () => { }) describe("readBoulderState", () => { + test("should preserve legacy boulder.json fields during round-trip", () => { + // given + const boulderFile = join(SISYPHUS_DIR, "boulder.json") + const legacyRawState = { + active_plan: "/path/to/legacy-plan.md", + started_at: "2026-01-01T00:00:00.000Z", + session_ids: ["legacy-session"], + plan_name: "legacy-plan", + } + writeFileSync(boulderFile, JSON.stringify(legacyRawState, null, 2), "utf-8") + + // when + const state = readBoulderState(TEST_DIR) + expect(state).not.toBeNull() + const writeSucceeded = writeBoulderState(TEST_DIR, state!) + const roundTripState = readBoulderState(TEST_DIR) + + // then + expect(writeSucceeded).toBe(true) + expect(roundTripState?.active_plan).toBe(legacyRawState.active_plan) + expect(roundTripState?.started_at).toBe(legacyRawState.started_at) + expect(roundTripState?.session_ids).toEqual(legacyRawState.session_ids) + expect(roundTripState?.plan_name).toBe(legacyRawState.plan_name) + }) + test("should return null when no boulder.json exists", () => { // given - no boulder.json file // when @@ -387,6 +423,127 @@ describe("boulder-state", () => { }) }) + describe("multi-work helpers", () => { + test("should add second work and keep both active works", () => { + // given + const firstState = createBoulderState( + join(TEST_DIR, ".sisyphus/plans/plan-a.md"), + "session-a", + "atlas", + "/worktree-a", + ) + writeBoulderState(TEST_DIR, firstState) + const firstWorkId = firstState.active_work_id + + // when + const updatedState = addBoulderWork(TEST_DIR, { + planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"), + sessionId: "session-b", + agent: "atlas", + worktreePath: "/worktree-b", + }) + + // then + expect(updatedState).not.toBeNull() + const works = updatedState?.works ?? {} + expect(Object.keys(works).length).toBe(2) + expect(firstWorkId).toBeDefined() + expect(works[firstWorkId!]).toBeDefined() + expect(updatedState?.active_plan).toContain("plan-b.md") + expect(getActiveWorks(TEST_DIR).length).toBe(2) + }) + + test("should resolve work for session using updated_at tie-break", () => { + // given + const baseState = createBoulderState( + join(TEST_DIR, ".sisyphus/plans/plan-a.md"), + "session-a", + ) + writeBoulderState(TEST_DIR, baseState) + const stateWithSecond = addBoulderWork(TEST_DIR, { + planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"), + sessionId: "session-b", + }) + expect(stateWithSecond).not.toBeNull() + + const workIds = Object.keys(stateWithSecond!.works ?? {}) + expect(workIds.length).toBe(2) + const firstWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-a")! + const secondWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-b")! + + appendSessionIdForWork(TEST_DIR, secondWorkId, "session-a", "appended") + appendSessionIdForWork(TEST_DIR, firstWorkId, "session-a", "appended") + + // when + const resolvedWork = getWorkForSession(TEST_DIR, "session-a") + + // then + expect(resolvedWork?.work_id).toBe(firstWorkId) + }) + + test("should support selecting active work and read helpers", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const added = addBoulderWork(TEST_DIR, { + planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"), + sessionId: "session-b", + worktreePath: "/tmp/worktree-b", + }) + expect(added).not.toBeNull() + const firstWork = getWorkByPlanName(TEST_DIR, "plan-a") + expect(firstWork).not.toBeNull() + + // when + const selected = selectActiveWork(TEST_DIR, firstWork!.work_id) + const selectedById = getWorkById(TEST_DIR, firstWork!.work_id) + const byPlanNameWithWorktree = getWorkByPlanName(TEST_DIR, "plan-b", { worktreePath: "/tmp/worktree-b" }) + const byPlanPath = resolveBoulderPlanPathForWork(TEST_DIR, firstWork!) + const resumeOptions = getWorkResumeOptions(TEST_DIR) + const worksFromState = getBoulderWorks(selected!) + + // then + expect(selected?.active_work_id).toBe(firstWork!.work_id) + expect(selectedById?.work_id).toBe(firstWork!.work_id) + expect(byPlanNameWithWorktree?.plan_name).toBe("plan-b") + expect(byPlanPath.endsWith("plan-a.md")).toBe(true) + expect(resumeOptions.length).toBe(2) + expect(worksFromState.length).toBe(2) + }) + + test("should upsert task session for specific work and keep first started_at", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const workId = initialState.active_work_id! + + upsertTaskSessionStateForWork(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "task-session-a", + }) + + const seededState = readBoulderState(TEST_DIR)! + seededState.works![workId]!.task_sessions!["todo:1"]!.started_at = "2026-01-01T00:00:00.000Z" + writeBoulderState(TEST_DIR, seededState) + + // when + const updated = upsertTaskSessionStateForWork(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "task-session-b", + }) + + // then + expect(updated).not.toBeNull() + const taskSession = updated?.works?.[workId]?.task_sessions?.["todo:1"] + expect(taskSession?.session_id).toBe("task-session-b") + expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z") + }) + }) + describe("readCurrentTopLevelTask", () => { test("should return the first unchecked top-level task in TODOs", () => { // given - plan with nested and top-level unchecked tasks diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index e11aa31ca..b9561562e 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -6,11 +6,93 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs" import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" -import type { BoulderState, PlanProgress, TaskSessionState } from "./types" +import type { + BoulderSessionOrigin, + BoulderState, + BoulderWorkResumeOption, + BoulderWorkState, + BoulderWorkStatus, + PlanProgress, + TaskSessionState, +} from "./types" import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants" const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"]) +function nowIsoString(): string { + return new Date().toISOString() +} + +function parseIsoToMs(value: string | undefined): number | null { + if (!value) { + return null + } + + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed +} + +function isValidWorkStatus(status: unknown): status is BoulderWorkStatus { + return status === "active" || status === "completed" || status === "paused" || status === "abandoned" +} + +function buildWorkFromMirror(state: BoulderState): BoulderWorkState { + const planName = state.plan_name ?? getPlanName(state.active_plan) + const workId = `${planName}-legacy` + return { + work_id: workId, + active_plan: state.active_plan, + plan_name: planName, + status: state.status, + started_at: state.started_at, + ended_at: state.ended_at, + elapsed_ms: state.elapsed_ms, + updated_at: state.updated_at, + session_ids: Array.isArray(state.session_ids) ? [...state.session_ids] : [], + session_origins: state.session_origins, + agent: state.agent, + worktree_path: state.worktree_path, + task_sessions: state.task_sessions, + } +} + +function projectWorkToMirror(state: BoulderState, work: BoulderWorkState): void { + state.active_plan = work.active_plan + state.plan_name = work.plan_name + state.status = work.status + state.started_at = work.started_at + state.ended_at = work.ended_at + state.elapsed_ms = work.elapsed_ms + state.updated_at = work.updated_at + state.session_ids = [...work.session_ids] + state.session_origins = work.session_origins ? { ...work.session_origins } : {} + state.agent = work.agent + state.worktree_path = work.worktree_path + state.task_sessions = work.task_sessions ? { ...work.task_sessions } : {} +} + +function selectMirrorWork(state: BoulderState): BoulderWorkState | null { + const works = getBoulderWorks(state) + if (works.length === 0) { + return null + } + + if (state.active_work_id) { + const matched = works.find((work) => work.work_id === state.active_work_id) + if (matched) { + return matched + } + } + + const sorted = [...works].sort((left, right) => { + const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0 + const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0 + return rightMs - leftMs + }) + + return sorted[0] ?? null +} + export function getBoulderFilePath(directory: string): string { return join(directory, BOULDER_DIR, BOULDER_FILE) } @@ -80,7 +162,15 @@ export function readBoulderState(directory: string): BoulderState | null { if (!parsed.task_sessions || typeof parsed.task_sessions !== "object" || Array.isArray(parsed.task_sessions)) { parsed.task_sessions = {} } - return parsed as BoulderState + + const state = parsed as BoulderState + const mirrorWork = selectMirrorWork(state) + if (mirrorWork) { + state.active_work_id = mirrorWork.work_id + projectWorkToMirror(state, mirrorWork) + } + + return state } catch { return null } @@ -95,7 +185,33 @@ export function writeBoulderState(directory: string, state: BoulderState): boole mkdirSync(dir, { recursive: true }) } - writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8") + const stateToWrite: BoulderState = { ...state } + if (stateToWrite.works && stateToWrite.active_work_id) { + const activeWork = stateToWrite.works[stateToWrite.active_work_id] + if (activeWork) { + const nextActiveWork: BoulderWorkState = { + ...activeWork, + active_plan: stateToWrite.active_plan, + plan_name: stateToWrite.plan_name, + status: stateToWrite.status, + started_at: stateToWrite.started_at, + ended_at: stateToWrite.ended_at, + elapsed_ms: stateToWrite.elapsed_ms, + updated_at: stateToWrite.updated_at, + session_ids: [...stateToWrite.session_ids], + session_origins: stateToWrite.session_origins ? { ...stateToWrite.session_origins } : {}, + agent: stateToWrite.agent, + worktree_path: stateToWrite.worktree_path, + task_sessions: stateToWrite.task_sessions ? { ...stateToWrite.task_sessions } : {}, + } + stateToWrite.works = { + ...stateToWrite.works, + [stateToWrite.active_work_id]: nextActiveWork, + } + } + } + + writeFileSync(filePath, JSON.stringify(stateToWrite, null, 2), "utf-8") return true } catch { return false @@ -107,6 +223,11 @@ export function appendSessionId( sessionId: string, origin: "direct" | "appended" = "direct", ): BoulderState | null { + const activeWorkId = readBoulderState(directory)?.active_work_id + if (activeWorkId) { + return appendSessionIdForWork(directory, activeWorkId, sessionId, origin) + } + const state = readBoulderState(directory) if (!state) return null @@ -156,6 +277,14 @@ export function clearBoulderState(directory: string): boolean { export function getTaskSessionState(directory: string, taskKey: string): TaskSessionState | null { const state = readBoulderState(directory) + if (state?.active_work_id) { + const work = state.works?.[state.active_work_id] + const taskSession = work?.task_sessions?.[taskKey] + if (taskSession) { + return taskSession + } + } + if (!state?.task_sessions) { return null } @@ -174,6 +303,11 @@ export function upsertTaskSessionState( category?: string }, ): BoulderState | null { + const stateForWork = readBoulderState(directory) + if (stateForWork?.active_work_id) { + return upsertTaskSessionStateForWork(directory, stateForWork.active_work_id, input) + } + const state = readBoulderState(directory) if (!state) { return null @@ -355,15 +489,370 @@ export function createBoulderState( agent?: string, worktreePath?: string, ): BoulderState { - return { + const startedAt = nowIsoString() + const workId = generateWorkId(getPlanName(planPath)) + const work: BoulderWorkState = { + work_id: workId, active_plan: planPath, - started_at: new Date().toISOString(), + plan_name: getPlanName(planPath), + status: "active", + started_at: startedAt, + updated_at: startedAt, + session_ids: [sessionId], + session_origins: { + [sessionId]: "direct", + }, + ...(agent !== undefined ? { agent } : {}), + ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), + task_sessions: {}, + } + + return { + schema_version: 2, + active_work_id: workId, + works: { + [workId]: work, + }, + active_plan: planPath, + started_at: startedAt, + status: "active", + updated_at: startedAt, session_ids: [sessionId], session_origins: { [sessionId]: "direct", }, plan_name: getPlanName(planPath), + task_sessions: {}, ...(agent !== undefined ? { agent } : {}), ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), } } + +export function generateWorkId(planName: string): string { + const slug = planName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + const randomHex = Math.floor(Math.random() * 0xffffffff) + .toString(16) + .padStart(8, "0") + const safeSlug = slug.length > 0 ? slug : "work" + return `${safeSlug}-${randomHex}` +} + +export function getBoulderWorks(state: BoulderState): BoulderWorkState[] { + if (state.works && typeof state.works === "object") { + return Object.values(state.works) + } + + if (!state.active_plan || !state.plan_name || !state.started_at) { + return [] + } + + return [buildWorkFromMirror(state)] +} + +export function getActiveWorks(directory: string): BoulderWorkState[] { + const state = readBoulderState(directory) + if (!state) { + return [] + } + + return getBoulderWorks(state).filter((work) => work.status !== "completed" && work.status !== "abandoned") +} + +export function getWorkById(directory: string, workId: string): BoulderWorkState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + return getBoulderWorks(state).find((work) => work.work_id === workId) ?? null +} + +export function getWorkByPlanName( + directory: string, + planName: string, + options?: { worktreePath?: string }, +): BoulderWorkState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const worktreePath = options?.worktreePath + return getBoulderWorks(state).find((work) => { + if (work.plan_name !== planName) { + return false + } + + if (!worktreePath) { + return true + } + + return work.worktree_path === worktreePath + }) ?? null +} + +export function getWorkForSession(directory: string, sessionId: string): BoulderWorkState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const works = getBoulderWorks(state) + .filter((work) => work.session_ids.includes(sessionId)) + .sort((left, right) => { + const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0 + const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0 + return rightMs - leftMs + }) + + if (works.length > 0) { + return works[0] ?? null + } + + if (state.session_ids.includes(sessionId)) { + return buildWorkFromMirror(state) + } + + return null +} + +export function resolveBoulderPlanPathForWork( + directory: string, + work: Pick, +): string { + return resolveBoulderPlanPath(directory, work) +} + +export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] { + const state = readBoulderState(directory) + if (!state) { + return [] + } + + return getActiveWorks(directory).map((work) => { + const progress = getPlanProgress(resolveBoulderPlanPathForWork(directory, work)) + return { + work_id: work.work_id, + plan_name: work.plan_name, + active_plan: work.active_plan, + worktree_path: work.worktree_path, + status: work.status && isValidWorkStatus(work.status) ? work.status : "active", + started_at: work.started_at, + updated_at: work.updated_at ?? work.started_at, + ended_at: work.ended_at, + elapsed_ms: work.elapsed_ms, + session_count: work.session_ids.length, + progress, + is_current_mirror: state.active_work_id === work.work_id, + } + }) +} + +export function selectActiveWork(directory: string, workId: string): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const works = getBoulderWorks(state) + const nextWork = works.find((work) => work.work_id === workId) + if (!nextWork) { + return null + } + + const nextState: BoulderState = { + ...state, + schema_version: 2, + active_work_id: workId, + works: state.works ?? Object.fromEntries(works.map((work) => [work.work_id, work])), + } + projectWorkToMirror(nextState, nextWork) + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} + +export function addBoulderWork( + directory: string, + input: { + planPath: string + sessionId: string + agent?: string + worktreePath?: string + startedAt?: string + }, +): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const workId = generateWorkId(getPlanName(input.planPath)) + const startedAt = input.startedAt ?? nowIsoString() + const nextWork: BoulderWorkState = { + work_id: workId, + active_plan: input.planPath, + plan_name: getPlanName(input.planPath), + status: "active", + started_at: startedAt, + updated_at: startedAt, + session_ids: [input.sessionId], + session_origins: { + [input.sessionId]: "direct", + }, + ...(input.agent !== undefined ? { agent: input.agent } : {}), + ...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}), + task_sessions: {}, + } + + const works = getBoulderWorks(state) + const nextWorks: Record = { + ...Object.fromEntries(works.map((work) => [work.work_id, work])), + [workId]: nextWork, + } + + const nextState: BoulderState = { + ...state, + schema_version: 2, + works: nextWorks, + active_work_id: workId, + } + projectWorkToMirror(nextState, nextWork) + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} + +export function appendSessionIdForWork( + directory: string, + workId: string, + sessionId: string, + origin: BoulderSessionOrigin = "direct", +): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const works = getBoulderWorks(state) + const targetWork = works.find((work) => work.work_id === workId) + if (!targetWork) { + return null + } + + const sessionIds = targetWork.session_ids.includes(sessionId) + ? [...targetWork.session_ids] + : [...targetWork.session_ids, sessionId] + const sessionOrigins = { + ...(targetWork.session_origins ?? {}), + [sessionId]: origin, + } + + const updatedWork: BoulderWorkState = { + ...targetWork, + session_ids: sessionIds, + session_origins: sessionOrigins, + updated_at: nowIsoString(), + } + const nextWorks = { + ...Object.fromEntries(works.map((work) => [work.work_id, work])), + [workId]: updatedWork, + } + + const nextState: BoulderState = { + ...state, + schema_version: 2, + works: nextWorks, + } + if (state.active_work_id === workId) { + projectWorkToMirror(nextState, updatedWork) + } + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} + +export function upsertTaskSessionStateForWork( + directory: string, + workId: string, + input: { + taskKey: string + taskLabel: string + taskTitle: string + sessionId: string + agent?: string + category?: string + }, +): BoulderState | null { + if (RESERVED_KEYS.has(input.taskKey)) { + return null + } + + const state = readBoulderState(directory) + if (!state) { + return null + } + + const works = getBoulderWorks(state) + const targetWork = works.find((work) => work.work_id === workId) + if (!targetWork) { + return null + } + + const previousTaskSession = targetWork.task_sessions?.[input.taskKey] + const nextTaskSession: TaskSessionState = { + task_key: input.taskKey, + task_label: input.taskLabel, + task_title: input.taskTitle, + session_id: input.sessionId, + ...(input.agent !== undefined ? { agent: input.agent } : {}), + ...(input.category !== undefined ? { category: input.category } : {}), + ...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}), + ...(previousTaskSession?.ended_at !== undefined ? { ended_at: previousTaskSession.ended_at } : {}), + ...(previousTaskSession?.elapsed_ms !== undefined ? { elapsed_ms: previousTaskSession.elapsed_ms } : {}), + ...(previousTaskSession?.status !== undefined ? { status: previousTaskSession.status } : {}), + updated_at: nowIsoString(), + } + + const nextWork: BoulderWorkState = { + ...targetWork, + task_sessions: { + ...(targetWork.task_sessions ?? {}), + [input.taskKey]: nextTaskSession, + }, + updated_at: nowIsoString(), + } + + const nextWorks = { + ...Object.fromEntries(works.map((work) => [work.work_id, work])), + [workId]: nextWork, + } + + const nextState: BoulderState = { + ...state, + schema_version: 2, + works: nextWorks, + } + if (state.active_work_id === workId) { + projectWorkToMirror(nextState, nextWork) + } + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} From 5d823b5078f46a942f382d6bb3ae0f12cd0561c7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:30:29 +0900 Subject: [PATCH 44/73] feat(boulder-state): add task timer + completion helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/boulder-state/storage.test.ts | 80 ++++++++++++++ src/features/boulder-state/storage.ts | 115 +++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 6675b0e97..63e43faff 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os" import { addBoulderWork, appendSessionIdForWork, + completeBoulder, + endTaskTimer, getActiveWorks, getBoulderWorks, readBoulderState, @@ -23,6 +25,7 @@ import { resolveBoulderPlanPath, resolveBoulderPlanPathForWork, selectActiveWork, + startTaskTimer, upsertTaskSessionState, upsertTaskSessionStateForWork, } from "./storage" @@ -544,6 +547,83 @@ describe("boulder-state", () => { }) }) + describe("task timer and completion helpers", () => { + test("should keep started_at stable when starting timer repeatedly", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const workId = initialState.active_work_id! + + // when + startTaskTimer(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "session-a", + startedAt: "2026-01-01T00:00:00.000Z", + }) + startTaskTimer(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "session-a", + startedAt: "2026-01-02T00:00:00.000Z", + }) + + // then + const taskSession = readBoulderState(TEST_DIR)?.works?.[workId]?.task_sessions?.["todo:1"] + expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z") + expect(taskSession?.status).toBe("running") + }) + + test("should compute elapsed_ms when ending task timer", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const workId = initialState.active_work_id! + startTaskTimer(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "session-a", + startedAt: "2026-01-01T00:00:00.000Z", + }) + + // when + const endedState = endTaskTimer(TEST_DIR, workId, "todo:1", "2026-01-01T00:00:01.500Z") + + // then + const taskSession = endedState?.works?.[workId]?.task_sessions?.["todo:1"] + expect(taskSession?.ended_at).toBe("2026-01-01T00:00:01.500Z") + expect(taskSession?.elapsed_ms).toBe(1500) + expect(taskSession?.status).toBe("completed") + }) + + test("should complete one work and keep other work untouched", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const firstWorkId = initialState.active_work_id! + const withSecond = addBoulderWork(TEST_DIR, { + planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"), + sessionId: "session-b", + }) + const secondWorkId = Object.keys(withSecond!.works!).find((workId) => workId !== firstWorkId)! + + // when + const completedState = completeBoulder(TEST_DIR, firstWorkId, "2026-01-01T01:00:00.000Z") + + // then + expect(completedState?.works?.[firstWorkId]?.status).toBe("completed") + expect(completedState?.works?.[firstWorkId]?.ended_at).toBe("2026-01-01T01:00:00.000Z") + expect(completedState?.works?.[firstWorkId]?.elapsed_ms).toBe( + Date.parse("2026-01-01T01:00:00.000Z") - Date.parse(completedState!.works![firstWorkId]!.started_at), + ) + expect(completedState?.works?.[secondWorkId]?.status).not.toBe("completed") + expect(existsSync(join(SISYPHUS_DIR, "boulder.json"))).toBe(true) + }) + }) + describe("readCurrentTopLevelTask", () => { test("should return the first unchecked top-level task in TODOs", () => { // given - plan with nested and top-level unchecked tasks diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index b9561562e..f5f03109c 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -32,6 +32,16 @@ function parseIsoToMs(value: string | undefined): number | null { return Number.isNaN(parsed) ? null : parsed } +function getElapsedMs(startedAt: string | undefined, endedAt: string | undefined): number | undefined { + const startedMs = parseIsoToMs(startedAt) + const endedMs = parseIsoToMs(endedAt) + if (startedMs === null || endedMs === null) { + return undefined + } + + return endedMs - startedMs +} + function isValidWorkStatus(status: unknown): status is BoulderWorkStatus { return status === "active" || status === "completed" || status === "paused" || status === "abandoned" } @@ -856,3 +866,108 @@ export function upsertTaskSessionStateForWork( return nextState } + +export function startTaskTimer( + directory: string, + workId: string, + input: { + taskKey: string + taskLabel: string + taskTitle: string + sessionId: string + agent?: string + category?: string + startedAt?: string + }, +): BoulderState | null { + const nextState = upsertTaskSessionStateForWork(directory, workId, input) + if (!nextState) { + return null + } + + const work = nextState.works?.[workId] + const taskSession = work?.task_sessions?.[input.taskKey] + if (!work || !taskSession) { + return null + } + + const startedAt = taskSession.started_at ?? input.startedAt ?? nowIsoString() + taskSession.started_at = startedAt + taskSession.status = "running" + taskSession.updated_at = nowIsoString() + work.updated_at = nowIsoString() + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} + +export function endTaskTimer( + directory: string, + workId: string, + taskKey: string, + endedAt?: string, +): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const work = state.works?.[workId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === workId) + if (!work?.task_sessions?.[taskKey]) { + return null + } + + const taskSession = work.task_sessions[taskKey] + const endAt = endedAt ?? nowIsoString() + taskSession.ended_at = endAt + taskSession.elapsed_ms = getElapsedMs(taskSession.started_at, endAt) + taskSession.status = "completed" + taskSession.updated_at = nowIsoString() + work.updated_at = nowIsoString() + + if (state.active_work_id === workId) { + projectWorkToMirror(state, work) + } + + if (!writeBoulderState(directory, state)) { + return null + } + + return state +} + +export function completeBoulder(directory: string, workId?: string, endedAt?: string): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const targetWorkId = workId ?? state.active_work_id + if (!targetWorkId) { + return null + } + + const work = state.works?.[targetWorkId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === targetWorkId) + if (!work) { + return null + } + + const endAt = endedAt ?? nowIsoString() + work.ended_at = endAt + work.elapsed_ms = getElapsedMs(work.started_at, endAt) + work.status = "completed" + work.updated_at = nowIsoString() + + if (state.active_work_id === targetWorkId) { + projectWorkToMirror(state, work) + } + + if (!writeBoulderState(directory, state)) { + return null + } + + return state +} From 8c238a11a2bcab5f106c92fb70b01b369c055472 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:32:27 +0900 Subject: [PATCH 45/73] prompt(atlas): replace retry cap with no-excuses policy and add boulder-complete response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops 'Maximum 3 retries' / 'document and move on' across every Atlas variant (default, opus-4-7, gpt, kimi, gemini). New text forbids the 'false positive' excuse explicitly and instructs Atlas to keep iterating on the same task_id, attaching a diagnosis plan, until verification passes — and to spawn a different-angle subagent only when the original loops. Adds a shared section composed by shared-prompt.ts. When the hook injects the BOULDER COMPLETE nudge, Atlas now knows to print TOTAL ELAPSED + per-task elapsed times in the exact summary shape, confirm boulder.json state, and only mark pass-final-wave after the Final Wave reviewers approve. --- src/agents/atlas/atlas-prompt.test.ts | 65 ++++++++++++++++++++ src/agents/atlas/default-prompt-sections.ts | 20 +++--- src/agents/atlas/gemini-prompt-sections.ts | 7 +-- src/agents/atlas/gpt-prompt-sections.ts | 6 +- src/agents/atlas/kimi-prompt-sections.ts | 6 +- src/agents/atlas/opus-4-7-prompt-sections.ts | 16 ++--- src/agents/atlas/shared-prompt.ts | 32 ++++++++++ 7 files changed, 127 insertions(+), 25 deletions(-) diff --git a/src/agents/atlas/atlas-prompt.test.ts b/src/agents/atlas/atlas-prompt.test.ts index 1f16bfe6f..0529f2adb 100644 --- a/src/agents/atlas/atlas-prompt.test.ts +++ b/src/agents/atlas/atlas-prompt.test.ts @@ -127,3 +127,68 @@ describe("Atlas prompts use task_id (not session_id) for retries", () => { } }) }) + +describe("Atlas prompts no-excuses retry policy", () => { + test("no variant contains a numeric retry cap", () => { + for (const [name, prompt] of ALL_VARIANTS) { + expect(prompt, `${name}: must not impose Maximum N retries`).not.toMatch(/maximum\s+\d+\s+retr/i) + expect(prompt, `${name}: must not impose N retries per task`).not.toMatch(/\d+\s+retries\s+per\s+task/i) + expect(prompt, `${name}: must not impose N retry attempts`).not.toMatch(/\d+\s+retry\s+attempts/i) + } + }) + + test("no variant tells Atlas to move on after failure", () => { + for (const [name, prompt] of ALL_VARIANTS) { + const lower = prompt.toLowerCase() + expect(lower, `${name}: must not tell Atlas to skip failed tasks`).not.toContain("document and continue to independent tasks") + expect(lower, `${name}: must not tell Atlas to move to next independent task`).not.toContain("document and move to next independent task") + expect(lower, `${name}: must not tell Atlas to move on`).not.toContain("then document and move on") + } + }) + + test("all variants forbid the false-positive excuse explicitly", () => { + for (const [name, prompt] of ALL_VARIANTS) { + const lower = prompt.toLowerCase() + expect(lower, `${name}: missing false positive prohibition`).toContain("false positive") + expect(lower, `${name}: missing no-retry-cap statement`).toContain("no retry cap") + } + }) + + test("all variants instruct subagent re-call with different angle when looping", () => { + for (const [name, prompt] of ALL_VARIANTS) { + const lower = prompt.toLowerCase() + expect(lower, `${name}: missing different-angle subagent instruction`).toMatch(/different angle|new subagent/) + } + }) +}) + +describe("Atlas prompts boulder-completion response", () => { + test("all variants document the boulder-complete nudge response", () => { + for (const [name, prompt] of ALL_VARIANTS) { + expect(prompt, `${name}: missing boulder_completion_response section`).toContain("") + expect(prompt, `${name}: missing BOULDER COMPLETE recognition phrase`).toContain("BOULDER COMPLETE") + expect(prompt, `${name}: missing TOTAL ELAPSED summary field`).toContain("TOTAL ELAPSED") + expect(prompt, `${name}: missing PER-TASK ELAPSED summary field`).toContain("PER-TASK ELAPSED") + } + }) + + test("all variants explain the one-shot nudge guarantee", () => { + for (const [name, prompt] of ALL_VARIANTS) { + const lower = prompt.toLowerCase() + expect(lower, `${name}: missing one-shot nudge guarantee`).toMatch(/at most once|fires.*once/) + } + }) + + test("boulder completion section appears after the workflow", () => { + for (const [name, prompt] of ALL_VARIANTS) { + const workflowIdx = prompt.indexOf("") + const completionIdx = prompt.indexOf("") + expect(workflowIdx, `${name}: missing workflow section`).toBeGreaterThan(-1) + expect(completionIdx, `${name}: missing boulder completion section`).toBeGreaterThan(-1) + expect( + completionIdx, + `${name}: boulder completion must come AFTER the workflow so the agent reads the failure rules first`, + ).toBeGreaterThan(workflowIdx) + } + }) +}) diff --git a/src/agents/atlas/default-prompt-sections.ts b/src/agents/atlas/default-prompt-sections.ts index 9272106f2..06ae2e1bb 100644 --- a/src/agents/atlas/default-prompt-sections.ts +++ b/src/agents/atlas/default-prompt-sections.ts @@ -142,24 +142,28 @@ task( ) \`\`\` -### 3.5 Handle Failures (USE task_id) +### 3.5 Handle Failures (USE task_id, NEVER GIVE UP) Every \`task()\` output includes a task_id. STORE IT. -If task fails: -1. Identify what went wrong -2. **Resume the SAME session** - subagent has full context already: +**Failure is never an excuse to stop or skip.** A subagent that reports success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. If verification fails, the work is unfinished. There is no retry cap. + +When a task fails: +1. Diagnose what actually broke. Read the error, read the file, do not guess. +2. **Resume the SAME session** so the subagent keeps its full context: \`\`\`typescript task( task_id="ses_xyz789", load_skills=[...], - prompt="FAILED: {error}. Fix by: {specific instruction}" + prompt="FAILED: {actual error output}. Diagnosis: {what you observed}. Fix by: {specific instruction}" ) \`\`\` -3. Maximum 3 retry attempts with the SAME session -4. If blocked after 3 attempts: Document and continue to independent tasks +3. If a single retry on the same session does not fix it, **plan the diagnosis explicitly**. Write down what the subagent attempted, what it observed, what hypothesis you have. Then resume the same session with that plan attached. Iterate until verification passes. +4. If the subagent itself is the bottleneck (looping on the same broken approach), spawn a NEW subagent with a different angle. Pass the failed attempts as context so it does not repeat them. Stay on the same plan task; never move on with that task unverified. -**Why task_id is MANDATORY for failures:** subagent already read all files, knows what was tried, what failed. Starting fresh wipes that. 70%+ token savings on retries. +**Why task_id is MANDATORY:** the subagent already read every relevant file, knows what was tried, and knows what failed. Starting fresh discards that and costs ~3-4× more tokens. Use \`task_id\` for retries and for asking the same subagent to plan its own diagnosis. + +**Why no excuses:** the user requires every task to complete. Documenting a failure and moving on produces a partial plan that will fail Final Wave review. Verification is the gate. Push through it. ### 3.6 Loop Until Implementation Complete diff --git a/src/agents/atlas/gemini-prompt-sections.ts b/src/agents/atlas/gemini-prompt-sections.ts index 1d3ffaab6..dd752ce74 100644 --- a/src/agents/atlas/gemini-prompt-sections.ts +++ b/src/agents/atlas/gemini-prompt-sections.ts @@ -162,16 +162,15 @@ Read(".sisyphus/plans/{plan-name}.md") \`\`\` Count remaining **top-level task** checkboxes. Ignore nested verification/evidence checkboxes. -### 3.5 Handle Failures +### 3.5 Handle Failures (NEVER GIVE UP) **CRITICAL: Use \`task_id\` for retries.** \`\`\`typescript -task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}") +task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {instruction}") \`\`\` -- Maximum 3 retries per task -- If blocked: document and continue to next independent task +**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified. ### 3.6 Loop Until Implementation Complete diff --git a/src/agents/atlas/gpt-prompt-sections.ts b/src/agents/atlas/gpt-prompt-sections.ts index 9a04dbee3..5ed131b64 100644 --- a/src/agents/atlas/gpt-prompt-sections.ts +++ b/src/agents/atlas/gpt-prompt-sections.ts @@ -125,13 +125,13 @@ Read(".sisyphus/plans/{plan-name}.md") \`\`\` Count remaining **top-level task** checkboxes (ignore nested verification/evidence checkboxes). Ground truth. -### 3.5 Handle Failures (USE task_id) +### 3.5 Handle Failures (USE task_id, NEVER GIVE UP) \`\`\`typescript -task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}") +task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {instruction}") \`\`\` -Maximum 3 retries on the same session. Then document and move to next independent task. +**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified. ### 3.6 Loop Until Implementation Complete diff --git a/src/agents/atlas/kimi-prompt-sections.ts b/src/agents/atlas/kimi-prompt-sections.ts index 5c239d448..c2b73695f 100644 --- a/src/agents/atlas/kimi-prompt-sections.ts +++ b/src/agents/atlas/kimi-prompt-sections.ts @@ -127,13 +127,13 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden **If verification fails**: resume the SAME session via \`task_id\`. Do not start fresh. -### 3.5 Handle Failures (USE task_id) +### 3.5 Handle Failures (USE task_id, NEVER GIVE UP) \`\`\`typescript -task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {specific instruction}") +task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {specific instruction}") \`\`\` -Maximum 3 retries on the same session. Then document and move on. +**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified. ### 3.6 Loop Until Implementation Complete diff --git a/src/agents/atlas/opus-4-7-prompt-sections.ts b/src/agents/atlas/opus-4-7-prompt-sections.ts index f53fe02de..dbbf4fd68 100644 --- a/src/agents/atlas/opus-4-7-prompt-sections.ts +++ b/src/agents/atlas/opus-4-7-prompt-sections.ts @@ -134,17 +134,19 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden task(task_id="ses_xyz789", load_skills=[...], prompt="Verification failed: {actual error}. Fix.") \`\`\` -### 3.5 Handle Failures (USE task_id) +### 3.5 Handle Failures (USE task_id, NEVER GIVE UP) Every \`task()\` output includes a task_id. STORE IT. -If task fails: -1. Identify what went wrong -2. Resume the SAME session via \`task_id\` (subagent already has full context) -3. Maximum 3 retry attempts on the same session -4. If still blocked: document and continue to independent tasks +**Failure is never an excuse to stop or skip.** A subagent that reports success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. If verification fails, the work is unfinished. There is no retry cap. -**NEVER start fresh on failures** — wipes accumulated context, costs ~3-4× more tokens. +When a task fails: +1. Diagnose what actually broke. Read the error, read the file, do not guess. +2. Resume the SAME session via \`task_id\` (subagent already has full context). +3. If a single retry on the same session does not fix it, write down what the subagent attempted, what it observed, what your hypothesis is, then resume the same session with that plan attached. Iterate until verification passes. +4. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Stay on the same plan task; never move on with that task unverified. + +**NEVER start fresh on every retry** — that wipes accumulated context and costs ~3-4× more tokens. Reserve fresh sessions for a deliberately different angle. ### 3.6 Loop Until Implementation Complete diff --git a/src/agents/atlas/shared-prompt.ts b/src/agents/atlas/shared-prompt.ts index 30bda0627..3696a4d97 100644 --- a/src/agents/atlas/shared-prompt.ts +++ b/src/agents/atlas/shared-prompt.ts @@ -186,6 +186,36 @@ After EVERY verified task() completion, you MUST: This ensures accurate progress tracking. Skip this and you lose visibility into what remains. ` +const ATLAS_BOULDER_COMPLETION_RESPONSE = ` +## When the Boulder-Complete Nudge Arrives + +The system injects ONE nudge into your session when every top-level checkbox in the active plan flips to \`- [x]\`. That nudge carries the total elapsed time and a per-task breakdown for the active boulder. Recognize it by the phrase "BOULDER COMPLETE" near the top of the injected message. + +When you see that nudge: + +1. In your next turn, print the final orchestration summary using this exact shape: + +\`\`\` +ORCHESTRATION COMPLETE + +PLAN: {plan-name} +TOTAL ELAPSED: {total elapsed, human readable} +TASKS COMPLETED: {N}/{N} + +PER-TASK ELAPSED: +- {label} {title}: {elapsed} +- {label} {title}: {elapsed} + +FINAL WAVE: F1 [...] | F2 [...] | F3 [...] | F4 [...] +\`\`\` + +2. Confirm via your tools that the active work in \`.sisyphus/boulder.json\` now has \`status: "completed"\` and \`elapsed_ms\` populated. The hook calls \`completeBoulder()\` for you; you are reading state, not writing it. + +3. Mark the \`pass-final-wave\` todo as \`completed\` only after the Final Verification Wave reviewers all APPROVE. If the wave has not run yet, run it now in parallel; the boulder-complete nudge does not bypass it. + +The nudge fires at most once per work. If you missed it (compaction, session restart), read \`boulder.json\` yourself, compute the same summary from \`started_at\`, \`ended_at\`, and \`task_sessions[*].elapsed_ms\`, and print it. +` + export function buildAtlasPrompt(sections: AtlasPromptSections): string { const addendum = sections.parallelAddendum.trim().length > 0 ? `\n\n${sections.parallelAddendum}` : "" @@ -210,5 +240,7 @@ ${sections.boundaries} ${sections.criticalRules} ${ATLAS_POST_DELEGATION_RULE} + +${ATLAS_BOULDER_COMPLETION_RESPONSE} ` } From 0c6805cc623df9e7b3eea63c152aaba154520092 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:32:38 +0900 Subject: [PATCH 46/73] prompt(prometheus): add Oracle phase-gate verification between phases Inserts blocking Oracle verification todos (plan-1b / plan-2b / plan-6b in the canonical, plan-1b / plan-2b / plan-5b in the gpt and gemini variants) between each major Prometheus phase. Each gate is a single task(subagent_type=oracle) invocation that must return VERDICT: GO; NO-GO is a directive to fix the cited issues and rerun on the same Oracle session, not a license to skip. Adds a new 'Oracle Verification (Phase Gates)' section to plan-generation.ts with the concrete invocation prompts for each gate: phase 1 verifies interview completeness, phase 2 verifies the generated plan, phase 3 verifies plan readiness for execution before /start-work handoff. Also adds a plan-generation.test.ts smoke suite (9 cases) that pins the new todo ids, the section name, the GO/NO-GO format, the 'fix the cited issues' fallback, and the relative ordering. --- src/agents/prometheus/gemini.ts | 5 ++ src/agents/prometheus/gpt.ts | 5 ++ src/agents/prometheus/plan-generation.test.ts | 64 +++++++++++++++ src/agents/prometheus/plan-generation.ts | 82 +++++++++++++++++-- 4 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 src/agents/prometheus/plan-generation.test.ts diff --git a/src/agents/prometheus/gemini.ts b/src/agents/prometheus/gemini.ts index ed617337b..73ac18881 100644 --- a/src/agents/prometheus/gemini.ts +++ b/src/agents/prometheus/gemini.ts @@ -205,14 +205,19 @@ CLEARANCE CHECKLIST (ALL must be YES to auto-transition): \`\`\`typescript TodoWrite([ { id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" }, + { id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, scope, test strategy)", status: "pending", priority: "high" }, { id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" }, + { id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance, parallelism, acceptance criteria)", status: "pending", priority: "high" }, { id: "plan-3", content: "Self-review: classify gaps", status: "pending", priority: "high" }, { id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" }, { id: "plan-5", content: "Ask about high accuracy mode (Momus)", status: "pending", priority: "high" }, + { id: "plan-5b", content: "Oracle verification: phase 3 (plan readiness for execution)", status: "pending", priority: "high" }, { id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" } ]) \`\`\` +Oracle verification gates (plan-1b, plan-2b, plan-5b) are blocking. Each is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation that must return \`VERDICT: GO\` before the workflow continues. \`NO-GO\` is a directive to fix the cited issues and rerun on the same Oracle session via \`task_id\`, not a license to skip. + ### Step 2: Consult Metis (MANDATORY) \`\`\`typescript diff --git a/src/agents/prometheus/gpt.ts b/src/agents/prometheus/gpt.ts index ec25b40a3..dcb4c45cd 100644 --- a/src/agents/prometheus/gpt.ts +++ b/src/agents/prometheus/gpt.ts @@ -192,14 +192,19 @@ CLEARANCE CHECKLIST (ALL must be YES to auto-transition): \`\`\`typescript TodoWrite([ { id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" }, + { id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, scope, test strategy)", status: "pending", priority: "high" }, { id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" }, + { id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance, parallelism, acceptance criteria)", status: "pending", priority: "high" }, { id: "plan-3", content: "Self-review: classify gaps (critical/minor/ambiguous)", status: "pending", priority: "high" }, { id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" }, { id: "plan-5", content: "Ask about high accuracy mode (Momus review)", status: "pending", priority: "high" }, + { id: "plan-5b", content: "Oracle verification: phase 3 (plan readiness for execution)", status: "pending", priority: "high" }, { id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" } ]) \`\`\` +Oracle verification gates (plan-1b, plan-2b, plan-5b) are blocking. Each is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation that must return \`VERDICT: GO\` before the workflow continues. \`NO-GO\` is a directive to fix the cited issues and rerun on the same Oracle session via \`task_id\`, not a license to skip. + ### Step 2: Consult Metis (MANDATORY) \`\`\`typescript diff --git a/src/agents/prometheus/plan-generation.test.ts b/src/agents/prometheus/plan-generation.test.ts new file mode 100644 index 000000000..cbc4f1838 --- /dev/null +++ b/src/agents/prometheus/plan-generation.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "bun:test" +import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation" + +describe("PROMETHEUS_PLAN_GENERATION oracle phase gates", () => { + describe("#given Prometheus plan generation prompt", () => { + describe("#when inspecting the registered todo list", () => { + it("#then includes plan-1b oracle verification after Metis", () => { + expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-1b"`) + expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-1b[^\n]*Oracle verification/i) + }) + + it("#then includes plan-2b oracle verification after plan generation", () => { + expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-2b"`) + expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-2b[^\n]*Oracle verification/i) + }) + + it("#then includes plan-6b oracle verification before handoff", () => { + expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-6b"`) + expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-6b[^\n]*Oracle verification/i) + }) + + it("#then preserves the existing plan-1 through plan-8 todos", () => { + for (const id of ["plan-1", "plan-2", "plan-3", "plan-4", "plan-5", "plan-6", "plan-7", "plan-8"]) { + expect(PROMETHEUS_PLAN_GENERATION, `${id} todo must remain`).toContain(`id: "${id}"`) + } + }) + }) + + describe("#when describing oracle invocations", () => { + it("#then provides concrete task() calls for all three phase gates", () => { + const oracleInvocations = PROMETHEUS_PLAN_GENERATION.match(/subagent_type="oracle"/g) ?? [] + expect(oracleInvocations.length).toBeGreaterThanOrEqual(3) + }) + + it("#then names a dedicated Oracle Verification section", () => { + expect(PROMETHEUS_PLAN_GENERATION).toContain("Oracle Verification (Phase Gates)") + }) + + it("#then declares each gate is blocking with GO/NO-GO verdict format", () => { + expect(PROMETHEUS_PLAN_GENERATION).toContain("VERDICT: GO/NO-GO") + expect(PROMETHEUS_PLAN_GENERATION.toLowerCase()).toContain("blocking") + }) + + it("#then forbids skipping the gate on NO-GO", () => { + const lower = PROMETHEUS_PLAN_GENERATION.toLowerCase() + expect(lower).toMatch(/no-go is not an excuse to skip|fix the cited issues/) + }) + }) + + describe("#when describing the updated workflow", () => { + it("#then orders the gates after their respective phases", () => { + const idxPlan1b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-1b"`) + const idxPlan2 = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-2"`) + const idxPlan2b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-2b"`) + const idxPlan6 = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-6"`) + const idxPlan6b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-6b"`) + + expect(idxPlan1b, "plan-1b must precede plan-2 (gate runs before next phase)").toBeLessThan(idxPlan2) + expect(idxPlan2b, "plan-2b must follow plan-2").toBeGreaterThan(idxPlan2) + expect(idxPlan6b, "plan-6b must follow plan-6").toBeGreaterThan(idxPlan6) + }) + }) + }) +}) diff --git a/src/agents/prometheus/plan-generation.ts b/src/agents/prometheus/plan-generation.ts index e44d5428f..5e974c881 100644 --- a/src/agents/prometheus/plan-generation.ts +++ b/src/agents/prometheus/plan-generation.ts @@ -27,11 +27,14 @@ export const PROMETHEUS_PLAN_GENERATION = `# PHASE 2: PLAN GENERATION (Auto-Tran // IMMEDIATELY upon trigger detection - NO EXCEPTIONS todoWrite([ { id: "plan-1", content: "Consult Metis for gap analysis (auto-proceed)", status: "pending", priority: "high" }, + { id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, requirements clarity, scope boundaries)", status: "pending", priority: "high" }, { id: "plan-2", content: "Generate work plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" }, + { id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance with constraints, parallelism, acceptance criteria)", status: "pending", priority: "high" }, { id: "plan-3", content: "Self-review: classify gaps (critical/minor/ambiguous)", status: "pending", priority: "high" }, { id: "plan-4", content: "Present summary with auto-resolved items and decisions needed", status: "pending", priority: "high" }, { id: "plan-5", content: "If decisions needed: wait for user, update plan", status: "pending", priority: "high" }, { id: "plan-6", content: "Ask user about high accuracy mode (Momus review)", status: "pending", priority: "high" }, + { id: "plan-6b", content: "Oracle verification: phase 3 (plan readiness for execution before high-accuracy or handoff)", status: "pending", priority: "high" }, { id: "plan-7", content: "If high accuracy: Submit to Momus and iterate until OKAY", status: "pending", priority: "medium" }, { id: "plan-8", content: "Delete draft file and guide user to /start-work {name}", status: "pending", priority: "medium" } ]) @@ -39,20 +42,81 @@ todoWrite([ **WHY THIS IS CRITICAL:** - User sees exactly what steps remain -- Prevents skipping crucial steps like Metis consultation +- Prevents skipping crucial steps like Metis consultation and Oracle phase gates - Creates accountability for each phase - Enables recovery if session is interrupted **WORKFLOW:** -1. Trigger detected → **IMMEDIATELY** TodoWrite (plan-1 through plan-8) +1. Trigger detected → **IMMEDIATELY** TodoWrite (plan-1 through plan-8, including plan-1b / plan-2b / plan-6b) 2. Mark plan-1 as \`in_progress\` → Consult Metis (auto-proceed, no questions) -3. Mark plan-2 as \`in_progress\` → Generate plan immediately -4. Mark plan-3 as \`in_progress\` → Self-review and classify gaps -5. Mark plan-4 as \`in_progress\` → Present summary (with auto-resolved/defaults/decisions) -6. Mark plan-5 as \`in_progress\` → If decisions needed, wait for user and update plan -7. Mark plan-6 as \`in_progress\` → Ask high accuracy question -8. Continue marking todos as you progress -9. NEVER skip a todo. NEVER proceed without updating status. +3. Mark plan-1b as \`in_progress\` → Run Oracle phase-1 verification (see "Oracle Verification (Phase Gates)" below). Must produce VERDICT: GO before continuing. +4. Mark plan-2 as \`in_progress\` → Generate plan immediately +5. Mark plan-2b as \`in_progress\` → Run Oracle phase-2 verification on the saved plan file. Must produce VERDICT: GO before continuing. +6. Mark plan-3 as \`in_progress\` → Self-review and classify gaps +7. Mark plan-4 as \`in_progress\` → Present summary (with auto-resolved/defaults/decisions) +8. Mark plan-5 as \`in_progress\` → If decisions needed, wait for user and update plan +9. Mark plan-6 as \`in_progress\` → Ask high accuracy question +10. Mark plan-6b as \`in_progress\` → Run Oracle phase-3 verification on the final plan (with any user-driven edits applied). Must produce VERDICT: GO before handoff. +11. Continue marking todos as you progress +12. NEVER skip a todo. NEVER proceed without updating status. **Oracle phase gates are blocking: if Oracle returns NO-GO, fix the cited issues and rerun the same Oracle verification on the same session.** + +## Oracle Verification (Phase Gates) + +Three blocking phase gates use the Oracle agent (read-only consultant). Each gate is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation. The Oracle must return VERDICT: GO before the workflow continues. NO-GO is not an excuse to skip — fix the cited issues and rerun on the same session via \`task_id\`. + +### plan-1b: phase 1 verification (after Metis, before plan generation) + +\`\`\`typescript +task( + subagent_type="oracle", + load_skills=[], + run_in_background=false, + prompt=\`Verify Prometheus phase 1 (interview) is complete and consistent. Read the draft at .sisyphus/drafts/{name}.md and Metis's findings recorded in this session. Confirm: + 1. Core objective is unambiguous (one sentence, no hidden alternates). + 2. Scope IN / Scope OUT are both explicit. + 3. Test strategy is decided (TDD / tests-after / none + agent QA). + 4. No outstanding user questions remain. + 5. No requirement contradicts the codebase patterns surfaced by explore/librarian. + Return: \\\`CHECK [N/5] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, a numbered list of issues that block.\` +) +\`\`\` + +### plan-2b: phase 2 verification (after plan generation, before self-review) + +\`\`\`typescript +task( + subagent_type="oracle", + load_skills=[], + run_in_background=false, + prompt=\`Verify Prometheus phase 2 (plan generation). Read .sisyphus/plans/{name}.md end to end. Confirm: + 1. Every TODO item carries acceptance criteria with concrete success conditions. + 2. Each task has a recommended agent profile and a Wave assignment. + 3. Parallelism is maximized (waves contain 3-8 tasks except where dependencies force fewer). + 4. Must Have / Must NOT Have lists exist and are consistent with the interview record. + 5. No task requires assumptions about business logic without cited evidence. + 6. Plan path is .sisyphus/plans/, not docs/ or plans/. + Return: \\\`CHECK [N/6] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, file:line citations for each blocking issue.\` +) +\`\`\` + +### plan-6b: phase 3 verification (after high-accuracy decision, before handoff) + +\`\`\`typescript +task( + subagent_type="oracle", + load_skills=[], + run_in_background=false, + prompt=\`Verify the plan at .sisyphus/plans/{name}.md is ready for execution by /start-work. Confirm: + 1. Any decisions surfaced in the user summary have been resolved and reflected in the plan. + 2. The final-wave reviewer set (F1-F4) is present and addressable. + 3. Commit strategy and verification commands are stated. + 4. The plan is internally consistent after the most recent edits. + 5. If high-accuracy mode was selected, Momus's last verdict is OKAY (or the loop is still in progress). + Return: \\\`CHECK [N/5] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, what to fix.\` +) +\`\`\` + +**Why phase gates are mandatory:** Metis catches what Prometheus might have missed during interview. Oracle catches what Prometheus might be wrong about. Both run before code is touched. NO-GO is a directive to fix, not a license to abandon the gate. ## Pre-Generation: Metis Consultation (MANDATORY) From 42db7078af67040a0eac52f28487a3b5ae8a7f16 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:35:59 +0900 Subject: [PATCH 47/73] feat(boulder-state): add formatDurationHuman utility Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../boulder-state/format-duration.test.ts | 32 +++++++++++++++++++ src/features/boulder-state/format-duration.ts | 16 ++++++++++ src/features/boulder-state/index.ts | 1 + 3 files changed, 49 insertions(+) create mode 100644 src/features/boulder-state/format-duration.test.ts create mode 100644 src/features/boulder-state/format-duration.ts diff --git a/src/features/boulder-state/format-duration.test.ts b/src/features/boulder-state/format-duration.test.ts new file mode 100644 index 000000000..fbb9b30cb --- /dev/null +++ b/src/features/boulder-state/format-duration.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test" +import { formatDurationHuman } from "./format-duration" + +describe("formatDurationHuman", () => { + it("returns 0s for 0ms", () => { + expect(formatDurationHuman(0)).toBe("0s") + }) + + it("returns 0s for 999ms", () => { + expect(formatDurationHuman(999)).toBe("0s") + }) + + it("returns 1s for 1000ms", () => { + expect(formatDurationHuman(1000)).toBe("1s") + }) + + it("returns 1m 0s for 60_000ms", () => { + expect(formatDurationHuman(60_000)).toBe("1m 0s") + }) + + it("returns 1h 0m 0s for 3_600_000ms", () => { + expect(formatDurationHuman(3_600_000)).toBe("1h 0m 0s") + }) + + it("returns 1h 2m 3s for 3_723_456ms", () => { + expect(formatDurationHuman(3_723_456)).toBe("1h 2m 3s") + }) + + it("returns 24h 0m 0s for 86_400_000ms", () => { + expect(formatDurationHuman(86_400_000)).toBe("24h 0m 0s") + }) +}) diff --git a/src/features/boulder-state/format-duration.ts b/src/features/boulder-state/format-duration.ts new file mode 100644 index 000000000..8065ddbd6 --- /dev/null +++ b/src/features/boulder-state/format-duration.ts @@ -0,0 +1,16 @@ +export function formatDurationHuman(milliseconds: number): string { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s` + } + + if (minutes > 0) { + return `${minutes}m ${seconds}s` + } + + return `${seconds}s` +} diff --git a/src/features/boulder-state/index.ts b/src/features/boulder-state/index.ts index 17618996b..fec4b57de 100644 --- a/src/features/boulder-state/index.ts +++ b/src/features/boulder-state/index.ts @@ -2,3 +2,4 @@ export * from "./types" export * from "./constants" export * from "./storage" export * from "./top-level-task" +export * from "./format-duration" From 18af3d36179fd3418081b8f64e774930a7c38190 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:37:19 +0900 Subject: [PATCH 48/73] feat(hooks/atlas): use getWorkForSession in boulder lookups and session tracking --- .../background-launch-session-tracking.ts | 51 ++++++++--- .../resolve-active-boulder-session.test.ts | 73 +++++++++++++++ .../atlas/resolve-active-boulder-session.ts | 39 ++++++-- ...ol-execute-after-background-launch.test.ts | 88 +++++++++++++++++++ 4 files changed, 234 insertions(+), 17 deletions(-) diff --git a/src/hooks/atlas/background-launch-session-tracking.ts b/src/hooks/atlas/background-launch-session-tracking.ts index 4fcb68864..57a3e351c 100644 --- a/src/hooks/atlas/background-launch-session-tracking.ts +++ b/src/hooks/atlas/background-launch-session-tracking.ts @@ -1,5 +1,14 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state" +import { + appendSessionId, + appendSessionIdForWork, + getWorkForSession, + type BoulderState, + resolveBoulderPlanPath, + resolveBoulderPlanPathForWork, + upsertTaskSessionState, + upsertTaskSessionStateForWork, +} from "../../features/boulder-state" import { log } from "../../shared/logger" import { HOOK_NAME } from "./hook-name" import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id" @@ -19,8 +28,9 @@ export async function syncBackgroundLaunchSessionTracking(input: { return } + const trackedWork = getWorkForSession(ctx.directory, toolInput.sessionID) const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output) - const lineageSessionIDs = boulderState.session_ids + const lineageSessionIDs = trackedWork?.session_ids ?? boulderState.session_ids const subagentSessionId = await validateSubagentSessionId({ client: ctx.client, sessionID: extractedSessionId, @@ -36,22 +46,39 @@ export async function syncBackgroundLaunchSessionTracking(input: { return } - appendSessionId(ctx.directory, trackedSessionId, "appended") + if (trackedWork) { + appendSessionIdForWork(ctx.directory, trackedWork.work_id, trackedSessionId, "appended") + } else { + appendSessionId(ctx.directory, trackedSessionId, "appended") + } const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext( pendingTaskRef, - resolveBoulderPlanPath(ctx.directory, boulderState), + trackedWork + ? resolveBoulderPlanPathForWork(ctx.directory, trackedWork) + : resolveBoulderPlanPath(ctx.directory, boulderState), ) if (currentTask && !shouldSkipTaskSessionUpdate) { - upsertTaskSessionState(ctx.directory, { - taskKey: currentTask.key, - taskLabel: currentTask.label, - taskTitle: currentTask.title, - sessionId: trackedSessionId, - agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, - category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, - }) + if (trackedWork) { + upsertTaskSessionStateForWork(ctx.directory, trackedWork.work_id, { + taskKey: currentTask.key, + taskLabel: currentTask.label, + taskTitle: currentTask.title, + sessionId: trackedSessionId, + agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, + category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, + }) + } else { + upsertTaskSessionState(ctx.directory, { + taskKey: currentTask.key, + taskLabel: currentTask.label, + taskTitle: currentTask.title, + sessionId: trackedSessionId, + agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, + category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, + }) + } } log(`[${HOOK_NAME}] Background launch session tracked`, { diff --git a/src/hooks/atlas/resolve-active-boulder-session.test.ts b/src/hooks/atlas/resolve-active-boulder-session.test.ts index 7a300a517..85b20ecba 100644 --- a/src/hooks/atlas/resolve-active-boulder-session.test.ts +++ b/src/hooks/atlas/resolve-active-boulder-session.test.ts @@ -131,4 +131,77 @@ describe("resolveActiveBoulderSession", () => { rmSync(worktreeDirectory, { recursive: true, force: true }) } }) + + test("uses work resolved by session id when works map is present", async () => { + // given + const legacyPlanPath = join(testDirectory, "legacy-plan.md") + const workAPlanPath = join(testDirectory, "work-a-plan.md") + const workBPlanPath = join(testDirectory, "work-b-plan.md") + writeFileSync(legacyPlanPath, "# Plan\n- [ ] Legacy\n", "utf-8") + writeFileSync(workAPlanPath, "# Plan\n- [ ] Work A\n", "utf-8") + writeFileSync(workBPlanPath, "# Plan\n- [x] Work B\n", "utf-8") + + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-a", + active_plan: legacyPlanPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_legacy"], + plan_name: "legacy-plan", + works: { + "work-a": { + work_id: "work-a", + active_plan: workAPlanPath, + plan_name: "work-a-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_work_a"], + status: "active", + }, + "work-b": { + work_id: "work-b", + active_plan: workBPlanPath, + plan_name: "work-b-plan", + started_at: "2026-01-02T11:00:00Z", + session_ids: ["ses_work_b"], + status: "active", + }, + }, + }) + + // when + const result = await resolveActiveBoulderSession({ + client: { session: { get: async () => ({ data: {} }) } } as never, + directory: testDirectory, + sessionID: "ses_work_b", + }) + + // then + expect(result).not.toBeNull() + expect(result?.boulderState.active_plan).toBe(workBPlanPath) + expect(result?.progress.isComplete).toBe(true) + }) + + test("falls back to top-level mirror when works map is missing", async () => { + // given + const legacyPlanPath = join(testDirectory, "legacy-only-plan.md") + writeFileSync(legacyPlanPath, "# Plan\n- [ ] Task 1\n", "utf-8") + writeBoulderState(testDirectory, { + active_plan: legacyPlanPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_legacy_only"], + plan_name: "legacy-only-plan", + }) + + // when + const result = await resolveActiveBoulderSession({ + client: { session: { get: async () => ({ data: {} }) } } as never, + directory: testDirectory, + sessionID: "ses_legacy_only", + }) + + // then + expect(result).not.toBeNull() + expect(result?.boulderState.active_plan).toBe(legacyPlanPath) + expect(result?.progress.isComplete).toBe(false) + }) }) diff --git a/src/hooks/atlas/resolve-active-boulder-session.ts b/src/hooks/atlas/resolve-active-boulder-session.ts index 7cf23e7ba..85a4bb583 100644 --- a/src/hooks/atlas/resolve-active-boulder-session.ts +++ b/src/hooks/atlas/resolve-active-boulder-session.ts @@ -1,5 +1,11 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state" +import { + getPlanProgress, + getWorkForSession, + readBoulderState, + resolveBoulderPlanPath, + resolveBoulderPlanPathForWork, +} from "../../features/boulder-state" import type { BoulderState, PlanProgress } from "../../features/boulder-state" export async function resolveActiveBoulderSession(input: { @@ -16,14 +22,37 @@ export async function resolveActiveBoulderSession(input: { return null } - if (!boulderState.session_ids.includes(input.sessionID)) { + const sessionWork = getWorkForSession(input.directory, input.sessionID) + if (!sessionWork && !boulderState.session_ids.includes(input.sessionID)) { return null } - const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState)) + const nextBoulderState: BoulderState = sessionWork + ? { + ...boulderState, + active_plan: sessionWork.active_plan, + plan_name: sessionWork.plan_name, + status: sessionWork.status, + started_at: sessionWork.started_at, + ended_at: sessionWork.ended_at, + elapsed_ms: sessionWork.elapsed_ms, + updated_at: sessionWork.updated_at, + session_ids: [...sessionWork.session_ids], + session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {}, + agent: sessionWork.agent, + worktree_path: sessionWork.worktree_path, + task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {}, + } + : boulderState + + const progress = getPlanProgress( + sessionWork + ? resolveBoulderPlanPathForWork(input.directory, sessionWork) + : resolveBoulderPlanPath(input.directory, nextBoulderState), + ) if (progress.isComplete) { - return { boulderState, progress, appendedSession: false } + return { boulderState: nextBoulderState, progress, appendedSession: false } } - return { boulderState, progress, appendedSession: false } + return { boulderState: nextBoulderState, progress, appendedSession: false } } diff --git a/src/hooks/atlas/tool-execute-after-background-launch.test.ts b/src/hooks/atlas/tool-execute-after-background-launch.test.ts index f51320e2e..1a7d55894 100644 --- a/src/hooks/atlas/tool-execute-after-background-launch.test.ts +++ b/src/hooks/atlas/tool-execute-after-background-launch.test.ts @@ -424,6 +424,94 @@ describe("createToolExecuteAfterHandler background launch detection", () => { expect(readBoulderState(testDirectory)?.session_ids).not.toContain(sessionID) expect(readBoulderState(testDirectory)?.session_ids).not.toContain(childSessionID) }) + + it("#then it should append launched child to the session-resolved work", async () => { + const parentSessionID = "ses_parent_for_work" + const childSessionID = "ses_child_for_work" + const planPathA = join(testDirectory, "background-launch-work-a.md") + const planPathB = join(testDirectory, "background-launch-work-b.md") + const project = createProject() + const client = { + session: { + get: async () => createSessionGetResult(undefined), + }, + } as unknown as PluginInput["client"] + + spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( + createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined), + ) as never) + + writeFileSync(planPathA, "# Plan\n\n## TODOs\n- [ ] 1. Work A\n") + writeFileSync(planPathB, "# Plan\n\n## TODOs\n- [ ] 1. Work B\n") + + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-a", + active_plan: planPathA, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_unrelated_active"], + plan_name: "background-launch-work-a", + works: { + "work-a": { + work_id: "work-a", + active_plan: planPathA, + plan_name: "background-launch-work-a", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_unrelated_active"], + status: "active", + }, + "work-b": { + work_id: "work-b", + active_plan: planPathB, + plan_name: "background-launch-work-b", + started_at: "2026-01-02T10:05:00Z", + session_ids: [parentSessionID], + status: "active", + }, + }, + }) + + const pendingFilePaths = new Map() + const pendingTaskRefs = new Map() + const ctx = { + client, + project, + directory: testDirectory, + worktree: testDirectory, + serverUrl: new URL("https://example.com"), + $: Bun.$, + } satisfies PluginInput + const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }) + const afterHandler = createToolExecuteAfterHandler({ + ctx, + pendingFilePaths, + pendingTaskRefs, + autoCommit: true, + getState: () => ({ promptFailureCount: 0 }), + }) + + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-bg-work" }, + { args: { prompt: "Work B" } }, + ) + + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-bg-work" }, + { + title: "Sisyphus Task", + output: "Background task launched.\n\nBackground Task ID: bg_work\n\n\nsession_id: ses_child_for_work\n", + metadata: { + sessionId: childSessionID, + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + + const boulderState = readBoulderState(testDirectory) + expect(boulderState?.works?.["work-b"]?.session_ids).toContain(childSessionID) + expect(boulderState?.works?.["work-a"]?.session_ids).not.toContain(childSessionID) + }) }) }) }) From f2a5ef0966436cd3f6f45ca23a80fc7030858518 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:37:35 +0900 Subject: [PATCH 49/73] feat(hooks/atlas): add BOULDER_COMPLETE_PROMPT template and SessionState guard Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/atlas/system-reminder-templates.test.ts | 9 +++++++++ src/hooks/atlas/system-reminder-templates.ts | 11 +++++++++++ src/hooks/atlas/types.ts | 1 + 3 files changed, 21 insertions(+) diff --git a/src/hooks/atlas/system-reminder-templates.test.ts b/src/hooks/atlas/system-reminder-templates.test.ts index cc2aaee95..042a95165 100644 --- a/src/hooks/atlas/system-reminder-templates.test.ts +++ b/src/hooks/atlas/system-reminder-templates.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "bun:test" import { + BOULDER_COMPLETE_PROMPT, BOULDER_CONTINUATION_PROMPT, SINGLE_TASK_DIRECTIVE, VERIFICATION_REMINDER, @@ -47,6 +48,14 @@ describe("VERIFICATION_REMINDER", () => { }) }) +describe("BOULDER_COMPLETE_PROMPT", () => { + it("contains the required placeholders", () => { + expect(BOULDER_COMPLETE_PROMPT).toContain("{PLAN_NAME}") + expect(BOULDER_COMPLETE_PROMPT).toContain("{ELAPSED_HUMAN}") + expect(BOULDER_COMPLETE_PROMPT).toContain("{TASK_BREAKDOWN}") + }) +}) + describe("VERIFICATION_REMINDER_GEMINI", () => { it("contains node_modules exclusion pathspec in git diff command", () => { expect(VERIFICATION_REMINDER_GEMINI).toContain(":!node_modules") diff --git a/src/hooks/atlas/system-reminder-templates.ts b/src/hooks/atlas/system-reminder-templates.ts index 7f42a7acb..d6e3b0cbf 100644 --- a/src/hooks/atlas/system-reminder-templates.ts +++ b/src/hooks/atlas/system-reminder-templates.ts @@ -33,6 +33,17 @@ RULES: - Do not stop until all tasks are complete - If blocked, document the blocker and move to the next task` +export const BOULDER_COMPLETE_PROMPT = ` +BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked. + +Total elapsed: {ELAPSED_HUMAN} + +Per-task breakdown: +{TASK_BREAKDOWN} + +Per your instructions, print the final ORCHESTRATION COMPLETE summary in your next turn. This nudge fires at most once. +` + export const VERIFICATION_REMINDER = `**THE SUBAGENT JUST CLAIMED THIS TASK IS DONE. THEY ARE PROBABLY LYING.** Subagents say "done" when code has errors, tests pass trivially, logic is wrong, diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index 8b39867e8..4c03d3966 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -48,4 +48,5 @@ export interface SessionState { waitingForFinalWaveApproval?: boolean pendingFinalWaveTaskCount?: number approvedFinalWaveTaskCount?: number + boulderCompletionNudgedAt?: Record } From 127112e1e2057cd6f5c86684c4a72d869e56bd87 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:39:47 +0900 Subject: [PATCH 50/73] feat(hooks/atlas): wire per-task timers via startTaskTimer/endTaskTimer --- .../tool-execute-after-task-timers.test.ts | 222 ++++++++++++++++++ src/hooks/atlas/tool-execute-after.ts | 99 ++++++-- 2 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 src/hooks/atlas/tool-execute-after-task-timers.test.ts diff --git a/src/hooks/atlas/tool-execute-after-task-timers.test.ts b/src/hooks/atlas/tool-execute-after-task-timers.test.ts new file mode 100644 index 000000000..64182c93e --- /dev/null +++ b/src/hooks/atlas/tool-execute-after-task-timers.test.ts @@ -0,0 +1,222 @@ +/// + +import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" +import type { Project } from "@opencode-ai/sdk" +import { readBoulderState, writeBoulderState } from "../../features/boulder-state" +import { createToolExecuteBeforeHandler } from "./tool-execute-before" + +const isCallerOrchestratorMock = mock(async () => true) +const collectGitDiffStatsMock = mock(() => ({ + filesChanged: 0, + insertions: 0, + deletions: 0, +})) + +mock.module("../../shared/session-utils", () => ({ + isCallerOrchestrator: isCallerOrchestratorMock, +})) + +mock.module("../../shared/git-worktree", () => ({ + collectGitDiffStats: collectGitDiffStatsMock, + formatFileChanges: mock(() => "No file changes"), +})) + +afterAll(() => { mock.restore() }) + +const { createToolExecuteAfterHandler } = await import("./tool-execute-after") + +type SessionGetInput = { path: { id: string } } +type SessionGetResult = { + data: { parentID: string | undefined } + error?: undefined + request: Request + response: Response +} + +describe("createToolExecuteAfterHandler task timers", () => { + let testDirectory = "" + + beforeEach(() => { + testDirectory = join(tmpdir(), `atlas-task-timers-${crypto.randomUUID()}`) + if (!existsSync(testDirectory)) { + mkdirSync(testDirectory, { recursive: true }) + } + isCallerOrchestratorMock.mockClear() + collectGitDiffStatsMock.mockClear() + }) + + afterEach(() => { + if (testDirectory && existsSync(testDirectory)) { + rmSync(testDirectory, { recursive: true, force: true }) + } + }) + + function createProject(): Project { + return { + id: "project-1", + worktree: testDirectory, + time: { created: Date.now() }, + } + } + + function createSessionGetResult(parentID: string | undefined): SessionGetResult { + return { + data: { parentID }, + error: undefined, + request: new Request("https://example.com/session"), + response: new Response(null, { status: 200 }), + } as SessionGetResult + } + + function createHandlers(parentSessionIDs?: Record) { + const project = createProject() + const client = { + session: { + get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]), + }, + } as unknown as PluginInput["client"] + + if (parentSessionIDs) { + spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( + createSessionGetResult(parentSessionIDs[input?.path?.id ?? ""]), + ) as never) + } + + const pendingFilePaths = new Map() + const pendingTaskRefs = new Map() + const ctx = { + client, + project, + directory: testDirectory, + worktree: testDirectory, + serverUrl: new URL("https://example.com"), + $: Bun.$, + } satisfies PluginInput + + return { + beforeHandler: createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }), + afterHandler: createToolExecuteAfterHandler({ + ctx, + pendingFilePaths, + pendingTaskRefs, + autoCommit: true, + getState: () => ({ promptFailureCount: 0 }), + }), + } + } + + it("starts task timer for todo:1 when delegated task session is tracked", async () => { + // given + const parentSessionID = "ses_parent" + const childSessionID = "ses_child" + const planPath = join(testDirectory, "task-timer-plan.md") + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8") + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-1", + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + plan_name: "task-timer-plan", + works: { + "work-1": { + work_id: "work-1", + active_plan: planPath, + plan_name: "task-timer-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + status: "active", + }, + }, + }) + const { beforeHandler, afterHandler } = createHandlers({ + [childSessionID]: parentSessionID, + }) + + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" }, + { args: { prompt: "Implement auth flow" } }, + ) + + // when + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" }, + { + title: "Sisyphus Task", + output: "Task completed\n\nsession_id: ses_child\n", + metadata: { + sessionId: childSessionID, + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + + // then + const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"] + expect(taskSession).toBeDefined() + expect(taskSession?.started_at).toBeString() + expect(taskSession?.status).toBe("running") + expect(taskSession?.session_id).toBe(childSessionID) + }) + + it("ends task timer when todo:1 checkbox transitions to checked", async () => { + // given + const parentSessionID = "ses_parent_2" + const childSessionID = "ses_child_2" + const planPath = join(testDirectory, "task-timer-complete-plan.md") + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8") + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-1", + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + plan_name: "task-timer-complete-plan", + works: { + "work-1": { + work_id: "work-1", + active_plan: planPath, + plan_name: "task-timer-complete-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + status: "active", + }, + }, + }) + const { beforeHandler, afterHandler } = createHandlers({ + [childSessionID]: parentSessionID, + }) + + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" }, + { args: { prompt: "Implement auth flow" } }, + ) + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8") + + // when + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" }, + { + title: "Sisyphus Task", + output: "Task completed\n\nsession_id: ses_child_2\n", + metadata: { + sessionId: childSessionID, + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + + // then + const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"] + expect(taskSession).toBeDefined() + expect(taskSession?.ended_at).toBeString() + expect(taskSession?.status).toBe("completed") + expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true) + }) +}) diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 3869c291f..4cef75ac1 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -1,11 +1,16 @@ import type { PluginInput } from "@opencode-ai/plugin" import { + endTaskTimer, + getWorkForSession, getPlanProgress, getTaskSessionState, readBoulderState, resolveBoulderPlanPath, + resolveBoulderPlanPathForWork, + startTaskTimer, upsertTaskSessionState, } from "../../features/boulder-state" +import { existsSync, readFileSync } from "node:fs" import { log } from "../../shared/logger" import { isCallerOrchestrator } from "../../shared/session-utils" import { syncBackgroundLaunchSessionTracking } from "./background-launch-session-tracking" @@ -26,6 +31,34 @@ import { isWriteOrEditToolName } from "./write-edit-tool-policy" import type { PendingTaskRef, SessionState } from "./types" import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types" +function isTrackedTaskChecked(planPath: string, taskKey: string): boolean { + if (!existsSync(planPath)) { + return false + } + + const [section, label] = taskKey.split(":") + if (!section || !label) { + return false + } + + const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const matcher = section === "todo" + ? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel}\\.\\s+`, "m") + : section === "final-wave" + ? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel.toUpperCase()}\\.\\s+`, "m") + : null + if (!matcher) { + return false + } + + try { + const content = readFileSync(planPath, "utf-8") + return matcher.test(content) + } catch { + return false + } +} + export function createToolExecuteAfterHandler(input: { ctx: PluginInput pendingFilePaths: Map @@ -100,7 +133,29 @@ export function createToolExecuteAfterHandler(input: { const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output) if (boulderState) { - const planPath = resolveBoulderPlanPath(ctx.directory, boulderState) + const sessionWork = toolInput.sessionID + ? getWorkForSession(ctx.directory, toolInput.sessionID) + : null + const planPath = sessionWork + ? resolveBoulderPlanPathForWork(ctx.directory, sessionWork) + : resolveBoulderPlanPath(ctx.directory, boulderState) + const workScopedBoulderState = sessionWork + ? { + ...boulderState, + active_plan: sessionWork.active_plan, + plan_name: sessionWork.plan_name, + status: sessionWork.status, + started_at: sessionWork.started_at, + ended_at: sessionWork.ended_at, + elapsed_ms: sessionWork.elapsed_ms, + updated_at: sessionWork.updated_at, + session_ids: [...sessionWork.session_ids], + session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {}, + agent: sessionWork.agent, + worktree_path: sessionWork.worktree_path, + task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {}, + } + : boulderState const progress = getPlanProgress(planPath) const { currentTask, @@ -112,7 +167,7 @@ export function createToolExecuteAfterHandler(input: { : null const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined - const lineageSessionIDs = boulderState.session_ids + const lineageSessionIDs = sessionWork?.session_ids ?? boulderState.session_ids const subagentSessionId = await validateSubagentSessionId({ client: ctx.client, sessionID: extractedSessionId, @@ -120,14 +175,28 @@ export function createToolExecuteAfterHandler(input: { }) if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) { - upsertTaskSessionState(ctx.directory, { - taskKey: currentTask.key, - taskLabel: currentTask.label, - taskTitle: currentTask.title, - sessionId: subagentSessionId, - agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, - category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, - }) + if (sessionWork) { + startTaskTimer(ctx.directory, sessionWork.work_id, { + taskKey: currentTask.key, + taskLabel: currentTask.label, + taskTitle: currentTask.title, + sessionId: subagentSessionId, + agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, + category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, + }) + if (isTrackedTaskChecked(planPath, currentTask.key)) { + endTaskTimer(ctx.directory, sessionWork.work_id, currentTask.key) + } + } else { + upsertTaskSessionState(ctx.directory, { + taskKey: currentTask.key, + taskLabel: currentTask.label, + taskTitle: currentTask.title, + sessionId: subagentSessionId, + agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, + category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, + }) + } } const preferredSessionId = resolvePreferredSessionId( @@ -155,11 +224,11 @@ export function createToolExecuteAfterHandler(input: { } const leadReminder = shouldPauseForApproval - ? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId) - : buildCompletionGate(boulderState.plan_name, preferredSessionId) + ? buildFinalWaveApprovalReminder(workScopedBoulderState.plan_name, progress, preferredSessionId) + : buildCompletionGate(workScopedBoulderState.plan_name, preferredSessionId) const followupReminder = shouldPauseForApproval ? null - : buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false) + : buildOrchestratorReminder(workScopedBoulderState.plan_name, progress, preferredSessionId, autoCommit, false) toolOutput.output = ` @@ -181,8 +250,8 @@ ${ ? "" : `\n${followupReminder}\n` }` - log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, { - plan: boulderState.plan_name, + log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, { + plan: workScopedBoulderState.plan_name, progress: `${progress.completed}/${progress.total}`, fileCount: gitStats.length, preferredSessionId, From 29b44fffd0bce61e9bdeff9e6c99fe60390141fc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:40:48 +0900 Subject: [PATCH 51/73] feat(hooks/atlas): call completeBoulder when progress.isComplete --- .../atlas/idle-event-complete-boulder.test.ts | 78 +++++++++++++++++++ src/hooks/atlas/idle-event.ts | 8 ++ 2 files changed, 86 insertions(+) create mode 100644 src/hooks/atlas/idle-event-complete-boulder.test.ts diff --git a/src/hooks/atlas/idle-event-complete-boulder.test.ts b/src/hooks/atlas/idle-event-complete-boulder.test.ts new file mode 100644 index 000000000..a03b27fe7 --- /dev/null +++ b/src/hooks/atlas/idle-event-complete-boulder.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { randomUUID } from "node:crypto" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" + +const { createAtlasHook } = await import("./index") + +describe("atlas hook idle-event complete boulder", () => { + let testDirectory = "" + + beforeEach(() => { + testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`) + if (!existsSync(testDirectory)) { + mkdirSync(testDirectory, { recursive: true }) + } + clearBoulderState(testDirectory) + }) + + afterEach(() => { + clearBoulderState(testDirectory) + if (existsSync(testDirectory)) { + rmSync(testDirectory, { recursive: true, force: true }) + } + }) + + it("marks work completed with ended_at and elapsed_ms when progress is complete", async () => { + // given + const sessionID = "ses_complete" + const planPath = join(testDirectory, "complete-plan.md") + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Done\n", "utf-8") + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-complete", + active_plan: planPath, + started_at: "2026-01-02T10:00:00.000Z", + session_ids: [sessionID], + plan_name: "complete-plan", + works: { + "work-complete": { + work_id: "work-complete", + active_plan: planPath, + plan_name: "complete-plan", + started_at: "2026-01-02T10:00:00.000Z", + session_ids: [sessionID], + status: "active", + }, + }, + }) + + const hook = createAtlasHook({ + directory: testDirectory, + client: { + session: { + get: async () => ({ data: { id: sessionID } }), + messages: async () => ({ data: [] }), + prompt: async () => ({ data: {} }), + promptAsync: async () => ({ data: {} }), + }, + }, + } as unknown as Parameters[0]) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID }, + }, + }) + + // then + const work = readBoulderState(testDirectory)?.works?.["work-complete"] + expect(work?.status).toBe("completed") + expect(work?.ended_at).toBeString() + expect((work?.elapsed_ms ?? 0) > 0).toBe(true) + }) +}) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 22a755468..417a1f5b9 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -1,6 +1,8 @@ import type { PluginInput } from "@opencode-ai/plugin" import { + completeBoulder, getPlanProgress, + getWorkForSession, getTaskSessionState, readBoulderState, readCurrentTopLevelTask, @@ -220,6 +222,12 @@ export async function handleAtlasSessionIdle(input: { const { boulderState, progress, appendedSession } = activeBoulderSession if (progress.isComplete) { + const work = getWorkForSession(ctx.directory, sessionID) + if (work) { + completeBoulder(ctx.directory, work.work_id) + } else { + completeBoulder(ctx.directory, boulderState.active_work_id) + } log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) return } From d6f4199cab589b2ad7e588a82347ed23ba50ede4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:40:57 +0900 Subject: [PATCH 52/73] feat(start-work): use getWorkResumeOptions for multi-work resume selection Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../start-work/context-info-builder.test.ts | 171 ++++++++++++++++++ src/hooks/start-work/context-info-builder.ts | 138 +++++++++++++- 2 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 src/hooks/start-work/context-info-builder.test.ts diff --git a/src/hooks/start-work/context-info-builder.test.ts b/src/hooks/start-work/context-info-builder.test.ts new file mode 100644 index 000000000..ffc08978b --- /dev/null +++ b/src/hooks/start-work/context-info-builder.test.ts @@ -0,0 +1,171 @@ +/// + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { randomUUID } from "node:crypto" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { buildStartWorkContextInfo } from "./context-info-builder" +import { + addBoulderWork, + createBoulderState, + getBoulderFilePath, + getWorkByPlanName, + readBoulderState, + writeBoulderState, +} from "../../features/boulder-state" +import * as boulderState from "../../features/boulder-state" + +describe("buildStartWorkContextInfo", () => { + let testDirectory = "" + + function createPluginInput() { + return { + directory: testDirectory, + } as never + } + + function writePlan(planName: string, content: string): string { + const plansDirectory = join(testDirectory, ".sisyphus", "plans") + mkdirSync(plansDirectory, { recursive: true }) + const planPath = join(plansDirectory, `${planName}.md`) + writeFileSync(planPath, content) + return planPath + } + + function readExistingState() { + return readBoulderState(testDirectory) + } + + beforeEach(() => { + testDirectory = join(tmpdir(), `context-info-builder-${randomUUID()}`) + mkdirSync(testDirectory, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(testDirectory)) { + rmSync(testDirectory, { recursive: true, force: true }) + } + }) + + test("lists multiple active works and asks agent to choose resume vs new when no explicit plan", () => { + // given + const clearSpy = spyOn(boulderState, "clearBoulderState") + const planAPath = writePlan("plan-alpha", "## TODOs\n- [ ] 1. Alpha") + const planBPath = writePlan("plan-beta", "## TODOs\n- [ ] 1. Beta") + const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a") + writeBoulderState(testDirectory, initialState) + addBoulderWork(testDirectory, { + planPath: planBPath, + sessionId: "session-b", + agent: "atlas", + worktreePath: "/tmp/worktree-b", + }) + + // when + const contextInfo = buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: null, + existingState: readExistingState(), + sessionId: "session-current", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: undefined, + worktreeBlock: "", + }) + + // then + expect(contextInfo).toContain("plan-alpha") + expect(contextInfo).toContain("plan-beta") + expect(contextInfo).toContain("Use the Question tool") + expect(clearSpy).toHaveBeenCalledTimes(0) + }) + + test("auto-resumes when exactly one active work exists and no explicit plan", () => { + // given + const clearSpy = spyOn(boulderState, "clearBoulderState") + const planPath = writePlan("single-active-plan", "## TODOs\n- [ ] 1. Single task") + const initialState = createBoulderState(planPath, "session-a", "atlas", "/tmp/worktree-single") + writeBoulderState(testDirectory, initialState) + + // when + const contextInfo = buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: null, + existingState: readExistingState(), + sessionId: "session-current", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: undefined, + worktreeBlock: "", + }) + + // then + expect(contextInfo).toContain("RESUMING existing work") + expect(contextInfo).toContain("single-active-plan") + expect(contextInfo).not.toContain("Use the Question tool") + expect(clearSpy).toHaveBeenCalledTimes(0) + }) + + test("explicit plan selects matching work only and never clears boulder state", () => { + // given + const clearSpy = spyOn(boulderState, "clearBoulderState") + const planAPath = writePlan("explicit-plan-a", "## TODOs\n- [ ] 1. A") + const planBPath = writePlan("explicit-plan-b", "## TODOs\n- [ ] 1. B") + const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a") + writeBoulderState(testDirectory, initialState) + addBoulderWork(testDirectory, { + planPath: planBPath, + sessionId: "session-b", + agent: "atlas", + worktreePath: "/tmp/worktree-b", + }) + + // when + const contextInfo = buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: "explicit-plan-a", + existingState: readExistingState(), + sessionId: "session-current", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: "/tmp/worktree-a", + worktreeBlock: "", + }) + + // then + expect(contextInfo).toContain("explicit-plan-a") + expect(contextInfo).not.toContain("explicit-plan-b") + expect(clearSpy).toHaveBeenCalledTimes(0) + + const selectedWork = getWorkByPlanName(testDirectory, "explicit-plan-a", { worktreePath: "/tmp/worktree-a" }) + const nextState = readBoulderState(testDirectory) + expect(selectedWork).not.toBeNull() + expect(nextState?.active_work_id).toBe(selectedWork?.work_id) + }) + + test("falls back to auto-select latest plan when no works exist", () => { + // given + const clearSpy = spyOn(boulderState, "clearBoulderState") + const coldStartPlanPath = writePlan("cold-start-plan", "## TODOs\n- [ ] 1. Cold start") + + // when + const contextInfo = buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: null, + existingState: null, + sessionId: "session-current", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: undefined, + worktreeBlock: "", + }) + + // then + expect(contextInfo).toContain("Auto-Selected Plan") + expect(contextInfo).toContain("cold-start-plan") + expect(contextInfo).toContain(coldStartPlanPath) + expect(existsSync(getBoulderFilePath(testDirectory))).toBe(true) + expect(clearSpy).toHaveBeenCalledTimes(0) + }) +}) diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 4ad7859c0..5ea4d8fce 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -1,13 +1,17 @@ import { statSync } from "node:fs" import { appendSessionId, - clearBoulderState, + addBoulderWork, createBoulderState, findPrometheusPlans, + getActiveWorks, getPlanName, getPlanProgress, + getWorkByPlanName, + getWorkResumeOptions, readBoulderState, resolveBoulderPlanPath, + selectActiveWork, writeBoulderState, } from "../../features/boulder-state" import { log } from "../../shared/logger" @@ -99,9 +103,73 @@ Ask the user which plan to work on.` No incomplete plans available. Create a new plan using the Prometheus agent.` } +function formatElapsedHuman(elapsedMs: number | undefined): string { + if (typeof elapsedMs !== "number" || elapsedMs <= 0) { + return "running" + } + + const totalSeconds = Math.floor(elapsedMs / 1000) + const seconds = totalSeconds % 60 + const totalMinutes = Math.floor(totalSeconds / 60) + const minutes = totalMinutes % 60 + const hours = Math.floor(totalMinutes / 60) + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s` + } + if (minutes > 0) { + return `${minutes}m ${seconds}s` + } + return `${seconds}s` +} + +function buildMultipleActiveWorksContext(params: { + resumeOptions: ReturnType + sessionId: string + timestamp: string +}): string { + const { resumeOptions, sessionId, timestamp } = params + const optionList = resumeOptions + .map((option, index) => `${index + 1}. ${option.plan_name} - ${option.progress.completed}/${option.progress.total} (${option.progress.total === 0 ? 0 : Math.floor((option.progress.completed / option.progress.total) * 100)}%) - elapsed: ${formatElapsedHuman(option.elapsed_ms)} - worktree: ${option.worktree_path ?? "current directory"} - sessions: ${option.session_count}`) + .join("\n") + + return ` + +## Multiple Active Works Found + +Current Time: ${timestamp} +Session ID: ${sessionId} + +${optionList} + +Use the Question tool to ask the user which plan to resume. +- If the user chooses one option, run /start-work {plan-name} for that plan. +- If the user chooses to start a new plan, proceed with cold-start auto-selection flow. +` +} + +function createNewWorkOrInitialize(params: { + directory: string + planPath: string + sessionId: string + activeAgent: string + worktreePath: string | undefined +}): void { + const { directory, planPath, sessionId, activeAgent, worktreePath } = params + const created = addBoulderWork(directory, { + planPath, + sessionId, + agent: activeAgent, + worktreePath, + }) + + if (!created) { + const initializedState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) + writeBoulderState(directory, initializedState) + } +} + function buildExplicitPlanContext(params: { explicitPlanName: string - existingState: ReturnType sessionId: string timestamp: string activeAgent: string @@ -109,9 +177,24 @@ function buildExplicitPlanContext(params: { worktreeBlock: string directory: string }): string { - const { explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + const { explicitPlanName, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: sessionId }) + const matchedWork = getWorkByPlanName(directory, explicitPlanName, { worktreePath }) + if (matchedWork) { + const selectedState = selectActiveWork(directory, matchedWork.work_id) + if (selectedState) { + return buildExistingSessionContext({ + existingState: selectedState, + sessionId, + activeAgent, + worktreePath, + worktreeBlock, + directory, + }) + } + } + const allPlans = findPrometheusPlans(directory) const matchedPlan = findPlanByName(allPlans, explicitPlanName) if (!matchedPlan) { @@ -127,9 +210,13 @@ function buildExplicitPlanContext(params: { All ${progress.total} tasks are done. Create a new plan using the Prometheus agent.` } - if (existingState) { - clearBoulderState(directory) - } + createNewWorkOrInitialize({ + directory, + planPath: matchedPlan, + sessionId, + activeAgent, + worktreePath, + }) return buildAutoSelectedPlanContext({ planPath: matchedPlan, @@ -287,11 +374,48 @@ export function buildStartWorkContextInfo(params: { }): string { const { ctx, explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock } = params + const resumeOptions = getWorkResumeOptions(ctx.directory) + .filter((option) => option.status === "active" || option.status === "paused") + + if (!explicitPlanName && resumeOptions.length > 1) { + return buildMultipleActiveWorksContext({ + resumeOptions, + sessionId, + timestamp, + }) + } + + if (!explicitPlanName && resumeOptions.length === 1) { + const onlyOption = resumeOptions[0] + const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id) + if (selectedState) { + return buildExistingSessionContext({ + existingState: selectedState, + sessionId, + activeAgent, + worktreePath, + worktreeBlock, + directory: ctx.directory, + }) + } + } + + if (!explicitPlanName && resumeOptions.length === 0 && getActiveWorks(ctx.directory).length === 0) { + return buildPlanDiscoveryContext({ + contextInfo: "", + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + directory: ctx.directory, + }) + } + let contextInfo = "" if (explicitPlanName) { contextInfo = buildExplicitPlanContext({ explicitPlanName, - existingState, sessionId, timestamp, activeAgent, From fb2f696b4744261329168722203c11832343a05a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:41:06 +0900 Subject: [PATCH 53/73] docs(start-work): document multi-work resume flow in agent template Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/builtin-commands/templates/start-work.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/features/builtin-commands/templates/start-work.ts b/src/features/builtin-commands/templates/start-work.ts index 890805072..70c0a8aa3 100644 --- a/src/features/builtin-commands/templates/start-work.ts +++ b/src/features/builtin-commands/templates/start-work.ts @@ -16,9 +16,13 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session. 2. **Check for active boulder state**: Read \`.sisyphus/boulder.json\` if it exists 3. **Decision logic**: - - If \`.sisyphus/boulder.json\` exists AND plan is NOT complete (has unchecked boxes): - - **APPEND** current session to session_ids - - Continue work on existing plan + - If multiple active works are listed in your context: + - This means boulder.json has more than one work with status: \`active\` or \`paused\` + - Use the Question tool to ask the user which plan to resume + - Resume by running \`/start-work {plan-name}\` for the selected plan + - If the user says "start a new plan", continue with cold-start auto-selection logic + - If exactly one active work is listed and the user did not name a plan: + - Auto-resume that single active work - If no active plan OR plan is complete: - List available plan files - If ONE plan: auto-select it From 30984939ebf63cb7e7d7b1499cadd9938d4ac6e3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:41:09 +0900 Subject: [PATCH 54/73] feat(cli/boulder): add types and formatter for boulder subcommand --- src/cli/boulder/formatter.test.ts | 62 +++++++++++++++++++++++++ src/cli/boulder/formatter.ts | 75 +++++++++++++++++++++++++++++++ src/cli/boulder/types.ts | 33 ++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/cli/boulder/formatter.test.ts create mode 100644 src/cli/boulder/formatter.ts create mode 100644 src/cli/boulder/types.ts diff --git a/src/cli/boulder/formatter.test.ts b/src/cli/boulder/formatter.test.ts new file mode 100644 index 000000000..cd787bf91 --- /dev/null +++ b/src/cli/boulder/formatter.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test" + +import { formatJsonOutput, formatTextOutput } from "./formatter" +import type { BoulderCliResult } from "./types" + +describe("boulder formatter", () => { + it("renders text output with statuses and progress", () => { + const result: BoulderCliResult = { + works: [ + { + work_id: "w1", + plan_name: "alpha", + active_plan: "/tmp/alpha.md", + status: "active", + started_at: "2026-05-10T00:00:00.000Z", + elapsed_human: "30m 0s", + total_tasks: 2, + completed_tasks: 1, + remaining_tasks: 1, + percentage: 50, + session_count: 2, + current_task: { + task_key: "todo:2", + task_title: "Alpha task", + elapsed_human: "1m 0s", + }, + }, + ], + } + + const textOutput = formatTextOutput(result) + expect(textOutput).toContain("boulder progress") + expect(textOutput).toContain("plan: alpha") + expect(textOutput).toContain("status: active") + expect(textOutput).toContain("progress: 50% (1/2)") + expect(textOutput).toContain("elapsed: 30m 0s") + }) + + it("renders parseable json output", () => { + const result: BoulderCliResult = { + works: [ + { + work_id: "w1", + plan_name: "alpha", + active_plan: "/tmp/alpha.md", + status: "completed", + started_at: "2026-05-10T00:00:00.000Z", + ended_at: "2026-05-10T00:01:00.000Z", + elapsed_ms: 60_000, + total_tasks: 2, + completed_tasks: 2, + remaining_tasks: 0, + percentage: 100, + session_count: 1, + }, + ], + } + + const jsonOutput = formatJsonOutput(result) + expect(JSON.parse(jsonOutput)).toEqual(result) + }) +}) diff --git a/src/cli/boulder/formatter.ts b/src/cli/boulder/formatter.ts new file mode 100644 index 000000000..94af0b603 --- /dev/null +++ b/src/cli/boulder/formatter.ts @@ -0,0 +1,75 @@ +import color from "picocolors" + +import type { BoulderWorkStatus } from "../../features/boulder-state" +import type { BoulderCliResult, BoulderCliWork } from "./types" + +function colorizeStatus(status: BoulderWorkStatus): string { + if (status === "active") { + return color.cyan(status) + } + + if (status === "completed") { + return color.green(status) + } + + if (status === "paused") { + return color.yellow(status) + } + + return color.red(status) +} + +function formatCurrentTask(work: BoulderCliWork): string { + if (!work.current_task) { + return "-" + } + + const elapsed = work.current_task.elapsed_human + ? ` (${work.current_task.elapsed_human})` + : "" + return `${work.current_task.task_title}${elapsed}` +} + +function formatWorkBlock(work: BoulderCliWork): string { + const elapsed = work.elapsed_human ?? "-" + const progress = `${work.percentage}% (${work.completed_tasks}/${work.total_tasks})` + + return [ + `plan: ${work.plan_name}`, + `status: ${colorizeStatus(work.status)}`, + `progress: ${progress}`, + `elapsed: ${elapsed}`, + `sessions: ${work.session_count}`, + `current task: ${formatCurrentTask(work)}`, + ].join("\n") +} + +export function formatTextOutput(result: BoulderCliResult): string { + const separator = color.dim("----------------------------------------") + const blocks = result.works.map((work) => formatWorkBlock(work)) + return ["boulder progress", ...blocks].join(`\n${separator}\n`) +} + +export function formatJsonOutput(result: BoulderCliResult): string { + return JSON.stringify(result, null, 2) +} + +export function formatNoBoulderMessage(isJson: boolean | undefined): string { + if (isJson) { + return JSON.stringify({ + error: "No boulder state found.", + }) + } + + return "No boulder state found." +} + +export function formatReadErrorMessage(isJson: boolean | undefined): string { + if (isJson) { + return JSON.stringify({ + error: "Failed to read boulder state.", + }) + } + + return "Failed to read boulder state." +} diff --git a/src/cli/boulder/types.ts b/src/cli/boulder/types.ts new file mode 100644 index 000000000..adefc72c5 --- /dev/null +++ b/src/cli/boulder/types.ts @@ -0,0 +1,33 @@ +import type { BoulderWorkStatus } from "../../features/boulder-state" + +export interface BoulderOptions { + directory?: string + workId?: string + json?: boolean +} + +export interface BoulderCliWork { + work_id: string + plan_name: string + active_plan: string + worktree_path?: string + status: BoulderWorkStatus + started_at: string + ended_at?: string + elapsed_human?: string + elapsed_ms?: number + total_tasks: number + completed_tasks: number + remaining_tasks: number + percentage: number + session_count: number + current_task?: { + task_key: string + task_title: string + elapsed_human?: string + } +} + +export interface BoulderCliResult { + works: BoulderCliWork[] +} From c34508235ff6deabe43de363f855f6b95c25b4e6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:41:18 +0900 Subject: [PATCH 55/73] feat(cli/boulder): implement boulder() entry point and register subcommand --- src/cli/boulder/boulder.test.ts | 215 ++++++++++++++++++++++++++++++++ src/cli/boulder/boulder.ts | 136 ++++++++++++++++++++ src/cli/boulder/index.ts | 1 + src/cli/cli-program.ts | 16 +++ 4 files changed, 368 insertions(+) create mode 100644 src/cli/boulder/boulder.test.ts create mode 100644 src/cli/boulder/boulder.ts create mode 100644 src/cli/boulder/index.ts diff --git a/src/cli/boulder/boulder.test.ts b/src/cli/boulder/boulder.test.ts new file mode 100644 index 000000000..c4fb9bc91 --- /dev/null +++ b/src/cli/boulder/boulder.test.ts @@ -0,0 +1,215 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { afterEach, describe, expect, it } from "bun:test" + +import { boulder } from "./boulder" + +function createTempDirectory(): string { + return mkdtempSync(join(tmpdir(), "omo-boulder-cli-")) +} + +function seedPlanAndState(directory: string): void { + const planDirectory = join(directory, ".sisyphus", "plans") + mkdirSync(planDirectory, { recursive: true }) + + const planAPath = join(planDirectory, "alpha.md") + const planBPath = join(planDirectory, "beta.md") + + writeFileSync( + planAPath, + [ + "## TODOs", + "- [x] 1. Alpha task done", + "- [ ] 2. Alpha task running", + ].join("\n"), + "utf-8", + ) + writeFileSync( + planBPath, + [ + "## TODOs", + "- [x] 1. Beta task done", + "- [x] 2. Beta task done too", + ].join("\n"), + "utf-8", + ) + + const boulderDirectory = join(directory, ".sisyphus") + mkdirSync(boulderDirectory, { recursive: true }) + + writeFileSync( + join(boulderDirectory, "boulder.json"), + JSON.stringify( + { + schema_version: 2, + active_work_id: "work-alpha", + active_plan: planAPath, + started_at: "2026-05-10T00:00:00.000Z", + ended_at: "2026-05-10T00:30:00.000Z", + elapsed_ms: 1_800_000, + status: "active", + updated_at: "2026-05-10T00:30:00.000Z", + session_ids: ["ses-1", "ses-2"], + plan_name: "alpha", + task_sessions: { + "todo:2": { + task_key: "todo:2", + task_label: "2", + task_title: "Alpha task running", + session_id: "ses-2", + elapsed_ms: 60000, + status: "running", + updated_at: "2026-05-10T00:30:00.000Z", + }, + }, + works: { + "work-alpha": { + work_id: "work-alpha", + active_plan: planAPath, + plan_name: "alpha", + status: "active", + started_at: "2026-05-10T00:00:00.000Z", + elapsed_ms: 1_800_000, + updated_at: "2026-05-10T00:30:00.000Z", + session_ids: ["ses-1", "ses-2"], + task_sessions: { + "todo:2": { + task_key: "todo:2", + task_label: "2", + task_title: "Alpha task running", + session_id: "ses-2", + elapsed_ms: 60000, + status: "running", + updated_at: "2026-05-10T00:30:00.000Z", + }, + }, + }, + "work-beta": { + work_id: "work-beta", + active_plan: planBPath, + plan_name: "beta", + status: "completed", + started_at: "2026-05-10T01:00:00.000Z", + ended_at: "2026-05-10T01:10:00.000Z", + elapsed_ms: 600000, + updated_at: "2026-05-10T01:10:00.000Z", + session_ids: ["ses-3"], + task_sessions: {}, + }, + }, + }, + null, + 2, + ), + "utf-8", + ) +} + +describe("boulder command", () => { + const createdDirectories: string[] = [] + const outputRestores: Array<() => void> = [] + + afterEach(() => { + for (const directory of createdDirectories) { + rmSync(directory, { recursive: true, force: true }) + } + createdDirectories.length = 0 + for (const restoreOutput of outputRestores) { + restoreOutput() + } + outputRestores.length = 0 + }) + + function captureOutput(target: "stdout" | "stderr", sink: { value: string }): void { + const originalWrite = process[target].write + process[target].write = ((chunk: string | Uint8Array) => { + sink.value += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8") + return true + }) as typeof process.stdout.write + + outputRestores.push(() => { + process[target].write = originalWrite + }) + } + + it("prints multi-work text mode with plan names and percentages", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + seedPlanAndState(directory) + + const stdout = { value: "" } + const stderr = { value: "" } + captureOutput("stdout", stdout) + captureOutput("stderr", stderr) + + const exitCode = await boulder({ directory }) + + expect(exitCode).toBe(0) + expect(stderr.value).toBe("") + expect(stdout.value).toContain("plan: alpha") + expect(stdout.value).toContain("plan: beta") + expect(stdout.value).toContain("progress: 50% (1/2)") + expect(stdout.value).toContain("progress: 100% (2/2)") + expect(stdout.value).toContain("elapsed:") + }) + + it("prints json mode with expected fields", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + seedPlanAndState(directory) + + const stdout = { value: "" } + captureOutput("stdout", stdout) + + const exitCode = await boulder({ directory, json: true }) + expect(exitCode).toBe(0) + + const parsed = JSON.parse(stdout.value) + expect(parsed.works).toHaveLength(2) + expect(parsed.works[0]).toHaveProperty("work_id") + expect(parsed.works[0]).toHaveProperty("percentage") + expect(parsed.works[0]).toHaveProperty("remaining_tasks") + }) + + it("returns 1 when boulder state does not exist", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + + const stderr = { value: "" } + captureOutput("stderr", stderr) + + const exitCode = await boulder({ directory }) + expect(exitCode).toBe(1) + expect(stderr.value).toContain("No boulder state found") + }) + + it("returns 1 when workId filter matches none", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + seedPlanAndState(directory) + + const stderr = { value: "" } + captureOutput("stderr", stderr) + + const exitCode = await boulder({ directory, workId: "missing" }) + expect(exitCode).toBe(1) + expect(stderr.value).toContain("No boulder state found") + }) + + it("returns one work when workId filter matches", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + seedPlanAndState(directory) + + const stdout = { value: "" } + captureOutput("stdout", stdout) + + const exitCode = await boulder({ directory, workId: "work-beta", json: true }) + expect(exitCode).toBe(0) + + const parsed = JSON.parse(stdout.value) + expect(parsed.works).toHaveLength(1) + expect(parsed.works[0].work_id).toBe("work-beta") + }) +}) diff --git a/src/cli/boulder/boulder.ts b/src/cli/boulder/boulder.ts new file mode 100644 index 000000000..7e07bf0a6 --- /dev/null +++ b/src/cli/boulder/boulder.ts @@ -0,0 +1,136 @@ +import { existsSync } from "node:fs" + +import { + getBoulderFilePath, + getBoulderWorks, + getPlanProgress, + readBoulderState, + readCurrentTopLevelTask, + resolveBoulderPlanPathForWork, +} from "../../features/boulder-state" +import type { BoulderWorkState } from "../../features/boulder-state" +import { + formatJsonOutput, + formatNoBoulderMessage, + formatReadErrorMessage, + formatTextOutput, +} from "./formatter" +import type { BoulderCliResult, BoulderCliWork, BoulderOptions } from "./types" + +function formatDurationHuman(durationMs: number): string { + if (durationMs < 1000) { + return `${durationMs}ms` + } + + const totalSeconds = Math.floor(durationMs / 1000) + const seconds = totalSeconds % 60 + const totalMinutes = Math.floor(totalSeconds / 60) + const minutes = totalMinutes % 60 + const hours = Math.floor(totalMinutes / 60) + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s` + } + + if (minutes > 0) { + return `${minutes}m ${seconds}s` + } + + return `${seconds}s` +} + +function getElapsedMs(work: BoulderWorkState): number | undefined { + if (work.elapsed_ms !== undefined) { + return work.elapsed_ms + } + + const startedAtMs = Date.parse(work.started_at) + if (Number.isNaN(startedAtMs)) { + return undefined + } + + const endedAtMs = work.ended_at ? Date.parse(work.ended_at) : Date.now() + if (Number.isNaN(endedAtMs)) { + return undefined + } + + return Math.max(0, endedAtMs - startedAtMs) +} + +function buildCliWork(directory: string, work: BoulderWorkState): BoulderCliWork { + const planPath = resolveBoulderPlanPathForWork(directory, work) + const progress = getPlanProgress(planPath) + const elapsedMs = getElapsedMs(work) + const currentTask = readCurrentTopLevelTask(planPath) + const taskSession = currentTask ? work.task_sessions?.[currentTask.key] : undefined + + let currentTaskElapsedHuman: string | undefined + if (taskSession?.elapsed_ms !== undefined) { + currentTaskElapsedHuman = formatDurationHuman(taskSession.elapsed_ms) + } else if (taskSession?.started_at) { + const startedAtMs = Date.parse(taskSession.started_at) + if (!Number.isNaN(startedAtMs)) { + currentTaskElapsedHuman = formatDurationHuman(Math.max(0, Date.now() - startedAtMs)) + } + } + + return { + work_id: work.work_id, + plan_name: work.plan_name, + active_plan: work.active_plan, + worktree_path: work.worktree_path, + status: work.status ?? "active", + started_at: work.started_at, + ended_at: work.ended_at, + elapsed_ms: elapsedMs, + elapsed_human: elapsedMs !== undefined ? formatDurationHuman(elapsedMs) : undefined, + total_tasks: progress.total, + completed_tasks: progress.completed, + remaining_tasks: Math.max(0, progress.total - progress.completed), + percentage: progress.total > 0 + ? Math.round((progress.completed / progress.total) * 100) + : 0, + session_count: work.session_ids.length, + current_task: currentTask + ? { + task_key: currentTask.key, + task_title: currentTask.title, + elapsed_human: currentTaskElapsedHuman, + } + : undefined, + } +} + +export async function boulder(options: BoulderOptions): Promise { + const directory = options.directory ?? process.cwd() + const boulderFilePath = getBoulderFilePath(directory) + const state = readBoulderState(directory) + if (!state) { + const message = existsSync(boulderFilePath) + ? formatReadErrorMessage(options.json) + : formatNoBoulderMessage(options.json) + + process.stderr.write(`${message}\n`) + return existsSync(boulderFilePath) ? 2 : 1 + } + + const works = getBoulderWorks(state) + const filteredWorks = options.workId + ? works.filter((work) => work.work_id === options.workId) + : works + + if (filteredWorks.length === 0) { + process.stderr.write(`${formatNoBoulderMessage(options.json)}\n`) + return 1 + } + + const cliWorks = filteredWorks.map((work) => buildCliWork(directory, work)) + const result: BoulderCliResult = { works: cliWorks } + + const output = options.json + ? formatJsonOutput(result) + : formatTextOutput(result) + + process.stdout.write(`${output}\n`) + return 0 +} diff --git a/src/cli/boulder/index.ts b/src/cli/boulder/index.ts new file mode 100644 index 000000000..1f69b2f40 --- /dev/null +++ b/src/cli/boulder/index.ts @@ -0,0 +1 @@ +export { boulder } from "./boulder" diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 49256d2da..ff1b63345 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -5,6 +5,7 @@ import { getLocalVersion } from "./get-local-version" import { doctor } from "./doctor" import { refreshModelCapabilities } from "./refresh-model-capabilities" import { createMcpOAuthCommand } from "./mcp-oauth" +import { boulder } from "./boulder" import type { InstallArgs } from "./types" import type { RunOptions } from "./run" import type { GetLocalVersionOptions } from "./get-local-version/types" @@ -202,6 +203,21 @@ program console.log(`oh-my-opencode v${VERSION}`) }) +program + .command("boulder") + .description("Show boulder progress, elapsed time, and per-task statistics") + .option("-d, --directory ", "Working directory") + .option("-w, --work-id ", "Filter to a specific work") + .option("--json", "Output as JSON") + .action(async (options) => { + const exitCode = await boulder({ + directory: options.directory, + workId: options.workId, + json: options.json ?? false, + }) + process.exit(exitCode) + }) + program.addCommand(createMcpOAuthCommand()) export function runCli(): void { From a1c6e6b77d32270045bd3c00e0cb824af6c514cf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:42:39 +0900 Subject: [PATCH 56/73] fixup! feat(hooks/atlas): use getWorkForSession in boulder lookups and session tracking --- src/hooks/atlas/background-launch-session-tracking.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/hooks/atlas/background-launch-session-tracking.ts b/src/hooks/atlas/background-launch-session-tracking.ts index 57a3e351c..24cd3b296 100644 --- a/src/hooks/atlas/background-launch-session-tracking.ts +++ b/src/hooks/atlas/background-launch-session-tracking.ts @@ -28,6 +28,10 @@ export async function syncBackgroundLaunchSessionTracking(input: { return } + if (typeof toolInput.sessionID !== "string") { + return + } + const trackedWork = getWorkForSession(ctx.directory, toolInput.sessionID) const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output) const lineageSessionIDs = trackedWork?.session_ids ?? boulderState.session_ids From 1ebf89cb9ff4b2aae83edc6ebeef33e24d3dfe03 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:43:40 +0900 Subject: [PATCH 57/73] feat(hooks/atlas): inject boulder-complete elapsed-time nudge once per work Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/atlas/idle-event.test.ts | 125 +++++++++++++++++++++++++++++ src/hooks/atlas/idle-event.ts | 75 +++++++++++++++-- 2 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 src/hooks/atlas/idle-event.test.ts diff --git a/src/hooks/atlas/idle-event.test.ts b/src/hooks/atlas/idle-event.test.ts new file mode 100644 index 000000000..8168de5e4 --- /dev/null +++ b/src/hooks/atlas/idle-event.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import { randomUUID } from "node:crypto" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" +import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state" +import { handleAtlasSessionIdle } from "./idle-event" +import type { SessionState } from "./types" + +describe("handleAtlasSessionIdle completion nudge", () => { + const SESSION_ID = "session-main-1" + + let testDirectory = "" + + beforeEach(() => { + testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`) + if (!existsSync(testDirectory)) { + mkdirSync(testDirectory, { recursive: true }) + } + _resetForTesting() + registerAgentName("atlas") + }) + + afterEach(() => { + if (existsSync(testDirectory)) { + rmSync(testDirectory, { recursive: true, force: true }) + } + _resetForTesting() + }) + + it("injects BOULDER COMPLETE prompt once per work with substituted elapsed and task breakdown", async () => { + // given + const planPath = join(testDirectory, "plan.md") + writeFileSync(planPath, "## TODOs\n- [x] 1. Parse input\n- [x] 2. Save output\n") + + const boulder = createBoulderState(planPath, SESSION_ID, "atlas") + const workId = boulder.active_work_id + if (!workId) { + throw new Error("Expected active_work_id") + } + + const work = boulder.works?.[workId] + if (!work) { + throw new Error("Expected active work") + } + + work.elapsed_ms = 65_000 + boulder.elapsed_ms = 65_000 + work.task_sessions = { + "todo:2": { + task_key: "todo:2", + task_label: "2", + task_title: "Save output", + session_id: "sub-2", + elapsed_ms: 4_000, + updated_at: new Date().toISOString(), + }, + "todo:1": { + task_key: "todo:1", + task_label: "1", + task_title: "Parse input", + session_id: "sub-1", + elapsed_ms: 61_000, + updated_at: new Date().toISOString(), + }, + } + boulder.task_sessions = work.task_sessions + + writeBoulderState(testDirectory, boulder) + + const promptRequests: Array<{ body?: { parts?: Array<{ text?: string }> } }> = [] + const promptAsyncMock = mock(async (request: { body?: { parts?: Array<{ text?: string }> } }) => { + promptRequests.push(request) + return { data: {} } + }) + + const ctx = { + directory: testDirectory, + client: { + session: { + promptAsync: promptAsyncMock, + }, + }, + } as PluginInput + + const sessionStateById = new Map() + const getState = (sessionId: string): SessionState => { + let state = sessionStateById.get(sessionId) + if (!state) { + state = { promptFailureCount: 0 } + sessionStateById.set(sessionId, state) + } + return state + } + + // when + await handleAtlasSessionIdle({ + ctx, + sessionID: SESSION_ID, + getState, + }) + + await handleAtlasSessionIdle({ + ctx, + sessionID: SESSION_ID, + getState, + }) + + // then + expect(promptAsyncMock).toHaveBeenCalledTimes(1) + + const promptText = promptRequests[0]?.body?.parts?.[0]?.text ?? "" + expect(promptText).toContain("BOULDER COMPLETE") + expect(promptText).toContain("Total elapsed: 1m 5s") + expect(promptText).toContain("- 1 Parse input: 1m 1s") + expect(promptText).toContain("- 2 Save output: 4s") + expect(promptText).not.toContain("{ELAPSED_HUMAN}") + + const persistedState = getState(SESSION_ID) + expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber() + expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("active") + }) +}) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 417a1f5b9..ab9c637d8 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -1,6 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { - completeBoulder, + formatDurationHuman, getPlanProgress, getWorkForSession, getTaskSessionState, @@ -8,15 +8,21 @@ import { readCurrentTopLevelTask, resolveBoulderPlanPath, } from "../../features/boulder-state" -import { getSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + isAgentRegistered, + resolveRegisteredAgentName, +} from "../../features/claude-code-session-state" import { getLastAgentFromSession } from "./session-last-agent" import { isSessionInBoulderLineage } from "./boulder-session-lineage" +import { createInternalAgentTextPart } from "../../shared" import { getAgentConfigKey } from "../../shared/agent-display-names" import { log } from "../../shared/logger" import { settleAfterSessionIdle } from "../shared/session-idle-settle" import { injectBoulderContinuation } from "./boulder-continuation-injector" import { HOOK_NAME } from "./hook-name" import { resolveActiveBoulderSession } from "./resolve-active-boulder-session" +import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates" import type { AtlasHookOptions, SessionState } from "./types" const CONTINUATION_COOLDOWN_MS = 5000 @@ -24,6 +30,11 @@ const FAILURE_BACKOFF_MS = 5 * 60 * 1000 const MAX_CONSECUTIVE_PROMPT_FAILURES = 10 const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000 +function getTaskLabelSortValue(taskLabel: string): number { + const parsed = Number.parseInt(taskLabel.replace(/[^0-9]/g, ""), 10) + return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed +} + function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean { const backgroundManager = options?.backgroundManager return backgroundManager @@ -207,6 +218,7 @@ export async function handleAtlasSessionIdle(input: { sessionID: string }): Promise { const { ctx, options, getState, sessionID } = input + const sessionState = getState(sessionID) log(`[${HOOK_NAME}] session.idle`, { sessionID }) @@ -223,11 +235,61 @@ export async function handleAtlasSessionIdle(input: { const { boulderState, progress, appendedSession } = activeBoulderSession if (progress.isComplete) { const work = getWorkForSession(ctx.directory, sessionID) - if (work) { - completeBoulder(ctx.directory, work.work_id) - } else { - completeBoulder(ctx.directory, boulderState.active_work_id) + if (!work || work.status === "abandoned") { + log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) + return } + + if (sessionState.boulderCompletionNudgedAt?.[work.work_id]) { + log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) + return + } + + const elapsedMilliseconds = work.elapsed_ms ?? (Date.now() - new Date(work.started_at).getTime()) + const elapsedHuman = formatDurationHuman(elapsedMilliseconds) + + const taskBreakdown = Object.values(work.task_sessions ?? {}) + .sort((left, right) => { + const leftSortValue = getTaskLabelSortValue(left.task_label) + const rightSortValue = getTaskLabelSortValue(right.task_label) + if (leftSortValue !== rightSortValue) { + return leftSortValue - rightSortValue + } + + return left.task_label.localeCompare(right.task_label) + }) + .map((task) => { + if (typeof task.elapsed_ms === "number") { + return `- ${task.task_label} ${task.task_title}: ${formatDurationHuman(task.elapsed_ms)}` + } + + return `- ${task.task_label} ${task.task_title}: (no timing)` + }) + .join("\n") + + const prompt = BOULDER_COMPLETE_PROMPT + .replace(/{PLAN_NAME}/g, work.plan_name) + .replace(/{ELAPSED_HUMAN}/g, elapsedHuman) + .replace(/{TASK_BREAKDOWN}/g, taskBreakdown.length > 0 ? taskBreakdown : "- (no task timings)") + + const atlasAgent = resolveRegisteredAgentName( + boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined), + ) + if (atlasAgent && isAgentRegistered(atlasAgent)) { + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: atlasAgent, + parts: [createInternalAgentTextPart(prompt)], + }, + query: { directory: ctx.directory }, + }) + sessionState.boulderCompletionNudgedAt = { + ...(sessionState.boulderCompletionNudgedAt ?? {}), + [work.work_id]: Date.now(), + } + } + log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) return } @@ -254,7 +316,6 @@ export async function handleAtlasSessionIdle(input: { return } - const sessionState = getState(sessionID) const now = Date.now() if (sessionState.waitingForFinalWaveApproval) { From de9c28a095bd1ca11de1d204b8d222bff43abea3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:49:36 +0900 Subject: [PATCH 58/73] fix(hooks/atlas): align completion behavior tests with task-4 timing updates --- src/hooks/atlas/idle-event.test.ts | 4 ++-- src/hooks/atlas/idle-event.ts | 7 +++++++ src/hooks/atlas/index.test.ts | 10 +++++----- src/hooks/atlas/tool-execute-after-task-timers.test.ts | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/hooks/atlas/idle-event.test.ts b/src/hooks/atlas/idle-event.test.ts index 8168de5e4..d97783c4e 100644 --- a/src/hooks/atlas/idle-event.test.ts +++ b/src/hooks/atlas/idle-event.test.ts @@ -83,7 +83,7 @@ describe("handleAtlasSessionIdle completion nudge", () => { promptAsync: promptAsyncMock, }, }, - } as PluginInput + } as unknown as PluginInput const sessionStateById = new Map() const getState = (sessionId: string): SessionState => { @@ -120,6 +120,6 @@ describe("handleAtlasSessionIdle completion nudge", () => { const persistedState = getState(SESSION_ID) expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber() - expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("active") + expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed") }) }) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index ab9c637d8..b4803bb5e 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { + completeBoulder, formatDurationHuman, getPlanProgress, getWorkForSession, @@ -235,6 +236,12 @@ export async function handleAtlasSessionIdle(input: { const { boulderState, progress, appendedSession } = activeBoulderSession if (progress.isComplete) { const work = getWorkForSession(ctx.directory, sessionID) + if (work) { + completeBoulder(ctx.directory, work.work_id) + } else { + completeBoulder(ctx.directory, boulderState.active_work_id) + } + if (!work || work.status === "abandoned") { log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) return diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 412cc9631..9e5692e44 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1490,7 +1490,7 @@ session_id: ses_untrusted_999 expect(callArgs.body.parts[0].text).toContain("2 remaining") }) - test("should not inject when boulder plan is complete", async () => { + test("should inject completion nudge when boulder plan is complete", async () => { // given - boulder state with complete plan const planPath = join(TEST_DIR, "complete-plan.md") writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2") @@ -1514,11 +1514,11 @@ session_id: ses_untrusted_999 }, }) - // then - should not call prompt - expect(mockInput._promptMock).not.toHaveBeenCalled() + // then + expect(mockInput._promptMock).toHaveBeenCalledTimes(1) }) - test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => { + test("should inject completion nudge when mirrored worktree plan is complete even if the main repo plan is stale", async () => { // given const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md") const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`) @@ -1549,7 +1549,7 @@ session_id: ses_untrusted_999 }) // then - expect(mockInput._promptMock).not.toHaveBeenCalled() + expect(mockInput._promptMock).toHaveBeenCalledTimes(1) } finally { rmSync(worktreeDir, { recursive: true, force: true }) } diff --git a/src/hooks/atlas/tool-execute-after-task-timers.test.ts b/src/hooks/atlas/tool-execute-after-task-timers.test.ts index 64182c93e..54b210233 100644 --- a/src/hooks/atlas/tool-execute-after-task-timers.test.ts +++ b/src/hooks/atlas/tool-execute-after-task-timers.test.ts @@ -217,6 +217,6 @@ describe("createToolExecuteAfterHandler task timers", () => { expect(taskSession).toBeDefined() expect(taskSession?.ended_at).toBeString() expect(taskSession?.status).toBe("completed") - expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true) + expect(typeof taskSession?.elapsed_ms).toBe("number") }) }) From 14b9a434d76bcf6e72189b4489fc487701022483 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:11:28 +0900 Subject: [PATCH 59/73] fix(cli/boulder): strip ANSI in formatter test so FORCE_COLOR CI passes picocolors emits ANSI escape codes when FORCE_COLOR is set (GitHub Actions default), so the literal toContain('status: active') assertion fails against the wrapped 'status: \x1b[36mactive\x1b[39m' output. Reuse the existing stripAnsi helper from src/cli/doctor/format-shared.ts in the test before assertion. Reproduced locally with FORCE_COLOR=1 bun test src/cli/boulder/formatter.test.ts. --- src/cli/boulder/formatter.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/boulder/formatter.test.ts b/src/cli/boulder/formatter.test.ts index cd787bf91..8fbfa48c5 100644 --- a/src/cli/boulder/formatter.test.ts +++ b/src/cli/boulder/formatter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" +import { stripAnsi } from "../doctor/format-shared" import { formatJsonOutput, formatTextOutput } from "./formatter" import type { BoulderCliResult } from "./types" @@ -28,7 +29,7 @@ describe("boulder formatter", () => { ], } - const textOutput = formatTextOutput(result) + const textOutput = stripAnsi(formatTextOutput(result)) expect(textOutput).toContain("boulder progress") expect(textOutput).toContain("plan: alpha") expect(textOutput).toContain("status: active") From 70351534d08c0e19825ab9b5b9bab5623896882c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:23:34 +0900 Subject: [PATCH 60/73] style(agents): replace em dashes with semicolons/periods Comply with the no-em-dash constraint flagged in PR #3943 review. Two single-line replacements: - opus-4-7-prompt-sections.ts:149 retry guidance copy - plan-generation.ts:65 Oracle gate guidance copy No behavioral change. --- src/agents/atlas/opus-4-7-prompt-sections.ts | 2 +- src/agents/prometheus/plan-generation.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/atlas/opus-4-7-prompt-sections.ts b/src/agents/atlas/opus-4-7-prompt-sections.ts index dbbf4fd68..71bd9b2ba 100644 --- a/src/agents/atlas/opus-4-7-prompt-sections.ts +++ b/src/agents/atlas/opus-4-7-prompt-sections.ts @@ -146,7 +146,7 @@ When a task fails: 3. If a single retry on the same session does not fix it, write down what the subagent attempted, what it observed, what your hypothesis is, then resume the same session with that plan attached. Iterate until verification passes. 4. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Stay on the same plan task; never move on with that task unverified. -**NEVER start fresh on every retry** — that wipes accumulated context and costs ~3-4× more tokens. Reserve fresh sessions for a deliberately different angle. +**NEVER start fresh on every retry**. That wipes accumulated context and costs ~3-4× more tokens. Reserve fresh sessions for a deliberately different angle. ### 3.6 Loop Until Implementation Complete diff --git a/src/agents/prometheus/plan-generation.ts b/src/agents/prometheus/plan-generation.ts index 5e974c881..efa932bab 100644 --- a/src/agents/prometheus/plan-generation.ts +++ b/src/agents/prometheus/plan-generation.ts @@ -62,7 +62,7 @@ todoWrite([ ## Oracle Verification (Phase Gates) -Three blocking phase gates use the Oracle agent (read-only consultant). Each gate is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation. The Oracle must return VERDICT: GO before the workflow continues. NO-GO is not an excuse to skip — fix the cited issues and rerun on the same session via \`task_id\`. +Three blocking phase gates use the Oracle agent (read-only consultant). Each gate is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation. The Oracle must return VERDICT: GO before the workflow continues. NO-GO is not an excuse to skip; fix the cited issues and rerun on the same Oracle session via \`task_id\`. ### plan-1b: phase 1 verification (after Metis, before plan generation) From ce2f3af001bad7fb4cce0f015ba293dcaa73f83a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:25:06 +0900 Subject: [PATCH 61/73] fix(boulder-state): make completeBoulder idempotent on already-completed works --- src/features/boulder-state/storage.test.ts | 21 +++++++++++++++++++++ src/features/boulder-state/storage.ts | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 63e43faff..2fe0438ad 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -622,6 +622,27 @@ describe("boulder-state", () => { expect(completedState?.works?.[secondWorkId]?.status).not.toBe("completed") expect(existsSync(join(SISYPHUS_DIR, "boulder.json"))).toBe(true) }) + + test("should keep first completion timing when completeBoulder is called repeatedly", () => { + // given + const initialState = createBoulderState( + join(TEST_DIR, ".sisyphus/plans/plan-idempotent.md"), + "session-a", + ) + writeBoulderState(TEST_DIR, initialState) + const workId = initialState.active_work_id! + + // when + const firstCompletedState = completeBoulder(TEST_DIR, workId, "2026-01-01T00:01:00Z") + const secondCompletedState = completeBoulder(TEST_DIR, workId, "2026-01-01T01:00:00Z") + + // then + expect(firstCompletedState?.works?.[workId]?.ended_at).toBe("2026-01-01T00:01:00Z") + expect(secondCompletedState?.works?.[workId]?.ended_at).toBe("2026-01-01T00:01:00Z") + expect(secondCompletedState?.works?.[workId]?.elapsed_ms).toBe( + Date.parse("2026-01-01T00:01:00Z") - Date.parse(secondCompletedState!.works![workId]!.started_at), + ) + }) }) describe("readCurrentTopLevelTask", () => { diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index f5f03109c..a07f2a40e 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -955,6 +955,10 @@ export function completeBoulder(directory: string, workId?: string, endedAt?: st return null } + if (work.status === "completed" && work.ended_at !== undefined && work.elapsed_ms !== undefined) { + return state + } + const endAt = endedAt ?? nowIsoString() work.ended_at = endAt work.elapsed_ms = getElapsedMs(work.started_at, endAt) From dd5f77562a704c82e4008d09e8f81ef1656a6cb0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:25:35 +0900 Subject: [PATCH 62/73] fix(boulder-state): missing plan file no longer reports isComplete=true --- src/features/boulder-state/storage.test.ts | 3 ++- src/features/boulder-state/storage.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 2fe0438ad..aa7bf1858 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -888,7 +888,8 @@ describe("boulder-state", () => { const progress = getPlanProgress("/non/existent/file.md") // then expect(progress.total).toBe(0) - expect(progress.isComplete).toBe(true) + expect(progress.completed).toBe(0) + expect(progress.isComplete).toBe(false) }) test("should support asterisk bullet top-level tasks", () => { diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index a07f2a40e..2eeda2436 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -395,7 +395,7 @@ type ProgressSection = "todo" | "final-wave" | "other" */ export function getPlanProgress(planPath: string): PlanProgress { if (!existsSync(planPath)) { - return { total: 0, completed: 0, isComplete: true } + return { total: 0, completed: 0, isComplete: false } } try { @@ -416,7 +416,7 @@ export function getPlanProgress(planPath: string): PlanProgress { // Simple plan: count all top-level checkboxes anywhere return getSimplePlanProgress(content) } catch { - return { total: 0, completed: 0, isComplete: true } + return { total: 0, completed: 0, isComplete: false } } } From 079a2cd65a4d1ccba12ff9053da2cfa267334613 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:25:52 +0900 Subject: [PATCH 63/73] fix(start-work): preserve existing works when starting an explicit new plan --- .../start-work/context-info-builder.test.ts | 48 +++++++++++++++++++ src/hooks/start-work/context-info-builder.ts | 37 +++++++++----- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/hooks/start-work/context-info-builder.test.ts b/src/hooks/start-work/context-info-builder.test.ts index ffc08978b..139cc179e 100644 --- a/src/hooks/start-work/context-info-builder.test.ts +++ b/src/hooks/start-work/context-info-builder.test.ts @@ -168,4 +168,52 @@ describe("buildStartWorkContextInfo", () => { expect(existsSync(getBoulderFilePath(testDirectory))).toBe(true) expect(clearSpy).toHaveBeenCalledTimes(0) }) + + test("keeps existing works when explicit new plan is started", () => { + // given + writePlan("work-a", "## TODOs\n- [ ] 1. Work A") + const workBPath = writePlan("work-b", "## TODOs\n- [ ] 1. Work B") + writePlan("new-plan-c", "## TODOs\n- [ ] 1. Work C") + + const initialState = createBoulderState( + join(testDirectory, ".sisyphus", "plans", "work-a.md"), + "session-a", + "atlas", + "/tmp/worktree-a", + ) + writeBoulderState(testDirectory, initialState) + + const workAId = initialState.active_work_id! + const withSecondWork = addBoulderWork(testDirectory, { + planPath: workBPath, + sessionId: "session-b", + agent: "atlas", + worktreePath: "/tmp/worktree-b", + }) + expect(withSecondWork).not.toBeNull() + const workBId = Object.keys(withSecondWork!.works!).find((workId) => workId !== workAId) + expect(workBId).toBeDefined() + + // when + buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: "new-plan-c", + existingState: readExistingState(), + sessionId: "session-c", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: undefined, + worktreeBlock: "", + }) + + // then + const nextState = readBoulderState(testDirectory) + const workIds = Object.keys(nextState?.works ?? {}) + expect(workIds.length).toBe(3) + expect(workIds).toContain(workAId) + expect(workIds).toContain(workBId!) + const workC = getWorkByPlanName(testDirectory, "new-plan-c") + expect(workC).not.toBeNull() + expect(workIds).toContain(workC!.work_id) + }) }) diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 5ea4d8fce..9fc8e0fd4 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -48,19 +48,14 @@ function findPlanByName(plans: string[], requestedName: string): string | null { return normalizedPartialMatch || null } -function buildAutoSelectedPlanContext(params: { +function buildAutoSelectedPlanContextInfoOnly(params: { planPath: string sessionId: string timestamp: string - activeAgent: string - worktreePath: string | undefined worktreeBlock: string - directory: string }): string { - const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + const { planPath, sessionId, timestamp, worktreeBlock } = params const progress = getPlanProgress(planPath) - const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) - writeBoulderState(directory, newState) return ` ## Auto-Selected Plan @@ -75,6 +70,27 @@ ${worktreeBlock} boulder.json has been created. Read the plan and begin execution.` } +function buildAutoSelectedPlanContextWithStateInit(params: { + planPath: string + sessionId: string + timestamp: string + activeAgent: string + worktreePath: string | undefined + worktreeBlock: string + directory: string +}): string { + const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) + writeBoulderState(directory, newState) + + return buildAutoSelectedPlanContextInfoOnly({ + planPath, + sessionId, + timestamp, + worktreeBlock, + }) +} + function buildMissingPlanContext(explicitPlanName: string, allPlans: string[]): string { const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete) if (incompletePlans.length > 0) { @@ -218,14 +234,11 @@ function buildExplicitPlanContext(params: { worktreePath, }) - return buildAutoSelectedPlanContext({ + return buildAutoSelectedPlanContextInfoOnly({ planPath: matchedPlan, sessionId, timestamp, - activeAgent, - worktreePath, worktreeBlock, - directory, }) } @@ -328,7 +341,7 @@ function buildPlanDiscoveryContext(params: { } if (incompletePlans.length === 1) { - return contextInfo + buildAutoSelectedPlanContext({ + return contextInfo + buildAutoSelectedPlanContextWithStateInit({ planPath: incompletePlans[0], sessionId, timestamp, From b8c25b3b755158efe2766541c0d455b09c134320 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:26:10 +0900 Subject: [PATCH 64/73] refactor(hooks/atlas): remove unused resolveSessionOrigin helper --- .../atlas/background-launch-session-tracking.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/hooks/atlas/background-launch-session-tracking.ts b/src/hooks/atlas/background-launch-session-tracking.ts index 24cd3b296..6f3d43e8b 100644 --- a/src/hooks/atlas/background-launch-session-tracking.ts +++ b/src/hooks/atlas/background-launch-session-tracking.ts @@ -112,17 +112,3 @@ async function resolveFallbackTrackedSessionId(input: { return undefined } } - -async function resolveSessionOrigin( - ctx: PluginInput, - sessionID: string, -): Promise<"direct" | "appended"> { - try { - const session = await ctx.client.session.get({ path: { id: sessionID } }) - return typeof session.data?.parentID === "string" && session.data.parentID.length > 0 - ? "appended" - : "direct" - } catch { - return "appended" - } -} From e3cddb365021d45ac1488dc1672ccef3d8199af0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:27:03 +0900 Subject: [PATCH 65/73] feat(hooks/atlas): end task timer when plan checkbox flips to checked via edit --- src/hooks/atlas/atlas-hook.ts | 3 + .../tool-execute-after-task-timers.test.ts | 77 ++++++++++++++++- src/hooks/atlas/tool-execute-after.ts | 86 ++++++++++++++++++- src/hooks/atlas/tool-execute-before.ts | 38 ++++++-- src/hooks/atlas/write-edit-tool-policy.ts | 2 +- 5 files changed, 195 insertions(+), 11 deletions(-) diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts index aa9e13c4e..4dc7c9e93 100644 --- a/src/hooks/atlas/atlas-hook.ts +++ b/src/hooks/atlas/atlas-hook.ts @@ -8,6 +8,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { const sessions = new Map() const pendingFilePaths = new Map() const pendingTaskRefs = new Map() + const pendingPlanSnapshots = new Map() const autoCommit = options?.autoCommit ?? true function getState(sessionID: string): SessionState { @@ -25,12 +26,14 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { ctx, pendingFilePaths, pendingTaskRefs, + pendingPlanSnapshots, isCallerOrchestrator: options?.isCallerOrchestrator, }), "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, + pendingPlanSnapshots, autoCommit, getState, isCallerOrchestrator: options?.isCallerOrchestrator, diff --git a/src/hooks/atlas/tool-execute-after-task-timers.test.ts b/src/hooks/atlas/tool-execute-after-task-timers.test.ts index 54b210233..6bda79239 100644 --- a/src/hooks/atlas/tool-execute-after-task-timers.test.ts +++ b/src/hooks/atlas/tool-execute-after-task-timers.test.ts @@ -78,7 +78,7 @@ describe("createToolExecuteAfterHandler task timers", () => { session: { get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]), }, - } as unknown as PluginInput["client"] + } as PluginInput["client"] if (parentSessionIDs) { spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( @@ -88,6 +88,7 @@ describe("createToolExecuteAfterHandler task timers", () => { const pendingFilePaths = new Map() const pendingTaskRefs = new Map() + const pendingPlanSnapshots = new Map() const ctx = { client, project, @@ -98,11 +99,17 @@ describe("createToolExecuteAfterHandler task timers", () => { } satisfies PluginInput return { - beforeHandler: createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }), + beforeHandler: createToolExecuteBeforeHandler({ + ctx, + pendingFilePaths, + pendingTaskRefs, + pendingPlanSnapshots, + }), afterHandler: createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, + pendingPlanSnapshots, autoCommit: true, getState: () => ({ promptFailureCount: 0 }), }), @@ -219,4 +226,70 @@ describe("createToolExecuteAfterHandler task timers", () => { expect(taskSession?.status).toBe("completed") expect(typeof taskSession?.elapsed_ms).toBe("number") }) + + it("ends task timer when plan checkbox flips to checked via edit tool", async () => { + // given + const parentSessionID = "ses_parent_3" + const planPath = join(testDirectory, "task-timer-edit-plan.md") + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8") + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-1", + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + plan_name: "task-timer-edit-plan", + task_sessions: { + "todo:1": { + task_key: "todo:1", + task_label: "1", + task_title: "Implement auth flow", + session_id: "ses_child_3", + started_at: "2026-01-02T10:00:00Z", + status: "running", + updated_at: "2026-01-02T10:00:00Z", + }, + }, + works: { + "work-1": { + work_id: "work-1", + active_plan: planPath, + plan_name: "task-timer-edit-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + status: "active", + task_sessions: {}, + }, + }, + }) + const { beforeHandler, afterHandler } = createHandlers() + + await beforeHandler( + { tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" }, + { args: { filePath: planPath, oldString: "- [ ] 1. Implement auth flow", newString: "- [x] 1. Implement auth flow" } }, + ) + + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8") + + // when + await afterHandler( + { tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" }, + { + title: "Edit", + output: "Updated file", + metadata: { + filePath: planPath, + }, + }, + ) + + // then + const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"] + expect(taskSession).toBeDefined() + expect(taskSession?.ended_at).toBeString() + expect(taskSession?.status).toBe("completed") + expect(typeof taskSession?.elapsed_ms).toBe("number") + expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true) + }) + }) diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 4cef75ac1..46928cb81 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -11,6 +11,7 @@ import { upsertTaskSessionState, } from "../../features/boulder-state" import { existsSync, readFileSync } from "node:fs" +import { resolve } from "node:path" import { log } from "../../shared/logger" import { isCallerOrchestrator } from "../../shared/session-utils" import { syncBackgroundLaunchSessionTracking } from "./background-launch-session-tracking" @@ -59,15 +60,77 @@ function isTrackedTaskChecked(planPath: string, taskKey: string): boolean { } } +const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i +const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i +const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/ +const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/ +const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/ +const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i + +function parseCheckedTopLevelTaskKeys(planContent: string): Set { + const checkedKeys = new Set() + const lines = planContent.split(/\r?\n/) + let section: "todo" | "final-wave" | "other" = "other" + + for (const line of lines) { + if (SECOND_LEVEL_HEADING_PATTERN.test(line)) { + section = TODO_HEADING_PATTERN.test(line) + ? "todo" + : FINAL_VERIFICATION_HEADING_PATTERN.test(line) + ? "final-wave" + : "other" + continue + } + + if (section !== "todo" && section !== "final-wave") { + continue + } + + const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN) + if (!checkedMatch || checkedMatch[1].length > 0) { + continue + } + + const taskBody = checkedMatch[2].trim() + if (section === "todo") { + const taskMatch = taskBody.match(TODO_TASK_PATTERN) + if (taskMatch?.[1]) { + checkedKeys.add(`todo:${taskMatch[1]}`) + } + continue + } + + const taskMatch = taskBody.match(FINAL_WAVE_TASK_PATTERN) + if (taskMatch?.[1]) { + checkedKeys.add(`final-wave:${taskMatch[1].toLowerCase()}`) + } + } + + return checkedKeys +} + +function readCheckedTaskKeysFromPlan(planPath: string): Set { + if (!existsSync(planPath)) { + return new Set() + } + + try { + return parseCheckedTopLevelTaskKeys(readFileSync(planPath, "utf-8")) + } catch { + return new Set() + } +} + export function createToolExecuteAfterHandler(input: { ctx: PluginInput pendingFilePaths: Map pendingTaskRefs: Map + pendingPlanSnapshots?: Map autoCommit: boolean getState: (sessionID: string) => SessionState isCallerOrchestrator?: (sessionID: string | undefined) => Promise }): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise { - const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input + const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client)) return async (toolInput, toolOutput): Promise => { // Guard against undefined output (e.g., from /review command - see issue #1035) @@ -81,12 +144,33 @@ export function createToolExecuteAfterHandler(input: { if (isWriteOrEditToolName(toolInput.tool)) { let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined + const planSnapshot = toolInput.callID && pendingPlanSnapshots + ? pendingPlanSnapshots.get(toolInput.callID) + : undefined if (toolInput.callID) { pendingFilePaths.delete(toolInput.callID) + pendingPlanSnapshots?.delete(toolInput.callID) } if (!filePath) { filePath = toolOutput.metadata?.filePath as string | undefined } + + if (filePath && toolInput.sessionID) { + const sessionWork = getWorkForSession(ctx.directory, toolInput.sessionID) + if (sessionWork) { + const planPath = resolveBoulderPlanPathForWork(ctx.directory, sessionWork) + if (resolve(filePath) === resolve(planPath) && planSnapshot !== undefined) { + const beforeCheckedKeys = parseCheckedTopLevelTaskKeys(planSnapshot) + const afterCheckedKeys = readCheckedTaskKeysFromPlan(planPath) + for (const taskKey of afterCheckedKeys) { + if (!beforeCheckedKeys.has(taskKey)) { + endTaskTimer(ctx.directory, sessionWork.work_id, taskKey) + } + } + } + } + } + if (filePath && !isSisyphusPath(filePath)) { toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER log(`[${HOOK_NAME}] Direct work reminder appended`, { diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index dd31f1c40..88e31fc13 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -2,7 +2,9 @@ import { log } from "../../shared/logger" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { isCallerOrchestrator } from "../../shared/session-utils" import type { PluginInput } from "@opencode-ai/plugin" -import { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } from "../../features/boulder-state" +import { existsSync, readFileSync } from "node:fs" +import { resolve } from "node:path" +import { getWorkForSession, readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "../../features/boulder-state" import { HOOK_NAME } from "./hook-name" import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates" import { isSisyphusPath } from "./sisyphus-path" @@ -13,12 +15,13 @@ export function createToolExecuteBeforeHandler(input: { ctx: PluginInput pendingFilePaths: Map pendingTaskRefs: Map + pendingPlanSnapshots?: Map isCallerOrchestrator?: (sessionID: string | undefined) => Promise }): ( toolInput: { tool: string; sessionID?: string; callID?: string }, toolOutput: { args: Record; message?: string } ) => Promise { - const { ctx, pendingFilePaths, pendingTaskRefs } = input + const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots } = input const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client)) function trackTask(callID: string, task: TrackedTopLevelTaskRef): void { @@ -38,6 +41,27 @@ export function createToolExecuteBeforeHandler(input: { // Store filePath for use in tool.execute.after if (toolInput.callID) { pendingFilePaths.set(toolInput.callID, filePath) + + const sessionID = toolInput.sessionID + const sessionWork = sessionID + ? getWorkForSession(ctx.directory, sessionID) + : null + const state = sessionWork ? null : readBoulderState(ctx.directory) + const planPath = sessionWork + ? resolveBoulderPlanPathForWork(ctx.directory, sessionWork) + : state + ? resolveBoulderPlanPath(ctx.directory, state) + : null + + if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) { + try { + if (existsSync(planPath)) { + pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8")) + } + } catch { + pendingPlanSnapshots.delete(toolInput.callID) + } + } } const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath) toolOutput.message = (toolOutput.message || "") + warning @@ -65,28 +89,28 @@ export function createToolExecuteBeforeHandler(input: { ? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState)) : null if (currentTask) { - const task = { + const trackedTask = { key: currentTask.key, label: currentTask.label, title: currentTask.title, } const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => ( - pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key + pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key )) if (hasExistingClaim) { pendingTaskRefs.set(toolInput.callID, { kind: "skip", reason: "ambiguous_task_key", - task, + task: trackedTask, }) log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, { sessionID: toolInput.sessionID, callID: toolInput.callID, - taskKey: task.key, + taskKey: trackedTask.key, }) } else { - trackTask(toolInput.callID, task) + trackTask(toolInput.callID, trackedTask) } } } diff --git a/src/hooks/atlas/write-edit-tool-policy.ts b/src/hooks/atlas/write-edit-tool-policy.ts index af75d2727..790f65351 100644 --- a/src/hooks/atlas/write-edit-tool-policy.ts +++ b/src/hooks/atlas/write-edit-tool-policy.ts @@ -1,4 +1,4 @@ -const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] +const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit", "hashline_edit"] export function isWriteOrEditToolName(toolName: string): boolean { return WRITE_EDIT_TOOLS.includes(toolName) From cf5fe757df7472a64a5424f5a32495c2951ba18f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:28:56 +0900 Subject: [PATCH 66/73] feat(hooks/atlas): parse task_key from delegation prompt for parallel batches --- .../tool-execute-after-task-timers.test.ts | 138 ++++++++++++++++++ src/hooks/atlas/tool-execute-before.ts | 66 ++++++++- 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/src/hooks/atlas/tool-execute-after-task-timers.test.ts b/src/hooks/atlas/tool-execute-after-task-timers.test.ts index 6bda79239..c565e5c2d 100644 --- a/src/hooks/atlas/tool-execute-after-task-timers.test.ts +++ b/src/hooks/atlas/tool-execute-after-task-timers.test.ts @@ -292,4 +292,142 @@ describe("createToolExecuteAfterHandler task timers", () => { expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true) }) + it("tracks parallel delegated tasks by task label from TASK section", async () => { + // given + const parentSessionID = "ses_parent_parallel" + const planPath = join(testDirectory, "task-timer-parallel-plan.md") + writeFileSync( + planPath, + "# Plan\n\n## TODOs\n- [ ] 1. First task\n- [ ] 2. Add tests\n- [ ] 3. Write docs\n", + "utf-8", + ) + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-1", + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + plan_name: "task-timer-parallel-plan", + works: { + "work-1": { + work_id: "work-1", + active_plan: planPath, + plan_name: "task-timer-parallel-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + status: "active", + }, + }, + }) + const { beforeHandler, afterHandler } = createHandlers({ + ses_child_parallel_2: parentSessionID, + ses_child_parallel_3: parentSessionID, + }) + + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" }, + { + args: { + prompt: "## 1. TASK\n- [ ] 2. Add tests\n\n## 2. CONTEXT\n...", + }, + }, + ) + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" }, + { + args: { + prompt: "## 1. TASK\n- [ ] 3. Write docs\n\n## 2. CONTEXT\n...", + }, + }, + ) + + // when + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" }, + { + title: "Sisyphus Task", + output: "Task completed\n\nsession_id: ses_child_parallel_2\n", + metadata: { + sessionId: "ses_child_parallel_2", + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" }, + { + title: "Sisyphus Task", + output: "Task completed\n\nsession_id: ses_child_parallel_3\n", + metadata: { + sessionId: "ses_child_parallel_3", + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + + // then + const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions + expect(taskSessions?.["todo:2"]?.task_key).toBe("todo:2") + expect(taskSessions?.["todo:3"]?.task_key).toBe("todo:3") + expect(taskSessions?.["todo:1"]).toBeUndefined() + }) + + it("falls back to current top-level task when TASK section label is missing", async () => { + // given + const parentSessionID = "ses_parent_fallback" + const childSessionID = "ses_child_fallback" + const planPath = join(testDirectory, "task-timer-fallback-plan.md") + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. First task\n", "utf-8") + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-1", + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + plan_name: "task-timer-fallback-plan", + works: { + "work-1": { + work_id: "work-1", + active_plan: planPath, + plan_name: "task-timer-fallback-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + status: "active", + }, + }, + }) + const { beforeHandler, afterHandler } = createHandlers({ + [childSessionID]: parentSessionID, + }) + + await beforeHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" }, + { + args: { + prompt: "No structured header in this prompt", + }, + }, + ) + + // when + await afterHandler( + { tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" }, + { + title: "Sisyphus Task", + output: "Task completed\n\nsession_id: ses_child_fallback\n", + metadata: { + sessionId: childSessionID, + agent: "sisyphus-junior", + category: "deep", + }, + }, + ) + + // then + const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions + expect(taskSessions?.["todo:1"]?.task_key).toBe("todo:1") + }) + }) diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index 88e31fc13..d6e4789ee 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -11,6 +11,49 @@ import { isSisyphusPath } from "./sisyphus-path" import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types" import { isWriteOrEditToolName } from "./write-edit-tool-policy" +const TASK_SECTION_HEADER_PATTERN = /^##\s*1\.\s*TASK\s*$/i +const TODO_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(\d+)\.\s+(.+)$/ +const FINAL_WAVE_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(F\d+)\.\s+(.+)$/i + +function parseTrackedTaskFromPrompt(prompt: string): TrackedTopLevelTaskRef | null { + const lines = prompt.split(/\r?\n/) + const taskHeaderIndex = lines.findIndex((line) => TASK_SECTION_HEADER_PATTERN.test(line.trim())) + if (taskHeaderIndex < 0) { + return null + } + + const startIndex = taskHeaderIndex + 1 + const endIndex = Math.min(lines.length, startIndex + 5) + for (let index = startIndex; index < endIndex; index += 1) { + const candidate = lines[index]?.trim() + if (!candidate) { + continue + } + + const finalWaveMatch = candidate.match(FINAL_WAVE_TASK_LINE_PATTERN) + if (finalWaveMatch?.[1] && finalWaveMatch[2]) { + const label = finalWaveMatch[1].toUpperCase() + return { + key: `final-wave:${label.toLowerCase()}`, + label, + title: finalWaveMatch[2].trim(), + } + } + + const todoMatch = candidate.match(TODO_TASK_LINE_PATTERN) + if (todoMatch?.[1] && todoMatch[2]) { + const label = todoMatch[1] + return { + key: `todo:${label}`, + label, + title: todoMatch[2].trim(), + } + } + } + + return null +} + export function createToolExecuteBeforeHandler(input: { ctx: PluginInput pendingFilePaths: Map @@ -84,15 +127,30 @@ export function createToolExecuteBeforeHandler(input: { reason: "explicit_resume", }) } else { + const prompt = typeof toolOutput.args.prompt === "string" ? toolOutput.args.prompt : "" + const taskFromPrompt = parseTrackedTaskFromPrompt(prompt) const boulderState = readBoulderState(ctx.directory) const currentTask = boulderState ? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState)) : null - if (currentTask) { + const resolvedTask = taskFromPrompt ?? (currentTask + ? { + key: currentTask.key, + label: currentTask.label, + title: currentTask.title, + } + : null) + if (resolvedTask) { + if (!taskFromPrompt) { + log(`[${HOOK_NAME}] TASK section parse failed; falling back to current top-level task`, { + sessionID: toolInput.sessionID, + callID: toolInput.callID, + }) + } const trackedTask = { - key: currentTask.key, - label: currentTask.label, - title: currentTask.title, + key: resolvedTask.key, + label: resolvedTask.label, + title: resolvedTask.title, } const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => ( pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key From 29c42485a8551c473a36a5851c300bf8ff9c3734 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:48:49 +0900 Subject: [PATCH 67/73] fix(hooks/atlas): capture plan snapshot for .sisyphus paths Oracle review of PR #3943 surfaced that endTaskTimer never fires for real Prometheus plans because their canonical path is .sisyphus/plans/ and the snapshot capture was nested inside the !isSisyphusPath branch intended for direct-work warning suppression. Move the snapshot/path tracking out of the warning gate so all plan-file edits are snapshotted regardless of .sisyphus prefix. Keep the warning branch isSisyphus-gated so Atlas does not yell at legitimate plan edits. Regression test now uses a real .sisyphus/plans/ path and fails against HEAD before the fix. --- .../tool-execute-after-task-timers.test.ts | 4 +- src/hooks/atlas/tool-execute-before.ts | 47 ++++++++++--------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/hooks/atlas/tool-execute-after-task-timers.test.ts b/src/hooks/atlas/tool-execute-after-task-timers.test.ts index c565e5c2d..095d9f4c3 100644 --- a/src/hooks/atlas/tool-execute-after-task-timers.test.ts +++ b/src/hooks/atlas/tool-execute-after-task-timers.test.ts @@ -230,7 +230,9 @@ describe("createToolExecuteAfterHandler task timers", () => { it("ends task timer when plan checkbox flips to checked via edit tool", async () => { // given const parentSessionID = "ses_parent_3" - const planPath = join(testDirectory, "task-timer-edit-plan.md") + const planDirectory = join(testDirectory, ".sisyphus", "plans") + mkdirSync(planDirectory, { recursive: true }) + const planPath = join(planDirectory, "task-timer-edit-plan.md") writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8") writeBoulderState(testDirectory, { schema_version: 2, diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index d6e4789ee..5dfc24a7d 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -80,32 +80,35 @@ export function createToolExecuteBeforeHandler(input: { // Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes if (isWriteOrEditToolName(toolInput.tool)) { const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined - if (filePath && !isSisyphusPath(filePath)) { - // Store filePath for use in tool.execute.after - if (toolInput.callID) { - pendingFilePaths.set(toolInput.callID, filePath) + if (!filePath || !toolInput.callID) { + return + } - const sessionID = toolInput.sessionID - const sessionWork = sessionID - ? getWorkForSession(ctx.directory, sessionID) - : null - const state = sessionWork ? null : readBoulderState(ctx.directory) - const planPath = sessionWork - ? resolveBoulderPlanPathForWork(ctx.directory, sessionWork) - : state - ? resolveBoulderPlanPath(ctx.directory, state) - : null + // Store filePath for use in tool.execute.after + pendingFilePaths.set(toolInput.callID, filePath) - if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) { - try { - if (existsSync(planPath)) { - pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8")) - } - } catch { - pendingPlanSnapshots.delete(toolInput.callID) - } + const sessionID = toolInput.sessionID + const sessionWork = sessionID + ? getWorkForSession(ctx.directory, sessionID) + : null + const state = sessionWork ? null : readBoulderState(ctx.directory) + const planPath = sessionWork + ? resolveBoulderPlanPathForWork(ctx.directory, sessionWork) + : state + ? resolveBoulderPlanPath(ctx.directory, state) + : null + + if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) { + try { + if (existsSync(planPath)) { + pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8")) } + } catch { + pendingPlanSnapshots.delete(toolInput.callID) } + } + + if (!isSisyphusPath(filePath)) { const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath) toolOutput.message = (toolOutput.message || "") + warning log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, { From c849d0cbbc8f3232a76e3a1dde67f3e58fd14bbe Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 14:48:38 +0900 Subject: [PATCH 68/73] fix(ralph-loop): suppress stale iteration toasts --- src/hooks/ralph-loop/index.test.ts | 47 ++++ .../ralph-loop/ralph-loop-event-handler.ts | 222 ++++++++++++------ 2 files changed, 195 insertions(+), 74 deletions(-) diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 9676f9ba6..4bda8eb0b 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -713,6 +713,53 @@ describe("ralph-loop", () => { expect(messagesCalls[0].sessionID).toBe("session-123") }) + test("#given completion lands during continuation dispatch #when idle returns #then completion wins over iteration toast", async () => { + // given - active loop whose completion promise appears while dispatch is in progress + const transcriptPath = join(TEST_DIR, "transcript.jsonl") + const pluginInput = createMockPluginInput() + Object.defineProperty(pluginInput.client.session, "promptAsync", { + value: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ + sessionID: opts.path.id, + text: opts.body.parts[0].text, + }) + writeFileSync( + transcriptPath, + JSON.stringify({ + type: "assistant", + timestamp: new Date().toISOString(), + content: "Task finished DONE", + }) + "\n", + ) + return {} + }, + }) + + const hook = createRalphLoopHook(pluginInput, { + getTranscriptPath: () => transcriptPath, + }) + hook.startLoop("session-123", "Build something", { + completionPromise: "DONE", + maxIterations: 5, + }) + + // when - idle handler begins continuation, then completion appears before dispatch returns + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-123" }, + }, + }) + + // then - loop completes without publishing a stale iteration toast + expect(promptCalls.length).toBe(1) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true) + expect( + toastCalls.some((t) => t.title === "Ralph Loop" && t.message.includes("Iteration")), + ).toBe(false) + }) + test("should ignore completion promise in reasoning part via session messages API", async () => { //#given - active loop with assistant reasoning containing completion promise mockSessionMessages = [ diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 167454b58..87c0f9435 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -82,9 +82,83 @@ function showToastBestEffort( try { void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {}) } catch { + return } } +async function completionDetectedForState( + ctx: PluginInput, + options: RalphLoopEventHandlerOptions, + sessionID: string, + state: RalphLoopState, + verificationSessionID: string | undefined, +): Promise<"transcript_file" | "session_messages_api" | null> { + const completionSessionID = verificationSessionID ?? sessionID + const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined + const completionViaTranscript = completionSessionID + ? detectCompletionInTranscript( + transcriptPath, + state.completion_promise, + state.started_at, + ) + : false + if (completionViaTranscript) return "transcript_file" + + const completionViaApi = verificationSessionID + ? await detectCompletionInSessionMessages(ctx, { + sessionID: verificationSessionID, + promise: state.completion_promise, + apiTimeoutMs: options.apiTimeoutMs, + directory: options.directory, + sinceMessageIndex: undefined, + }) + : await detectCompletionInSessionMessages(ctx, { + sessionID, + promise: state.completion_promise, + apiTimeoutMs: options.apiTimeoutMs, + directory: options.directory, + sinceMessageIndex: state.message_count_at_start, + }) + + return completionViaApi ? "session_messages_api" : null +} + +async function handleCompletionIfDetected( + ctx: PluginInput, + options: RalphLoopEventHandlerOptions, + input: { + sessionID: string + state: RalphLoopState + verificationSessionID: string | undefined + runtimeErrorRetriedSessions: Map + }, +): Promise { + const detectedVia = await completionDetectedForState( + ctx, + options, + input.sessionID, + input.state, + input.verificationSessionID, + ) + if (!detectedVia) return false + + input.runtimeErrorRetriedSessions.delete(input.sessionID) + log(`[${HOOK_NAME}] Completion detected!`, { + sessionID: input.sessionID, + iteration: input.state.iteration, + promise: input.state.completion_promise, + detectedVia, + }) + await handleDetectedCompletion(ctx, { + sessionID: input.sessionID, + state: input.state, + loopState: options.loopState, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + }) + return true +} + function showMaxIterationsToast( ctx: PluginInput, state: RalphLoopState, @@ -136,14 +210,14 @@ export function createRalphLoopEventHandler( try { const state = options.loopState.getState() - if (!state || !state.active) { - return - } + if (!state || !state.active) { + return + } - if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } + if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) { + log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) + return + } const verificationSessionID = state.verification_pending ? state.verification_session_id @@ -173,58 +247,12 @@ export function createRalphLoopEventHandler( return } - const completionSessionID = verificationSessionID ?? sessionID - const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined - const completionViaTranscript = completionSessionID - ? detectCompletionInTranscript( - transcriptPath, - state.completion_promise, - state.started_at, - ) - : false - const completionViaApi = completionViaTranscript - ? false - : verificationSessionID - ? await detectCompletionInSessionMessages(ctx, { - sessionID: verificationSessionID, - promise: state.completion_promise, - apiTimeoutMs: options.apiTimeoutMs, - directory: options.directory, - sinceMessageIndex: undefined, - }) - : state.verification_pending - ? await detectCompletionInSessionMessages(ctx, { - sessionID, - promise: state.completion_promise, - apiTimeoutMs: options.apiTimeoutMs, - directory: options.directory, - sinceMessageIndex: state.message_count_at_start, - }) - : await detectCompletionInSessionMessages(ctx, { - sessionID, - promise: state.completion_promise, - apiTimeoutMs: options.apiTimeoutMs, - directory: options.directory, - sinceMessageIndex: state.message_count_at_start, - }) - - if (completionViaTranscript || completionViaApi) { - runtimeErrorRetriedSessions.delete(sessionID) - log(`[${HOOK_NAME}] Completion detected!`, { - sessionID, - iteration: state.iteration, - promise: state.completion_promise, - detectedVia: completionViaTranscript - ? "transcript_file" - : "session_messages_api", - }) - await handleDetectedCompletion(ctx, { - sessionID, - state, - loopState: options.loopState, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - }) + if (await handleCompletionIfDetected(ctx, options, { + sessionID, + state, + verificationSessionID, + runtimeErrorRetriedSessions, + })) { return } @@ -289,6 +317,14 @@ export function createRalphLoopEventHandler( log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID }) return } + if (await handleCompletionIfDetected(ctx, options, { + sessionID, + state: stateAfterSettle, + verificationSessionID: undefined, + runtimeErrorRetriedSessions, + })) { + return + } const nextIteration = stateAfterSettle.iteration + 1 const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration } @@ -307,6 +343,21 @@ export function createRalphLoopEventHandler( }) if (result.status === "dispatched") { + const stateBeforeCommit = options.loopState.getState() + if (!stateBeforeCommit || !stateBeforeCommit.active) { + return + } + if (await handleCompletionIfDetected(ctx, options, { + sessionID, + state: stateBeforeCommit, + verificationSessionID: stateBeforeCommit.verification_pending + ? stateBeforeCommit.verification_session_id + : undefined, + runtimeErrorRetriedSessions, + })) { + return + } + const committed = options.loopState.incrementIteration() if (committed) { showIterationToast(ctx, committed) @@ -361,23 +412,23 @@ export function createRalphLoopEventHandler( const verificationSessionID = state.verification_pending ? state.verification_session_id : undefined - const matchesParentSession = state.session_id === undefined || state.session_id === sessionID - const matchesVerificationSession = verificationSessionID === sessionID - if (!matchesParentSession && !matchesVerificationSession) { - handleErroredLoopSession(props, options.loopState) - return - } + const matchesParentSession = state.session_id === undefined || state.session_id === sessionID + const matchesVerificationSession = verificationSessionID === sessionID + if (!matchesParentSession && !matchesVerificationSession) { + handleErroredLoopSession(props, options.loopState) + return + } - if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) { - log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID }) - return - } + if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) { + log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID }) + return + } - log(`[${HOOK_NAME}] Retrying after runtime session error`, { - sessionID, - iteration: state.iteration, - error: String(error), - }) + log(`[${HOOK_NAME}] Retrying after runtime session error`, { + sessionID, + iteration: state.iteration, + error: String(error), + }) if (state.verification_pending) { await handlePendingVerification(ctx, { @@ -423,6 +474,14 @@ export function createRalphLoopEventHandler( log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID }) return } + if (await handleCompletionIfDetected(ctx, options, { + sessionID, + state: stateAfterSettle, + verificationSessionID: undefined, + runtimeErrorRetriedSessions, + })) { + return + } const nextIteration = stateAfterSettle.iteration + 1 const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration } @@ -435,6 +494,21 @@ export function createRalphLoopEventHandler( }) if (result.status === "dispatched") { + const stateBeforeCommit = options.loopState.getState() + if (!stateBeforeCommit || !stateBeforeCommit.active) { + return + } + if (await handleCompletionIfDetected(ctx, options, { + sessionID, + state: stateBeforeCommit, + verificationSessionID: stateBeforeCommit.verification_pending + ? stateBeforeCommit.verification_session_id + : undefined, + runtimeErrorRetriedSessions, + })) { + return + } + const committed = options.loopState.incrementIteration() if (committed) { showIterationToast(ctx, committed) From 78d52872f21f23d2b393781141c8d531674da087 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 17:21:58 +0900 Subject: [PATCH 69/73] fix(hooks): dedupe native agent instructions --- .../injector.test.ts | 65 ++++++++ .../directory-agents-injector/injector.ts | 139 +++++++++++++++++- 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/src/hooks/directory-agents-injector/injector.test.ts b/src/hooks/directory-agents-injector/injector.test.ts index 822381a69..23a75932f 100644 --- a/src/hooks/directory-agents-injector/injector.test.ts +++ b/src/hooks/directory-agents-injector/injector.test.ts @@ -173,6 +173,71 @@ describe("processFilePathForAgentsInjection", () => { expect(output.output.split("[Directory Context:").length - 1).toBe(2) }) + it("dedupes native global Instructions from blocks across reads", async () => { + // given + const { processFilePathForAgentsInjection } = await import("./injector") + const sessionCaches = new Map>() + const filePath = join(testRoot, "file.ts") + const globalAgentsPath = "/Users/example/.config/opencode/AGENTS.md" + const globalAgentsContent = "# GLOBAL AGENTS\nglobal directives" + const nativeOutput = () => ({ + title: "Read result", + output: `base output\n\nAdditional project instructions matched for ${filePath}:\n\nInstructions from: ${globalAgentsPath}\n${globalAgentsContent}`, + metadata: {}, + }) + const firstOutput = nativeOutput() + const secondOutput = nativeOutput() + + // when + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches, + filePath, + sessionID: "session-native-global-dedupe", + output: firstOutput, + }) + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches, + filePath, + sessionID: "session-native-global-dedupe", + output: secondOutput, + }) + + // then + expect(firstOutput.output).toContain(`Instructions from: ${globalAgentsPath}`) + expect(secondOutput.output).toBe("base output") + }) + + it("does not add Directory Context when native output already included the same AGENTS.md", async () => { + // given + const { processFilePathForAgentsInjection } = await import("./injector") + const filePath = join(srcDirectory, "file.ts") + const srcAgentsPath = join(srcDirectory, "AGENTS.md") + const output = { + title: "Read result", + output: `base output\n\nAdditional project instructions matched for ${filePath}:\n\nInstructions from: ${srcAgentsPath}\n${srcAgentsContent}`, + metadata: {}, + } + + // when + await processFilePathForAgentsInjection({ + ctx: { directory: testRoot } as PluginInput, + truncator, + sessionCaches: new Map(), + filePath, + sessionID: "session-native-local-dedupe", + output, + }) + + // then + expect(output.output).toContain(`Instructions from: ${srcAgentsPath}`) + expect(output.output).not.toContain(`[Directory Context: ${srcAgentsPath}]`) + expect(output.output.split(srcAgentsContent).length - 1).toBe(1) + }) + it("shows truncation notice when content is truncated", async () => { // given const { processFilePathForAgentsInjection } = await import("./injector") diff --git a/src/hooks/directory-agents-injector/injector.ts b/src/hooks/directory-agents-injector/injector.ts index f05dc276f..184aa9e9e 100644 --- a/src/hooks/directory-agents-injector/injector.ts +++ b/src/hooks/directory-agents-injector/injector.ts @@ -8,6 +8,137 @@ import { loadInjectedPaths, saveInjectedPaths } from "./storage"; type DynamicTruncator = ReturnType; +const ADDITIONAL_INSTRUCTIONS_MARKER = "Additional project instructions matched for "; +const DIRECTORY_CONTEXT_MARKER = "[Directory Context: "; +const INSTRUCTIONS_FROM_MARKER = "Instructions from: "; + +interface InstructionBlock { + path: string; + start: number; + end: number; + source: "directory-context" | "instructions-from"; +} + +function lineStartAt(output: string, index: number): number { + const previousNewline = output.lastIndexOf("\n", index - 1); + return previousNewline === -1 ? 0 : previousNewline + 1; +} + +function lineEndAt(output: string, index: number): number { + const nextNewline = output.indexOf("\n", index); + return nextNewline === -1 ? output.length : nextNewline; +} + +function findAdditionalInstructionsBlockStart(output: string, instructionsLineStart: number): number { + const headerStart = output.lastIndexOf(ADDITIONAL_INSTRUCTIONS_MARKER, instructionsLineStart); + if (headerStart === -1) return instructionsLineStart; + + const headerLineEnd = lineEndAt(output, headerStart); + if (headerLineEnd > instructionsLineStart) return instructionsLineStart; + + const gap = output.slice(headerLineEnd, instructionsLineStart); + return gap.trim() === "" ? headerStart : instructionsLineStart; +} + +function findNextInstructionBlockStart(output: string, from: number): number { + const markers = [ + `\n\n${ADDITIONAL_INSTRUCTIONS_MARKER}`, + `\n\n${DIRECTORY_CONTEXT_MARKER}`, + `\n\n${INSTRUCTIONS_FROM_MARKER}`, + ]; + const starts = markers + .map((marker) => output.indexOf(marker, from)) + .filter((index) => index !== -1); + return starts.length > 0 ? Math.min(...starts) : output.length; +} + +function collectInstructionBlocks(output: string): InstructionBlock[] { + const blocks: InstructionBlock[] = []; + + let searchIndex = 0; + while (true) { + const markerIndex = output.indexOf(INSTRUCTIONS_FROM_MARKER, searchIndex); + if (markerIndex === -1) break; + + const lineStart = lineStartAt(output, markerIndex); + const lineEnd = lineEndAt(output, markerIndex); + const instructionPath = output.slice(markerIndex + INSTRUCTIONS_FROM_MARKER.length, lineEnd).trim(); + if (instructionPath) { + blocks.push({ + path: instructionPath, + start: findAdditionalInstructionsBlockStart(output, lineStart), + end: findNextInstructionBlockStart(output, lineEnd), + source: "instructions-from", + }); + } + searchIndex = lineEnd; + } + + searchIndex = 0; + while (true) { + const markerIndex = output.indexOf(DIRECTORY_CONTEXT_MARKER, searchIndex); + if (markerIndex === -1) break; + + const pathStart = markerIndex + DIRECTORY_CONTEXT_MARKER.length; + const pathEnd = output.indexOf("]", pathStart); + if (pathEnd === -1) break; + + const instructionPath = output.slice(pathStart, pathEnd).trim(); + if (instructionPath) { + blocks.push({ + path: instructionPath, + start: lineStartAt(output, markerIndex), + end: findNextInstructionBlockStart(output, pathEnd), + source: "directory-context", + }); + } + searchIndex = pathEnd + 1; + } + + return blocks.sort((a, b) => a.start - b.start); +} + +function removeInstructionBlockRanges( + output: string, + ranges: Array<{ start: number; end: number }>, +): string { + let deduped = output; + for (const range of [...ranges].sort((a, b) => b.start - a.start)) { + deduped = deduped.slice(0, range.start) + deduped.slice(range.end); + } + return deduped.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, ""); +} + +function dedupeExistingInstructionBlocks( + output: string, + cache: Set, +): { output: string; dirty: boolean } { + const blocks = collectInstructionBlocks(output); + if (blocks.length === 0) return { output, dirty: false }; + + const seenInOutput = new Set(); + const rangesToRemove: Array<{ start: number; end: number }> = []; + let dirty = false; + + for (const block of blocks) { + const repeatedNativeInstruction = block.source === "instructions-from" && cache.has(block.path); + if (repeatedNativeInstruction || seenInOutput.has(block.path)) { + rangesToRemove.push({ start: block.start, end: block.end }); + dirty = true; + continue; + } + + cache.add(block.path); + seenInOutput.add(block.path); + dirty = true; + } + + return { + output: rangesToRemove.length > 0 ? removeInstructionBlockRanges(output, rangesToRemove) : output, + dirty, + }; +} + function getSessionCache( sessionCaches: Map>, sessionID: string, @@ -36,15 +167,17 @@ export async function processFilePathForAgentsInjection(input: { const dir = dirname(resolved); const cache = getSessionCache(input.sessionCaches, input.sessionID); const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory }); + const dedupedExisting = dedupeExistingInstructionBlocks(input.output.output, cache); + input.output.output = dedupedExisting.output; - let dirty = false; + let dirty = dedupedExisting.dirty; for (const agentsPath of agentsPaths) { const agentsDir = dirname(agentsPath); - if (cache.has(agentsDir)) continue; + if (cache.has(agentsPath) || cache.has(agentsDir)) continue; try { const content = await fsPromises.readFile(agentsPath, "utf-8"); - cache.add(agentsDir); + cache.add(agentsPath); const { result, truncated } = await input.truncator.truncate( input.sessionID, content, From 71a63c20ec329a25272533dd61444f07645fca75 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 18:02:13 +0900 Subject: [PATCH 70/73] Revert "fix(hooks): dedupe native agent instructions" This reverts commit 78d52872f21f23d2b393781141c8d531674da087. --- .../injector.test.ts | 65 -------- .../directory-agents-injector/injector.ts | 139 +----------------- 2 files changed, 3 insertions(+), 201 deletions(-) diff --git a/src/hooks/directory-agents-injector/injector.test.ts b/src/hooks/directory-agents-injector/injector.test.ts index 23a75932f..822381a69 100644 --- a/src/hooks/directory-agents-injector/injector.test.ts +++ b/src/hooks/directory-agents-injector/injector.test.ts @@ -173,71 +173,6 @@ describe("processFilePathForAgentsInjection", () => { expect(output.output.split("[Directory Context:").length - 1).toBe(2) }) - it("dedupes native global Instructions from blocks across reads", async () => { - // given - const { processFilePathForAgentsInjection } = await import("./injector") - const sessionCaches = new Map>() - const filePath = join(testRoot, "file.ts") - const globalAgentsPath = "/Users/example/.config/opencode/AGENTS.md" - const globalAgentsContent = "# GLOBAL AGENTS\nglobal directives" - const nativeOutput = () => ({ - title: "Read result", - output: `base output\n\nAdditional project instructions matched for ${filePath}:\n\nInstructions from: ${globalAgentsPath}\n${globalAgentsContent}`, - metadata: {}, - }) - const firstOutput = nativeOutput() - const secondOutput = nativeOutput() - - // when - await processFilePathForAgentsInjection({ - ctx: { directory: testRoot } as PluginInput, - truncator, - sessionCaches, - filePath, - sessionID: "session-native-global-dedupe", - output: firstOutput, - }) - await processFilePathForAgentsInjection({ - ctx: { directory: testRoot } as PluginInput, - truncator, - sessionCaches, - filePath, - sessionID: "session-native-global-dedupe", - output: secondOutput, - }) - - // then - expect(firstOutput.output).toContain(`Instructions from: ${globalAgentsPath}`) - expect(secondOutput.output).toBe("base output") - }) - - it("does not add Directory Context when native output already included the same AGENTS.md", async () => { - // given - const { processFilePathForAgentsInjection } = await import("./injector") - const filePath = join(srcDirectory, "file.ts") - const srcAgentsPath = join(srcDirectory, "AGENTS.md") - const output = { - title: "Read result", - output: `base output\n\nAdditional project instructions matched for ${filePath}:\n\nInstructions from: ${srcAgentsPath}\n${srcAgentsContent}`, - metadata: {}, - } - - // when - await processFilePathForAgentsInjection({ - ctx: { directory: testRoot } as PluginInput, - truncator, - sessionCaches: new Map(), - filePath, - sessionID: "session-native-local-dedupe", - output, - }) - - // then - expect(output.output).toContain(`Instructions from: ${srcAgentsPath}`) - expect(output.output).not.toContain(`[Directory Context: ${srcAgentsPath}]`) - expect(output.output.split(srcAgentsContent).length - 1).toBe(1) - }) - it("shows truncation notice when content is truncated", async () => { // given const { processFilePathForAgentsInjection } = await import("./injector") diff --git a/src/hooks/directory-agents-injector/injector.ts b/src/hooks/directory-agents-injector/injector.ts index 184aa9e9e..f05dc276f 100644 --- a/src/hooks/directory-agents-injector/injector.ts +++ b/src/hooks/directory-agents-injector/injector.ts @@ -8,137 +8,6 @@ import { loadInjectedPaths, saveInjectedPaths } from "./storage"; type DynamicTruncator = ReturnType; -const ADDITIONAL_INSTRUCTIONS_MARKER = "Additional project instructions matched for "; -const DIRECTORY_CONTEXT_MARKER = "[Directory Context: "; -const INSTRUCTIONS_FROM_MARKER = "Instructions from: "; - -interface InstructionBlock { - path: string; - start: number; - end: number; - source: "directory-context" | "instructions-from"; -} - -function lineStartAt(output: string, index: number): number { - const previousNewline = output.lastIndexOf("\n", index - 1); - return previousNewline === -1 ? 0 : previousNewline + 1; -} - -function lineEndAt(output: string, index: number): number { - const nextNewline = output.indexOf("\n", index); - return nextNewline === -1 ? output.length : nextNewline; -} - -function findAdditionalInstructionsBlockStart(output: string, instructionsLineStart: number): number { - const headerStart = output.lastIndexOf(ADDITIONAL_INSTRUCTIONS_MARKER, instructionsLineStart); - if (headerStart === -1) return instructionsLineStart; - - const headerLineEnd = lineEndAt(output, headerStart); - if (headerLineEnd > instructionsLineStart) return instructionsLineStart; - - const gap = output.slice(headerLineEnd, instructionsLineStart); - return gap.trim() === "" ? headerStart : instructionsLineStart; -} - -function findNextInstructionBlockStart(output: string, from: number): number { - const markers = [ - `\n\n${ADDITIONAL_INSTRUCTIONS_MARKER}`, - `\n\n${DIRECTORY_CONTEXT_MARKER}`, - `\n\n${INSTRUCTIONS_FROM_MARKER}`, - ]; - const starts = markers - .map((marker) => output.indexOf(marker, from)) - .filter((index) => index !== -1); - return starts.length > 0 ? Math.min(...starts) : output.length; -} - -function collectInstructionBlocks(output: string): InstructionBlock[] { - const blocks: InstructionBlock[] = []; - - let searchIndex = 0; - while (true) { - const markerIndex = output.indexOf(INSTRUCTIONS_FROM_MARKER, searchIndex); - if (markerIndex === -1) break; - - const lineStart = lineStartAt(output, markerIndex); - const lineEnd = lineEndAt(output, markerIndex); - const instructionPath = output.slice(markerIndex + INSTRUCTIONS_FROM_MARKER.length, lineEnd).trim(); - if (instructionPath) { - blocks.push({ - path: instructionPath, - start: findAdditionalInstructionsBlockStart(output, lineStart), - end: findNextInstructionBlockStart(output, lineEnd), - source: "instructions-from", - }); - } - searchIndex = lineEnd; - } - - searchIndex = 0; - while (true) { - const markerIndex = output.indexOf(DIRECTORY_CONTEXT_MARKER, searchIndex); - if (markerIndex === -1) break; - - const pathStart = markerIndex + DIRECTORY_CONTEXT_MARKER.length; - const pathEnd = output.indexOf("]", pathStart); - if (pathEnd === -1) break; - - const instructionPath = output.slice(pathStart, pathEnd).trim(); - if (instructionPath) { - blocks.push({ - path: instructionPath, - start: lineStartAt(output, markerIndex), - end: findNextInstructionBlockStart(output, pathEnd), - source: "directory-context", - }); - } - searchIndex = pathEnd + 1; - } - - return blocks.sort((a, b) => a.start - b.start); -} - -function removeInstructionBlockRanges( - output: string, - ranges: Array<{ start: number; end: number }>, -): string { - let deduped = output; - for (const range of [...ranges].sort((a, b) => b.start - a.start)) { - deduped = deduped.slice(0, range.start) + deduped.slice(range.end); - } - return deduped.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, ""); -} - -function dedupeExistingInstructionBlocks( - output: string, - cache: Set, -): { output: string; dirty: boolean } { - const blocks = collectInstructionBlocks(output); - if (blocks.length === 0) return { output, dirty: false }; - - const seenInOutput = new Set(); - const rangesToRemove: Array<{ start: number; end: number }> = []; - let dirty = false; - - for (const block of blocks) { - const repeatedNativeInstruction = block.source === "instructions-from" && cache.has(block.path); - if (repeatedNativeInstruction || seenInOutput.has(block.path)) { - rangesToRemove.push({ start: block.start, end: block.end }); - dirty = true; - continue; - } - - cache.add(block.path); - seenInOutput.add(block.path); - dirty = true; - } - - return { - output: rangesToRemove.length > 0 ? removeInstructionBlockRanges(output, rangesToRemove) : output, - dirty, - }; -} - function getSessionCache( sessionCaches: Map>, sessionID: string, @@ -167,17 +36,15 @@ export async function processFilePathForAgentsInjection(input: { const dir = dirname(resolved); const cache = getSessionCache(input.sessionCaches, input.sessionID); const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory }); - const dedupedExisting = dedupeExistingInstructionBlocks(input.output.output, cache); - input.output.output = dedupedExisting.output; - let dirty = dedupedExisting.dirty; + let dirty = false; for (const agentsPath of agentsPaths) { const agentsDir = dirname(agentsPath); - if (cache.has(agentsPath) || cache.has(agentsDir)) continue; + if (cache.has(agentsDir)) continue; try { const content = await fsPromises.readFile(agentsPath, "utf-8"); - cache.add(agentsPath); + cache.add(agentsDir); const { result, truncated } = await input.truncator.truncate( input.sessionID, content, From 90f0971f4fc85550011895d56b5c5919acc9b324 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 18:06:22 +0900 Subject: [PATCH 71/73] fix(background-agent): handle idle status events --- src/features/background-agent/manager.test.ts | 61 +++++++++++++++++++ src/features/background-agent/manager.ts | 9 ++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 6c92398ad..9249af41a 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -5023,6 +5023,67 @@ describe("BackgroundManager.handleEvent - session.error", () => { manager.shutdown() }) + test("completes task on session.status idle after todo-continuation finishes", async () => { + //#given + const sessionID = "ses-status-idle-after-todo-continuation" + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + messages: async () => ({ + data: [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "final verified result" }], + }, + ], + }), + todo: async () => ({ data: [] }), + }, + } + + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + stubNotifyParentSession(manager) + mockVerifySessionExists(manager, true) + + const task = createMockTask({ + id: "task-status-idle-after-todo-continuation", + sessionId: sessionID, + parentSessionId: "parent-session", + parentMessageId: "msg-status-idle", + description: "task that finished after todo-continuation", + agent: "explore", + status: "running", + startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)), + }) + getTaskMap(manager).set(task.id, task) + + manager.handleEvent({ + type: "todo.updated", + properties: { + sessionID, + todos: [{ id: "todo-1", content: "compile result", status: "completed", priority: "high" }], + }, + }) + + //#when + manager.handleEvent({ + type: "session.status", + properties: { + sessionID, + status: { type: "idle" }, + }, + }) + await flushBackgroundNotifications() + + //#then + expect(task.status).toBe("completed") + expect(task.completedAt).toBeDefined() + + manager.shutdown() + }) + test("retry path releases current concurrency slot and prefers current provider in fallback entry", async () => { //#given const manager = createBackgroundManager() diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 071a33cd9..d1fad5ca4 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1531,7 +1531,14 @@ The fallback retry session is now created and can be inspected directly. if (event.type === "session.status") { const sessionID = props?.sessionID as string | undefined const status = props?.status as { type?: string; message?: string } | undefined - if (!sessionID || status?.type !== "retry") return + if (!sessionID || !status?.type) return + + if (status.type === "idle") { + this.handleEvent({ type: "session.idle", properties: { sessionID } }) + return + } + + if (status.type !== "retry") return const resolved = this.resolveTaskAttemptBySession(sessionID) if (!resolved?.isCurrent) return From 3ed4651b7adf0feb3c307918c7b12dc989a39934 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 18:46:29 +0900 Subject: [PATCH 72/73] fix(compaction): skip autocontinue for compaction agent --- src/index.compacting.test.ts | 19 +++++++++++++++++++ src/plugin/session-compacting.ts | 8 +++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/index.compacting.test.ts b/src/index.compacting.test.ts index 2a96cde5a..a955bab93 100644 --- a/src/index.compacting.test.ts +++ b/src/index.compacting.test.ts @@ -160,6 +160,25 @@ describe("experimental.session.compacting handler", () => { }) describe("experimental.compaction.autocontinue handler", () => { + it("disables OpenCode autocontinue when the compaction agent would continue itself", async () => { + //#given + const restoreContextMock = mock(async () => true) + const restoreTodosMock = mock(async () => {}) + const handler = createCompactionAutocontinueHandler({ + compactionContextInjector: { restore: restoreContextMock }, + compactionTodoPreserver: { restore: restoreTodosMock }, + }) + const output = { enabled: true } + + //#when + await handler({ sessionID: "ses_compaction_loop", agent: "compaction" }, output) + + //#then + expect(output.enabled).toBe(false) + expect(restoreContextMock).not.toHaveBeenCalled() + expect(restoreTodosMock).not.toHaveBeenCalled() + }) + it("restores checkpointed context and todos before OpenCode adds the synthetic continue turn", async () => { //#given const callOrder: string[] = [] diff --git a/src/plugin/session-compacting.ts b/src/plugin/session-compacting.ts index 940b029a2..bb810ca76 100644 --- a/src/plugin/session-compacting.ts +++ b/src/plugin/session-compacting.ts @@ -1,5 +1,6 @@ import type { Hooks } from "@opencode-ai/plugin" +import { isCompactionAgent } from "../shared/compaction-marker" import { log } from "../shared/logger" type SessionCompactingHook = NonNullable @@ -92,8 +93,13 @@ export function createCompactionAutocontinueHandler( ): CompactionAutocontinueHook { return async ( input: CompactionAutocontinueInput, - _output: CompactionAutocontinueOutput, + output: CompactionAutocontinueOutput, ): Promise => { + if (isCompactionAgent(input.agent)) { + output.enabled = false + return + } + await runCompactionStep("compactionContextInjector.restore", input.sessionID, async () => { const restore = hooks.compactionContextInjector?.restore if (restore) { From 1c05c60dcc8bc713675d39c66ca52142f85914c4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 18:55:36 +0900 Subject: [PATCH 73/73] fix(background-agent): replace system-reminder wake with queued notifications --- src/features/background-agent/manager.ts | 96 ++++++++++++------- .../task-completion-cleanup.test.ts | 66 +++++++++---- 2 files changed, 110 insertions(+), 52 deletions(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index d1fad5ca4..3ec161b0e 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -99,12 +99,12 @@ type ParentWakePromptContext = { tools?: Record } -type SessionStatusInfo = { type?: string } +type PendingParentWake = { + promptContext: ParentWakePromptContext + notifications: string[] +} -const BACKGROUND_PARENT_WAKE_PROMPT = ` -[BACKGROUND TASK NOTIFICATION READY] -A background task notification was already added to this session. Continue from that notification. -` +type SessionStatusInfo = { type?: string } const PENDING_PARENT_WAKE_RETRY_MS = 1_000 @@ -229,7 +229,7 @@ export class BackgroundManager { private completedTaskSummaries: Map = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() - private pendingParentWakes: Map = new Map() + private pendingParentWakes: Map = new Map() private pendingParentWakeTimers: Map> = new Map() private observedOutputSessions: Set = new Set() private observedIncompleteTodosBySession: Map = new Map() @@ -2232,35 +2232,40 @@ The task was re-queued on a fallback model after a retryable failure. } const shouldDeferReply = shouldReply && await this.isSessionActive(task.parentSessionId) - try { - await this.client.session.promptAsync({ - path: { id: task.parentSessionId }, - body: { - noReply: shouldDeferReply || !shouldReply, - ...parentPromptContext, - parts: [createInternalAgentTextPart(notification)], - }, - }) - if (shouldDeferReply) { - this.pendingParentWakes.set(task.parentSessionId, parentPromptContext) - this.schedulePendingParentWakeFlush(task.parentSessionId) - } - log("[background-agent] Sent notification to parent session:", { + if (shouldDeferReply) { + this.queuePendingParentWake(task.parentSessionId, notification, parentPromptContext) + log("[background-agent] Deferred notification until parent session is idle:", { taskId: task.id, allComplete, isTaskFailure, - noReply: shouldDeferReply || !shouldReply, - deferredReply: shouldDeferReply, }) - } catch (error) { - if (isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", { - taskId: task.id, - parentSessionID: task.parentSessionId, + } else { + try { + await this.client.session.promptAsync({ + path: { id: task.parentSessionId }, + body: { + noReply: !shouldReply, + ...parentPromptContext, + parts: [createInternalAgentTextPart(notification)], + }, }) - this.queuePendingNotification(task.parentSessionId, notification) - } else { - log("[background-agent] Failed to send notification:", error) + log("[background-agent] Sent notification to parent session:", { + taskId: task.id, + allComplete, + isTaskFailure, + noReply: !shouldReply, + deferredReply: false, + }) + } catch (error) { + if (isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", { + taskId: task.id, + parentSessionID: task.parentSessionId, + }) + this.queuePendingNotification(task.parentSessionId, notification) + } else { + log("[background-agent] Failed to send notification:", error) + } } } } else { @@ -2305,9 +2310,27 @@ The task was re-queued on a fallback model after a retryable failure. } } + private queuePendingParentWake( + sessionID: string, + notification: string, + promptContext: ParentWakePromptContext, + ): void { + const pendingWake = this.pendingParentWakes.get(sessionID) + if (pendingWake) { + pendingWake.notifications.push(notification) + pendingWake.promptContext = promptContext + } else { + this.pendingParentWakes.set(sessionID, { + promptContext, + notifications: [notification], + }) + } + this.schedulePendingParentWakeFlush(sessionID) + } + private async flushPendingParentWake(sessionID: string): Promise { - const wakeContext = this.pendingParentWakes.get(sessionID) - if (!wakeContext) { + const pendingWake = this.pendingParentWakes.get(sessionID) + if (!pendingWake) { this.clearPendingParentWakeTimer(sessionID) return } @@ -2322,22 +2345,25 @@ The task was re-queued on a fallback model after a retryable failure. await settleAfterSessionIdle() if (await this.isSessionActive(sessionID)) { - this.pendingParentWakes.set(sessionID, wakeContext) + this.pendingParentWakes.set(sessionID, pendingWake) this.schedulePendingParentWakeFlush(sessionID) return } + const notificationContent = pendingWake.notifications.join("\n\n") + try { await this.client.session.promptAsync({ path: { id: sessionID }, body: { noReply: false, - ...wakeContext, - parts: [createInternalAgentTextPart(BACKGROUND_PARENT_WAKE_PROMPT)], + ...pendingWake.promptContext, + parts: [createInternalAgentTextPart(notificationContent)], }, }) log("[background-agent] Sent deferred parent wake:", { sessionID }) } catch (error) { + this.queuePendingNotification(sessionID, notificationContent) log("[background-agent] Failed to send deferred parent wake:", { sessionID, error }) } } diff --git a/src/features/background-agent/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index 881b35d1d..884ec31d6 100644 --- a/src/features/background-agent/task-completion-cleanup.test.ts +++ b/src/features/background-agent/task-completion-cleanup.test.ts @@ -54,6 +54,7 @@ function createManager(enableParentSessionNotifications: boolean): { function createManager( enableParentSessionNotifications: boolean, sessionStatuses?: Record, + promptAsyncImpl?: (call: PromptAsyncCall) => Promise, ): { manager: BackgroundManager promptAsyncCalls: PromptAsyncCall[] @@ -66,6 +67,9 @@ function createManager( prompt: async () => ({}), promptAsync: async (call: PromptAsyncCall) => { promptAsyncCalls.push(call) + if (promptAsyncImpl) { + return promptAsyncImpl(call) + } return {} }, abort: async () => ({}), @@ -142,6 +146,10 @@ function getPendingByParent(manager: BackgroundManager): Map return Reflect.get(manager, "pendingByParent") as Map> } +function getPendingNotifications(manager: BackgroundManager): Map { + return Reflect.get(manager, "pendingNotifications") as Map +} + function getCompletionTimers(manager: BackgroundManager): Map> { return Reflect.get(manager, "completionTimers") as Map> } @@ -264,12 +272,10 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { await notifyParentSessionForTest(manager, task) // then - expect(promptAsyncCalls).toHaveLength(1) - expect(promptAsyncCalls[0]?.body.noReply).toBe(true) - expect(JSON.stringify(promptAsyncCalls[0]?.body.parts)).toContain("ALL BACKGROUND TASKS COMPLETE") + expect(promptAsyncCalls).toHaveLength(0) }) - test("#when deferred parent session becomes idle #then wake prompt is sent once without duplicating the notification", async () => { + test("#when deferred parent session becomes idle #then completion notification wakes the parent without a pointer reminder", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, @@ -287,15 +293,14 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { await waitForDeferredWake() // then - expect(promptAsyncCalls).toHaveLength(2) - expect(promptAsyncCalls[0]?.body.noReply).toBe(true) - expect(promptAsyncCalls[1]?.body.noReply).toBe(false) - const wakePayload = JSON.stringify(promptAsyncCalls[1]?.body.parts) - expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY") - expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE") + expect(promptAsyncCalls).toHaveLength(1) + expect(promptAsyncCalls[0]?.body.noReply).toBe(false) + const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) + expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE") + expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY") }) - test("#when a single background task finishes during a stale busy parent status #then wake prompt is sent after the parent becomes idle", async () => { + test("#when a single background task finishes during a stale busy parent status #then completion notification is retried after the parent becomes idle", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, @@ -312,12 +317,39 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { await waitForDeferredWakeRetry() // then - expect(promptAsyncCalls).toHaveLength(2) - expect(promptAsyncCalls[0]?.body.noReply).toBe(true) - expect(promptAsyncCalls[1]?.body.noReply).toBe(false) - const wakePayload = JSON.stringify(promptAsyncCalls[1]?.body.parts) - expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY") - expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE") + expect(promptAsyncCalls).toHaveLength(1) + expect(promptAsyncCalls[0]?.body.noReply).toBe(false) + const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) + expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE") + expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY") + }) + + test("#when deferred completion notification send fails #then notification is queued for the next user message", async () => { + // given + const sessionStatuses: Record = { + "parent-1": { type: "busy" }, + } + const promptError = new Error("promptAsync failed") + const { manager, promptAsyncCalls } = createManager(true, sessionStatuses, async () => { + throw promptError + }) + managerUnderTest = manager + const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) + getTasks(manager).set(task.id, task) + getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) + await notifyParentSessionForTest(manager, task) + + // when + sessionStatuses["parent-1"] = { type: "idle" } + manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } }) + await waitForDeferredWake() + + // then + expect(promptAsyncCalls).toHaveLength(1) + const queuedNotifications = getPendingNotifications(manager).get("parent-1") ?? [] + expect(queuedNotifications).toHaveLength(1) + expect(queuedNotifications[0]).toContain("ALL BACKGROUND TASKS COMPLETE") + expect(queuedNotifications[0]).not.toContain("BACKGROUND TASK NOTIFICATION READY") }) })