Merge pull request #1941 from code-yeongyu/fix/issue-1939-initial-pane-spawn

fix(tmux): skip agent area width guard when 0 agent panes exist
This commit is contained in:
YeonGyu-Kim
2026-02-18 17:46:57 +09:00
committed by GitHub
7 changed files with 342 additions and 12 deletions

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, ChildSession, SessionStatus } from "./types"
const createMockContext = (overrides: {
childrenBySession?: Record<string, ChildSession[]>
statuses?: Record<string, SessionStatus>
verbose?: boolean
} = {}): RunContext => {
const {
childrenBySession = { "test-session": [] },
statuses = {},
verbose = false,
} = overrides
return {
client: {
session: {
todo: mock(() => Promise.resolve({ data: [] })),
children: mock((opts: { path: { id: string } }) =>
Promise.resolve({ data: childrenBySession[opts.path.id] ?? [] })
),
status: mock(() => Promise.resolve({ data: statuses })),
},
} as unknown as RunContext["client"],
sessionID: "test-session",
directory: "/test",
abortController: new AbortController(),
verbose,
}
}
describe("checkCompletionConditions verbose waiting logs", () => {
it("does not print busy waiting line when verbose is disabled", async () => {
// given
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
consoleLogSpy.mockClear()
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [],
},
statuses: { "child-1": { type: "busy" } },
verbose: false,
})
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(false)
expect(consoleLogSpy).not.toHaveBeenCalled()
})
it("prints busy waiting line when verbose is enabled", async () => {
// given
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
consoleLogSpy.mockClear()
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [],
},
statuses: { "child-1": { type: "busy" } },
verbose: true,
})
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(false)
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Waiting: session child-1... is busy")
)
})
})

View File

@@ -12,7 +12,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
if (continuationState.hasActiveHookMarker) {
const reason = continuationState.activeHookMarkerReason ?? "continuation hook is active"
console.log(pc.dim(` Waiting: ${reason}`))
logWaiting(ctx, reason)
return false
}
@@ -24,7 +24,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
return false
}
if (!areContinuationHooksIdle(continuationState)) {
if (!areContinuationHooksIdle(ctx, continuationState)) {
return false
}
@@ -35,14 +35,17 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
}
}
function areContinuationHooksIdle(continuationState: ContinuationState): boolean {
function areContinuationHooksIdle(
ctx: RunContext,
continuationState: ContinuationState
): boolean {
if (continuationState.hasActiveBoulder) {
console.log(pc.dim(" Waiting: boulder continuation is active"))
logWaiting(ctx, "boulder continuation is active")
return false
}
if (continuationState.hasActiveRalphLoop) {
console.log(pc.dim(" Waiting: ralph-loop continuation is active"))
logWaiting(ctx, "ralph-loop continuation is active")
return false
}
@@ -61,7 +64,7 @@ async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
)
if (incompleteTodos.length > 0) {
console.log(pc.dim(` Waiting: ${incompleteTodos.length} todos remaining`))
logWaiting(ctx, `${incompleteTodos.length} todos remaining`)
return false
}
@@ -96,9 +99,7 @@ async function areAllDescendantsIdle(
for (const child of children) {
const status = allStatuses[child.id]
if (status && status.type !== "idle") {
console.log(
pc.dim(` Waiting: session ${child.id.slice(0, 8)}... is ${status.type}`)
)
logWaiting(ctx, `session ${child.id.slice(0, 8)}... is ${status.type}`)
return false
}
@@ -114,3 +115,11 @@ async function areAllDescendantsIdle(
return true
}
function logWaiting(ctx: RunContext, message: string): void {
if (!ctx.verbose) {
return
}
console.log(pc.dim(` Waiting: ${message}`))
}

View File

@@ -9,6 +9,7 @@ import { executeOnCompleteHook } from "./on-complete-hook"
import { resolveRunAgent } from "./agent-resolver"
import { pollForCompletion } from "./poll-for-completion"
import { loadAgentProfileColors } from "./agent-profile-colors"
import { suppressRunInput } from "./stdin-suppression"
export { resolveRunAgent }
@@ -53,11 +54,15 @@ export async function run(options: RunOptions): Promise<number> {
serverCleanup()
}
process.on("SIGINT", () => {
const restoreInput = suppressRunInput()
const handleSigint = () => {
console.log(pc.yellow("\nInterrupted. Shutting down..."))
restoreInput()
cleanup()
process.exit(130)
})
}
process.on("SIGINT", handleSigint)
try {
const sessionID = await resolveSession({
@@ -124,6 +129,9 @@ export async function run(options: RunOptions): Promise<number> {
} catch (err) {
cleanup()
throw err
} finally {
process.removeListener("SIGINT", handleSigint)
restoreInput()
}
} catch (err) {
if (jsonManager) jsonManager.restore()

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, mock } from "bun:test"
import { EventEmitter } from "node:events"
import { suppressRunInput } from "./stdin-suppression"
type FakeStdin = EventEmitter & {
isTTY?: boolean
isRaw?: boolean
setRawMode: ReturnType<typeof mock<(mode: boolean) => void>>
isPaused: ReturnType<typeof mock<() => boolean>>
resume: ReturnType<typeof mock<() => void>>
pause: ReturnType<typeof mock<() => void>>
}
function createFakeStdin(options: {
isTTY?: boolean
isRaw?: boolean
paused?: boolean
} = {}): FakeStdin {
const emitter = new EventEmitter() as FakeStdin
emitter.isTTY = options.isTTY ?? true
emitter.isRaw = options.isRaw ?? false
emitter.setRawMode = mock((mode: boolean) => {
emitter.isRaw = mode
})
emitter.isPaused = mock(() => options.paused ?? false)
emitter.resume = mock(() => {})
emitter.pause = mock(() => {})
return emitter
}
describe("suppressRunInput", () => {
it("ignores non-tty stdin", () => {
// given
const stdin = createFakeStdin({ isTTY: false })
const onInterrupt = mock(() => {})
// when
const restore = suppressRunInput(stdin, onInterrupt)
restore()
// then
expect(stdin.setRawMode).not.toHaveBeenCalled()
expect(stdin.resume).not.toHaveBeenCalled()
expect(onInterrupt).not.toHaveBeenCalled()
})
it("enables raw mode and restores it", () => {
// given
const stdin = createFakeStdin({ isRaw: false, paused: true })
// when
const restore = suppressRunInput(stdin)
restore()
// then
expect(stdin.setRawMode).toHaveBeenNthCalledWith(1, true)
expect(stdin.resume).toHaveBeenCalledTimes(1)
expect(stdin.setRawMode).toHaveBeenNthCalledWith(2, false)
expect(stdin.pause).toHaveBeenCalledTimes(1)
})
it("calls interrupt handler on ctrl-c", () => {
// given
const stdin = createFakeStdin()
const onInterrupt = mock(() => {})
const restore = suppressRunInput(stdin, onInterrupt)
// when
stdin.emit("data", "\u0003")
restore()
// then
expect(onInterrupt).toHaveBeenCalledTimes(1)
})
it("does not call interrupt handler on arrow-key escape", () => {
// given
const stdin = createFakeStdin()
const onInterrupt = mock(() => {})
const restore = suppressRunInput(stdin, onInterrupt)
// when
stdin.emit("data", "\u001b[A")
restore()
// then
expect(onInterrupt).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,52 @@
type StdinLike = {
isTTY?: boolean
isRaw?: boolean
setRawMode?: (mode: boolean) => void
isPaused?: () => boolean
resume: () => void
pause: () => void
on: (event: "data", listener: (chunk: string | Uint8Array) => void) => void
removeListener: (event: "data", listener: (chunk: string | Uint8Array) => void) => void
}
function includesCtrlC(chunk: string | Uint8Array): boolean {
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")
return text.includes("\u0003")
}
export function suppressRunInput(
stdin: StdinLike = process.stdin,
onInterrupt: () => void = () => {
process.kill(process.pid, "SIGINT")
}
): () => void {
if (!stdin.isTTY) {
return () => {}
}
const wasRaw = stdin.isRaw === true
const wasPaused = stdin.isPaused?.() ?? false
const canSetRawMode = typeof stdin.setRawMode === "function"
const onData = (chunk: string | Uint8Array) => {
if (includesCtrlC(chunk)) {
onInterrupt()
}
}
if (canSetRawMode) {
stdin.setRawMode!(true)
}
stdin.on("data", onData)
stdin.resume()
return () => {
stdin.removeListener("data", onData)
if (canSetRawMode) {
stdin.setRawMode!(wasRaw)
}
if (wasPaused) {
stdin.pause()
}
}
}

View File

@@ -258,6 +258,100 @@ describe("decideSpawnActions", () => {
expect(result.actions[0].type).toBe("spawn")
})
it("returns canSpawn=true when 0 agent panes exist and mainPane occupies full window width", () => {
// given - tmux reports mainPane.width === windowWidth when no splits exist
// agentAreaWidth = max(0, 252 - 252 - 1) = 0, which is < minPaneWidth
// but with 0 agent panes, the early return should be skipped
const windowWidth = 252
const windowHeight = 56
const state: WindowState = {
windowWidth,
windowHeight,
mainPane: { paneId: "%0", width: windowWidth, height: windowHeight, left: 0, top: 0, title: "main", isActive: true },
agentPanes: [],
}
// when
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
// then - should NOT be blocked by agentAreaWidth check
expect(result.canSpawn).toBe(true)
expect(result.actions.length).toBe(1)
expect(result.actions[0].type).toBe("spawn")
})
it("returns canSpawn=false when 0 agent panes and window genuinely too narrow to split", () => {
// given - window so narrow that even splitting mainPane wouldn't work
// canSplitPane requires width >= 2*minPaneWidth + DIVIDER_SIZE = 2*40+1 = 81
const windowWidth = 70
const windowHeight = 56
const state: WindowState = {
windowWidth,
windowHeight,
mainPane: { paneId: "%0", width: windowWidth, height: windowHeight, left: 0, top: 0, title: "main", isActive: true },
agentPanes: [],
}
// when
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
// then - should fail because mainPane itself is too small to split
expect(result.canSpawn).toBe(false)
expect(result.reason).toContain("too small")
})
it("returns canSpawn=false when agent panes exist but agent area too small", () => {
// given - 1 agent pane exists, but agent area is below minPaneWidth
// this verifies the early return still works for currentCount > 0
const state: WindowState = {
windowWidth: 180,
windowHeight: 44,
mainPane: { paneId: "%0", width: 160, height: 44, left: 0, top: 0, title: "main", isActive: true },
agentPanes: [{ paneId: "%1", width: 19, height: 44, left: 161, top: 0, title: "agent-0", isActive: false }],
}
// when
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
// then - agent area = max(0, 180-160-1) = 19, which is < agentPaneWidth(40)
expect(result.canSpawn).toBe(false)
expect(result.reason).toContain("too small")
})
it("spawns at exact minimum splittable width with 0 agent panes", () => {
// given - canSplitPane requires width >= 2*agentPaneWidth + DIVIDER_SIZE = 2*40+1 = 81
const exactThreshold = 2 * defaultConfig.agentPaneWidth + 1
const state: WindowState = {
windowWidth: exactThreshold,
windowHeight: 56,
mainPane: { paneId: "%0", width: exactThreshold, height: 56, left: 0, top: 0, title: "main", isActive: true },
agentPanes: [],
}
// when
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
// then - exactly at threshold should succeed
expect(result.canSpawn).toBe(true)
})
it("rejects spawn 1 pixel below minimum splittable width with 0 agent panes", () => {
// given - 1 below exact threshold
const belowThreshold = 2 * defaultConfig.agentPaneWidth
const state: WindowState = {
windowWidth: belowThreshold,
windowHeight: 56,
mainPane: { paneId: "%0", width: belowThreshold, height: 56, left: 0, top: 0, title: "main", isActive: true },
agentPanes: [],
}
// when
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
// then - 1 below threshold should fail
expect(result.canSpawn).toBe(false)
})
it("replaces oldest pane when existing panes are too small to split", () => {
// given - existing pane is below minimum splittable size
const state = createWindowState(220, 30, [

View File

@@ -32,7 +32,7 @@ export function decideSpawnActions(
)
const currentCount = state.agentPanes.length
if (agentAreaWidth < minPaneWidth) {
if (agentAreaWidth < minPaneWidth && currentCount > 0) {
return {
canSpawn: false,
actions: [],