feat(state): clean up per-bottle state on session end (except capability-block)
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 1m35s

Previously every bottle launch left ~/.claude-bottle/state/<identity>/
behind forever — metadata.json on every run, plus per-bottle
Dockerfile + transcript snapshot on capability-block rebuilds. The
metadata accumulated debris across launches; the only state worth
keeping was the capability-block rebuild bundle.

Make cleanup the default; preserve only on capability-block.

- bottle_state.py: .preserve marker helpers (mark_preserved,
  is_preserved, clear_preserve_marker, preserve_marker_path) +
  cleanup_state(identity) that rm -rf's the per-bottle dir.
- capability_apply.apply_capability_change writes mark_preserved
  before teardown so cli.py's session-end cleanup keeps the dir.
- prepare.py clears any leftover marker at launch (start or resume),
  so a marker from a prior capability-block doesn't keep state
  alive past a subsequent normal session-end.
- cli/start.py runs the cleanup decision AFTER the launch context
  closes: if is_preserved → print resume hint; else cleanup_state.
  The resume hint moves out of the launch with-block (was previously
  printed unconditionally — would have misled the operator about
  whether state was actually kept).

Future-proof: cli.py never persists state speculatively. If the
agent wants to be resumable, it has to go through capability-block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 06:51:13 -04:00
parent e07b8ecdb0
commit 9dbd20398e
6 changed files with 144 additions and 3 deletions
+20 -3
View File
@@ -17,6 +17,7 @@ import tempfile
from pathlib import Path
from ..backend import BottleSpec, get_bottle_backend
from ..backend.docker.bottle_state import cleanup_state, is_preserved
from ..log import die, info
from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line
@@ -99,13 +100,29 @@ def _launch_bottle(
claude_args.append("--remote-control")
bottle.exec_claude(claude_args, tty=True)
info(f"session ended; container {bottle.name} will be removed")
if identity:
info(f"to resume this bottle: ./cli.py resume {identity}")
return 0
# Context exited → containers + networks gone. Now decide
# what to do with the per-bottle state dir on the host:
# capability-block apply sets the preserve marker before it
# tears the bottle down, so the operator can resume from the
# new Dockerfile + transcript snapshot. Any other session
# end (normal exit, agent crash, Ctrl-C) leaves no marker,
# and the state dir gets cleaned up so ~/.claude-bottle/state/
# doesn't accumulate per-launch debris.
_settle_state(identity)
return 0
finally:
shutil.rmtree(stage_dir, ignore_errors=True)
def _settle_state(identity: str) -> None:
if not identity:
return
if is_preserved(identity):
info(f"to resume this bottle: ./cli.py resume {identity}")
return
cleanup_state(identity)
def _identity_from_plan(plan: object) -> str:
"""Backend-specific: the docker plan exposes the identity as
`.slug`. Other backends in the future would expose their own