Issue details
A single long agent turn grows memory monotonically until macOS jetsam kills the process. Observed in the wild: a 23-minute interactive turn (~80 steps across a parent session and one subagent, mostly grep_files and read_file over the repo) was killed at 81 GB:
memorystatus: killing largest compressed process fx [83726] 81028 MB
The turn arena in src/core/agent/runtime/orchestrator.zig is freed only when run() returns, and it backs more than the turn's own state. Tools receive it as ctx.allocator and nest their scratch arenas on top of it:
// src/tools/filesystem/grep_files.zig
var arena_state = std.heap.ArenaAllocator.init(ctx.allocator);
defer arena_state.deinit(); // no-op against an arena backing
deinit on an arena-backed arena reclaims nothing, so every call permanently retains its full internal working set: for grep_files that is the git grep stdout (up to 8 MB per call) plus every scanned candidate file; prepareModelOutput retains the sanitize/mask copies of every raw tool result; the presentation helpers retain an arguments parse per status update. Separately, persistRecoveryCheckpoint deep-copies all tool results accumulated so far into the turn arena one to two times per settled provider attempt, which is quadratic in step count.
The growth is hard to see coming: the arena's pages are written once and never touched again, so the OS compresses them. RSS looks flat or even falls while the physical footprint climbs, until jetsam picks the process as the largest compressed one.
Reproduction (headless fx ask against a stubbed Codex endpoint that requests one repository-wide grep_files per step, RSS sampled):
- 50 steps: 303 MB peak RSS in about 10 seconds
- 300 steps: 1.9 GB physical footprint by step ~90;
vmmap attributes it to MALLOC_LARGE (3.0 GB virtual, 1.6 GB compressed), i.e. the turn arena's chunks
Giving every tool call the per-call arena that file mutations already use (with copy-out of the dispatch-owned survivors into the result allocator), and building the checkpoint copy in a scratch arena (every checkpoint sink already dupes or serializes before returning), cuts the 50-step workload from 303 MB to 177 MB peak RSS with no behavior change; the full test suite passes. PR follows.
The remaining per-step growth is the per-attempt provider request assembly (buildRequest serializes the whole conversation into the turn arena on every attempt, and a retried attempt's Result.deinit(arena) frees nothing). That is a larger lifetime change and worth a separate issue/PR.
Trace
No response
Sensitive information
Issue details
A single long agent turn grows memory monotonically until macOS jetsam kills the process. Observed in the wild: a 23-minute interactive turn (~80 steps across a parent session and one subagent, mostly
grep_filesandread_fileover the repo) was killed at 81 GB:The turn arena in
src/core/agent/runtime/orchestrator.zigis freed only whenrun()returns, and it backs more than the turn's own state. Tools receive it asctx.allocatorand nest their scratch arenas on top of it:deiniton an arena-backed arena reclaims nothing, so every call permanently retains its full internal working set: forgrep_filesthat is thegit grepstdout (up to 8 MB per call) plus every scanned candidate file;prepareModelOutputretains the sanitize/mask copies of every raw tool result; the presentation helpers retain an arguments parse per status update. Separately,persistRecoveryCheckpointdeep-copies all tool results accumulated so far into the turn arena one to two times per settled provider attempt, which is quadratic in step count.The growth is hard to see coming: the arena's pages are written once and never touched again, so the OS compresses them. RSS looks flat or even falls while the physical footprint climbs, until jetsam picks the process as the largest compressed one.
Reproduction (headless
fx askagainst a stubbed Codex endpoint that requests one repository-widegrep_filesper step, RSS sampled):vmmapattributes it toMALLOC_LARGE(3.0 GB virtual, 1.6 GB compressed), i.e. the turn arena's chunksGiving every tool call the per-call arena that file mutations already use (with copy-out of the dispatch-owned survivors into the result allocator), and building the checkpoint copy in a scratch arena (every checkpoint sink already dupes or serializes before returning), cuts the 50-step workload from 303 MB to 177 MB peak RSS with no behavior change; the full test suite passes. PR follows.
The remaining per-step growth is the per-attempt provider request assembly (
buildRequestserializes the whole conversation into the turn arena on every attempt, and a retried attempt'sResult.deinit(arena)frees nothing). That is a larger lifetime change and worth a separate issue/PR.Trace
No response
Sensitive information