From ac4601562a294d540d1469d48118c7c83fa84f88 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 21 Jul 2026 20:38:04 +0400 Subject: [PATCH] fix(shell): timeout is recoverable + coaches detach, not a workflow kill A foreground long-running command (npm run dev, vite, a --watch task) hit the 30s timeout and returned recoverable=false, giving the stage no transition to take -> WorkflowFailed "no transition condition matched" (killed session d734e1de). A non-zero exit right below already returns recoverable=true; a timeout is no more fatal. Return recoverable and coach the model to start the process detached (nohup ... &) and verify separately, or run a one-shot that exits. No background-process registry: the model backgrounds it itself via `&`, which already routes through `sh -c`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S --- .../correx/infrastructure/tools/shell/ShellTool.kt | 14 ++++++++++++-- .../infrastructure/tools/shell/ShellToolTest.kt | 5 +++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt index 4b0b8b0e..d6ac43ec 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt @@ -293,10 +293,20 @@ class ShellTool( killTree(process) stdoutDeferred.cancel() stderrDeferred.cancel() + // A timeout is NOT fatal — it's usually a long-running/watch/server command (npm run dev, + // vite, a watcher) run in the foreground, where it never exits. Return recoverable so the + // stage keeps going and coach the model to detach it, instead of killing the workflow with + // a dead-end failure (no transition matched). ponytail: no background-process registry — + // the model backgrounds it itself via `&`/nohup, which already routes through `sh -c`. return@coroutineScope ToolResult.Failure( invocationId = request.invocationId, - reason = "Process timed out after ${timeoutMs}ms", - recoverable = false, + reason = "Process did not exit within ${timeoutMs}ms and was killed. If this is a " + + "long-running command (a dev server, `npm run dev`/`vite`/`serve`, a `--watch` " + + "task), start it detached so it returns immediately — e.g. " + + "`nohup > /tmp/dev.log 2>&1 &` — then verify separately (curl the port, or " + + "read the log). If you only needed its output, run a one-shot command that exits " + + "(e.g. `npm run build`, not `npm run dev`).", + recoverable = true, ) } val exitCode = process.exitValue() diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt index efaec0ee..77b2b975 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt @@ -155,8 +155,9 @@ class ShellToolTest { assertTrue(result is ToolResult.Failure) val failure = result as ToolResult.Failure - assertEquals("Process timed out after 100ms", failure.reason) - assertFalse(failure.recoverable) + assertTrue(failure.reason.contains("did not exit within 100ms"), failure.reason) + assertTrue(failure.reason.contains("detached"), "must coach the model to background it") + assertTrue(failure.recoverable, "a timeout is a long-running command, not a fatal error") } @Test