fix(dashboard): fall back to fresh claude when --continue has no session
`--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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user