fix(cli-run): silence wait noise and suppress raw arrow escape input
This commit is contained in:
parent
a60a153d19
commit
5bb0e69dea
76
src/cli/run/completion-verbose-logging.test.ts
Normal file
76
src/cli/run/completion-verbose-logging.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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(() => {})
|
||||||
|
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(() => {})
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user