Skip to content

fix: bound generation history and flush per turn to stop Stop-hook OOM on long sessions - #122

Draft
hassiebp wants to merge 2 commits into
mainfrom
hassiebbot/bound-generation-history-311b
Draft

hassiebp wants to merge 2 commits into
mainfrom
hassiebbot/bound-generation-history-311b

Conversation

@hassiebp

Copy link
Copy Markdown
Collaborator

Closes LFE-17701. Closes #119.

Problem

On long Codex sessions the Stop hook aborts with a V8 heap OOM, and from then on none of that session's turns reach Langfuse.

Every generation's input is the full conversation so far: earlier turns plus earlier steps of the current turn, tool outputs included. Each input is JSON.stringify'd onto its span, so memory grows roughly quadratically with session length. All pending turns are converted before a single flush at shutdown, and only then are turns marked in the sidecar.

That gives three ways to fail, each of which leaves every turn unmarked and the next hook with an even bigger backlog:

  1. Heap OOM: a hard abort() that the hook's try/catch can't catch.
  2. RangeError: Invalid string length: the batched OTLP request body passes V8's ~512 MB string limit. This happens even without an OOM.
  3. Hook timeout: after that serializer error, forceFlush waits out the 30 s export timeout, which is over the 30 s Codex hook timeout.

Changes

1. Bound the conversation attached to each generation (max_history_chars, default 200000, 0 disables).

  • The budget covers the whole conversation: earlier turns and earlier steps of the current turn. Capping only earlier turns doesn't fix one long turn; a single 60-step turn with 500 KB tool outputs still failed.
  • It keeps the most recent whole messages that fit and replaces the rest with one [N earlier message(s) omitted …] system marker.
  • No message is ever cut, so this stays in line with fix: stop truncating traced content at 20,000 characters #90's rule that traced content should never be truncated. Only the repeated copies of older history are dropped. Tool spans, generation outputs and earlier turns' traces still hold every message in full.
  • Message sizes are cached per message object, so applying the budget costs about the budget per generation.
  • Configurable via langfuse.json or LANGFUSE_CODEX_MAX_HISTORY_CHARS. Invalid values fall back to the default without discarding the rest of the config file.

2. Flush and mark each turn before converting the next.

  • convertRollout takes an onTurnExported callback. The hook uses it to flush() and then markTurnUploaded per turn.
  • A backlog now drains turn by turn instead of all-or-nothing, and only one turn's spans are held in memory at a time.
  • Delivery semantics are unchanged: a turn is marked only after its export succeeds. A failed flush stops the loop and leaves the remaining turns retryable.

Measurements

Built hook, synthetic rollouts, local sink that accepts uploads, --max-old-space-size=1536:

Workload Before After
1 turn × 60 steps × 500 KB tool output ~916 MB heap, Invalid string length, 33 s, nothing uploaded 74 MB, 0.5 s, 1/1 marked
18 turns × 20 steps × 20 KB ~1.3 GB heap, Invalid string length, 36 s, nothing uploaded 51 MB, 0.7 s, 18/18 marked
40 turns × 20 steps × 20 KB heap OOM, SIGABRT (exit 134) 80 MB, 1.4 s, 40/40 marked

Tests

  • config.test.ts: the default value, reading from the file and from env, and invalid values being ignored without dropping the file.
  • trace.test.ts: the budget covering earlier turns and current-turn steps, the exact marker text, and 0 meaning no cap.
  • hook-command.test.ts: the end-to-end hook delivers turn 1, the sink rejects turn 2, and the sidecar contains only turn-1.
  • All 5 new tests fail on main and pass here. pnpm lint and pnpm test (125 tests) pass.

Linear Issue: LFE-17701

Open in Web Open in Cursor 

cursoragent and others added 2 commits October 21, 2026 14:11
Every generation serialized the full session history (earlier turns plus
earlier steps of the current turn) onto its span, so memory grew
quadratically with session length and long sessions crashed the Stop hook
with a V8 heap OOM or an oversized export request.

Keep only the most recent messages that fit in max_history_chars
(default 200000, 0 disables) and replace the rest with a single
omitted-messages marker. Messages are never cut, and tool spans keep their
full output.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>
The Stop hook converted every pending turn, flushed once, and only then
marked turns in the sidecar. A backlog therefore sat in memory all at once
and was delivered all-or-nothing: any crash, timeout or failed export left
every turn unmarked, so the next hook retried an even larger backlog.

Flush after each turn and mark it as soon as its export succeeds, so a
backlog drains incrementally and only one turn's spans are held at a time.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>
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.

Stop hook V8 heap OOM on long sessions: every generation rebuilds its full input from the entire session history (O(turns × history))

2 participants