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:
78
src/cli/run/completion-verbose-logging.test.ts
Normal file
78
src/cli/run/completion-verbose-logging.test.ts
Normal 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")
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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}`))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
89
src/cli/run/stdin-suppression.test.ts
Normal file
89
src/cli/run/stdin-suppression.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
52
src/cli/run/stdin-suppression.ts
Normal file
52
src/cli/run/stdin-suppression.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -32,7 +32,7 @@ export function decideSpawnActions(
|
||||
)
|
||||
const currentCount = state.agentPanes.length
|
||||
|
||||
if (agentAreaWidth < minPaneWidth) {
|
||||
if (agentAreaWidth < minPaneWidth && currentCount > 0) {
|
||||
return {
|
||||
canSpawn: false,
|
||||
actions: [],
|
||||
|
||||
Reference in New Issue
Block a user