Skip to content

Resume Claude Code sessions after retryable interruptions - #123

Merged
merlerm merged 9 commits into
mainfrom
resume-across-rate-limit
Jul 23, 2026
Merged

Resume Claude Code sessions after retryable interruptions#123
merlerm merged 9 commits into
mainfrom
resume-across-rate-limit

Conversation

@merlerm

@merlerm merlerm commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Base status

#121 (runtime-metrics) was squash-merged into main as 48aa4ed. This branch has been rebased onto that updated main, and the PR targets main. The former stacked-PR dependency is satisfied.

Problems

Subscription usage caps

When Claude Code hits the subscription usage cap, the experiment runner sleeps until the reset and launches a new CLI/container process. Previously that process:

  • started without the interrupted conversation context;
  • received a fresh --max-budget-usd and --max-turns, making a timing-dependent retry a different experimental condition; and
  • discarded the interrupted attempt's spend and retry signal (the underlying runtime metrics landed in Record generation-time runtime metrics to results.json #121).

The sandbox files survived, but the agent had to reconstruct its reasoning from disk.

Output-token overflows

Claude can also abort a run with Claude's response exceeded the 32768 output token maximum. Raising CLAUDE_CODE_MAX_OUTPUT_TOKENS is not a reliable fix: the model can consume the larger allowance and still exceed it. Previously this error could leave a partial approach.py and fail the entire experiment.

Context exhaustion and current reset messages

Auto-compaction normally protects Claude's context window, but runs have also stopped with Prompt is too long. Separately, the current subscription message includes minutes (You've hit your session limit · resets 1:30pm (UTC)), which the old hour-only regex did not match or schedule correctly.

What this PR changes

Resume Claude Code context

  • Claude sessions are persisted from the first attempt so they are available if a retry becomes necessary.
  • A retry uses --continue. Every generation has its own sandbox working directory and isolated session store, so “most recent session in this directory” identifies that generation.
  • Docker and Apptainer persist /home/node/.claude/projects under the generation's sandbox directory, across ephemeral container recreation.
  • The local backend places Claude state under sandbox-local .agent_home.

Preserve the experimental budget

  • Interrupted attempts' reported cost and turns are subtracted from the original limits before resuming.
  • A finite exhausted budget is never converted to 0, because both CLI limits interpret zero as unlimited.
  • If a finite-budget interrupted attempt does not report its cost, the runner stops rather than silently granting an unknowable remaining budget.
  • Retry counts and aborted spend remain visible through generation metrics.

Resuming is intentionally not claimed to be identical to an uninterrupted run: rebuilding context can consume uncached transcript input tokens. The retry metrics let analyses identify or exclude these runs.

Recover output-token overflows

  • The exact observed overflow is detected in assistant text, result errors, or stderr.
  • A partial output file is not evaluated after this interruption.
  • The same isolated conversation resumes immediately, without the rate-limit sleep, using only the remaining dollar and turn budget.
  • The continuation prompt asks Claude to avoid repeating reasoning, use tools directly, and finish concisely.
  • At most two output-overflow resumptions are allowed, preventing an infinite loop for unlimited-budget configurations.
  • gen_output_token_limit_hit and gen_output_token_retries make the condition visible in results.json; aborted tokens and cost use the existing interruption metrics.

Compact an oversized prompt before resuming

  • Prompt is too long is detected in assistant text, result errors, or stderr, and partial output is not evaluated.
  • The runner resumes the same isolated session with /compact and focused instructions to retain requirements, decisions, failed approaches, and current implementation state while dropping verbose tool output.
  • Work resumes only after Claude emits a real compact_boundary event. A normal sandbox “output file not found” result from the local slash command is not treated as failure when that confirmation exists; conversely, an unconfirmed/failed compaction is never assumed to have worked.
  • Compaction tokens, turns, and cost count against the original budget. The subsequent work process receives only what remains.
  • At most two successful compact-and-resume cycles are allowed. gen_prompt_too_long_hit and gen_prompt_too_long_retries expose the condition in results.

Parse minute-bearing usage resets

  • The current resets 1:30pm (UTC) format is recognized.
  • Both hour and minute are retained, timezone interpretation is preserved, and the sleep targets five minutes after the stated reset (13:35 UTC in this example).
  • Existing hour-only messages such as resets 3am retain their prior behavior.

Keep experiments out of the operator's Claude state

Session persistence must not add experiment conversations to the operator's normal ~/.claude history or session picker.

  • Local runs set CLAUDE_CONFIG_DIR to the sandbox-local state directory.
  • Host credentials are copied for the lifetime of one local CLI process, never symlinked, and removed immediately afterward.
  • Docker and Apptainer receive a writable throwaway config containing only copied credentials when an OAuth environment token is unavailable.
  • Host transcripts, history, memory, settings, file history, and job artifacts are neither copied nor live-mounted.
  • Token refreshes and other CLI writes cannot modify the live host Claude directory.

Only the experiment's isolated transcript state survives between retry attempts.

Scope: OpenCode

Conversation resume is implemented only for Claude Code. OpenCode is currently used with local/open-source models in this project, where Claude subscription caps and these Claude-specific errors do not apply. OpenCode session persistence/resume semantics are deliberately out of scope rather than being guessed at in this PR.

Validation

  • Full repository suite: 544 passed
  • Focused sandbox/backend suite: 144 passed
  • A local end-to-end fake-CLI test reproduces the exact output-overflow message and exercises subprocess launch, isolated state, stream parsing, budget carry, the concise continuation prompt, --continue, and completion.
  • A second end-to-end fake-CLI test reproduces Prompt is too long, requires the isolated /compact continuation and compact_boundary, then requires a separate resumed work process before it completes.
  • The existing local rate-limit end-to-end test exercises detection, budget carry, --continue, and completion.
  • A parallel end-to-end test forces two independent generations to hit a synthetic cap together and proves each --continue retry recovers only its own prompt-specific session marker.
  • The Docker integration test recreates the container with --rm and proves the first container's session marker reaches the resumed container.
  • Unit coverage includes assistant/result/stderr detection, partial-output rejection, successful and failed compaction, bounded overflow/compaction retries, exact minute-bearing reset parsing and sleep calculation, host-state exclusion, credentials-only mounts, credential cleanup, retry metrics, budget/turn carry, exhausted limits, and missing interrupted cost.
  • Black, isort, mypy, pylint on touched files, and git diff --check pass.

Limitations / not tested

  • Real Claude subscription caps, real 32k output overflows, and real context exhaustion cannot be triggered deterministically. Automated end-to-end tests inject a fake Claude CLI while exercising the real runner, parser, retry, compaction orchestration, and local session-persistence paths. We did not spend subscription quota trying to force these failures.
  • The overflow recovery depends on Claude Code persisting enough conversation state to make --continue useful after the API abort. The automated test proves our state and invocation plumbing, not the proprietary CLI/API's handling of every real mid-response failure.
  • Anthropic documents that /compact can itself fail if the context is already too full to generate its summary. In that case this PR records the failure and stops safely; it does not silently clear the session or restart without context. The automated test covers both confirmed and failed compaction, but not a real Claude-generated summary.
  • If Claude does not report interrupted cost and the run has a finite dollar budget, recovery stops safely; guessing would risk granting extra experimental budget.
  • After two resumed output overflows, the run remains failed rather than looping forever.
  • Apptainer command construction, auth isolation, and bind arguments are unit-tested, but there is no SIF image in this worktree. A live resumed Apptainer run was not possible here. Apptainer is used on a separate cluster and should be smoke-tested there; the implementation mirrors the Docker mount that passed end to end, but writable-tmpfs/nested-bind behavior remains the main platform-specific assumption.
  • The agent's transient todo/task UI state is not separately persisted. Conversation transcripts and sandbox files are the state required for reasoning continuity.
  • Resume overhead can change token allocation versus an uninterrupted run even though it does not grant a new configured budget. This is why interrupted runs remain explicitly marked in results.

Worktree note

Two unrelated pre-existing formatter-only edits in integration_tests/red_team_sandbox.py and tests/utils/test_env_in_sandbox.py were intentionally left out of every commit in this PR.

@merlerm

merlerm commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Last one for today I promise! Although there is another one I'm working on since I noticed evaluation object counts were being leaked to the agent unadvertedly, but I will finish that PR once everything else is merged to avoid things getting out of hand. The other two PRs were small enough that I could work on this one as well

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds resumable Claude Code runs across subscription usage-cap resets by persisting CLI session state per sandbox generation, resuming with --continue, and carrying forward remaining budget/turn limits while isolating Claude credentials/config from the operator’s live ~/.claude.

Changes:

  • Persist Claude session state per sandbox generation and resume interrupted runs with --continue (local + Docker + Apptainer).
  • Carry over remaining experimental max_budget_usd / max_turns across rate-limit retries without accidentally converting exhausted limits into “unlimited”.
  • Add/extend unit + integration tests covering resume behavior, budget carry, and host-state exclusion.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/utils/test_sandbox.py Adds tests for sandbox-local Claude config redirection and credential cleanup.
tests/utils/test_resume_integration.py New end-to-end test using a fake Claude CLI to validate --continue + session persistence.
tests/utils/test_rate_limit.py Extends retry-loop tests to cover carried budget/turns and unknown-cost handling.
tests/utils/test_docker_sandbox.py Adds tests for extra volume mounts, credentials-only auth mounts, and Docker resume across --rm.
tests/utils/test_backends.py Validates Claude CLI command includes session persistence and adds --continue only when resuming.
tests/utils/test_apptainer_sandbox.py Updates auth tests to use contextmanager + validates credentials-only bind fallback.
src/robocode/utils/sandbox.py Redirects local Claude runs to sandbox-local CLAUDE_CONFIG_DIR via a contextmanager.
src/robocode/utils/sandbox_types.py Adds resume_previous_session to SandboxConfig.
src/robocode/utils/rate_limit.py Refactors dispatch, carries remaining budget/turns, and resumes sessions across rate-limit sleeps.
src/robocode/utils/docker_sandbox.py Adds session-store persistence via an extra bind mount and switches to credentials-only throwaway config fallback.
src/robocode/utils/claude_auth.py New module implementing credentials-only throwaway config + sandbox-local config with credential cleanup.
src/robocode/utils/backends/claude.py Enables session persistence by default and appends --continue when resuming.
src/robocode/utils/apptainer_sandbox.py Mirrors Docker auth isolation + adds session-store bind mount persistence for resume.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/robocode/utils/claude_auth.py Outdated
Comment thread src/robocode/utils/claude_auth.py Outdated
Comment thread src/robocode/utils/docker_sandbox.py
Comment thread src/robocode/utils/apptainer_sandbox.py
@merlerm
merlerm requested a review from tomsilver July 21, 2026 15:27
Base automatically changed from runtime-metrics to main July 22, 2026 08:03
merlerm added 7 commits July 22, 2026 10:32
When a generation hits the subscription usage cap, the retry loop now
continues the same CLI conversation with the budget the run had left,
instead of restarting cold and re-deriving from scratch. A run that
straddles a usage-window reset ends up equivalent to one that never hit
the cap, rather than being penalized (lost context) or advantaged (a
fresh full budget on every retry).

Sessions are persisted so they can be resumed (the --no-session-persistence
flag is dropped) and, on retry, the CLI is launched with --continue, which
reattaches to the most recent conversation in the sandbox working
directory (unique per generation). To survive the container --rm, the
Claude session store is bind-mounted from a host directory under the
sandbox dir into /home/node/.claude/projects for the docker and apptainer
backends; the local backend uses the host store directly. The retry loop
carries the remaining dollar and turn budget into each resumed attempt so
total spend matches an uninterrupted run.

Verified end-to-end that claude -p --continue resumes prior context in
headless stream-json mode and that sessions are stored under the
working-directory-hashed projects path that the mount targets.
The local backend runs the CLI directly on the host, so persisting sessions
for resume would otherwise write conversation transcripts, history, and
caches into the host ~/.claude, leaving state that outlives the run and can
only be cleaned by reaching outside the sandbox.

The local runner now points CLAUDE_CONFIG_DIR at a sandbox-local .agent_home
and symlinks the host credentials file in, so the CLI authenticates while
writing all of its state under the sandbox dir; removing the sandbox removes
it. Token auth via CLAUDE_CODE_OAUTH_TOKEN needs no file and passes through
the environment. The docker and apptainer backends already confine the store
to the sandbox (bind-mounted .agent_sessions) with an ephemeral container
home, so this only changes the local path.

Verified end-to-end that --continue resumes under the relocated config dir
and that no session is written to the host ~/.claude.
A real usage-cap interruption cannot be triggered on demand, so the test
swaps the claude binary (via ROBOCODE_CLAUDE_CMD) for a stand-in that reports
a rate limit on its first call and completes on the second. The full real
path runs: run_with_rate_limit_retry -> run_agent_in_sandbox (subprocess,
CLAUDE_CONFIG_DIR redirect, session dir) -> parse_stream -> rate-limit
detection -> resume with --continue and carried budget.

The stand-in completes only when it is resumed (--continue present) and can
read back the session marker it persisted on the first attempt, so a passing
run proves both that the flag reaches the CLI and that the session store
survives the retry. Confirmed non-vacuous by mutation: dropping --continue
from the command makes the test fail.
…ner --rm

Complements the local end-to-end test with the docker-specific risk it
cannot cover: that the .agent_sessions bind-mount actually carries the CLI
session store across a fresh container. A fake claude (via ROBOCODE_CLAUDE_CMD)
reports a rate limit in the first container, writing a marker into the mounted
session store; the retry launches a second container that completes only if it
receives --continue and finds that marker, proving the store survived the --rm.

Gated on the sandbox image being present, like the other container tests, so
it skips where docker is unavailable. Verified non-vacuous by mutation:
dropping the session mount makes the marker vanish across containers and the
test fails.
@merlerm
merlerm force-pushed the resume-across-rate-limit branch from 0d0ba11 to 5ceb7a2 Compare July 22, 2026 09:15
@merlerm merlerm changed the title Resume Claude Code sessions across usage-cap resets Resume Claude Code sessions after retryable interruptions Jul 22, 2026
@merlerm

merlerm commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

validated in a live session so I feel good enough merging this

@merlerm
merlerm merged commit ea2c519 into main Jul 23, 2026
4 checks passed
@merlerm
merlerm deleted the resume-across-rate-limit branch July 23, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants