refactor!: rename project to bot-bottle

Assisted-by: Codex
This commit is contained in:
2026-05-28 17:56:14 -04:00
parent 8875d8cc17
commit c08b09dc9f
200 changed files with 1271 additions and 1271 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Main CLI dispatcher.
Commands: cleanup, dashboard, edit, info, init, list, resume, start
"""
from __future__ import annotations
import sys
from ..log import Die, die
from ._common import PROG
from . import list as _list_mod
from .cleanup import cmd_cleanup
from .dashboard import cmd_dashboard
from .edit import cmd_edit
from .info import cmd_info
from .init import cmd_init
from .resume import cmd_resume
from .start import cmd_start
cmd_list = _list_mod.cmd_list
COMMANDS = {
"cleanup": cmd_cleanup,
"dashboard": cmd_dashboard,
"edit": cmd_edit,
"info": cmd_info,
"init": cmd_init,
"list": cmd_list,
"resume": cmd_resume,
"start": cmd_start,
}
def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
sys.stderr.write("Commands:\n")
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
sys.stderr.write(" dashboard view + approve/modify/reject pending supervise proposals (PRD 0013)\n")
sys.stderr.write(" edit open an agent in vim for editing\n")
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
sys.stderr.write(" list list available agents or active containers\n")
sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n")
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n")
sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n")
def main(argv: list[str] | None = None) -> int:
if argv is None:
argv = sys.argv[1:]
if not argv:
usage()
return 2
command = argv[0]
rest = argv[1:]
if command in ("-h", "--help"):
usage()
return 0
handler = COMMANDS.get(command)
if handler is None:
usage()
die(f"unknown command: {command}")
try:
return handler(rest) or 0
except Die as e:
return e.code if isinstance(e.code, int) else 1
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())
+20
View File
@@ -0,0 +1,20 @@
"""Shared constants and tty helper for cli subcommands."""
from __future__ import annotations
import os
import sys
from pathlib import Path
PROG = "cli.py"
USER_CWD = os.getcwd()
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
def read_tty_line() -> str:
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
try:
with open("/dev/tty", "r") as tty:
return tty.readline().rstrip("\n")
except OSError:
return sys.stdin.readline().rstrip("\n")
+64
View File
@@ -0,0 +1,64 @@
"""cleanup: stop and remove all orphaned bot-bottle resources.
Walks every registered backend (docker + smolmachines) so a single
`./cli.py cleanup` reaps both backends' leftovers — orphaned
smolvm machines won't survive a docker-only cleanup pass (issue
addressed alongside #77).
Each backend's `prepare_cleanup` enumerates its own resources;
docker's `_list_orphan_state_dirs` consults
`enumerate_active_agents()` for the union of live identities so
state dirs of running smolmachines bottles aren't reaped. State
dirs are shared layout, so docker is the single owner of that
bucket.
State dirs with `.preserve` are intentionally never touched — they
hold capability-block rebuilds or crash snapshots the operator may
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
is the path for those.
"""
from __future__ import annotations
import sys
from ..backend import get_bottle_backend, known_backend_names
from ..log import info
from ._common import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int:
# Order: stable backend iteration so the y/N output is
# deterministic across runs.
plans = [
(name, get_bottle_backend(name)) for name in known_backend_names()
]
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
if all(p.empty for _, _, p in prepared):
info("no bot-bottle resources to clean up")
return 0
for name, _, plan in prepared:
if plan.empty:
continue
info(f"--- {name} backend ---")
plan.print()
if not _prompt_yes("remove all of the above?"):
info("cleanup: skipped")
return 0
for name, backend, plan in prepared:
if plan.empty:
continue
backend.cleanup(plan)
info("cleanup: done")
return 0
def _prompt_yes(message: str) -> bool:
sys.stderr.write(f"bot-bottle: {message} [y/N] ")
sys.stderr.flush()
reply = read_tty_line()
return reply in ("y", "Y", "yes", "YES")
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
"""edit: open an agent in vim for editing."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from ..log import die
from ._common import PROG, USER_CWD
def cmd_edit(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} edit", add_help=True)
parser.add_argument("scope", choices=["user", "project"])
parser.add_argument("name")
args = parser.parse_args(argv)
if args.scope == "user":
target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
else:
target_file = Path(USER_CWD) / "bot-bottle.json"
if not target_file.is_file():
die(f"{target_file} does not exist")
try:
doc = json.loads(target_file.read_text())
except json.JSONDecodeError:
die(f"{target_file} is not valid JSON")
if args.name not in (doc.get("agents") or {}):
die(f"agent '{args.name}' not found in {target_file}")
line = 1
text = target_file.read_text().splitlines()
needle = f'"{args.name}"'
for idx, l in enumerate(text, start=1):
if needle in l:
line = idx
break
os.execvp("vim", ["vim", f"+{line}", str(target_file)])
+45
View File
@@ -0,0 +1,45 @@
"""info: print env, skills, and prompt details for a named agent."""
from __future__ import annotations
import argparse
from ..log import info
from ..manifest import Manifest
from ._common import PROG, USER_CWD
def cmd_info(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} info", add_help=True)
parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv)
manifest = Manifest.resolve(USER_CWD)
manifest.require_agent(args.name)
agent = manifest.agents[args.name]
bottle = manifest.bottle_for(args.name)
env_names = list(bottle.env.keys())
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
print()
info(f"agent : {args.name}")
info(f"env (names only): {', '.join(env_names) if env_names else '(none)'}")
info(f"skills : {' '.join(agent.skills) if agent.skills else '(none)'}")
info(
f"prompt : {len(agent.prompt)} chars; "
f"first line: {prompt_first_line or '(empty)'}"
)
info(f"bottle : {agent.bottle}")
if bottle.git:
for e in bottle.git:
info(
f" git remote : {e.Name} -> {e.Upstream} "
f"(IdentityFile={e.IdentityFile})"
)
if e.KnownHostKey:
info(f" KnownHostKey: {e.KnownHostKey}")
else:
info(" git remotes : (none)")
print()
return 0
+207
View File
@@ -0,0 +1,207 @@
"""init: interactively create a new agent and add it to bot-bottle.json."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
from ..log import die, info, warn
from ._common import PROG, USER_CWD, read_tty_line
def cmd_init(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} init", add_help=True)
parser.add_argument("scope", choices=["user", "project"])
args = parser.parse_args(argv)
if args.scope == "user":
target_file = Path(os.environ["HOME"]) / "bot-bottle.json"
else:
target_file = Path(USER_CWD) / "bot-bottle.json"
print(file=sys.stderr)
info(f"bot-bottle init — adding a new agent to {target_file}")
print(file=sys.stderr)
# Agent name
agent_name = ""
while not agent_name:
sys.stderr.write("Agent name: ")
sys.stderr.flush()
agent_name = read_tty_line().replace(" ", "-")
if not agent_name:
warn("agent name cannot be empty")
if not re.match(r"^[a-z0-9][a-z0-9-]*$", agent_name):
warn(
f"agent name '{agent_name}' contains non-slug characters; "
f"it will still work but may cause container naming issues"
)
existing: dict[str, Any] = {}
if target_file.is_file():
try:
existing = json.loads(target_file.read_text())
except json.JSONDecodeError:
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
if agent_name in (existing.get("agents") or {}):
sys.stderr.write(
f'bot-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
)
sys.stderr.flush()
ow = read_tty_line()
if ow not in ("y", "Y", "yes", "YES"):
info("aborted")
return 0
# Skills
print(file=sys.stderr)
sys.stderr.write("Skills (space or comma separated, or Enter for none): ")
sys.stderr.flush()
skills_input = read_tty_line()
skill_list: list[str] = []
if skills_input:
cleaned = skills_input.replace(",", " ")
skill_list = [s for s in cleaned.split() if s]
# Prompt
print(file=sys.stderr)
info("System prompt — enter text, then a lone '.' on its own line to finish (just '.' to leave empty):")
prompt_lines: list[str] = []
while True:
line = read_tty_line()
if line == ".":
break
prompt_lines.append(line)
prompt_content = "\n".join(prompt_lines)
# Bottle association
print(file=sys.stderr)
sys.stderr.write("Associate this agent with a bottle? [y/N] ")
sys.stderr.flush()
bottle_yn = read_tty_line()
bottle_name = ""
bottle_env: dict[str, str] = {}
bottle_ssh: list[dict[str, Any]] = []
bottle_exists_already = False
if bottle_yn in ("y", "Y", "yes", "YES"):
while not bottle_name:
sys.stderr.write(" Bottle name: ")
sys.stderr.flush()
bottle_name = read_tty_line().replace(" ", "-")
if not bottle_name:
warn("bottle name cannot be empty")
if bottle_name in (existing.get("bottles") or {}):
bottle_exists_already = True
info(f"Bottle '{bottle_name}' already exists in {target_file}; agent will reference it.")
else:
info(f"Creating new bottle '{bottle_name}'.")
bottle_env = _prompt_for_env_vars()
sys.stderr.write(" Add SSH host entries to this bottle? [y/N] ")
sys.stderr.flush()
ssh_yn = read_tty_line()
if ssh_yn in ("y", "Y", "yes", "YES"):
bottle_ssh = _prompt_for_ssh_entries()
# Build agent JSON
agent_def: dict[str, Any] = {"skills": skill_list, "prompt": prompt_content}
if bottle_name:
agent_def["bottle"] = bottle_name
merged: dict[str, Any] = {
"bottles": dict(existing.get("bottles") or {}),
"agents": dict(existing.get("agents") or {}),
}
if bottle_name and not bottle_exists_already:
merged["bottles"][bottle_name] = {"env": bottle_env, "ssh": bottle_ssh}
merged["agents"][agent_name] = agent_def
target_file.write_text(json.dumps(merged, indent=2) + "\n")
info(f"Agent '{agent_name}' written to {target_file}.")
info(f"Run '{PROG} info {agent_name}' to verify.")
print(file=sys.stderr)
return 0
def _prompt_for_env_vars() -> dict[str, str]:
print(file=sys.stderr)
info("Env vars — enter each var name then its mode. Press Enter with no name to finish.")
info(" Modes: secret (prompt at runtime) | interpolated (read from host env) | literal (hardcoded value)")
out: dict[str, str] = {}
while True:
print(file=sys.stderr)
sys.stderr.write(" Var name (or Enter to finish): ")
sys.stderr.flush()
vname = read_tty_line()
if not vname:
break
sys.stderr.write(" Mode [secret/interpolated/literal] (default: secret): ")
sys.stderr.flush()
vmode = read_tty_line() or "secret"
if vmode == "secret":
sys.stderr.write(f' Prompt message shown to user (default: "enter {vname}"): ')
sys.stderr.flush()
smsg = read_tty_line()
value = f"?{smsg}" if smsg else "?"
elif vmode == "interpolated":
sys.stderr.write(f" Host env var to read from (default: {vname}): ")
sys.stderr.flush()
hvar = read_tty_line() or vname
value = "${" + hvar + "}"
elif vmode == "literal":
sys.stderr.write(" Value: ")
sys.stderr.flush()
value = read_tty_line()
else:
warn(f"unknown mode '{vmode}'; using secret")
value = "?"
out[vname] = value
return out
def _prompt_for_ssh_entries() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
while True:
print(file=sys.stderr)
sys.stderr.write(" SSH Host alias (or Enter to finish): ")
sys.stderr.flush()
shost = read_tty_line()
if not shost:
break
sys.stderr.write(" Hostname (actual hostname or IP): ")
sys.stderr.flush()
shostname = read_tty_line()
sys.stderr.write(" User: ")
sys.stderr.flush()
suser = read_tty_line()
sys.stderr.write(" Port (default: 22): ")
sys.stderr.flush()
sport_raw = read_tty_line() or "22"
if not sport_raw.isdigit():
warn("port must be a number; defaulting to 22")
sport_raw = "22"
sport = int(sport_raw)
sys.stderr.write(" IdentityFile (path to private key on host): ")
sys.stderr.flush()
sidentity = read_tty_line()
sys.stderr.write(" KnownHostKey (optional, Enter to skip): ")
sys.stderr.flush()
skhk = read_tty_line()
entry: dict[str, Any] = {
"Host": shost,
"Hostname": shostname,
"User": suser,
"Port": sport,
"IdentityFile": sidentity,
}
if skhk:
entry["KnownHostKey"] = skhk
out.append(entry)
return out
+37
View File
@@ -0,0 +1,37 @@
"""list: list available agents or active bottles."""
from __future__ import annotations
import argparse
import sys
from ..backend import enumerate_active_agents
from ..manifest import Manifest
from ._common import PROG, USER_CWD
def cmd_list(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True)
parser.add_argument("scope", choices=["available", "active"])
args = parser.parse_args(argv)
if args.scope == "available":
manifest = Manifest.resolve(USER_CWD)
for name in manifest.agents.keys():
print(name)
return 0
# `active` enumerates every backend (docker + smolmachines)
# so smolmachines bottles aren't hidden behind the env var.
active = enumerate_active_agents()
if not active:
print("no active bot-bottle bottles", file=sys.stderr)
return 0
# One line per bottle: `<backend>\t<slug>\t<agent>\t<status>`.
# Tab-separated keeps the format stable for shell pipelines;
# the dashboard renders the same data through its own
# formatter.
for b in active:
services = ",".join(b.services) if b.services else "-"
print(f"{b.backend_name}\t{b.slug}\t{b.agent_name}\t{services}")
return 0
+59
View File
@@ -0,0 +1,59 @@
"""resume: re-launch a bottle by its identity.
Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
(agent_name, cwd, copy_cwd) the bottle was originally started with,
then runs the same launch core as `start` — but pinned to the
recorded identity so the new bottle picks up any per-bottle Dockerfile
(from capability-block apply) and transcript snapshot under the same
state dir.
Use case: an agent calls capability-block, the dashboard approves
and tears down the bottle, the operator runs
./cli.py resume <identity>
to bring up the replacement with the new capabilities baked in.
"""
from __future__ import annotations
import argparse
from ..backend import BottleSpec
from ..backend.docker.bottle_state import read_metadata
from ..log import die
from ..manifest import Manifest
from ._common import PROG, USER_CWD
from .start import _launch_bottle
def cmd_resume(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--remote-control", action="store_true")
parser.add_argument(
"identity",
help="bottle identity from a prior `start` (see its session-end output)",
)
args = parser.parse_args(argv)
metadata = read_metadata(args.identity)
if metadata is None:
die(
f"no state recorded for identity {args.identity!r}; "
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
)
manifest = Manifest.resolve(USER_CWD)
manifest.require_agent(metadata.agent_name)
spec = BottleSpec(
manifest=manifest,
agent_name=metadata.agent_name,
copy_cwd=metadata.copy_cwd,
user_cwd=metadata.cwd or USER_CWD,
identity=metadata.identity,
)
return _launch_bottle(
spec,
dry_run=args.dry_run,
remote_control=args.remote_control,
)
+250
View File
@@ -0,0 +1,250 @@
"""start: boot a sandboxed container for a named agent and attach an
interactive claude-code session. The container is torn down when the
session ends.
The launch core is shared with `cli.py resume <identity>` and (PRD
0020 chunk 1+) the dashboard's in-process start flow: see the
public helpers `prepare_with_preflight`, `attach_claude`, and the
private orchestrator `_launch_bottle`.
"""
from __future__ import annotations
import argparse
import os
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Callable
from ..agent_provider import runtime_for
from ..backend import (
Bottle,
BottleSpec,
get_bottle_backend,
known_backend_names,
)
from ..backend.docker.bottle_plan import DockerBottlePlan
from ..backend.docker.bottle_state import (
cleanup_state,
is_preserved,
mark_preserved,
)
from ..backend.docker.capability_apply import snapshot_transcript
from ..log import info
from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line
def cmd_start(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image")
parser.add_argument("--remote-control", action="store_true")
parser.add_argument(
"--backend",
choices=known_backend_names(),
default=None,
help=(
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
"or 'docker'). Overrides the env var when set."
),
)
parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
manifest = Manifest.resolve(USER_CWD)
spec = BottleSpec(
manifest=manifest,
agent_name=args.name,
copy_cwd=args.cwd,
user_cwd=USER_CWD,
)
return _launch_bottle(
spec,
dry_run=dry_run,
remote_control=args.remote_control,
backend_name=args.backend,
)
# --- Public helpers shared with the dashboard (PRD 0020) -----------------
def prepare_with_preflight(
spec: BottleSpec,
*,
stage_dir: Path,
render_preflight: Callable[[DockerBottlePlan], None],
prompt_yes: Callable[[], bool],
dry_run: bool = False,
backend_name: str | None = None,
) -> tuple[DockerBottlePlan | None, str]:
"""Run `backend.prepare`, render the preflight summary via the
injected callable, prompt y/N via the injected callable. The CLI
binds these to stderr/stdin; the dashboard binds them to a
curses modal.
`backend_name` selects which backend prepares the plan
(`None` → `$BOT_BOTTLE_BACKEND` → `docker`). Dashboard
passes the value from its new-agent backend-picker modal; the
CLI passes whatever `--backend` resolved to.
Returns `(plan, identity)`. `plan` is None on dry-run or
operator-N, but `identity` is set as soon as `backend.prepare`
returns so callers can reap the prepare-time state dir via
`settle_state(identity)` in their finally — exactly the existing
semantics."""
backend = get_bottle_backend(backend_name)
plan = backend.prepare(spec, stage_dir=stage_dir)
identity = _identity_from_plan(plan)
render_preflight(plan)
if dry_run:
info("dry-run requested; not starting container.")
return None, identity
if not prompt_yes():
info("aborted by user")
return None, identity
return plan, identity
def attach_claude(
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
provider_template: str = "claude",
) -> int:
"""Run claude inside `bottle` as an interactive session. Blocks
until the session ends; returns the claude process's exit code.
`resume=True` adds `--continue` so claude picks up its most
recent session non-interactively (no session-picker prompt) —
the right shape for the dashboard's Enter re-attach (PRD 0020
chunk 3), where a bottle typically has exactly one session.
First-attach paths (`./cli.py start`, the dashboard's new-agent
flow) leave it False.
Used as the inner step of `./cli.py start` (one-shot) and by the
dashboard, which calls it from inside a `curses.endwin → … →
stdscr.refresh()` handoff so the curses surface gets out of the
terminal's way while claude has it."""
runtime = runtime_for(provider_template)
info(
f"attaching interactive {provider_template} session "
"(Ctrl-D or 'exit' to leave; container will be removed)"
)
claude_args = list(runtime.bypass_args)
if remote_control:
claude_args.extend(runtime.remote_control_args)
if resume:
claude_args.extend(runtime.resume_args)
return bottle.exec_claude(claude_args, tty=True)
def capture_session_state(identity: str, exit_code: int) -> None:
"""Inside the launch context, while the container is still
alive: snapshot the transcript and mark for preservation if
claude crashed. Public for the dashboard's death-handling path
(PRD 0020 open question 3)."""
if not identity:
return
snapshot_transcript(identity)
if exit_code != 0:
mark_preserved(identity)
def settle_state(identity: str) -> None:
"""Post-teardown housekeeping: print the resume hint if the
state was preserved, otherwise reap the per-bottle state dir.
Public so the dashboard's explicit-stop path calls the same
settlement the CLI uses on context exit."""
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
identity attribute; for now we duck-type to keep this layer
backend-agnostic."""
return getattr(plan, "slug", "")
def _text_prompt_yes() -> bool:
"""Default `prompt_yes` for CLI use: reads y/N from the
controlling tty via stderr prompt + tty-line read."""
sys.stderr.write("bot-bottle: launch this agent? [y/N] ")
sys.stderr.flush()
reply = read_tty_line()
return reply in ("y", "Y", "yes", "YES")
def _text_render_preflight(*, remote_control: bool):
def _render(plan: DockerBottlePlan) -> None:
plan.print(remote_control=remote_control)
return _render
def _launch_bottle(
spec: BottleSpec,
*,
dry_run: bool,
remote_control: bool,
backend_name: str | None = None,
) -> int:
"""Shared launch core for `start` and `resume`. Builds the plan,
prints / dry-runs / prompts as appropriate, brings the bottle up,
attaches claude, and prints the resume hint on session end."""
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
identity = ""
try:
plan, identity = prepare_with_preflight(
spec,
stage_dir=stage_dir,
render_preflight=_text_render_preflight(remote_control=remote_control),
prompt_yes=_text_prompt_yes,
dry_run=dry_run,
backend_name=backend_name,
)
if plan is None:
return 0
backend = get_bottle_backend(backend_name)
with backend.launch(plan) as bottle:
provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_claude(
bottle,
remote_control=remote_control,
provider_template=provider_template,
)
info(
f"session ended (exit {exit_code}); "
f"container {bottle.name} will be removed"
)
# While the container is still alive: always snapshot the
# transcript and — if the agent exited non-zero — mark
# the state for preservation. Capability-block already
# did both before triggering teardown from the dashboard;
# this picks up crashes / Ctrl-Cs / OOM kills the same
# way. snapshot_transcript is best-effort so the
# capability-block path's prior snapshot isn't clobbered
# when the container is already gone.
if provider_template == "claude":
capture_session_state(identity, exit_code)
return 0
finally:
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
# sources under state/<slug>/. If we never reached the
# launch context (dry-run, preflight-N, prepare exception), or
# we did but nothing requested preservation, reap them along
# with everything else. `settle_state` subsumes the prior
# post-launch settlement and the new pre-launch cleanup.
settle_state(identity)
shutil.rmtree(stage_dir, ignore_errors=True)