From 40374c86851cad03c38791a383ba6166f96ca3eb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 01:19:52 +0900 Subject: [PATCH 1/3] fix(boulder-state): remove dead worktree sync helper Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/boulder-state/index.ts | 1 - .../boulder-state/worktree-sync.test.ts | 88 ------------------- src/features/boulder-state/worktree-sync.ts | 34 ------- 3 files changed, 123 deletions(-) delete mode 100644 src/features/boulder-state/worktree-sync.test.ts delete mode 100644 src/features/boulder-state/worktree-sync.ts diff --git a/src/features/boulder-state/index.ts b/src/features/boulder-state/index.ts index a174e1a57..17618996b 100644 --- a/src/features/boulder-state/index.ts +++ b/src/features/boulder-state/index.ts @@ -2,4 +2,3 @@ export * from "./types" export * from "./constants" export * from "./storage" export * from "./top-level-task" -export * from "./worktree-sync" diff --git a/src/features/boulder-state/worktree-sync.test.ts b/src/features/boulder-state/worktree-sync.test.ts deleted file mode 100644 index 60f3e240d..000000000 --- a/src/features/boulder-state/worktree-sync.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test" -import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs" -import { join } from "node:path" -import { tmpdir } from "node:os" -import { syncSisyphusStateFromWorktree } from "./worktree-sync" - -describe("syncSisyphusStateFromWorktree", () => { - const BASE = join(tmpdir(), "worktree-sync-test-" + Date.now()) - const WORKTREE = join(BASE, "worktree") - const MAIN_REPO = join(BASE, "main") - - beforeEach(() => { - mkdirSync(WORKTREE, { recursive: true }) - mkdirSync(MAIN_REPO, { recursive: true }) - }) - - afterEach(() => { - if (existsSync(BASE)) { - rmSync(BASE, { recursive: true, force: true }) - } - }) - - test("#given no .sisyphus in worktree #when syncing #then returns true without error", () => { - const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) - - expect(result).toBe(true) - expect(existsSync(join(MAIN_REPO, ".sisyphus"))).toBe(false) - }) - - test("#given .sisyphus with boulder.json in worktree #when syncing #then copies to main repo", () => { - const worktreeSisyphus = join(WORKTREE, ".sisyphus") - mkdirSync(worktreeSisyphus, { recursive: true }) - writeFileSync(join(worktreeSisyphus, "boulder.json"), '{"active_plan":"/plan.md","plan_name":"test"}') - - const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) - - expect(result).toBe(true) - const copied = readFileSync(join(MAIN_REPO, ".sisyphus", "boulder.json"), "utf-8") - expect(JSON.parse(copied).plan_name).toBe("test") - }) - - test("#given nested .sisyphus dirs in worktree #when syncing #then copies full tree recursively", () => { - const worktreePlans = join(WORKTREE, ".sisyphus", "plans") - const worktreeNotepads = join(WORKTREE, ".sisyphus", "notepads", "my-plan") - mkdirSync(worktreePlans, { recursive: true }) - mkdirSync(worktreeNotepads, { recursive: true }) - writeFileSync(join(worktreePlans, "my-plan.md"), "- [x] Task 1\n- [ ] Task 2") - writeFileSync(join(worktreeNotepads, "learnings.md"), "learned something") - - const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) - - expect(result).toBe(true) - expect(readFileSync(join(MAIN_REPO, ".sisyphus", "plans", "my-plan.md"), "utf-8")).toContain("Task 1") - expect(readFileSync(join(MAIN_REPO, ".sisyphus", "notepads", "my-plan", "learnings.md"), "utf-8")).toBe("learned something") - }) - - test("#given existing .sisyphus in main repo #when syncing #then worktree state overwrites stale state", () => { - const mainSisyphus = join(MAIN_REPO, ".sisyphus") - mkdirSync(mainSisyphus, { recursive: true }) - writeFileSync(join(mainSisyphus, "boulder.json"), '{"plan_name":"old"}') - - const worktreeSisyphus = join(WORKTREE, ".sisyphus") - mkdirSync(worktreeSisyphus, { recursive: true }) - writeFileSync(join(worktreeSisyphus, "boulder.json"), '{"plan_name":"updated"}') - - const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) - - expect(result).toBe(true) - const content = readFileSync(join(mainSisyphus, "boulder.json"), "utf-8") - expect(JSON.parse(content).plan_name).toBe("updated") - }) - - test("#given pre-existing files in main .sisyphus #when syncing #then preserves files not in worktree", () => { - const mainSisyphus = join(MAIN_REPO, ".sisyphus", "rules") - mkdirSync(mainSisyphus, { recursive: true }) - writeFileSync(join(mainSisyphus, "my-rule.md"), "existing rule") - - const worktreeSisyphus = join(WORKTREE, ".sisyphus") - mkdirSync(worktreeSisyphus, { recursive: true }) - writeFileSync(join(worktreeSisyphus, "boulder.json"), '{"plan_name":"new"}') - - const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) - - expect(result).toBe(true) - expect(readFileSync(join(MAIN_REPO, ".sisyphus", "rules", "my-rule.md"), "utf-8")).toBe("existing rule") - expect(existsSync(join(MAIN_REPO, ".sisyphus", "boulder.json"))).toBe(true) - }) -}) diff --git a/src/features/boulder-state/worktree-sync.ts b/src/features/boulder-state/worktree-sync.ts deleted file mode 100644 index 98a7bdb9f..000000000 --- a/src/features/boulder-state/worktree-sync.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { existsSync, cpSync, mkdirSync } from "node:fs" -import { join } from "node:path" -import { BOULDER_DIR } from "./constants" -import { log } from "../../shared/logger" - -export function syncSisyphusStateFromWorktree(worktreePath: string, mainRepoPath: string): boolean { - const srcDir = join(worktreePath, BOULDER_DIR) - const destDir = join(mainRepoPath, BOULDER_DIR) - - if (!existsSync(srcDir)) { - log("[worktree-sync] No .sisyphus directory in worktree, nothing to sync", { worktreePath }) - return true - } - - try { - if (!existsSync(destDir)) { - mkdirSync(destDir, { recursive: true }) - } - - cpSync(srcDir, destDir, { recursive: true, force: true }) - log("[worktree-sync] Synced .sisyphus state from worktree to main repo", { - worktreePath, - mainRepoPath, - }) - return true - } catch (err) { - log("[worktree-sync] Failed to sync .sisyphus state", { - worktreePath, - mainRepoPath, - error: String(err), - }) - return false - } -} From 938c9200925a9f79015cb67a8e77222ee08ec5e0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 01:19:52 +0900 Subject: [PATCH 2/3] test(background-agent): cover abort timeout handling Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../abort-with-timeout.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/features/background-agent/abort-with-timeout.test.ts diff --git a/src/features/background-agent/abort-with-timeout.test.ts b/src/features/background-agent/abort-with-timeout.test.ts new file mode 100644 index 000000000..c41290f96 --- /dev/null +++ b/src/features/background-agent/abort-with-timeout.test.ts @@ -0,0 +1,57 @@ +import { afterAll, describe, expect, mock, test } from "bun:test" + +const logMock = mock(() => {}) + +mock.module("../../shared", () => ({ + log: logMock, +})) + +import { abortWithTimeout } from "./abort-with-timeout" +import type { OpencodeClient } from "./opencode-client" + +function createClient(abort: (...args: Array) => Promise): OpencodeClient { + return { + session: { + abort: abort as never, + }, + } as never +} + +describe("abortWithTimeout", () => { + afterAll(() => { + mock.restore() + }) + + test("#given abort resolves before timeout #when abortWithTimeout runs #then it returns true", async () => { + // given + const abort = mock(async () => ({})) + + // when + const result = await abortWithTimeout(createClient(abort), "session-1", 10) + + // then + expect(result).toBe(true) + expect(abort).toHaveBeenCalledWith({ path: { id: "session-1" } }) + expect(logMock).not.toHaveBeenCalled() + }) + + test("#given abort hangs indefinitely #when abortWithTimeout runs #then it logs warning and continues", async () => { + // given + const abort = mock(() => new Promise(() => {})) + + // when + const result = await Promise.race([ + abortWithTimeout(createClient(abort), "session-2", 1), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("abort timeout test exceeded wait budget")), 100) + }), + ]) + + // then + expect(result).toBe(false) + expect(logMock).toHaveBeenCalledWith( + "[background-agent] Session abort timed out; continuing cleanup:", + { sessionID: "session-2", timeoutMs: 1 }, + ) + }) +}) From f5740d68c7bf2141949be6ae210ee358dbbaf7ee Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 01:19:52 +0900 Subject: [PATCH 3/3] fix(background-agent): bound session abort waits Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../background-agent/abort-with-timeout.ts | 35 +++++++++++++++++++ .../fallback-retry-handler.ts | 3 +- src/features/background-agent/manager.test.ts | 4 +-- src/features/background-agent/manager.ts | 9 ++--- src/features/background-agent/task-poller.ts | 5 +-- 5 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 src/features/background-agent/abort-with-timeout.ts diff --git a/src/features/background-agent/abort-with-timeout.ts b/src/features/background-agent/abort-with-timeout.ts new file mode 100644 index 000000000..49f1170f2 --- /dev/null +++ b/src/features/background-agent/abort-with-timeout.ts @@ -0,0 +1,35 @@ +import { log } from "../../shared" +import type { OpencodeClient } from "./opencode-client" + +export async function abortWithTimeout( + client: OpencodeClient, + sessionID: string, + timeoutMs = 10_000, +): Promise { + let timeoutHandle: ReturnType | undefined + + try { + const result = await Promise.race([ + client.session.abort({ path: { id: sessionID } }).then(() => "aborted" as const), + new Promise<"timed_out">((resolve) => { + timeoutHandle = setTimeout(() => { + resolve("timed_out") + }, timeoutMs) + }), + ]) + + if (result === "timed_out") { + log("[background-agent] Session abort timed out; continuing cleanup:", { + sessionID, + timeoutMs, + }) + return false + } + + return true + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle) + } + } +} diff --git a/src/features/background-agent/fallback-retry-handler.ts b/src/features/background-agent/fallback-retry-handler.ts index f169fa4eb..58549cc98 100644 --- a/src/features/background-agent/fallback-retry-handler.ts +++ b/src/features/background-agent/fallback-retry-handler.ts @@ -10,6 +10,7 @@ import { selectFallbackProvider, } from "../../shared/model-error-classifier" import { transformModelForProvider } from "../../shared/provider-model-id-transform" +import { abortWithTimeout } from "./abort-with-timeout" export async function tryFallbackRetry(args: { task: BackgroundTask @@ -123,7 +124,7 @@ export async function tryFallbackRetry(args: { } if (previousSessionID) { - await client.session.abort({ path: { id: previousSessionID } }).catch(() => {}) + await abortWithTimeout(client, previousSessionID).catch(() => {}) } queue.push({ task, input: retryInput }) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index db5635d33..35766f368 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -238,7 +238,7 @@ function stubNotifyParentSession(manager: BackgroundManager): void { } async function flushBackgroundNotifications(): Promise { - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 12; i++) { await Promise.resolve() } } @@ -2570,7 +2570,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { abortCalled, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 100)), ]) - await Promise.resolve() + await flushBackgroundNotifications() // then const updatedTask = manager.getTask(task.id) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index dc4d23d6b..2dc01e959 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -57,6 +57,7 @@ import { join } from "node:path" import { pruneStaleTasksAndNotifications } from "./task-poller" import { checkAndInterruptStaleTasks } from "./task-poller" import { removeTaskToastTracking } from "./remove-task-toast-tracking" +import { abortWithTimeout } from "./abort-with-timeout" import { MIN_SESSION_GONE_POLLS, verifySessionExists as verifySessionStillExists, @@ -193,9 +194,7 @@ export class BackgroundManager { private async abortSessionWithLogging(sessionID: string, reason: string): Promise { try { - await this.client.session.abort({ - path: { id: sessionID }, - }) + await abortWithTimeout(this.client, sessionID) } catch (error) { log(`[background-agent] Failed to abort session during ${reason}:`, { sessionID, @@ -1985,9 +1984,7 @@ export class BackgroundManager { if (task.status === "running" && task.sessionID) { abortRequests.push({ sessionID: task.sessionID, - promise: this.client.session.abort({ - path: { id: task.sessionID }, - }), + promise: abortWithTimeout(this.client, task.sessionID), }) } } diff --git a/src/features/background-agent/task-poller.ts b/src/features/background-agent/task-poller.ts index 0f2c6e2ce..6fa179bd7 100644 --- a/src/features/background-agent/task-poller.ts +++ b/src/features/background-agent/task-poller.ts @@ -13,6 +13,7 @@ import { TERMINAL_TASK_TTL_MS, TASK_TTL_MS, } from "./constants" +import { abortWithTimeout } from "./abort-with-timeout" import { removeTaskToastTracking } from "./remove-task-toast-tracking" import { MIN_SESSION_GONE_POLLS, verifySessionExists } from "./session-existence" @@ -167,7 +168,7 @@ export async function checkAndInterruptStaleTasks(args: { onTaskInterrupted(task) - abortPromises.push(client.session.abort({ path: { id: sessionID } })) + abortPromises.push(abortWithTimeout(client, sessionID)) log(`[background-agent] Task ${task.id} interrupted: no progress since start`) try { @@ -205,7 +206,7 @@ export async function checkAndInterruptStaleTasks(args: { onTaskInterrupted(task) - abortPromises.push(client.session.abort({ path: { id: sessionID } })) + abortPromises.push(abortWithTimeout(client, sessionID)) log(`[background-agent] Task ${task.id} interrupted: stale timeout`) try {