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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
This commit is contained in:
2026-07-21 20:38:04 +04:00
parent f5aaa255ef
commit ac4601562a
2 changed files with 15 additions and 4 deletions
@@ -293,10 +293,20 @@ class ShellTool(
killTree(process) killTree(process)
stdoutDeferred.cancel() stdoutDeferred.cancel()
stderrDeferred.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( return@coroutineScope ToolResult.Failure(
invocationId = request.invocationId, invocationId = request.invocationId,
reason = "Process timed out after ${timeoutMs}ms", reason = "Process did not exit within ${timeoutMs}ms and was killed. If this is a " +
recoverable = false, "long-running command (a dev server, `npm run dev`/`vite`/`serve`, a `--watch` " +
"task), start it detached so it returns immediately — e.g. " +
"`nohup <cmd> > /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() val exitCode = process.exitValue()
@@ -155,8 +155,9 @@ class ShellToolTest {
assertTrue(result is ToolResult.Failure) assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure val failure = result as ToolResult.Failure
assertEquals("Process timed out after 100ms", failure.reason) assertTrue(failure.reason.contains("did not exit within 100ms"), failure.reason)
assertFalse(failure.recoverable) 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 @Test