Skip to content

fix(agent): keep claude_code resume across failed/cancelled runs - #36

Open
CatJuly wants to merge 1 commit into
HKUDS:mainfrom
CatJuly:fix/claude-code-resume-after-failed-run
Open

fix(agent): keep claude_code resume across failed/cancelled runs#36
CatJuly wants to merge 1 commit into
HKUDS:mainfrom
CatJuly:fix/claude-code-resume-after-failed-run

Conversation

@CatJuly

@CatJuly CatJuly commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

When a Claude Code run launched by OpenOPC fails, is cancelled by the user, or hits a timeout, the next conversation turn silently starts a brand-new provider session — the agent replies as if the conversation never happened ("I have no context of that").

Reproduction (observed in a real task-mode project):

  1. Start a task-mode conversation with claude_code as the execution agent; chat a few turns (resume works, context accumulates).
  2. Stop the running task (or let a run fail / time out).
  3. Send the next message in the same conversation.
  4. The spawned claude process gets no --resume flag, starts a fresh session with only the new Task Brief, and answers with no memory of the conversation.

In the affected project, 2 UI conversations fragmented into 8 separate Claude sessions (~/.claude/projects/<workspace>/), one of them 249 user messages deep before it was dropped.

Root cause

Cross-invocation continuity relies on --resume <session-id> seeded from the persisted external_sessions row. Resume eligibility is gated by NON_RESUMABLE_EXTERNAL_SESSION_STATUSES (failed, cancelled, hard_timeout, …) in opc/layer3_agent/external_session_identity.py, and the broker additionally clears the role token and the task's resume pin after a failed run.

That gate conflates two independent things: the run's outcome and the provider thread's resumability. Claude Code persists every transcript under ~/.claude/projects/<cwd>/<session-id>.jsonl; claude --resume <id> replays it even when the launching process died mid-run. Marking the token dead throws the whole conversation away for no reason.

Fix

  • external_session_identity.py: new DURABLE_TRANSCRIPT_AGENT_TYPES = {"claude_code"} + agent_resume_survives_run_failure(). external_session_status_allows_resume() accepts an optional agent_type; external_session_allows_resume() reads the row's own agent_type, so select_best_external_resume_session() / provider_token_from_external_session() pick up the policy without signature changes.
  • external_broker.py: _stored_provider_token_allows_resume() treats a terminal-but-failed newest row as resumable for durable agents; _persist_session() keeps the role token and the task resume pin after a failed run of a durable agent.
  • engine.py: the three bare-status call sites (checkpoint-restore gate, checkpoint token seeding, checkpoint token veto) now pass the session's agent_type.

Codex / opencode / cursor keep the existing conservative behavior (no durability claim made for them); unfinalized provider_stream tokens also keep the strict rejection rule — only terminal statuses are affected.

Testing

  • New tests/test_external_resume_survives_failed_run.py (12 tests): identity-helper policy matrix, broker restore re-seeding --resume from failed/cancelled claude_code rows, persist keeping the pin, and codex mirrors asserting the conservative path is unchanged.
  • Existing suites: test_external_session_continuity.py (all 22 pass unchanged — the codex veto/clear semantics are preserved), test_company_runtime_suspend_resume.py, test_external_agent_monitoring.py, test_metadata_ownership.py, test_company_collaboration.py, test_parallel_runtime_isolation.py, test_wake_and_delivery.py.
  • Failure list is identical to the clean origin/main baseline on the same machine (5 pre-existing environment failures, GBK-path related), i.e. no regression.

Scope

Python only — no frontend changes. Behavior changes only for agent_type == "claude_code"; other external agents are untouched.

Every conversation turn spawns a fresh `claude --print` process and
continuity relies on `--resume <session-id>` seeded from the persisted
external_sessions row. When a run ends failed/cancelled/timed-out, that
status is treated as "session dead": the next turn skips resume, starts a
blank provider session, and the agent answers as if the conversation never
happened. One stopped task is enough to silently drop hours of context
(observed: 2 UI conversations fragmenting into 8 separate Claude sessions).

Claude Code persists every transcript under
`~/.claude/projects/<cwd>/<session-id>.jsonl`, and `--resume <id>` replays
it regardless of how the launching process ended - the run outcome and the
provider thread's resumability are independent. Introduce
DURABLE_TRANSCRIPT_AGENT_TYPES in external_session_identity and thread it
through the status gates: select_best_external_resume_session /
provider_token_from_external_session now read the row's own agent_type,
broker restore accepts terminal-but-failed rows for durable agents, and
broker persist no longer clears the role token or the task resume pin
after a failed claude_code run. Codex/opencode/cursor keep the existing
conservative behavior.

Verified with tests/test_external_resume_survives_failed_run.py (12 new
tests) plus tests/test_external_session_continuity.py,
test_company_runtime_suspend_resume.py, test_external_agent_monitoring.py,
test_metadata_ownership.py, test_company_collaboration.py,
test_parallel_runtime_isolation.py, test_wake_and_delivery.py - failure
list identical to the clean origin/main baseline (5 pre-existing
environment failures, no regression).

@LZH-YS1998 LZH-YS1998 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.

Thanks for tracing the context-loss problem and for the focused tests. The root cause is valid, but the current capability policy is too broad to merge safely.

external_session_status_allows_resume() returns true for every status whenever the agent is claude_code, and _persist_session() then preserves the matching task and role token after every failed run. A durable transcript does not prove that a resume token is still usable. If the failure was caused by a missing or corrupted session, a changed workspace/CWD, an invalid --resume target, or a provider-side context/resume rejection, every subsequent Continue can remain pinned to the same bad token and fail again. That can prevent the work item from making progress.

Please revise this PR so that:

  1. Durable transcript capability is separated from a known-usable resume token; do not make all terminal statuses unconditionally resumable.
  2. An explicit provider resume failure clears the matching task/role pin and performs at most one fresh-session or context-replay fallback.
  3. The intended behavior remains covered for cancellation/timeouts after a real provider session has been established.
  4. Tests cover a missing/invalid resume token and repeated Continue attempts, proving that the same poisoned token is not retried indefinitely.

I integrated this head with the current main in an isolated checkout and the targeted suites passed (99 tests plus 6 subtests), but the new tests currently encode the unsafe all-status policy, so passing them does not close this failure mode.

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.

2 participants