Skip to content

fix(coding-agent): persist kernel stderr to disk and bound the in-memory tail - #1947

Merged
sethkarten merged 10 commits into
mainfrom
fix/kernel-stderr-log
Sep 4, 2026
Merged

fix(coding-agent): persist kernel stderr to disk and bound the in-memory tail#1947
sethkarten merged 10 commits into
mainfrom
fix/kernel-stderr-log

Conversation

@snimu

@snimu snimu commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

When a Python kernel failed to start, the failure was often undiagnosable: its stderr lived only in an in-memory string that died with the process, and the startup error showed just the last kilobyte of it. Nothing stayed on disk to inspect after the fact — and on the other end, a long-lived chatty kernel grew that same string without bound for its whole lifetime, even though every reader only ever showed the tail.

The log is startup-shaped because the kernel's stderr is startup-shaped: the runtime dup2's fd 2 into its protocol pipe before it reports ready (repl.py _setup_fds), so the process-level stderr only ever carries pre-ready bytes — interpreter, venv, and import failures; everything after ready already flows through the protocol into the bounded per-execution stderr. The kernel now always spawns with stderr piped, and the manager's data handler is the single write path into kernel-stderr.log in the session's artifact directory: it appends raw chunks under a 5 MiB per-spawn write budget, and once the budget is spent it writes one [stderr log budget exhausted] marker, then keeps draining the pipe but discards — so a kernel that spews until the 30s ready timeout (a corrupted venv looping import warnings, multiplied by repair retries) cannot land more than the budget on disk per spawn, and a full pipe can never wedge a pre-ready kernel. The file rotates to .old when a spawn finds it over the budget, hard-capping per-session disk at roughly 2× the budget.

Startup failure messages read their 1 KiB tail from the in-memory string — bounded to an 8 KiB tail, decoded across chunk boundaries, and fed by the same pipe as the file, so it sees every logged byte plus host-side [kernel] diagnostics. On kernel exit the pipe is drained to quiescence before the stream is torn down, so the kernel's last words reach both the tail and the log even when an orphaned grandchild holds the pipe's write end open past the parent's death. Sessions without an artifact directory (or whose log open fails) keep the memory-tail behavior; the log persists across kernel restarts within the rotation cap.

Validation: three pins — a kernel that writes CR progress output and a split multi-byte character with no trailing newline before dying produces a log byte-identical to what it wrote, with that tail in the startup error; a kernel that spews 6 MiB before dying leaves a log capped at the budget plus the exhaustion marker while the startup error still shows its final words and the script runs to completion (the pipe stayed drained); and a log pre-filled past 5 MiB rotates to .old at the next spawn, with the new incarnation's stderr starting a fresh file. Kernel/ipython/repl suites, tsgo, and root npm run check green.

Linear: RES-1246 https://linear.app/primeintellect/issue/RES-1246/persist-python-kernel-stderr-to-disk-and-bound-the-in-memory-buffer

LOC

Total src: +123/−6 (net +117); tests: +244/−3 (net +241).

Note

Persist kernel stderr to disk with per-spawn budget and bound the in-memory tail

  • Adds an optional stderrLogPath to KernelManagerOptions; when set, ReplKernelManager.doStart opens an append log and rotates the prior file when it exceeds the size threshold.
  • Bounds the retained in-memory stderr tail to a configured character limit via appendKernelStderrText and appendKernelDiagnostic, replacing unbounded accumulation for the kernel lifetime.
  • Writes raw stderr bytes to the log up to a per-spawn budget, emits a one-time exhaustion marker, and continues draining the stream through process exit so final output is not lost.
  • IpythonKernelProvisioner.startKernel configures the log path under the session snapshot/artifact directory when it exists.
  • shutdown and the pre-ready exit path in waitForReady now wait for stderr stream closure before finalizing, instead of discarding buffered data.
  • Risk: ReplKernelManager.shutdown no longer destroys the child stderr stream immediately; any caller relying on synchronous stderr teardown during shutdown may see delayed closure until the exit-drain completes.

Changes since #1947 opened

  • Wrapped stderr log file rotation in ReplKernelManager.openStderrLogFd with try/catch error handling that logs a diagnostic message and continues appending to the existing log file when rotation fails, instead of throwing an error [86de757]
  • Added 'end' and 'close' event listeners to child.stderr in ReplKernelManager.start to flush buffered partial UTF-8 character data from the TextDecoder and close the stderr log file descriptor [86de757]
  • Added a new test validating that stderr logging continues when rotation fails, and updated the existing stderr bytes test to verify partial UTF-8 character handling [86de757]
  • Extended the mocked child.stderr type in the configuredManager test utility to include a once method implementation [86de757]
  • Reworked stderr stream lifecycle management in ReplKernelManager kernel process exit handler [78a1822]
  • Added tests for ReplKernelManager stderr handling edge cases [78a1822]
  • Added writeFullySync utility function and replaced direct fs.writeSync calls in ReplKernelManager kernel stderr data handler with writeFullySync to handle partial writes, including writing the budget-exhausted marker as a Buffer [5698258]
  • Extended node:fs mock in repl-kernel-startup.test.ts to simulate short writes and updated test assertions to verify byte fidelity under partial write conditions [5698258]
  • Modified ReplKernelManager.openStderrLog to return both file descriptor and remaining write budget based on existing log file size, and implemented rotation logic that attempts to move oversized logs to .old before resetting budget [2a056a9]
  • Updated ReplKernelManager.start to track stderr write budget from log file initialization and enforce capacity limits by writing a budget-exhausted marker and discarding further pre-ready stderr bytes when exceeded [2a056a9]
  • Fixed ReplKernelManager.shutdown to conditionally destroy child stderr stream only when the child process has not exited, preserving buffered output for already-exited processes [2a056a9]
  • Added test in repl-kernel-shutdown.test.ts verifying that kill() destroys stderr only when the child process is alive and not when it has an exitCode [2a056a9]
  • Updated tests in repl-kernel-startup.test.ts to verify that rotation-failure scenarios respect capacity limits and that write budget equals remaining file capacity at MAX size [2a056a9]

Macroscope summarized e9a7d24.


Note

Medium Risk
Changes kernel subprocess stderr lifecycle and synchronous file I/O on every spawn; behavior is heavily tested but affects startup-failure diagnostics and teardown timing.

Overview
Pre-ready Python kernel stderr is now persisted and memory-safe instead of living only in an unbounded in-memory string.

ReplKernelManager accepts optional stderrLogPath and, when set, appends raw pre-ready stderr to kernel-stderr.log with rotation to .old when the file exceeds 5 MiB, a per-spawn write budget based on remaining file capacity, and continued pipe draining after the budget is exhausted (with a one-time marker) so spew cannot wedge startup. The in-memory diagnostics tail is capped at 8 KiB with UTF-8 decoding across chunk boundaries.

Startup failures wait for stderr stream closure before building the error tail; exit/teardown avoids destroying stderr on already-exited children so last bytes are not dropped, while still destroying the pipe for live children that ignore kill.

IpythonKernelProvisioner sets the log path under the session artifact directory when a snapshot dir exists. Tests cover byte fidelity, rotation, budget caps, rotation failure, grandchild stderr noise, and shutdown stderr destroy rules.

Reviewed by Cursor Bugbot for commit 2a056a9. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts Outdated
Comment thread packages/coding-agent/src/core/tools/ipython.ts
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts Outdated
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts
@snimu
snimu requested a review from xeophon September 1, 2026 13:14
xeophon
xeophon previously approved these changes Sep 1, 2026
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts Outdated
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts Outdated
kernelStderr accumulated the kernel's stderr in memory for its lifetime
while every reader shows at most the last 1 KiB. Hand the spawned kernel
an fd onto kernel-stderr.log in the session artifact directory (rotated
once per spawn) so the full pre-ready stderr stays inspectable, read
failure tails from that file, and bound the in-memory diagnostics tail
to 8 KiB (ENG-5832).
@snimu
snimu force-pushed the fix/kernel-stderr-log branch from 2b0f40f to b951f55 Compare September 1, 2026 13:58
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts Outdated
…up failures

stderrTail() loaded the whole log to slice 1KB off the end; a kernel that
spews to stderr until the 30s ready timeout can leave a log far too large
to buffer in the host, so the failure report now does a positional read
of the last 1KB only.
@sethkarten
sethkarten self-requested a review September 3, 2026 22:39
…udget

Handing the log fd straight to spawn left pre-ready spew only time-bounded
(30s ready timeout x disk speed), and rotation then kept the oversized file
as `.old` indefinitely. Spawn now always pipes stderr and the data handler
is the single write path: it appends under a 5 MiB per-spawn budget, writes
one exhaustion marker, then keeps draining but discards, so per-session
disk is hard-capped at ~2x the budget while a blocked pipe can never wedge
a pre-ready kernel. On exit the stream drains to quiescence before being
destroyed (its EOF can be held hostage by orphaned grandchildren), keeping
the kernel's last words in the tail; the file-tail read is gone since the
in-memory tail now sees every logged byte.
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 52ac831. Configure here.

Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts
A kernel that dies right after an incomplete UTF-8 sequence left those
bytes buffered in the StringDecoder forever, so the startup error's tail
silently dropped the truncated final character (the log file keeps the
raw bytes either way). Flush decoder.end() on both 'end' and 'close':
natural EOF can beat 'exit' while its queued 'close' emission lands
after the tail is built, and a drain-destroyed stream never emits 'end'.
Rotation and open shared one try, so a rotation failure (a locked or
undeletable `.old` target) dropped the log entirely for that spawn.
Scope the failure to rotation itself: record a diagnostic and keep
appending to the oversized file instead of losing the log.
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts Outdated
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts Outdated
The quiescence loop re-derived away: after the kernel exits, the tail
only needs the bytes it wrote before dying, which one event-loop turn
delivers from the bounded pipe buffer — output arriving later is a
surviving grandchild's post-mortem noise, not the kernel's last words,
and re-checking for it let such a writer defer teardown indefinitely in
principle. Destroy after a single setImmediate instead. The log fd close
in the stream's 'close' listener is also caught now: an fs error there
was an uncaught exception in an event handler, killing the host.
Comment thread packages/coding-agent/src/core/kernel/repl-manager.ts
fs.writeSync may write fewer bytes than asked (partial ENOSPC, signal
interruption); the single call dropped the unwritten suffix while the
budget still decremented by the full chunk. Loop until each chunk (and
the exhaustion marker) is fully on disk. Also unpins the close-failure
diagnostic from the startup error text: the 'close' listener that
appends it can run after the tail is built (the closed-flag ordering
already documented for the decoder flush), which CI hit; that pin's
essence is host survival, which the unhandled-error check carries.
…und teardown drains

The per-spawn write budget was a fresh 5 MiB regardless of the file's
size, so a repeatedly restarted noisy kernel grew an unrotatable log
without bound (rotation failure kept the file AND re-granted a full
budget), and even with rotation working the strict over-cap check let
current + .old transiently reach ~4x. The budget is now the file's
remaining capacity after the rotation attempt, restoring the ~2x
per-session cap in every path.

Teardown regains its stderr bound: a still-alive child may ignore the
kill signal and never emit 'exit', so the post-exit drain would never
run — cleanupResources destroys its stderr again in exactly that case,
while an exited child keeps the drain that preserves its last words.
@sethkarten
sethkarten merged commit 5c2750b into main Sep 4, 2026
23 checks passed
@sethkarten
sethkarten deleted the fix/kernel-stderr-log branch September 4, 2026 16:04
Dmatut7 added a commit to Dmatut7/prime-agent that referenced this pull request Sep 5, 2026
…g uptake (FIFO nonblock, tail quota, rotation catch, spawn offset)

Hardens this fork's fd-direct kernel stderr log:

- open the log with O_NONBLOCK and assert a regular file, so a planted
  FIFO cannot block the spawn inside the global kernel-boot permit with
  the ready timeout not yet armed
- keep a rotation failure in an inner catch, so a leftover .old directory
  no longer disables the session log and reports "cannot open"
- create the log's parent directory through ensurePrivateDirectory (0700)
- cap the report at 768 file bytes plus 256 host diagnostic chars and put
  the kernel traceback last, so a long host diagnostic cannot push the
  traceback out of the error message
- record the log window start per spawn and read only the current
  incarnation's bytes; when no window is known (no log path configured, or
  the descriptor could not be opened and stdio degraded to a pipe) the file
  is not read at all, because its bytes belong to a previous incarnation
- use readSync's return value so a short read cannot emit NUL padding

The fork tracks PrimeIntellect-ai#1947 as of 2026-09-02 (ca7f26c). Upstream redesigned the
same area on 09-04 (head 5698258, still open) around a pipe plus a write
budget; those upstream commits are not taken here, so the pipe-fallback path
keeps the limitations of the revision this fork took.
Dmatut7 added a commit to Dmatut7/prime-agent that referenced this pull request Sep 5, 2026
audit-findings.md: add the PrimeIntellect-ai#1947 staleness finding and the B task that
subordinated it; record the F75 empirical conclusion and the three F76 facts;
add the known-gaps list (4) to §2.2 (2) covering the two deferred corrections
and the two new tasks.

FORK_NOTES.md: update the F75 todo from a plan to a completed empirical result;
add the three F76 facts; record the omitStreamingMessages line-number
correction; add the PrimeIntellect-ai#1947 staleness note and the B task.

local-axes.md: add gate item 7 covering the streaming line-number correction and
the pre-enable trap (daemon-mode.ts:4184-4185 unconditional inheritance).
Dmatut7 added a commit to Dmatut7/prime-agent that referenced this pull request Sep 5, 2026
The S9 todo item 8 was written with a join over a string, which iterates its
characters, so the whole paragraph landed as 590 one-character lines. The text
was intact but unsearchable: grep for "staleness" returned nothing, and that note
is a key input for the next upstream sync.

Rejoin it into the single markdown line it was meant to be. Verified: grep -c
staleness goes 0 -> 1, one-character lines go 470 -> 43 (all blank), the byte
delta is exactly the 589 removed newlines, and items 1-8 plus the following
section are intact.
Dmatut7 added a commit to Dmatut7/prime-agent that referenced this pull request Sep 5, 2026
R3 merge-back. F4 cleared first: the parallel lane's uncommitted
preserve_thinking/enable_search WIP was committed at aa80279 (it was
breaking the repo type check), which unblocked this merge.

Brings in 237 files from the upstream 0.9.x line: daemon schema 26->27
(digest 589a2219bc8b), version 0.8.1->0.9.1, the mermaid rendering
feature (grok-mermaid), and cherry-picked upstream PRs PrimeIntellect-ai#2027/PrimeIntellect-ai#1947/PrimeIntellect-ai#1896.
All 264 local-only files and 54 local-only tests preserved (merge, never
whole-tree checkout).

openai-completions.ts auto-merged (R3 changes and the WIP compat feature
touch disjoint regions). FORK_NOTES.md conflict resolved by folding the
R3 sync detail into the running update log. Installed grok-mermaid (new
upstream dep, already in the merged lockfile) into node_modules.

Verified on the merged tree: biome 1036 files clean, tsgo 0 errors,
installer + browser-smoke pass, openai-completions tests 20/20.
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.

3 participants