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:
commit
aad938a21f
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) {
|
if (continuationState.hasActiveHookMarker) {
|
||||||
const reason = continuationState.activeHookMarkerReason ?? "continuation hook is active"
|
const reason = continuationState.activeHookMarkerReason ?? "continuation hook is active"
|
||||||
console.log(pc.dim(` Waiting: ${reason}`))
|
logWaiting(ctx, reason)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!areContinuationHooksIdle(continuationState)) {
|
if (!areContinuationHooksIdle(ctx, continuationState)) {
|
||||||
return false
|
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) {
|
if (continuationState.hasActiveBoulder) {
|
||||||
console.log(pc.dim(" Waiting: boulder continuation is active"))
|
logWaiting(ctx, "boulder continuation is active")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (continuationState.hasActiveRalphLoop) {
|
if (continuationState.hasActiveRalphLoop) {
|
||||||
console.log(pc.dim(" Waiting: ralph-loop continuation is active"))
|
logWaiting(ctx, "ralph-loop continuation is active")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +64,7 @@ async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (incompleteTodos.length > 0) {
|
if (incompleteTodos.length > 0) {
|
||||||
console.log(pc.dim(` Waiting: ${incompleteTodos.length} todos remaining`))
|
logWaiting(ctx, `${incompleteTodos.length} todos remaining`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,9 +99,7 @@ async function areAllDescendantsIdle(
|
|||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const status = allStatuses[child.id]
|
const status = allStatuses[child.id]
|
||||||
if (status && status.type !== "idle") {
|
if (status && status.type !== "idle") {
|
||||||
console.log(
|
logWaiting(ctx, `session ${child.id.slice(0, 8)}... is ${status.type}`)
|
||||||
pc.dim(` Waiting: session ${child.id.slice(0, 8)}... is ${status.type}`)
|
|
||||||
)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,3 +115,11 @@ async function areAllDescendantsIdle(
|
|||||||
|
|
||||||
return true
|
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 { resolveRunAgent } from "./agent-resolver"
|
||||||
import { pollForCompletion } from "./poll-for-completion"
|
import { pollForCompletion } from "./poll-for-completion"
|
||||||
import { loadAgentProfileColors } from "./agent-profile-colors"
|
import { loadAgentProfileColors } from "./agent-profile-colors"
|
||||||
|
import { suppressRunInput } from "./stdin-suppression"
|
||||||
|
|
||||||
export { resolveRunAgent }
|
export { resolveRunAgent }
|
||||||
|
|
||||||
@ -53,11 +54,15 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
serverCleanup()
|
serverCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
const restoreInput = suppressRunInput()
|
||||||
|
const handleSigint = () => {
|
||||||
console.log(pc.yellow("\nInterrupted. Shutting down..."))
|
console.log(pc.yellow("\nInterrupted. Shutting down..."))
|
||||||
|
restoreInput()
|
||||||
cleanup()
|
cleanup()
|
||||||
process.exit(130)
|
process.exit(130)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", handleSigint)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionID = await resolveSession({
|
const sessionID = await resolveSession({
|
||||||
@ -124,6 +129,9 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
cleanup()
|
cleanup()
|
||||||
throw err
|
throw err
|
||||||
|
} finally {
|
||||||
|
process.removeListener("SIGINT", handleSigint)
|
||||||
|
restoreInput()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (jsonManager) jsonManager.restore()
|
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")
|
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", () => {
|
it("replaces oldest pane when existing panes are too small to split", () => {
|
||||||
// given - existing pane is below minimum splittable size
|
// given - existing pane is below minimum splittable size
|
||||||
const state = createWindowState(220, 30, [
|
const state = createWindowState(220, 30, [
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export function decideSpawnActions(
|
|||||||
)
|
)
|
||||||
const currentCount = state.agentPanes.length
|
const currentCount = state.agentPanes.length
|
||||||
|
|
||||||
if (agentAreaWidth < minPaneWidth) {
|
if (agentAreaWidth < minPaneWidth && currentCount > 0) {
|
||||||
return {
|
return {
|
||||||
canSpawn: false,
|
canSpawn: false,
|
||||||
actions: [],
|
actions: [],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user