fix: harden cmux fallback retries and tmux runtime assertions

This commit is contained in:
Kenny
2026-03-29 18:57:49 +08:00
parent 7088120045
commit 6ffadaaa51
5 changed files with 178 additions and 31 deletions

View File

@@ -16,7 +16,7 @@ export function createIdleNotificationScheduler(options: {
platform: Platform
config: SessionNotificationConfig
hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean>
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<void>
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<boolean>
playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise<void>
}) {
const notifiedSessions = new Set<string>()
@@ -134,9 +134,21 @@ export function createIdleNotificationScheduler(options: {
return
}
notifiedSessions.add(sessionID)
const delivered = await options.send(options.ctx, options.platform, sessionID)
if (!delivered) {
return
}
await options.send(options.ctx, options.platform, sessionID)
if (notificationVersions.get(sessionID) !== version) {
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
return
}
notifiedSessions.add(sessionID)
if (options.config.playSound && options.config.soundPath) {
await options.playSound(options.ctx, options.platform, options.config.soundPath)

View File

@@ -499,6 +499,113 @@ describe("session-notification", () => {
expect(notificationCalls).toHaveLength(1)
})
test("retries idle notification when cmux fails on unsupported platform", async () => {
const sessionID = "cmux-unsupported-retry"
let cmuxSendCalls = 0
let cmuxNotifyCommandCalls = 0
let cmuxAvailable = true
const detectPlatformSpy = spyOn(sender, "detectPlatform").mockReturnValue("unsupported")
try {
const cmuxAdapter = createCmuxAdapter({
canSendViaCmux: () => cmuxAvailable,
hasDowngraded: () => !cmuxAvailable,
send: async () => {
cmuxSendCalls += 1
if (!cmuxAvailable) {
return false
}
cmuxNotifyCommandCalls += 1
cmuxAvailable = false
return false
},
})
const hook = createSessionNotification(
createMockPluginInput(),
{
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
},
{
resolvedMultiplexer: createCmuxRuntime(),
cmuxNotificationAdapter: cmuxAdapter,
},
)
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(cmuxSendCalls).toBe(2)
expect(cmuxNotifyCommandCalls).toBe(1)
expect(notificationCalls).toHaveLength(0)
} finally {
detectPlatformSpy.mockRestore()
}
})
test("skips unsupported idle scheduling when cmux was never available", async () => {
const sessionID = "cmux-unsupported-unavailable"
let cmuxSendCalls = 0
const detectPlatformSpy = spyOn(sender, "detectPlatform").mockReturnValue("unsupported")
try {
const cmuxAdapter = createCmuxAdapter({
canSendViaCmux: () => false,
hasDowngraded: () => false,
send: async () => {
cmuxSendCalls += 1
return false
},
})
const hook = createSessionNotification(
createMockPluginInput(),
{
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
},
{
resolvedMultiplexer: createCmuxRuntime(),
cmuxNotificationAdapter: cmuxAdapter,
},
)
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(cmuxSendCalls).toBe(0)
expect(notificationCalls).toHaveLength(0)
} finally {
detectPlatformSpy.mockRestore()
}
})
test("suppresses duplicate idle notifications while using cmux backend", async () => {
const sessionID = "cmux-duplicate"
let cmuxSendCalls = 0

View File

@@ -88,15 +88,21 @@ export function createSessionNotification(
mergedConfig.title,
mergedConfig.message,
)
if (!deliveredViaCmux && platform !== "unsupported") {
if (deliveredViaCmux) {
return true
}
if (platform === "unsupported") {
return false
}
await sessionNotificationSender.sendSessionNotification(
hookCtx,
platform,
mergedConfig.title,
mergedConfig.message,
)
}
return
return true
}
const content = await buildReadyNotificationContent(hookCtx, {
@@ -107,14 +113,15 @@ export function createSessionNotification(
const deliveredViaCmux = await cmuxNotificationAdapter.send(content.title, content.message)
if (deliveredViaCmux) {
return
return true
}
if (platform === "unsupported") {
return
return false
}
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message)
return true
},
playSound: sessionNotificationSender.playSessionNotificationSound,
})
@@ -172,7 +179,13 @@ export function createSessionNotification(
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported" && !cmuxNotificationAdapter.canSendViaCmux()) return
const cannotDeliverOnUnsupportedPlatform =
currentPlatform === "unsupported" && !cmuxNotificationAdapter.canSendViaCmux()
const shouldFastExitUnsupportedEvent =
cannotDeliverOnUnsupportedPlatform
&& (event.type !== "session.idle" || !cmuxNotificationAdapter.hasDowngraded())
if (shouldFastExitUnsupportedEvent) return
const props = event.properties as Record<string, unknown> | undefined

View File

@@ -1,7 +1,12 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { beforeEach, describe, expect, spyOn, test } from "bun:test"
import { resetMultiplexerPathCacheForTesting } from "../../tools/interactive-bash/tmux-path-resolver"
import { resetResolvedMultiplexerRuntimeForTesting } from "../../shared/tmux"
import {
createDisabledMultiplexerRuntime,
resetResolvedMultiplexerRuntimeForTesting,
setResolvedMultiplexerRuntime,
} from "../../shared/tmux"
import { analyzePaneContent, getCurrentTmuxSession } from "../tmux"
import * as tmuxPathResolver from "../../tools/interactive-bash/tmux-path-resolver"
describe("openclaw tmux helpers", () => {
beforeEach(() => {
@@ -21,16 +26,17 @@ describe("openclaw tmux helpers", () => {
test("getCurrentTmuxSession does not synthesize a session from TMUX_PANE", async () => {
const originalTmux = process.env.TMUX
const originalTmuxPane = process.env.TMUX_PANE
const originalDisableTmuxFlag = process.env.OH_MY_OPENCODE_DISABLE_TMUX
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue("/usr/bin/tmux")
try {
process.env.TMUX = "/tmp/tmux-501/default,1,0"
process.env.TMUX_PANE = "%42"
process.env.OH_MY_OPENCODE_DISABLE_TMUX = "1"
setResolvedMultiplexerRuntime(createDisabledMultiplexerRuntime())
const sessionName = await getCurrentTmuxSession()
expect(sessionName).toBeNull()
expect(getTmuxPathSpy).not.toHaveBeenCalled()
} finally {
if (originalTmux === undefined) {
delete process.env.TMUX
@@ -44,11 +50,7 @@ describe("openclaw tmux helpers", () => {
process.env.TMUX_PANE = originalTmuxPane
}
if (originalDisableTmuxFlag === undefined) {
delete process.env.OH_MY_OPENCODE_DISABLE_TMUX
} else {
process.env.OH_MY_OPENCODE_DISABLE_TMUX = originalDisableTmuxFlag
}
getTmuxPathSpy.mockRestore()
}
})
})

View File

@@ -1,10 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import {
resetResolvedMultiplexerRuntimeForTesting,
setResolvedMultiplexerRuntime,
type ResolvedMultiplexer,
} from "../../shared/tmux"
import { createInteractiveBashTool, interactive_bash } from "./tools"
import * as tmuxPathResolver from "./tmux-path-resolver"
const mockToolContext = {
sessionID: "test-session",
@@ -47,20 +48,32 @@ describe("interactive_bash runtime resolution", () => {
test("createInteractiveBashTool without runtime resolves current runtime on execute", async () => {
resetResolvedMultiplexerRuntimeForTesting()
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
try {
const tool = createInteractiveBashTool()
setResolvedMultiplexerRuntime(createTmuxEnabledRuntime())
const result = await tool.execute({ tmux_command: "capture-pane -p" }, mockToolContext)
expect(result).not.toContain("pane control is unavailable")
expect(result).toBe("Error: tmux executable is not reachable")
} finally {
getTmuxPathSpy.mockRestore()
}
})
test("interactive_bash singleton resolves current runtime on execute", async () => {
resetResolvedMultiplexerRuntimeForTesting()
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
try {
setResolvedMultiplexerRuntime(createTmuxEnabledRuntime())
const result = await interactive_bash.execute({ tmux_command: "capture-pane -p" }, mockToolContext)
expect(result).not.toContain("pane control is unavailable")
expect(result).toBe("Error: tmux executable is not reachable")
} finally {
getTmuxPathSpy.mockRestore()
}
})
})