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.
tl;dr
When
ClaudeCodeAgent._run()raises partway through a checkpoint, the harness saves theprevious checkpoint's
stdout.jsonlandstderr.loginto 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-solveprompt, harness at06b5c06). file_merger died at checkpoint 2 with theAttributeErrorfrom #34. I openedcheckpoint_2/agent/stdout.jsonlto 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.jsonlandstderr.loghold only the last attempt. The attempt that timed out, usually the one youwant to read, is overwritten.
What I expected
checkpoint_N/agent/stdout.jsonlholds what checkpoint N streamed, however the checkpointended. 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 fromself.final_result,agent.py:913-923on main:final_resultis assigned in two places,run()at line 734 andretry()at line 805, andboth assignments come after
_run()returns. Nothing clears it.reset()clears_last_steps,_last_prompt,_last_commandand_got_successful_result, and leavesfinal_resultalone. So when_run()raises,final_resultstill points at the lastcheckpoint that finished, and
save_artifacts()writes that checkpoint's output into thecrashed checkpoint's directory.
The retry case is the same assignment seen from the other side.
retry()replacesfinal_result, so the first attempt's stdout and stderr have nowhere to live.How to reproduce
Unit test, no network. The linked PR adds
TestStreamTranscriptArtifactstotests/agent_runner/agents/claude_code_agent_test.py. It stubsstream_cli_commandto playone stream per process, each ending in a
RuntimeResultor an exception raised mid-stream.06b5c06), test file copied overThe two failures on main:
test_crashed_checkpoint_saves_its_own_partial_stream. Checkpoint 1 finishes, checkpoint 2raises
RuntimeErrorafter one line. On main,checkpoint_2/stdout.jsonlcontains{"type": "system", "subtype": "init", "session_id": "checkpoint-1"}.test_retry_appends_to_the_attempt_it_retries. The first process times out and the retryfinishes. On main the saved file has the
attempt-2line and noattempt-1line.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.jsonlreports nodifference.
The fix
24 lines in
agent.py, plus the test._run()passesstream_cli_commanda parser that appends each raw line toself._stream_linesbefore parsing it._write_artifacts()writesstdout.jsonlfromthose lines. A crash leaves the partial stream, including the line that raised, since the
append comes first.
run()andretry()append each finished process's stderr toself._stderr_lines, andstderr.logcomes from that. It was the last use offinal_result, so the field is gone.run()clears both lists.retry()clears neither, so a retry's output lands after theattempt it retries.
stderr.log, where before it wrote another checkpoint's.Its own stderr is still lost, since
stream_cli_commandhands stderr over only in thefinished 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_commandstrips 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.