Repository navigation
fix(code-execution): an abort or dispose while the interpreter starts stops the script - #458
Merged
nissa-seru merged 4 commits intoOct 18, 2026
Conversation
added 4 commits
October 17, 2026 12:34
… stops the script PyRunner.exec claimed the runner with a `starting` flag while it awaited the interpreter, and recorded its pending exec only once the interpreter was ready. abort() returns when there's no pending exec, and dispose() settles only a pending one, so in that window both did nothing to the exec: an abort was lost, and the script then ran to its end or its time limit after its turn had died. Meanwhile `busy` was true, so each later code_execution of the agent was refused as another script already running. dispose() also tore the starting interpreter down, and teardownChild removes the child's exit listeners before the kill, the readiness promise's among them, so exec waited out the 15 s start timeout and then reported a start failure that hadn't happened. This is xpkg's flaky "abort() settles a running script and reclaims the interpreter": at load 83 the interpreter took longer than the test's 500 ms wait to start, and the abort landed in that window. - exec creates its pending record before it awaits the interpreter, in place of `starting`, and `busy` is `pending !== null`. abort() and dispose() settle it in either phase and reclaim the interpreter, starting or not. The record's `sent` marks the script as sent; exec checks the record once the interpreter is ready and returns the settled result without sending if it has already settled, as for an abort on the tick exec is called on a warm interpreter. - reclaim() fails the readiness of an interpreter that is still starting, and so clears its 15 s timer, so an exec waiting on it returns at once. It's in reclaim() rather than teardownChild(), which an interpreter's own exit also runs, so that exit still reads "exited before becoming ready". - The child's exit handler settles only a sent exec, so an interpreter that exits before it's ready still reads "Failed to start python runtime". exec's catch settles and reclaims only while its own record is unsettled, so an exec aborted while starting can't fail the exec that claimed the runner after it, or kill its interpreter. The abort test now waits for its script's first tool call rather than 500 ms, so it tests a running script, as its name says. New cases, in "PyRunner: an abort or dispose while the interpreter starts": an abort on the tick exec is called; an abort 300 ms into a start that takes 1 s (a shell stand-in for python3), the old test's steps; an exec issued on the tick an abort settles the first; an abort on a warm interpreter on the exec's tick, with a 300 ms deadline that must not reach the next exec; a dispose, and an abort then a dispose, each settling well before the 15 s start timeout; and a stand-in that exits before it's ready, which still reads as a failed start.
…'s error listener, a kill only for a child with a pid, and tests that can't hang or pass by timing The review of 46a458c (af-f71-abort-while-starting, wf-2317) and Gordon-2414's read of its unread spots found: - A miss, from Gordon's runs (J16402, J16438): a spawn that fails, such as for a missing interpreter, leaves a child with no pid and a null exitCode until Node's 'error' for it a tick later, and teardownChild killed any child with a null exitCode. Node then signals whatever pid the child's handle held, since libuv sets it only for a spawn that succeeds (Gordon's reading of libuv 1.46.0 and Node 20.20.2): in a fresh Node 20 process that was 0, so SIGKILL went to the host's own process group (his strace), and on Node 24 a different large pid each time. dispose() on exec's tick reached it on main already; this change's claim before the await lets abort() reach it too. teardownChild now kills only a child with a pid. - I7: send() catches only what write() throws, and the interpreter's stdin had no 'error' listener, so a write to an interpreter that no longer reads its input fails afterwards on the stream and is thrown in the host as an uncaught `write EPIPE`. A probe showed it at both heads: on main for exec's own first write to an interpreter that closed its input after saying it was ready, and at 46a458c also for the cancel that abort() now writes to a starting interpreter. ensureChild gives the stream a listener that logs; the interpreter's exit settles its exec, as before. - I8: the stream-error retry and the provider's cooldown and wait reset a turn to run again rather than end it (Gordon read the six sites). abortAgentScript's comment and the fragment say a script is aborted when its turn ends or is reset to run again. - I1: the abort test's wait for its script's first tool call had no bound, and npm test sets no per-test timeout. It now races that call against the exec. - I3: the 300 ms test stood in a fixed wait for "still starting". Its stand-in now never says it's ready (`exec sleep 30`), so the abort lands while it starts however late the wait ends. - I4: no abort-only case was timed. The tick abort now settles within 5 s, against the 15 s start timeout. - I9, I10, I11: the stand-in cases are skipped on Windows; abort()'s docstring and the warm test's comment say what they mean. New cases: an abort while a stand-in that has closed its input starts, and an exec to one that closes it once it's ready, each throw nothing; an abort and a dispose on exec's tick with a missing interpreter each settle with their own text, and no kill reaches a child with no pid. A stub of ChildProcess.prototype.kill records such a kill and passes on only a child's with a pid, so with the check taken out the case fails on every Node and nothing is signalled (Gordon's suggestion: a real kill's red depended on the heap). The `until` helper moves to the file's top. The fragment adds the logged write and the kill.
…at stops reading its input, and pin the reclaim's kill The review of aae1f00 (af-f71-r2, wf-2375: 14 issues) found: - I1: the stdin 'error' listener only logged. An interpreter can stop reading its input and run on: after a script's os.close(0), the runtime's reader thread dies with EBADF. Every later message to it is lost, so the next exec waited out its time limit plus the 10 s grace and was told it had reached its time limit (a probe with the real runtime: 13 s with a 3 s limit). On main 3adbdb8 the same write was an uncaught `write EPIPE` that ended the host's process, on Node 24.22.0 and 20.20.2. The listener now kills an interpreter that is still running, and its exit settles the exec with "python runtime stopped reading its input, so it was killed and the interpreter restarted ... A script that closes its standard input (file descriptor 0) does this." The message is used only when the exit is the kill's SIGKILL, so an interpreter that had died already settles with its own code. - I2: no test pinned the reclaim's kill. The start-window stand-in now writes its pid, and the test checks the process is gone after the abort (the reclaim of a starting interpreter, and teardownChild's kill of a child with a pid). The running-script abort test now sets a variable before its tool call and checks the next exec doesn't see it. - I3: a within() helper bounds at 10 s the abort test's wait for its script's tool call and the kill-stub test's await of each exec. - I4: the closed-input test's stand-in no longer exits on its own (exec sleep 30), so the exec settles only at the failed write. - I6: the two settle bounds go from 5 s to 10 s, still below the 15 s readiness timeout they guard against. - I8, I9: the start-window test waits for the stand-in's pid file rather than 300 ms, and two test names say what they check. - I12, I13: teardownChild's comment names both outcomes of a kill without a pid (the host's own process group on Node 20.20.2, another process's pid on 24.19), and abort()'s docstring lists a turn reset to run again. Not changed: I5 (a log line on a failed spawn's tick), I7 (no realistic read error on stdout or stderr), I14 (a probe under EMFILE on Node 24.22.0 and 20.20.2 found the child's stdio streams present and the 'error' heard; PyRunner settled "Failed to start python runtime ... EMFILE" with nothing uncaught). The fragment says what callers now get.
…d name the closed-input race in the changelog settlePending appends "script stopped: it reached its … time limit\n" to the stderr (and a background script's tail) of an exec that its deadline stopped. It joined the note to the text as it was, so after the runner's own texts, which end without a newline, the two ran together on one line: "python runtime exited unexpectedly (code=3, signal=none)script stopped: …" on main, and this branch's "… (file descriptor 0) does this.script stopped: …" in the closed-input race below. The note now starts on a line of its own after a text that doesn't end with a newline; an empty text gets the note alone, as before. The race (found while saving the PR's receipts, Ada-1017's call room-203 #133952): after a script's os.close(0), 13 runs at 11f0f64 on the VPS (Node 24.22.0 and 20.20.2; af-f71-checks/probes-race-11f0f647.txt and probes-ebadf-11f0f647.txt): - 7: the next exec ran, and the one after settled within 5 ms with the stopped-reading text; - 4: the next exec settled within 3 ms with that text, without running; - 2: the next exec's message was written without an error and never ran, so it waited out its 3 s limit, then settled with that text and the time-limit note (3004 and 3008 ms). Why the message in the last case is lost is not shown. Its cause, the runtime's protocol sharing fds 0 and 1 with every script, is main's, and this change leaves it as it is (af-f71-checks/probes-protocol-fds.txt). The changelog fragment's closed-input bullet now says that such a script sometimes settles only at its time limit. Test: "the time-limit note starts on a line of its own after an interpreter exit, whose text ends without one" (the real runtime; a script whose SIGINT handler exits the interpreter with code 3, stopped at a 1 s limit). With settlePending's join as at 11f0f64, it fails with the run-together text. code-execution.test.js, 61 tests, passes on Node 24.22.0 and 20.20.2 at this tree.
[Medium impact] The PR appears safe to merge; no actionable issues were found. Summary
Reviews (1) · Last reviewed commit: "fix(code-execution): put the time-limit ..." · Reviewed by Greptile |
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main gained anima-research#457, anima-research#458, anima-research#459, anima-research#454, anima-research#460, anima-research#464, anima-research#412 and anima-research#467 since 86a3683. One block conflicts, in restorePersistedDeferredWrites: anima-research#454 narrowed its try to reading the recovery file, so that a file it can't read or take fails create(), and moved the parsing and numbering after it, while this branch changed that numbering (boot numbers the restored queue 1..N). The resolution takes main's side of the block, then makes this branch's change to main's numbering where it now stands: the queue is ordered by valid seq, then the rest in file order, numbered 1..N after the already-landed entries are left out, and the counter goes on from N; the file is rewritten for an entry without a valid seq, not for the new numbers. main's check for writes that already landed, which now refuses create() when a context can't be read, is kept as main has it, over the ordered entries. Everything else merged without a conflict. The only other change of main's within forty lines of one of this branch's is anima-research#457's to an ephemeral run's idle watchdog, which now rejects a run that stop() has begun to end as stopped; it sits above the run's finally, which this branch changes, and neither depends on the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main's anima-research#458 restructured PyRunner.exec: the exec is pending before its interpreter starts (sent marks when the script went to it), a start failure settles it through settlePending, an interpreter that stops reading its input is ended, and the time-limit note starts on a line of its own. This branch's texts go onto those paths: - a start failure says why the script didn't run (startFailure); - the pending exec carries ceilingMs, backgroundCeilingMs and background, set where anima-research#458 now creates it, so the kill at the limit and the time-limit note offer what helps; - an interpreter's exit under a script reads as interpreterExit, except anima-research#458's input-closed case, whose text is kept as it is; - the note keeps anima-research#458's line of its own and gains the remedy. anima-research#458's tests keep pinning its behaviour; the start-exit test and the time-limit-note test now expect this branch's texts. A comment in reclaim names the readiness error by its new text, "exited as it started".
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
… exit The fragment from anima-research#458 gave "python runtime exited unexpectedly (…)" as a runner text that ends without a newline. This branch words that exit anew, so the example names the case rather than the earlier words.
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main brings agent-framework anima-research#456 (the workspace file tools' fixes), anima-research#460 (editors' files and nested mounts), anima-research#410, anima-research#455, anima-research#457, anima-research#458, anima-research#459, anima-research#454, anima-research#464, anima-research#412 and anima-research#467. - write, edit and delete: anima-research#456 answers a change stored in the workspace but not on disk with one frame (storedButUnfinished), which leads with "stored, so don't send it again" and gives the push's reason. This branch had reworded those frames to say where force goes. Its frame goes and anima-research#456's stays: every reason a write's push can return names materialize where it offers force, and so do this branch's mode notes. Each success keeps its modeKept note, and commitToolChange keeps anima-research#456's onCommitted and answers { refused, modeKept }. - write: an overwrite keeps the entry's mode inside anima-research#456's guarded commit, as an edit does. - reconcile.ts: an effect that fails untouched names its kind, a chmod step's included, beside anima-research#456's account of what's in the way (inTheWay). - edit: anima-research#456's edit takes a change on disk in first, with a pass over its path. Where disk changed only a file's mode, that pass takes the mode in, as a listing does, and the edit's note read it as disk's copy having changed and the edit applying to that text. A mode taken in alone isn't a change to the text, so the note no longer counts one. The edit then writes at the mode disk has and leaves nothing owed. - anima-research#460: a push skips a path in another mount's directory before it plans, so it plans no chmod there either. - The changelog fragment drops its sentence about the write tools' frame, which anima-research#456's replaces, and no longer names an edit among what says that nothing records which side changed a mode after a refused widening, since an edit's pass takes disk's mode in first. - Tests: the test of a refused narrowing's frame takes anima-research#456's wording, the test of the force sentence goes with that sentence, and two tests pin an edit after a mode disk changed and after a refused widening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 task done
1 task done
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
…eads a script's output The code_execution runtime ran its protocol on fds 0 and 1, which every script and every command it runs shares, and kept a script's output in memory, read back at the script's end. So a command's output (subprocess without capture_output, os.system, C code writing to fds 1 and 2) reached only the host's log as "non-protocol" lines; a line without a newline ran into exec_result and left the exec waiting out its time limit; a command reading its input (cat, ssh) took the host's messages; os.close(0) or (1) broke the interpreter; and whatever a script printed died with the interpreter on a kill or a crash, along with a fatal error's text. Now the host spawns the runtime with stdio ignore/pipe/pipe/pipe/pipe: - fd 0 is /dev/null (Node's 'ignore'), so a command reading its input gets its end at once. - fds 3 (host -> py) and 4 (py -> host) carry the protocol. The runtime first checks both are open, and if not writes one sentence to fd 2 (the host's channel didn't reach it; a pythonPath wrapper must pass fds 3 and 4 on, which a container or sudo's defaults don't) and exits 2, so the failed start says what to fix. Then it makes them non-inheritable, so no command is given them. No fallback to fds 0 and 1, which would keep two protocols. - fds 1 and 2 are pipes the host reads itself (OutputStream). Each exec op carries a random mark; when the script returns, the runtime sends exec_result (now only the return code), flushes the script's writers and writes the mark into both streams through private copies of fds 1 and 2. The host settles once it has exec_result and both marks, so everything the script and the commands it waited for wrote is in the result; each stream is capped at 2,000,000 characters with the same note as before. - Output arriving while no exec runs (a command left running) goes to the host's log, labelled by stream; after a reclaim the old pipes stay read until whatever holds them closes them. - The runtime restores fds 0, 1 and 2 from its private copies at each exec's start, and gives the script fresh line-buffered writers over 1 and 2 (UTF-8, backslashreplace): a line reaches the host as it's printed, in order with commands' output. 100,000 prints cost about three times what CappedIO did (measured while designing). - faulthandler is enabled on the runtime's private copy of fd 2, so a segfault or an abort leaves a traceback; Py_FatalError's own text reaches the host because fd 2 is the host's pipe. - A kill, abort, dispose or crash now settles with the output so far ahead of its message (stopPending). An unexpected exit waits for both pipes to end, or EXIT_OUTPUT_GRACE_MS when a command still holds them, and logs the last of what the interpreter wrote. A start that fails says what the interpreter wrote to stderr. - Background: the runtime joins fd 2 onto fd 1's pipe, so the journal keeps the order written; the host writes the journal (Journal: same 50 MB cap, same 4,000-character tail) in place of the runtime's LogTee, so a crashed background script's tail is real. - When the runtime can't go on because a script closed one of its own fds (fd 4, or its private copies), it says why on stderr and exits (code 70) rather than leaving the exec to its time limit. anima-research#458's kill of an interpreter that stops reading moves to fd 3; since fd 3 is a duplex socket that ends when the far side closes, the first message that can't reach it is caught by the write's callback as well as by 'error'. Removed from the runtime: CappedIO, LogTee, the result assembly and the log-file handling. The tool description now says what the script and the commands it runs print comes back; CodeExecutionConfig.pythonPath's doc says a wrapper must pass fds 3 and 4 on. Changelog: the script-facing changes in a fixed fragment; the hosts' part (a pythonPath wrapper must pass fds 3 and 4 on; PYTHON_RUNTIME_SOURCE's new protocol) in a breaking fragment under **Hosts:**, so this releases in a new minor version. Tests: the stand-ins that stop reading now close fd 3 and answer on fd 4; the stop-table driver reads the script's output from fds 1 and 2 through a file; new real-runtime tests for each piece above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
…aths main brings anima-research#413 (what code_execution's failures say, and what helps) and anima-research#458's merge, both already composed with this change's base, plus other packages' fixes that touch no file of this change's. The conflicts were in py-runner.ts and one test; runtime-py.ts merged clean (anima-research#413's tool-function argument checks sit apart from this change's fds and writers). Resolved as this change's structure with anima-research#413's texts: - the deadline kill goes through stopPending, with anima-research#413's wording and limitRemedy; - the time-limit note is anima-research#413's, with its remedy, through onItsOwnLine; - an exit under a script reads as anima-research#413's interpreterExit, settled after the pipes as before; the host's log keeps the short technical form ("python runtime exited unexpectedly (code=…, signal=…)") with the last words; - the readiness errors take anima-research#413's texts ("exited as it started", "didn't start within 15 seconds"); an exit's rejection carries its exit code and what it wrote to stderr, for startFailure. Where anima-research#413's text and this change's meet, three choices of the merge's own: - startFailure: a failed start that wrote to stderr gets anima-research#413's sentence, then "It wrote to stderr: <its last words>". Kept whole rather than through boundedErrorMessage, whose cut keeps a text's head and would drop a traceback's last line. - The runtime's missing-channel check (fds 3 and 4) now exits with 78, sysexits' EX_CONFIG, which Python never exits with on its own; for that exit startFailure ends with the runtime's own sentences instead of anima-research#413's "Run it again", which they contradict ("calling it again fails the same way"), so the result ends with what whoever runs the host must change. - The stopped-reading text is shaped as anima-research#413's (stoppedReading): "script stopped: …", the remedy, and no "variables from earlier scripts" for a background script, as anima-research#413's interpreterExit does. Tests: this change's expectations move to anima-research#413's texts (a helper builds interpreterExit's text); anima-research#413's start-exit test now also sees what Node wrote; a background exec meets the stopped-reading text without the variables clause. code-execution.test.ts passes 105/105 on Node 24.22.0 and Node 20.20.2 (Node 20 with --test-timeout=300000: the file takes about 63 s there, past the 60 s a file was given before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PyRunner.abort()anddispose()now stop acode_executionscript whose interpreter is still starting, as they stop a running one. Before, an abort in that window did nothing: the script ran to its end after its turn had died, and the agent's next scripts were refused meanwhile. This is the cause of the flaky "abort() settles a running script and reclaims the interpreter". An interpreter that stops reading its input, as one does after a script closes its standard input, is now ended at the first message that can't reach it, where that message's failed write was thrown in the host and ended its process. An abort or a dispose onexec's tick, when the interpreter couldn't be spawned, no longer signals another process. And the time-limit note now starts on a line of its own.What was wrong
execclaimed the runner with astartingflag while it awaited the interpreter, and recorded its pending exec only once the interpreter was ready.abort()returns when there's no pending exec, so an abort in that window did nothing, thoughbusywas already true.abortAgentScript, which checksbusy). So a turn that ended or was reset then kept its script, which ran to its end or its time limit, and its tool calls were still dispatched (read in the code, see "Not verified"). Eachcode_executioncall of the agent's later turns was refused as another script already running until then.dispose()settles only a pending exec. The teardown it runs removes the child'sexitlisteners before it kills the child, the readiness promise's among them. Soexecwaited out the interpreter's 15 s start timeout and then answered "Failed to start python runtime (python3): python runtime did not become ready within 15s". A background script's cancel, an abort then a dispose, went this way.send()catches only whatwrite()throws. A pipe whose reader is gone fails afterwards, on the stream, which had noerrorlistener, so the failure was an uncaughtwrite EPIPE, which ends the host's process.os.close(0), the runtime's reader thread dies with EBADF, and the interpreter runs on. Sometimes the next exec still reaches it and runs first. Onmain, the first message that then failed to reach it, such as a later exec's, ended the host's process, on Node 24.22.0 and 20.20.2 (a probe with the real runtime).main, an interpreter that closed its input after saying it was ready threw onexec's own first write.mainand at46a458c).exitCodeof null until Node'serrorfor it a tick later.teardownChildkilled any child with a nullexitCode, so an abort or a dispose in that tick killed it. Node then signals whatever pid the child's handle held, since libuv sets it only for a spawn that succeeds (Gordon-2414's reading of libuv 1.46.0 and Node 20.20.2). In a fresh Node 20 process that was 0, soSIGKILLwent to the host's own process group, as Gordon's strace shows. On Node 24.19.0 it was a different large pid in each of his five runs, each ESRCH, and a pid that happened to be live would have been signalled.dispose()reached it onmainalready, and this change's claim before the await letsabort()reach it too.mainat a load of 25, a fresh interpreter took 100 ms to start. The flaky test aborted 500 ms afterexec. In fix(stop): a stop at a tool boundary stops the turn #382's rerun, at a load of 83, the call resolved after 61 s withabortedundefined, as an abort lost this way leaves it.Changes
execcreates its pending record before it awaits the interpreter, in place ofstarting, andbusyispending !== null.abort()anddispose()then settle the record in either phase and reclaim the interpreter, a starting one too.sentmarks the script as sent.execreturns the settled result without sending if the record has already settled. That's the case when an abort comes on the tickexecis called, on a warm interpreter.reclaim()fails the readiness of an interpreter that is still starting, which also clears its 15 s timer, so anexecwaiting on it returns at once. This is inreclaim()rather thanteardownChild(), which an interpreter's own exit also runs, so that exit still reads "exited before becoming ready".exithandler settles only a sent exec, so an interpreter that exits before it's ready still reads "Failed to start python runtime".exec's catch settles and reclaims only while its own record is unsettled, so an exec aborted while starting can't fail the exec that claimed the runner after it, or kill that exec's interpreter.errorlistener, which logs a failed write and kills the interpreter if it's still running. Its exit then settles the exec, saying the runtime stopped reading its input.teardownChildkills only a child with a pid.settlePendingputs the time-limit note on a line of its own after a text that doesn't end with a newline, instderrand in a background script'stail.abort()'s docstring andabortAgentScript's comment say a starting script is aborted too, and that a turn reset to run again aborts it as one that ends does.What callers will notice
An abort or a dispose while the interpreter starts, or on the tick
execis called, settles the exec at once, with the result it gives a running script:aborted: true, andscript aborted by host: <reason>orcode_execution runner disposed. The script never runs. After an abort,busyis false as soon as the call returns, and the next exec starts a fresh interpreter.A background script cancelled as it starts settles at once, so its
tool:completedtrace comes then, not 15 s later. The cancel's own answer is unchanged, and nothing shows the exec's text, before this change or after: a cancelled script's settle wakes no one.A failed write to an interpreter is logged as
[pytc:<label>] write to the python runtime failed: <error>, where it was thrown in the host. If the interpreter is still running, it's killed, and its exec settles with "python runtime stopped reading its input, so it was killed and the interpreter restarted (variables from earlier scripts are gone). A script that closes its standard input (file descriptor 0) does this."An interpreter killed from elsewhere can settle with that text too. If it dies by a SIGKILL from elsewhere and the host then writes it a message, such as a tool's answer, before Node has reported the exit, the write fails with
write EPIPEand the exec settles with the text above, naming a script that closes its standard input. At this head, with a SIGKILL the script sent itself during a tool call and the host's event loop held across the death so that the write came first, it settled that way in 3 of 3 runs on each of Node 24.22.0 and 20.20.2. The kernel's out-of-memory killer sends the same signal (__oom_kill_processin the kernel'smm/oom_kill.c). With the loop free, Node reported the exit first, and the exec settled with "python runtime exited unexpectedly (code=null, signal=SIGKILL)". So did a stand-in for the interpreter that died with the time limit's cancel unread and no write after it, followed by the time-limit note.After a script's
os.close(0), 13 runs with the real runtime on the VPS, on Node 24.22.0 and 20.20.2, went three ways:Why the message is lost in those 2 isn't shown. Its cause is under "Not changed here".
The time-limit note starts on a line of its own after a text that doesn't end with a newline, such as "python runtime exited unexpectedly (code=3, signal=none)". It used to run on at the end of that text's line.
An abort or a dispose on
exec's tick with an interpreter that can't be spawned signals no process. It signalled whatever pid Node held, which in a fresh Node 20 process was the host's own process group.Nothing else changes: an interpreter that fails to start reads as before, and an abort of a running script is as it was.
What each part is for
The change is for one thing: an abort or a dispose stops the runner's script in whatever phase the exec is, so a turn that ends ends its script.
The pending record from the claim is the fix, and it takes the place of
starting: one record now says the runner is busy, in every phase. A flag thatexecread once the interpreter was ready would settle the abort only then, as late as the 15 s start timeout, and leave two fields saying one thing.senttells an exec waiting for its interpreter from one whose script went out, for the exit handler.Failing the readiness in
reclaim()lets an abort or a dispose return at once. Racing the readiness against the settled result inexecinstead would leave the killed interpreter's 15 s timer running. When it fired, its cleanup would clear the runner's ready resolver, which by then could be the next interpreter's, if that one were still starting.The exit handler's guard and the catch's guard come with the record. An exec now has a record while its interpreter starts, so each place that settles one has to tell whose it is and in which phase.
The check once the interpreter is ready keeps an exec settled on the tick it was called, on a warm interpreter, from arming its deadline. That timer would interrupt, and at its grace kill, whatever exec the runner held when it fired.
The pid check in
teardownChildkeeps a kill from leaving the runner's own child: a failed spawn's child has nothing to kill, and Node'serrorfor it settles its readiness.The input stream's
errorlistener keeps a failed write from being thrown in the host. Its kill is for an interpreter that has stopped reading its input but runs on, as after a script closes its standard input: every later message to it is lost, so without the kill its exec waits out its time limit, 10 minutes by default, and then reads as having run out of time. With the kill, an exec still waits out its limit when its own message is lost without an error, as in the 2 runs above. The MCPL stdio transport ends its server on a pipe error the same way. An interpreter that had died already settles with its own exit code, since the message names a stopped input only for the kill'sSIGKILL.The note's own line keeps the runner's own texts, which end without a newline, from running into the note. It's one helper in
settlePending.The tests, each with what it protects:
execis called settles it within 10 s, against the 15 s start timeout, withbusyfalse; the script never calls its tool, and the next exec runs: the defect itself, and the readiness failed inreclaim()on the abort's own path;teardownChild's kill;reclaim();exec's tick with a missing interpreter each settle with their own text within 10 s, and no kill reaches a child with no pid, for the pid check. A stub ofChildProcess.prototype.killrecords such a kill and passes on only a child's with a pid. So with the check taken out, the case fails whatever Node would do with the kill (it was run on 24.22.0 and 20.20.2), and nothing is signalled;maintoo, since it pins a text this change could have broken;The stand-in cases are skipped on Windows, since they're shell scripts, and the note's case is too, with the other time-limit cases that interrupt a script.
The fragment says what callers now get.
Not taken:
Checks
An internal review ran on
46a458c, and again on the round that answered it,46a458c..aae1f00.aae1f00and11f0f64answer them.90409f3, the note's own line and its test, was read by Greta-2425 rather than reviewed again.At
90409f3, from a clean worktree and a clean build, on Linux with Node 24.22.0:code-execution.test.jshas 61 tests, 11 more thanmain's 50. Onmain's sources, 10 of them fail: 9 of the 10 start-window cases, and the note's case. The start-window case that passes is the failed-start text, which pins whatmaindoes. The rewritten abort test passes onmaintoo, since an abort of a running script works there.code-execution.test.js:reclaim(): the tick abort, timed against the start timeout, and the two dispose cases;teardownChild: the missing-interpreter case, whose stub records the kill;abort()'s reclaim, orteardownChild's kill, taken out, the start-window stand-in is left running, and its test fails. That was a separate run at11f0f64, on Node 24.22.0, and this head changes neither part.At its merge onto
main:mainhas since moved to3af13bf, 107 commits on, none of them insrc/code-executionor its tests. The branch merges onto it cleanly. At that merge, made locally, CI's checks ran from a clean worktree and a clean build:node --test --test-force-exitoverdist/test/*.test.js): every test it counted passes, 2644 on Node 24.22.0 and 2645 on Node 20.20.2.main's own CI at3af13bfcounted 2634 on each (run 39064228131, Linux), so the merge's 2645 aremain's 2634 and this change's 11. Node 24's count is one short, since the forced exit can end a file before its tests report (npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294). The run doesn't say which file's report was left out; each file alone ran them all.main's CI.At
11f0f64onmain3adbdb86, before the note's line, the same checks ran on the branch alone, and every test they counted passed.Not verified
abortAgentScript, which checksbusyand callsabort(). I read that and the dispatch of a script's tool calls (handleScriptToolCalltodispatchScriptToolCall, with no check that the turn is alive), and ran the runner alone./bin/sh, and CI has no Windows leg.Not changed here
The runtime's protocol shares fds 0 and 1 with every script, as on
main, and the race above is one case of it. A script that closes either, or a child process that reads fd 0 or writes to fd 1, can take or garble the runtime's messages. Onmainand at11f0f64alike, with a 3 s limit:os.close(1), an exec waits out its limit and the grace, then reads "did not respond";os.system("printf partial")runs into the result's line, which the host can't parse, with the same wait;os.system("echo …")'s output reaches only the host's log, as a "non-protocol stdout line";subprocess.run(["cat"])blocks on the protocol's input until the deadline. Onmainit took the deadline's cancel.This change leaves that as it is.
A read error on the interpreter's stdout or stderr still has no listener, as on
main. The MCPL stdio transport ends its server on one. But neither the internal review that raised it nor I found a way a read error arises on these pipes, as a write to a closed one does.Open pull requests this meets
exec's catch, the exit handler, where the record is created, and the note's join. So the two conflict insrc/code-execution/py-runner.ts. Merged together, fix: tool presentation and code_execution errors say what happened and what helps #413's texts go inside this change's guards, and its fields are set where the record is now created. The exit handler keeps this change's text for an interpreter killed after a failed write, beside fix: tool presentation and code_execution errors say what happened and what helps #413's for one that exited on its own. This change's note line goes around fix: tool presentation and code_execution errors say what happened and what helps #413's note, which fix: tool presentation and code_execution errors say what happened and what helps #413's own texts also precede without a newline. This change's failed-start and note tests then expect fix: tool presentation and code_execution errors say what happened and what helps #413's texts. Both add a test at the end of one block, so the test file conflicts too.mainalready, and this change adds no file to their conflicts.🤖 Ansel-2428 (Claude Opus 5.5), a corvid of the house. Ada-1017, also a house corvid, chose the design: the exec's record from its claim, the readiness failed in
reclaim(), and the exit handler's and the catch's guards. House corvids read it before it opened: Gordon-2414 found the stray kill by running what the internal review could only read, and Greta-2425 read it whole ataae1f00, and each round after.