From 5238dd484e663099760419f3226ad571b046f46b Mon Sep 17 00:00:00 2001 From: Momentum96 Date: Thu, 7 May 2026 23:15:26 +0900 Subject: [PATCH 1/3] fix(background-agent): start promptAsync before blocking tmux callback - Fixes BackgroundManager to launch promptAsync before invoking the blocking tmux callback. - Adds regression test to ensure promptAsync is called before tmux callback. --- src/features/background-agent/manager.test.ts | 96 +++++++++++++++++-- src/features/background-agent/manager.ts | 50 +++++----- 2 files changed, 110 insertions(+), 36 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index e04d5976c..b94806583 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -322,6 +322,82 @@ function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToast } } +describe("BackgroundManager tmux callback ordering", () => { + test("starts promptAsync before a blocking tmux callback resolves", async () => { + //#given + const events: string[] = [] + let resolveTmuxCallback: () => void = () => {} + const tmuxCallbackPromise = new Promise((resolve) => { + resolveTmuxCallback = resolve + }) + + const client = { + session: { + get: async () => { + events.push("session.get") + return { data: { directory: "/tmp/test" } } + }, + create: async () => { + events.push("session.create") + return { data: { id: "ses_manager_blocking_tmux" } } + }, + promptAsync: async () => { + events.push("promptAsync") + return { data: {} } + }, + abort: async () => ({ data: {} }), + }, + } + + const onSubagentSessionCreated = mock(async () => { + events.push("tmux.callback.start") + await tmuxCallbackPromise + events.push("tmux.callback.end") + }) + const manager = new BackgroundManager({ + pluginContext: createPluginInput(client, "/tmp/test"), + tmuxConfig: { + enabled: true, + layout: "main-vertical", + main_pane_size: 60, + main_pane_min_width: 120, + agent_pane_min_width: 40, + isolation: "inline", + }, + onSubagentSessionCreated, + enableParentSessionNotifications: false, + }) + const originalTmux = process.env.TMUX + process.env.TMUX = "/tmp/fake-tmux-socket" + + try { + //#when + await manager.launch({ + description: "Blocking tmux test", + prompt: "Do work", + agent: "general", + parentSessionId: "ses_parent", + parentMessageId: "msg_parent", + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + //#then + expect(events).toContain("session.create") + expect(events).toContain("promptAsync") + expect(events).toContain("tmux.callback.start") + const promptIdx = events.indexOf("promptAsync") + const tmuxStartIdx = events.indexOf("tmux.callback.start") + expect(promptIdx < tmuxStartIdx).toBe(true) + expect(events).not.toContain("tmux.callback.end") + } finally { + resolveTmuxCallback() + if (originalTmux === undefined) delete process.env.TMUX + else process.env.TMUX = originalTmux + manager.shutdown() + } + }) +}) + describe("BackgroundManager session.error fallback hydration", () => { test("hydrates fallbackChain from session fallback state before retrying sync child-session errors", async () => { //#given @@ -3463,7 +3539,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0) }) - test("should keep task cancelled when cancelled during tmux callback before running state is assigned", async () => { + test("should start prompt before tmux callback cancellation", async () => { // given resetClaudeCodeSessionState() const originalTmuxEnvironment = process.env.TMUX @@ -3474,9 +3550,9 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { const abortCalls: string[] = [] const promptAsyncSessionIDs: string[] = [] let taskID: string | undefined - let resolveAbortCalled: (() => void) | undefined - const abortCalled = new Promise((resolve) => { - resolveAbortCalled = resolve + let resolveCancelCalled: (() => void) | undefined + const cancelCalled = new Promise((resolve) => { + resolveCancelCalled = resolve }) manager.shutdown() @@ -3496,7 +3572,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { status: async () => ({ data: {} }), abort: async ({ path }: { path: { id: string } }) => { abortCalls.push(path.id) - resolveAbortCalled?.() return {} }, }, @@ -3523,6 +3598,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { source: "test", abortSession: false, }) + resolveCancelCalled?.() }, } ) @@ -3539,7 +3615,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { // when await Promise.race([ - abortCalled, + cancelCalled, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 500)), ]) await flushBackgroundNotifications() @@ -3547,12 +3623,12 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { // then const updatedTask = manager.getTask(task.id) expect(updatedTask?.status).toBe("cancelled") - expect(updatedTask?.sessionId).toBeUndefined() - expect(promptAsyncSessionIDs).not.toContain(createdSessionID) - expect(abortCalls).toEqual([createdSessionID]) + expect(updatedTask?.sessionId).toBe(createdSessionID) + expect(promptAsyncSessionIDs).toContain(createdSessionID) + expect(abortCalls).toEqual([]) expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0) expect(getRootDescendantCounts(manager).has("parent-session")).toBe(false) - expect(subagentSessions.has(createdSessionID)).toBe(false) + expect(subagentSessions.has(createdSessionID)).toBe(true) } finally { resetClaudeCodeSessionState() if (originalTmuxEnvironment === undefined) { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 84611c578..310e44048 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -761,33 +761,8 @@ export class BackgroundManager { this.settlePreStartDescendantReservation(task) subagentSessions.add(sessionID) - log("[background-agent] tmux callback check", { - hasCallback: !!this.onSubagentSessionCreated, - tmuxEnabled: this.tmuxEnabled, - isInsideTmux: isInsideTmux(), - sessionID, - parentID: input.parentSessionId, - }) - - if (!input.suppressTmuxSpawn && this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) { - log("[background-agent] Invoking tmux callback NOW", { sessionID }) - await this.onSubagentSessionCreated({ - sessionID, - parentID: input.parentSessionId, - title: input.description, - }).catch((err) => { - log("[background-agent] Failed to spawn tmux pane:", err) - }) - log("[background-agent] tmux callback completed, waiting 200ms") - await new Promise(r => setTimeout(r, 200)) - } else { - log("[background-agent] SKIP tmux callback - conditions not met", { - suppressTmuxSpawn: !!input.suppressTmuxSpawn, - }) - } - if (this.tasks.get(task.id)?.status === "cancelled") { - await this.abortSessionWithLogging(sessionID, "cancelled during tmux setup") + await this.abortSessionWithLogging(sessionID, "cancelled during launch setup") subagentSessions.delete(sessionID) if (task.rootSessionId) { this.unregisterRootDescendant(task.rootSessionId) @@ -982,6 +957,29 @@ The fallback retry session is now created and can be inspected directly. }) } }) + + log("[background-agent] tmux callback check", { + hasCallback: !!this.onSubagentSessionCreated, + tmuxEnabled: this.tmuxEnabled, + isInsideTmux: isInsideTmux(), + sessionID, + parentID: input.parentSessionId, + }) + + if (!input.suppressTmuxSpawn && this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) { + log("[background-agent] Invoking tmux callback (fire-and-forget)", { sessionID }) + void this.onSubagentSessionCreated({ + sessionID, + parentID: input.parentSessionId, + title: input.description, + }).catch((err) => { + log("[background-agent] Failed to spawn tmux pane:", err) + }) + } else { + log("[background-agent] SKIP tmux callback - conditions not met", { + suppressTmuxSpawn: !!input.suppressTmuxSpawn, + }) + } } getTask(id: string): BackgroundTask | undefined { From efb862ce9fd94f6b853d3167205530c2a6e0d29f Mon Sep 17 00:00:00 2001 From: Momentum96 Date: Fri, 8 May 2026 00:31:54 +0900 Subject: [PATCH 2/3] fix(tmux): prefer real tmux when session env exists --- src/shared/tmux/cmux-detect.ts | 5 +++-- src/shared/tmux/runner.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/shared/tmux/cmux-detect.ts b/src/shared/tmux/cmux-detect.ts index 202733c77..bb152bdc1 100644 --- a/src/shared/tmux/cmux-detect.ts +++ b/src/shared/tmux/cmux-detect.ts @@ -6,6 +6,7 @@ * 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 + const tmuxEnvironment = process.env.TMUX + return tmuxEnvironment?.includes("cmuxterm") === true || + (Boolean(process.env.CMUX_SOCKET_PATH) && !tmuxEnvironment) } diff --git a/src/shared/tmux/runner.test.ts b/src/shared/tmux/runner.test.ts index d0edeac5e..8b822086c 100644 --- a/src/shared/tmux/runner.test.ts +++ b/src/shared/tmux/runner.test.ts @@ -63,6 +63,33 @@ afterAll(async () => { }) describe("runTmuxCommand", () => { + test("#given cmux socket and real tmux session #when run #then uses requested executable instead of cmux compat", async () => { + // given + const originalCmuxSocketPath = process.env.CMUX_SOCKET_PATH + const originalTmux = process.env.TMUX + process.env.CMUX_SOCKET_PATH = "/tmp/cmux.sock" + process.env.TMUX = "/private/tmp/tmux-501/default,123,0" + + try { + // when + const result = await runTmuxCommand("sh", ["-c", "printf '%s\\n' real-tmux"]) + + // then + expect(result).toEqual({ + success: true, + output: "real-tmux", + stdout: "real-tmux", + stderr: "", + exitCode: 0, + }) + } finally { + 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 + } + }) + test("#given command exits 0 with stdout #when run #then success true, output and stdout equal trimmed value, stderr empty", async () => { // given const commandArguments = ["-c", "printf '%s\\n' '%42'"] From e90ff8058e5f614d6ce9b7009405d9ea2765b41c Mon Sep 17 00:00:00 2001 From: Momentum96 Date: Fri, 8 May 2026 00:31:59 +0900 Subject: [PATCH 3/3] fix(tmux): treat busy sessions as attachable --- .../attachable-session-status.test.ts | 18 ++++++++++++++++++ .../tmux-subagent/attachable-session-status.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 src/features/tmux-subagent/attachable-session-status.test.ts diff --git a/src/features/tmux-subagent/attachable-session-status.test.ts b/src/features/tmux-subagent/attachable-session-status.test.ts new file mode 100644 index 000000000..6ff1ce9ad --- /dev/null +++ b/src/features/tmux-subagent/attachable-session-status.test.ts @@ -0,0 +1,18 @@ +/// + +import { describe, expect, test } from "bun:test" + +import { isAttachableSessionStatus } from "./attachable-session-status" + +describe("isAttachableSessionStatus", () => { + test("#given a busy session #when checking attachability #then it is attachable", () => { + //#given + const status = "busy" + + //#when + const attachable = isAttachableSessionStatus(status) + + //#then + expect(attachable).toBe(true) + }) +}) diff --git a/src/features/tmux-subagent/attachable-session-status.ts b/src/features/tmux-subagent/attachable-session-status.ts index 22dc89770..f525e5e0a 100644 --- a/src/features/tmux-subagent/attachable-session-status.ts +++ b/src/features/tmux-subagent/attachable-session-status.ts @@ -1,4 +1,4 @@ -const ATTACHABLE_SESSION_STATUSES = ["idle", "running"] as const +const ATTACHABLE_SESSION_STATUSES = ["idle", "running", "busy"] as const export type AttachableSessionStatus = (typeof ATTACHABLE_SESSION_STATUSES)[number]