fix(dashboard): fall back to fresh claude when --continue has no session
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m7s

`--continue` exits non-zero when an agent has been spun up but
never typed at — there's no transcript to resume. Re-attaching
to such an agent via Enter (tmux mode) was crashing the pane.

Wrap the resume invocation in `sh -c '<cmd> --continue || <cmd>'`
so a failed `--continue` cleanly falls through to a fresh
claude. The shell adds microseconds and the fallback only
kicks in when --continue would have failed anyway.

New `_build_resume_argv_with_fallback(bottle)` builds the
shell-wrapped docker exec argv with proper shlex quoting (so
paths-with-spaces in `--append-system-prompt-file` survive).
Only the tmux re-attach path uses it; first-attach + foreground
handoff are unchanged.

489 unit tests pass (4 new for the fallback builder).
This commit is contained in:
2026-05-26 15:34:21 -04:00
parent 7e20d75f00
commit 1a1ba6abd5
2 changed files with 89 additions and 3 deletions
+43 -3
View File
@@ -15,6 +15,7 @@ import argparse
import contextlib
import curses
import os
import shlex
import shutil
import subprocess
import sys
@@ -773,6 +774,38 @@ def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[
return args
def _build_resume_argv_with_fallback(
bottle, *, remote_control: bool = False,
) -> list[str]:
"""Build a docker-exec argv that runs `claude --continue` and
falls back to plain `claude` if no prior session exists.
`--continue` exits non-zero when an agent has been spun up
but never typed at — there's no transcript to resume. The
shell-level `||` wrapper makes that case start a fresh
session instead of crashing the pane. The trade-off: we
invoke `sh -c` inside the container, so the command is two
`claude` invocations behind a tiny shell rather than one
direct exec. Acceptable; the shell adds microseconds and
the fallback only kicks in when --continue would have
failed anyway."""
base_args = ["--dangerously-skip-permissions"]
if remote_control:
base_args.append("--remote-control")
base_docker = bottle.claude_docker_argv(base_args)
# Split docker-prefix from the claude-and-args tail so we
# can compose `<claude…> --continue || <claude…>` inside
# `sh -c`. The `claude` token is the marker.
claude_idx = base_docker.index("claude")
prefix = base_docker[:claude_idx]
claude_cmd = " ".join(shlex.quote(a) for a in base_docker[claude_idx:])
return [
*prefix,
"sh", "-c",
f"{claude_cmd} --continue || {claude_cmd}",
]
def _build_split_pane_argv(docker_argv: list[str]) -> list[str]:
"""Pure helper: wrap a docker-exec argv with `tmux split-window
-h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print
@@ -1008,9 +1041,16 @@ def _attach_in_tmux(
The Enter re-attach key passes this; passive paths (the
auto-attach after a stop) leave it False so the operator
stays in the dashboard pane."""
docker_argv = bottle.claude_docker_argv(
_claude_runtime_args(resume=resume),
)
if resume:
# `--continue` exits non-zero when no prior session
# exists (agent spun up but never typed at). Wrap with a
# shell-level fallback so the pane lands in a fresh
# claude instead of crashing.
docker_argv = _build_resume_argv_with_fallback(bottle)
else:
docker_argv = bottle.claude_docker_argv(
_claude_runtime_args(resume=False),
)
pane_id = _ensure_right_pane(tmux_state, docker_argv)
if pane_id is None:
# tmux failed (missing binary, server died, size error).
@@ -463,6 +463,52 @@ class TestTmuxPaneArgvBuilders(unittest.TestCase):
self.assertIn("%abc.123", argv)
class TestResumeArgvWithFallback(unittest.TestCase):
"""The `claude --continue || claude` shell fallback for the
tmux re-attach path. Without it, an agent that's been spun
up but never typed at crashes the pane on Enter because
--continue has no session to resume."""
def _bottle(self, prompt_path: str | None = None):
from claude_bottle.backend.docker.bottle import DockerBottle
return DockerBottle(
container="claude-bottle-dev-abc",
teardown=lambda: None,
prompt_path_in_container=prompt_path,
)
def test_wraps_in_sh_c_with_or_fallback(self):
argv = dashboard._build_resume_argv_with_fallback(self._bottle())
# Must end with `sh -c '<cmd> --continue || <cmd>'`.
self.assertEqual(
["docker", "exec", "-it", "claude-bottle-dev-abc", "sh", "-c"],
argv[:6],
)
inner = argv[6]
self.assertIn("--continue", inner)
self.assertIn("||", inner)
# Both branches mention claude.
self.assertEqual(2, inner.count("claude"))
def test_inner_args_quoted_safely(self):
# Paths with spaces would break naive concatenation.
bottle = self._bottle("/home/with space/.prompt")
argv = dashboard._build_resume_argv_with_fallback(bottle)
inner = argv[-1]
# shlex.quote should single-quote any token with a space.
self.assertIn("'/home/with space/.prompt'", inner)
def test_includes_skip_permissions(self):
argv = dashboard._build_resume_argv_with_fallback(self._bottle())
self.assertIn("--dangerously-skip-permissions", argv[-1])
def test_includes_prompt_file_flag_when_set(self):
bottle = self._bottle("/home/node/.claude-bottle-prompt.txt")
argv = dashboard._build_resume_argv_with_fallback(bottle)
self.assertIn("--append-system-prompt-file", argv[-1])
self.assertIn("/home/node/.claude-bottle-prompt.txt", argv[-1])
class TestClaudeRuntimeArgs(unittest.TestCase):
"""The argv passed to `bottle.claude_docker_argv` on each
attach. Locked here so the tmux + foreground paths build