Skip to content

fix(code-execution): an abort or dispose while the interpreter starts stops the script - #458

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/code-execution-abort-while-starting
Oct 18, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/code-execution-abort-while-starting

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

PyRunner.abort() and dispose() now stop a code_execution script 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 on exec'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

  • An abort while the interpreter started was lost. 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, so an abort in that window did nothing, though busy was already true.
    • What an agent met: the framework aborts an agent's script when the turn it belongs to ends, or is reset to run again after a stream error or a provider's wait (abortAgentScript, which checks busy). 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"). Each code_execution call of the agent's later turns was refused as another script already running until then.
  • A dispose there waited 15 s, then reported a start failure. dispose() settles only a pending exec. The teardown it runs removes the child's exit listeners before it kills the child, the readiness promise's among them. So exec waited 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.
  • A write to an interpreter that no longer read its input was thrown in the host. send() catches only what write() throws. A pipe whose reader is gone fails afterwards, on the stream, which had no error listener, so the failure was an uncaught write EPIPE, which ends the host's process.
    • A script can cause it. After a script runs 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. On main, 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).
    • A stand-in shows it too: on main, an interpreter that closed its input after saying it was ready threw on exec's own first write.
    • This change's abort writes a cancel to a starting interpreter, which met the same (found by the internal review, and confirmed with a probe on main and at 46a458c).
  • A kill of a child whose spawn had failed signalled another process. A spawn that fails, such as for a missing interpreter, leaves a child with no pid and an exitCode of null until Node's error for it a tick later. teardownChild killed any child with a null exitCode, 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, so SIGKILL went 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 on main already, and this change's claim before the await lets abort() reach it too.
  • When: the interpreter starts at an agent's first script, after it has been idle (5 minutes by default), and after a script is aborted, killed at its time limit, or crashes. On main at a load of 25, a fresh interpreter took 100 ms to start. The flaky test aborted 500 ms after exec. 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 with aborted undefined, as an abort lost this way leaves it.

Changes

  • exec creates its pending record before it awaits the interpreter, in place of starting, and busy is pending !== null.
    • abort() and dispose() then settle the record in either phase and reclaim the interpreter, a starting one too.
    • The record's sent marks the script as sent.
    • Once the interpreter is ready, exec returns the settled result without sending if the record has already settled. That's the case when an abort comes on the tick exec is called, on a warm interpreter.
  • reclaim() fails the readiness of an interpreter that is still starting, which also clears its 15 s timer, so an exec waiting on it returns at once. This is 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 that exec's interpreter.
  • The interpreter's input stream gets an error listener, 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.
  • teardownChild kills only a child with a pid.
  • settlePending puts the time-limit note on a line of its own after a text that doesn't end with a newline, in stderr and in a background script's tail.
  • Comments: abort()'s docstring and abortAgentScript'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 exec is called, settles the exec at once, with the result it gives a running script: aborted: true, and script aborted by host: <reason> or code_execution runner disposed. The script never runs. After an abort, busy is 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:completed trace 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 EPIPE and 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_process in the kernel's mm/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:

    • in 7, the next exec ran, and the one after settled that way within 5 ms;
    • in 4, the next exec settled that way within 3 ms, without running;
    • in 2, the next exec's message was written without an error and never ran. That exec waited out its time limit, 3 s there and 10 minutes by default. Its time limit's cancel was then the write that failed, so it settled with the same text, and the time-limit note below it.

    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 that exec read 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.

  • sent tells 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 in exec instead 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 teardownChild keeps a kill from leaving the runner's own child: a failed spawn's child has nothing to kill, and Node's error for it settles its readiness.

  • The input stream's error listener 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's SIGKILL.

  • 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:

    • the abort test now waits for its script's first tool call, not 500 ms, so it tests a running script, as its name says: the existing contract, without the timing that made it flaky. An exec that settles first, or a call that doesn't come within 10 s, fails it rather than leaving it waiting. The next exec then finds the aborted script's variable gone, for the reclaim;
    • an abort on the tick exec is called settles it within 10 s, against the 15 s start timeout, with busy false; the script never calls its tool, and the next exec runs: the defect itself, and the readiness failed in reclaim() on the abort's own path;
    • an abort while a start that never finishes is under way, with a shell stand-in for python3 that writes its pid and never says it's ready: the flaky test's case, as fix(stop): a stop at a tool boundary stops the turn #382's rerun failed it. The stand-in is then killed, for the reclaim of a starting interpreter and teardownChild's kill;
    • a second exec, issued on the tick an abort settles the first, runs whole, for the catch's guard;
    • an abort on a warm interpreter on the exec's tick, with a 300 ms deadline that must not reach the next exec, for the check once the interpreter is ready;
    • a dispose, and an abort then a dispose, each settle well inside the 15 s start timeout, for the readiness failed in reclaim();
    • an abort while a stand-in that has closed its input starts throws nothing, and an exec to one that closes its input once it's ready and runs on settles within 10 s with the message above, for the input stream's listener and its kill;
    • an abort and a dispose on 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 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 whatever Node would do with the kill (it was run on 24.22.0 and 20.20.2), and nothing is signalled;
    • a stand-in that exits before it's ready still reads as a failed start, for the exit handler's guard. It passes on main too, since it pins a text this change could have broken;
    • a script whose SIGINT handler exits its interpreter with code 3, stopped at a 1 s limit, reads the exit's text and then the note on a line of its own, for the note's line. It uses the real runtime.

    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:

  • Keeping the starting interpreter for the next exec. It would save one start, about 100 ms, and leave an interpreter ready with no exec waiting.
  • A flag read once the interpreter is ready, which settles the abort late (above).

Checks

An internal review ran on 46a458c, and again on the round that answered it, 46a458c..aae1f00. aae1f00 and 11f0f64 answer 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:

  • The typecheck and the build are clean.
  • Red-first: this head's code-execution.test.js has 61 tests, 11 more than main's 50. On main'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 what main does. The rewritten abort test passes on main too, since an abort of a running script works there.
  • Each piece has a test. Each of the nine parts, taken out on its own, fails at least one case of code-execution.test.js:
    • the claim from the start: 12 cases fail or are cancelled, among them the same-tick concurrency test, the missing-python test and the framework's two-calls test;
    • the readiness failed in reclaim(): the tick abort, timed against the start timeout, and the two dispose cases;
    • the exit handler's guard: the failed-start text;
    • the catch's guard: the second exec on the abort's tick;
    • the check once the interpreter is ready: the warm same-tick abort;
    • the pid check in teardownChild: the missing-interpreter case, whose stub records the kill;
    • the input stream's listener: both stand-ins that stop reading their input;
    • the listener's kill: the stand-in that stops reading its input and runs on;
    • the note's own line: the note's case.
  • The kill of a starting interpreter: with abort()'s reclaim, or teardownChild's kill, taken out, the start-window stand-in is left running, and its test fails. That was a separate run at 11f0f64, on Node 24.22.0, and this head changes neither part.

At its merge onto main: main has since moved to 3af13bf, 107 commits on, none of them in src/code-execution or 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:

  • The typecheck and the build are clean.
  • Each file alone, on Node 24.22.0: all 166 test files pass, 2645 tests.
  • CI's test step (node --test --test-force-exit over dist/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 at 3af13bf counted 2634 on each (run 39064228131, Linux), so the merge's 2645 are main'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.
  • CI's unforced step passes 107 of 107 on each, as on main's CI.

At 11f0f64 on main 3adbdb86, before the note's line, the same checks ran on the branch alone, and every test they counted passed.

Not verified

  • The framework path is read, not run. A turn that ends, or is reset to run again, while its script's interpreter starts goes through abortAgentScript, which checks busy and calls abort(). I read that and the dispatch of a script's tool calls (handleScriptToolCall to dispatchScriptToolCall, with no check that the turn is alive), and ran the runner alone.
  • Platforms: my runs are on Linux. CI runs macOS too. The stand-in tests use /bin/sh, and CI has no Windows leg.
  • Why the stray kill's pid varies: Gordon's reading of libuv's source, that it sets a child's pid only for a spawn that succeeds, explains the runs. Neither of us has traced which memory gives which pid.

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. On main and at 11f0f64 alike, with a 3 s limit:

    • after 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. On main it 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


🤖 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 at aae1f00, and each round after.

Ansel-2428 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.
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

PyRunner now tracks each exec() before Python is ready, so abort() and dispose() can stop a script during startup.

  • An agent's script stops even when its Python interpreter is still starting.
  • Python scripts settle when their interpreter stops reading input.
  • Failed interpreter spawns no longer lead the runner to signal a process without a pid.
  • Time-limit notes start on a fresh line when the script text has no newline.

Reviews (1) · Last reviewed commit: "fix(code-execution): put the time-limit ..." · Reviewed by Greptile

@nissa-seru
nissa-seru merged commit 2fd2afd into anima-research:main Oct 18, 2026
7 checks passed
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant