fix(headless): keep idle timeout off during interactive tools

This commit is contained in:
mastertyko 2026-04-12 14:04:15 +02:00
parent 791ce1b35e
commit 1ab3d9a04f
3 changed files with 64 additions and 3 deletions

View file

@ -70,6 +70,7 @@ export const IDLE_TIMEOUT_MS = 15_000
// between tool calls (e.g. after mkdir, before writing files). Use a
// longer idle timeout to avoid killing the session prematurely (#808).
export const NEW_MILESTONE_IDLE_TIMEOUT_MS = 120_000
const INTERACTIVE_HEADLESS_TOOLS = new Set(['ask_user_questions', 'secure_env_collect'])
export function isTerminalNotification(event: Record<string, unknown>): boolean {
if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false
@ -89,6 +90,14 @@ export function isMilestoneReadyNotification(event: Record<string, unknown>): bo
return /milestone\s+m\d+.*ready/i.test(String(event.message ?? ''))
}
export function isInteractiveHeadlessTool(toolName: string | undefined): boolean {
return INTERACTIVE_HEADLESS_TOOLS.has(String(toolName ?? ''))
}
export function shouldArmHeadlessIdleTimeout(toolCallCount: number, interactiveToolCount: number): boolean {
return toolCallCount > 0 && interactiveToolCount === 0
}
// ---------------------------------------------------------------------------
// Quick Command Detection
// ---------------------------------------------------------------------------

View file

@ -30,6 +30,8 @@ import {
FIRE_AND_FORGET_METHODS,
IDLE_TIMEOUT_MS,
NEW_MILESTONE_IDLE_TIMEOUT_MS,
isInteractiveHeadlessTool,
shouldArmHeadlessIdleTimeout,
EXIT_SUCCESS,
EXIT_ERROR,
EXIT_BLOCKED,
@ -367,6 +369,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
let exitCode = 0
let milestoneReady = false // tracks "Milestone X ready." for auto-chaining
const recentEvents: TrackedEvent[] = []
const interactiveToolCallIds = new Set<string>()
// JSON batch mode: cost aggregation (cumulative-max pattern per K004)
let cumulativeCostUsd = 0
@ -460,7 +463,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
function resetIdleTimer(): void {
if (idleTimer) clearTimeout(idleTimer)
if (toolCallCount > 0) {
if (shouldArmHeadlessIdleTimeout(toolCallCount, interactiveToolCallIds.size)) {
idleTimer = setTimeout(() => {
completed = true
resolveCompletion()
@ -484,6 +487,20 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
client.onEvent((event) => {
const eventObj = event as unknown as Record<string, unknown>
trackEvent(eventObj)
const eventType = String(eventObj.type ?? '')
if (eventType === 'tool_execution_start') {
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? '')
if (toolCallId && isInteractiveHeadlessTool(String(eventObj.toolName ?? ''))) {
interactiveToolCallIds.add(toolCallId)
}
} else if (eventType === 'tool_execution_end') {
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? '')
if (toolCallId) {
interactiveToolCallIds.delete(toolCallId)
}
}
resetIdleTimer()
// Answer injector: observe events for question metadata
@ -492,7 +509,6 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
// --json / --output-format stream-json: forward events as JSONL to stdout (filtered if --events)
// --output-format json (batch mode): suppress streaming, track cost for final result
if (options.json && options.outputFormat === 'stream-json') {
const eventType = String(eventObj.type ?? '')
if (!options.eventFilter || options.eventFilter.has(eventType)) {
process.stdout.write(JSON.stringify(eventObj) + '\n')
}

View file

@ -150,7 +150,15 @@ test('empty filter blocks all events', () => {
assert.ok(!shouldEmit('message_update'))
})
import { mapStatusToExitCode, EXIT_SUCCESS, EXIT_ERROR, EXIT_BLOCKED, EXIT_CANCELLED } from '../headless-events.js'
import {
mapStatusToExitCode,
EXIT_SUCCESS,
EXIT_ERROR,
EXIT_BLOCKED,
EXIT_CANCELLED,
isInteractiveHeadlessTool,
shouldArmHeadlessIdleTimeout,
} from '../headless-events.js'
// ─── mapStatusToExitCode ─────────────────────────────────────────────────
@ -185,3 +193,31 @@ test('mapStatusToExitCode: "cancelled" returns EXIT_CANCELLED', () => {
test('mapStatusToExitCode: unknown status returns EXIT_ERROR', () => {
assert.equal(mapStatusToExitCode('unknown'), EXIT_ERROR)
})
test('isInteractiveHeadlessTool: ask_user_questions is interactive', () => {
assert.equal(isInteractiveHeadlessTool('ask_user_questions'), true)
})
test('isInteractiveHeadlessTool: secure_env_collect is interactive', () => {
assert.equal(isInteractiveHeadlessTool('secure_env_collect'), true)
})
test('isInteractiveHeadlessTool: non-interactive tools stay false', () => {
assert.equal(isInteractiveHeadlessTool('bash'), false)
assert.equal(isInteractiveHeadlessTool(undefined), false)
})
test('shouldArmHeadlessIdleTimeout: arms after tool calls when no interactive tool is in flight', () => {
assert.equal(shouldArmHeadlessIdleTimeout(1, 0), true)
assert.equal(shouldArmHeadlessIdleTimeout(3, 0), true)
})
test('shouldArmHeadlessIdleTimeout: stays disarmed while interactive tools are in flight (#3714)', () => {
assert.equal(shouldArmHeadlessIdleTimeout(1, 1), false)
assert.equal(shouldArmHeadlessIdleTimeout(5, 2), false)
})
test('shouldArmHeadlessIdleTimeout: stays disarmed before any tool call has started', () => {
assert.equal(shouldArmHeadlessIdleTimeout(0, 0), false)
assert.equal(shouldArmHeadlessIdleTimeout(0, 1), false)
})