Skip to content

bug: ClaudeCodeAgent saves the previous checkpoint's stdout.jsonl when a checkpoint crashes #36

Description

@carver

tl;dr

When ClaudeCodeAgent._run() raises partway through a checkpoint, the harness saves the
previous checkpoint's stdout.jsonl and stderr.log into the crashed checkpoint's directory.
The lines that caused the crash are gone.

Even worse, the files seem to be fine and error-free at a quick glance.

Disclosure: Fable prepared the below text and part of the PR (I wrangled it a bit after an Opus draft, to get the code quality up 😆 ).

If you have any guidelines about that that I missed, I'm happy to rewrite it manually. For accuracy, I did read through all of this issue, and the PR, of course.

What I tried

I ran the benchmark with CC version=2.1.251 (Opus 5, just-solve prompt, harness at
06b5c06). file_merger died at checkpoint 2 with the AttributeError from #34. I opened
checkpoint_2/agent/stdout.jsonl to find the line the parser tripped on.

What actually happened

The file was a copy of checkpoint 1's stream, and nothing in it could raise. The line the
traceback pointed at was in no saved file, so the cause stayed a guess until the same event
turned up in a checkpoint that survived it.

I no longer have that run directory, so the evidence here is the unit test below, which
reproduces the same thing on main.

A retry has the same weakness in a milder form. When a process times out and retry() runs,
stdout.jsonl and stderr.log hold only the last attempt. The attempt that timed out, usually the one you
want to read, is overwritten.

What I expected

checkpoint_N/agent/stdout.jsonl holds what checkpoint N streamed, however the checkpoint
ended. After a crash that is the partial stream up to the crash. After a retry it is every
attempt in order.

What's going on?

_write_artifacts() writes both files from self.final_result, agent.py:913-923 on main:

if self.final_result is not None:
    (output_dir / self.STDOUT_FILENAME).write_text(
        self.final_result.stdout or ""
    )
    (output_dir / self.STDERR_FILENAME).write_text(
        self.final_result.stderr
    )

final_result is assigned in two places, run() at line 734 and retry() at line 805, and
both assignments come after _run() returns. Nothing clears it. reset() clears
_last_steps, _last_prompt, _last_command and _got_successful_result, and leaves
final_result alone. So when _run() raises, final_result still points at the last
checkpoint that finished, and save_artifacts() writes that checkpoint's output into the
crashed checkpoint's directory.

The retry case is the same assignment seen from the other side. retry() replaces
final_result, so the first attempt's stdout and stderr have nowhere to live.

How to reproduce

Unit test, no network. The linked PR adds TestStreamTranscriptArtifacts to
tests/agent_runner/agents/claude_code_agent_test.py. It stubs stream_cli_command to play
one stream per process, each ending in a RuntimeResult or an exception raised mid-stream.

uv run pytest tests/agent_runner/agents/claude_code_agent_test.py -k TestStreamTranscriptArtifacts -q
Checkout Result
main (06b5c06), test file copied over 2 failed
#37 2 passed

The two failures on main:

  • test_crashed_checkpoint_saves_its_own_partial_stream. Checkpoint 1 finishes, checkpoint 2
    raises RuntimeError after one line. On main, checkpoint_2/stdout.jsonl contains
    {"type": "system", "subtype": "init", "session_id": "checkpoint-1"}.
  • test_retry_appends_to_the_attempt_it_retries. The first process times out and the retry
    finishes. On main the saved file has the attempt-2 line and no attempt-1 line.

To see it in a real run, take any checkpoint after the first whose _run() raises. Before
#35 lands, a 2.1.251 run of file_merger or sith does it (reproduction 4 in #34). Then
cmp checkpoint_{N-1}/agent/stdout.jsonl checkpoint_N/agent/stdout.jsonl reports no
difference.

The fix

24 lines in agent.py, plus the test.

  • _run() passes stream_cli_command a parser that appends each raw line to
    self._stream_lines before parsing it. _write_artifacts() writes stdout.jsonl from
    those lines. A crash leaves the partial stream, including the line that raised, since the
    append comes first.
  • run() and retry() append each finished process's stderr to self._stderr_lines, and
    stderr.log comes from that. It was the last use of final_result, so the field is gone.
  • run() clears both lists. retry() clears neither, so a retry's output lands after the
    attempt it retries.
  • A crashed checkpoint writes no stderr.log, where before it wrote another checkpoint's.
    Its own stderr is still lost, since stream_cli_command hands stderr over only in the
    finished result.

One behavior change to check against anything that reads these files. A process that prints
nothing to stdout or stderr now writes no file for that stream, where main writes an empty
one. For a checkpoint that
finishes on its first attempt the file has the same lines main writes, minus what
stream_cli_command strips before parsing: whitespace around each line, and blank lines.

The draft PR is open, and I'll mark it ready when I'm happy with it enough for review.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions