fix(headless): disable overall timeout for auto-mode, fix lock-guard auto-select (#2586)

Auto-mode sessions are long-running (minutes to hours) with their own
internal per-unit timeout via auto-supervisor. The 300s overall timeout
was killing active sessions mid-execution, triggering wasteful restart
cycles.

Changes:
- Disable overall timeout for auto-mode when using the default 300s
  (user can still set --timeout explicitly, including --timeout 0)
- Guard timeout timer creation for null when timeout is 0
- Cancel overall timeout when new-milestone --auto chains into auto-mode
- Fix headless auto-responder to pick "Force start" for lock-guard
  prompts instead of "View status" (which silently blocked auto-mode)
- Allow --timeout 0 to explicitly disable timeout for any command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-25 22:18:05 -06:00
parent 5f8bbbc6e1
commit fde0be6979
2 changed files with 36 additions and 15 deletions

View file

@ -40,9 +40,18 @@ export function handleExtensionUIRequest(
let response: Record<string, unknown>
switch (method) {
case 'select':
response = { type: 'extension_ui_response', id, value: event.options?.[0] ?? '' }
case 'select': {
// Lock-guard prompts list "View status" first, but headless needs "Force start"
// to proceed. Detect by title and pick the force option.
const title = String(event.title ?? '')
let selected = event.options?.[0] ?? ''
if (title.includes('Auto-mode is running') && event.options) {
const forceOption = event.options.find(o => o.toLowerCase().includes('force start'))
if (forceOption) selected = forceOption
}
response = { type: 'extension_ui_response', id, value: selected }
break
}
case 'confirm':
response = { type: 'extension_ui_response', id, confirmed: true }
break

View file

@ -90,8 +90,8 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
if (!positionalStarted && arg.startsWith('--')) {
if (arg === '--timeout' && i + 1 < args.length) {
options.timeout = parseInt(args[++i], 10)
if (Number.isNaN(options.timeout) || options.timeout <= 0) {
process.stderr.write('[headless] Error: --timeout must be a positive integer (milliseconds)\n')
if (Number.isNaN(options.timeout) || options.timeout < 0) {
process.stderr.write('[headless] Error: --timeout must be a non-negative integer (milliseconds, 0 to disable)\n')
process.exit(1)
}
} else if (arg === '--json') {
@ -183,6 +183,14 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
options.timeout = 600_000 // 10 minutes
}
// auto-mode sessions are long-running (minutes to hours) with their own internal
// per-unit timeout via auto-supervisor. Disable the overall timeout unless the
// user explicitly set --timeout.
const isAutoMode = options.command === 'auto'
if (isAutoMode && options.timeout === 300_000) {
options.timeout = 0
}
// Supervised mode cannot share stdin with --context -
if (options.supervised && options.context === '-') {
process.stderr.write('[headless] Error: --supervised cannot be used with --context - (both require stdin)\n')
@ -337,12 +345,14 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
// Precompute supervised response timeout
const responseTimeout = options.responseTimeout ?? 30_000
// Overall timeout
const timeoutTimer = setTimeout(() => {
process.stderr.write(`[headless] Timeout after ${options.timeout / 1000}s\n`)
exitCode = 1
resolveCompletion()
}, options.timeout)
// Overall timeout (disabled when options.timeout === 0, e.g. auto-mode)
const timeoutTimer = options.timeout > 0
? setTimeout(() => {
process.stderr.write(`[headless] Timeout after ${options.timeout / 1000}s\n`)
exitCode = 1
resolveCompletion()
}, options.timeout)
: null
// Event handler
client.onEvent((event) => {
@ -434,7 +444,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
interrupted = true
exitCode = 1
client.stop().finally(() => {
clearTimeout(timeoutTimer)
if (timeoutTimer) clearTimeout(timeoutTimer)
if (idleTimer) clearTimeout(idleTimer)
process.exit(exitCode)
})
@ -447,7 +457,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
await client.start()
} catch (err) {
process.stderr.write(`[headless] Error: Failed to start RPC session: ${err instanceof Error ? err.message : String(err)}\n`)
clearTimeout(timeoutTimer)
if (timeoutTimer) clearTimeout(timeoutTimer)
process.exit(1)
}
@ -456,7 +466,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
if (!internalProcess?.stdin) {
process.stderr.write('[headless] Error: Cannot access child process stdin\n')
await client.stop()
clearTimeout(timeoutTimer)
if (timeoutTimer) clearTimeout(timeoutTimer)
process.exit(1)
}
@ -511,7 +521,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
process.stderr.write('[headless] Milestone ready — chaining into auto-mode...\n')
}
// Reset completion state for the auto-mode phase
// Reset completion state for the auto-mode phase.
// Disable the overall timeout — auto-mode has its own internal supervisor.
if (timeoutTimer) clearTimeout(timeoutTimer)
completed = false
milestoneReady = false
blocked = false
@ -532,7 +544,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
}
// Cleanup
clearTimeout(timeoutTimer)
if (timeoutTimer) clearTimeout(timeoutTimer)
if (idleTimer) clearTimeout(idleTimer)
pendingResponseTimers.forEach((timer) => clearTimeout(timer))
pendingResponseTimers.clear()