Skip to content

fix(code-execution): stop blocking code at the time limit - #240

Merged
antra-tess merged 5 commits into
mainfrom
fix/code-exec-blocking-deadline
Oct 7, 2026
Merged

antra-tess merged 5 commits into
mainfrom
fix/code-exec-blocking-deadline

Conversation

@antra-tess

@antra-tess antra-tess commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Why

A code_execution time limit did not stop blocking code (#235, finding F3). At the deadline the host sends {op:"cancel"}, and the runtime cancels the script's asyncio task, but the cancellation only lands when the event loop gets control back. A script stuck in time.sleep(), a busy loop or a blocking socket read never yields, so it kept running:

  • If it returned within the 10s kill grace, it exited with code 0 and the result had no time-limit note (feat(code-execution): per-call time limit (time_limit_ms) #203), so it read as a success.
  • Otherwise it was killed at deadline + 10s. Its stdout was lost, the message was the older "script killed after exceeding Ns deadline", and the respawn threw away the interpreter's variables.

This applied to every call, including those on the default limit, not only calls that pass time_limit_ms.

What this does

  • Host (py-runner.ts): at the deadline it sends the cancel op and then SIGINT to the interpreter. It repeats the SIGINT every 200ms until the script reports or is killed. It uses process.kill(pid), not child.kill(), because child.kill() marks the child killed: the runner then stops writing to it and respawns it on the next exec. Windows is skipped, because there Node's kill() terminates the process; it keeps the old behavior.

  • Runtime (runtime-py.ts): before asyncio.run it installs its own SIGINT handler. Because the handler isn't the default one, asyncio doesn't install its own, which would cancel main() and end the interpreter.

    • One stop state per script. Each script has a _ScriptStop, owned by the runtime, that moves PENDING → REQUESTED → DELIVERED. Whichever path delivers first moves it to DELIVERED: a KeyboardInterrupt raised in the script's own code, or the cancellation of its task. After that, the queued cancellation, repeated SIGINTs and the cancel op all do nothing, and they never touch a different or finished script. The script's own cleanup (except/finally, and awaits inside them) runs to completion or to the kill; we never stop a script twice.
    • The handler. If the script's own code is on the stack (_run_script's frame, reached before any send frame), the handler raises KeyboardInterrupt there. Anywhere else, it only queues the cancellation with call_soon_threadsafe, which also wakes select(). It never cancels from inside the handler, where a cancellation could land between main()'s done() check and set_result. As defense in depth, those set_result sites tolerate a future that is already done.
    • Before start. A stop requested before the script starts is delivered as the script starts, so the script still reports. Before this change, a cancel op that arrived that early left the script reporting nothing.
    • Refactor. main()'s dispatch moved into _dispatch(), so a test driver can run it. _dispatch() builds the script task with asyncio.Task(...), so a task factory a script installed can't start the next script early.

    Where a signal or the cancel op can land, and what happens. This table is also in the code, and the new test covers each row:

    # Lands Result
    1 In the script's own code, running or blocking KeyboardInterrupt there
    2 Script waiting on an await (loop idle) Cancellation queued; it wakes the loop and lands at that await
    3 main() mid-dispatch, between the done() check of a future the script awaits and set_result (reply behind the cancellation) Queued; set_result completes; the script gets CancelledError instead of the reply
    4 Script writing a protocol line Queued; the line stays whole; the cancellation lands at the script's next await
    5 Reply ahead of the queued cancellation, then the script awaits Cancellation lands at that await
    6 Reply ahead, then the script blocks The next (repeated) signal raises KeyboardInterrupt; the queued cancellation then does nothing, so cleanup that awaits is not cut short
    7 Reply ahead, then the script finishes Queued cancellation does nothing, and the next script is unaffected
    8 Before the script starts (signal or cancel op) Delivered as CancelledError when the script starts
    9 Cancel op before the signal Delivered; the signal and its repeats do nothing; cleanup after a caught CancelledError runs
    10 Cancel op or repeated signal after delivery Nothing
    11 Script finished, reporting its result Nothing
    12 Between scripts Nothing
    13 A previous script installed an eager task factory (interpreter state persists) No effect: the runtime constructs its script task directly, not through the loop's factory, and the stop state is assigned before the script's first step

    The earlier versions used separate flags, and each review round found an interleaving that slipped between them: the first round a waiting script that resumed into blocking code (rows 5 and 6), the second row 3, the third row 6. On the head before the restructure, rows 6, 8 and 10 fail. Row 13 (fourth review round) failed on the head before it was fixed: the next script ran its blocking call to completion.

    It checks the stack rather than asyncio.current_task(). The task also counts as current while asyncio runs its own code between steps (call_soon after a bare yield), and a KeyboardInterrupt raised there escapes the event loop. With a current-task check, a stress run tore a tool_call line and lost an exec_result.

  • Interrupted result: the script's stdout so far is kept. stderr shows where the script was stopped (File "<script>", line N, with the runtime's own frames left out) followed by KeyboardInterrupt: script interrupted by host, and the host appends the feat(code-execution): per-call time limit (time_limit_ms) #203 note. The interpreter and its globals survive.

  • Reader thread: it starts with SIGINT blocked, so the signal reaches the main thread, where the blocking call is.

  • Kill backstop: the 10s grace and SIGKILL stay. They still apply to code that never sees the interrupt: a long call inside C code such as sum(range(10**11)), code that swallows KeyboardInterrupt, and blocking code in a task the script started (the interrupt is raised only in the script's own task). The kill message now names the time limit and says that earlier variables are gone. PyRunner takes cancelGraceMs (default 10s), so the backstop can be tested quickly.

    • Known gap on Python ≤ 3.11, not changed here: asyncio.wait_for can swallow a cancellation when the awaited result arrives at the same moment (a CPython bug, fixed in 3.12). A script waiting on a tool call at the deadline can then lose its cancellation, from the SIGINT or the cancel op, and run on until the kill. The stress run below shows the same hang on unmodified main under 3.9 without any SIGINT, so it isn't caused by this change.
  • Changelog fragment: changelog.d/fix-code-exec-blocking-deadline.fixed.md.

Alternatives that were probed and rejected:

  • _thread.interrupt_main(): it raises only after the blocking call returns.
  • Bare SIGINT with the default handler: on Python ≥ 3.11, asyncio.run's handler cancels main() and the interpreter exits.
  • A subprocess per exec: it breaks the documented persistence of variables between calls.

Tests

  • New: test/code-execution.test.ts has a new describe block, "code_execution time limit vs blocking code", with 9 tests (skipped on win32):

    • time.sleep, a busy loop and a blocking recv each stop at a 1s limit. Each result is not aborted, keeps its stdout, includes the feat(code-execution): per-call time limit (time_limit_ms) #203 note and the script line, and arrives well inside the kill grace. All three run in the same interpreter (same PID).
    • Variables survive an interrupted script.
    • A script waiting on an await is still stopped by the cancel and is not reported as interrupted.
    • A SIGINT between scripts does nothing.
    • A SIGINT while a script waits on a tool call cancels it, even though the tool result arrives and the script would then block. This is deterministic: no cancel op is sent. On the first version of this PR, the script ran its 30s sleep and returned 0.
    • Review's scenario: await asyncio.sleep(1), then time.sleep(5), under a 1s limit, is stopped every time.
    • The stop table, deterministic. test/helpers/pytc-stop-rows.ts is a Python driver that loads the runtime as a module and drives its own _dispatch and SIGINT handler. It lands the signal or the cancel op at each row's exact point: inside the write of a tool_call or exec_result line, between a done() check and set_result, before the first step, and with a real signal during blocking code. Every row of the table is asserted. The driver's results were stable over 10 runs on 3.9 and 3.14.
    • Eager task factory (Python 3.12+, skipped on older versions): one exec installs asyncio.eager_task_factory. The next exec's time.sleep(30) is still interrupted at its 1s limit, not killed.
    • Kill backstop: a script that catches KeyboardInterrupt and keeps blocking is killed after a 500ms grace. The result is aborted and carries the time-limit and lost-variables message. The next exec gets a NameError for a variable set earlier.

    On unmodified main, every test except the await-cancel one fails.

  • Python versions: the code_execution file passes on Python 3.9, 3.11, 3.12, 3.13 and 3.14.

  • Stress: a protocol-level harness ran about 2,000 execs across 3.9 to 3.14, on both the first and the final version. Each exec was stopped at a random moment by cancel plus SIGINT, with extra stray SIGINTs. In the final run, 40% of execs got the SIGINT alone, with no cancel op, and the harness repeated the SIGINT the way the host does. Script shapes were bare-yield loops, tool-call loops with large payloads, gather, sleep, busy and print loops. Results: no interpreter deaths, no torn protocol lines, and globals intact throughout.

  • Full suite: npm run typecheck and npm run build are clean. On the final commit, npm test reported 1229 tests: 1221 passed, 3 failed, 5 skipped. All three failures are mcpl-awareness-barrier startup-gating tests that hit an MCPL timeout under full-suite load. That file doesn't use code_execution, and it passes 25/25 when run alone. It's the known timing flake that also shows up on main.

Refs #235 (F3), #203.

🤖 Generated with Claude Code

The deadline's cancel op is an asyncio cancellation, so it reached a script
only at an await. Blocking code (time.sleep, a busy loop, a blocking read)
ran past the limit: it either returned and read as a success, or was killed
after the 10s grace with its output and the interpreter's variables lost.

At the deadline the host now also sends the interpreter SIGINT (not on
Windows, where Node's kill() ends the process). The runtime installs its own
handler, which raises KeyboardInterrupt only while the running script's
frames are on the stack and at most once per script; anywhere else (between
scripts, a suspended script, the protocol loop, a protocol write) it does
nothing. _run_script catches it, so the result keeps the output so far,
shows the line the script was stopped at, and gets the time-limit note, and
the interpreter and its globals survive. The reader thread blocks SIGINT so
the signal reaches the main thread. The kill grace stays as the backstop,
and its message now names the time limit and the lost variables.

Refs #235 (F3), #203.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5 Tier: apex

[High risk] Changes how code execution timeouts interrupt running scripts.

The PR appears safe to merge; the previous eager-task finding is fixed and no new blocking issue was found.

What we checked:

  • Early signals stay safe: Before the script starts, _on_sigint records a stop without using st.task. Task creation defers the script's first step, where _run_script delivers the recorded stop.

Summary

This PR stops blocking Python scripts at their time limit while keeping their output and saved variables when the interpreter survives.

  • The host sends cancel and repeated SIGINT, with a kill backstop.
  • The runtime stops each script once so its cleanup can finish.
  • The current head fixes the eager-task finding and adds regression coverage. No new issues were found.

Reviews (5) · Last reviewed commit: "fix(code-execution): build the script ta..." · Reviewed by Greptile

Comment thread src/code-execution/runtime-py.ts Resolved
Comment thread test/code-execution.test.ts Resolved
The SIGINT handler did nothing while the script was suspended on an await,
leaving that case to the cancel op. But the script could resume (its timer
fired, a tool result arrived) and enter blocking code before main() read
the op: the deadline then went unenforced and the script returned 0.

A SIGINT that finds the script waiting, or mid-way through a protocol
write, now cancels the script task itself, as asyncio.run's own SIGINT
handler does, and wakes select() so the cancellation runs at once; the
task gets CancelledError at its resume point, before any of its code runs.
In the script's own code it still raises KeyboardInterrupt. Either way the
handler acts once per script, and the cancel op no longer cancels a second
time.

PyRunner takes cancelGraceMs (default 10s), so the kill backstop can be
tested: a script that swallows the interrupt and keeps blocking is killed,
and the next exec starts without the earlier variables.

Refs #235 (F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

@greptileai please re-review at the current head (a4d9e59).

Comment thread src/code-execution/runtime-py.ts Resolved
… handler

Cancelling the script task inside the signal handler could land between
main()'s check of a future the script awaits directly (a wake ack, and a
tool result on Python >= 3.12) and its set_result: the future was
cancelled under it, set_result raised InvalidStateError out of main(), and
the interpreter died.

The handler now only schedules the cancellation with
call_soon_threadsafe (which also wakes select()), bound to the script's
task, so it runs at a loop-iteration boundary. The script stays armed
until the cancellation runs; should it resume into blocking code first,
the next SIGINT interrupts it there, so the host repeats the deadline
SIGINT every 200ms until the script reports or is killed. The runtime
ignores the repeats once the script was interrupted or cancelled, so a
script that catches the cancellation can still finish. main()'s
set_result sites also tolerate an already-done future.

Refs #235 (F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

@greptileai please re-review at the current head (8a67faa).

Comment thread src/code-execution/runtime-py.ts Resolved
Three review rounds each found an interleaving between the SIGINT
handler, the cancellation it queues, and the cancel op, all tracked by
separate flags. The last: a reply let the script run ahead of the queued
cancellation into blocking code, the next SIGINT raised KeyboardInterrupt
there, and the queued cancellation then cut the script's cleanup short.

Each script now has one _ScriptStop, PENDING -> REQUESTED -> DELIVERED,
owned by the runtime. Whichever path delivers first (KeyboardInterrupt in
the script's own code, or the task cancellation, which runs only in loop
context) moves it to DELIVERED; the queued cancellation, repeated
SIGINTs and the cancel op all do nothing after that, and nothing for a
different or finished script. A stop requested before the script started
is delivered as it starts, so it still reports (a cancel op that early
used to leave it reporting nothing). main()'s dispatch moves into
_dispatch() so a driver can exercise it.

The table of where a signal or cancel op can land is in the runtime;
a new test drives each row through the runtime's own functions.

Refs #235 (F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

@greptileai please re-review at the current head (bef4db7).

Comment thread src/code-execution/runtime-py.ts Resolved
…tate

The script task came from asyncio.ensure_future(), which uses the loop's
task factory. Interpreter state persists, so a script can install one; an
eager factory (asyncio.eager_task_factory, 3.12+) then ran the next
script inside _dispatch(), before _stop and st.task were assigned, and
_on_sigint() ignored the deadline during its first blocking call.

_dispatch() now assigns the stop state first and constructs
asyncio.Task directly, which no task factory affects. Row 13 of the stop
table; a PyRunner test and a driver row cover it on 3.12+.

Refs #235 (F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

@greptileai please re-review at the current head (1f3e783).

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Validated two-panel on the scratch rig (both Weft panels; receipts preserved per run). One process note for transparency: our first rig pass produced a false "bun blocker" — the rig had swapped only dist/ while bun's export condition loads src/, so our bun runs were testing main. The second panel caught it from a handler check that made no sense; with src/ swapped too, the divergence vanished entirely. The before/after reds from main are kept in our receipts as the defect's before-picture.

Verdict: APPROVE

Verified first-hand

  1. The PR's tests pass on the repo's real harness (build + pretest +
    node --test over dist): the blocking-code suite and the stop-table
    test, 9/9 matched. Worktree at PR head 1f3e783.
  2. The design: the 13-row landing-site table in runtime-py.ts is
    executed row by row by the stop-rows driver — the analysis IS the test.
    Single _ScriptStop state (stopped exactly once, by whichever path is
    first); the handler raises only with the script's own frame on the
    stack, send short-circuiting so a mid-protocol signal only queues;
    process.kill so the child isn't marked killed and protocol state
    survives; direct asyncio.Task construction dodging a script-installed
    eager task factory; row 6 asserting a queued cancellation can't cut an
    interrupted script's cleanup short. The module-level handler install
    also (deliberately) keeps asyncio.run from installing its own
    main-cancelling handler — confirmed live: getsignal(SIGINT) is the
    runtime's handler immediately before the loop starts, under node AND
    bun (opus's install-real probe on the PR's actual runtime source,
    spawned exactly as PyRunner spawns it).
  3. Direct runner probes, both runtimes (node 20.20.2, bun 1.3.14,
    python 3.14.6, macOS): blocking time.sleep(10) under a 2000ms limit
    stops at ~2.05s on both; stdout before the limit kept; stderr carries
    the script-located traceback (no runtime frames) + the #203-style
    message; same interpreter afterwards; variables survive. Main's build
    under the same probes runs the script to completion on both runtimes —
    the before/after split is the fix, cleanly.
  4. Full-host validation on the deploy runtime (our Campaign-A4
    choreography: scripted mock, real host under bun, zero spend): 11/11
    ×3 consecutive. A4.2a — the spec case this PR exists for — green:
    blocking script stopped at its limit with the documented message.
    A4.6 re-pinned from the old severity (grace-kill at ~17.4s,
    destructive) to the fixed contract: a time.sleep(30) with no
    per-call limit now stops AT the 6s deployment default (~7.3s
    wall-clock including report), output kept, no kill, interpreter
    intact. Refusals, cooperative exactness, above-ceiling capping,
    no-leak-between-calls, and the composed inner-call case all hold
    unchanged. The old A4.2a/A4.6 reds ×3 from main are preserved in the
    receipts as the defect's before-picture.

Non-blocking note for the queue

With the exports map's "bun": "./src/index.ts" condition, bun consumers
execute src/ while CI tests dist/ under node. Same code today — but a bun
leg for the new blocking-code suite (or a src/dist agreement check) would
pin the deploy runtime permanently and catch any future drift of this
class. (This note is about the package's test matrix, not about #240,
which behaves identically on both runtimes.)

Small appreciations

  • The kill-path message now naming the limit and the lost variables turns
    the old opaque line into something an agent can act on.
  • cancelGraceMs as an option is the right seam for tests.
  • Row 8's cancel-op-before-start case ("used to leave it reporting
    nothing") quietly fixes a bug nobody had filed.

@antra-tess
antra-tess merged commit 730d48c into main Oct 7, 2026
6 checks passed
antra-tess added a commit that referenced this pull request Oct 7, 2026
…ool-calls

Resolves framework.ts against #242's per-script deferred end-turn and
#238's pattern check: a foreground script now carries both its RFC-007
origin and its end-turn mark, both cleared in one finally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	src/framework.ts
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…onto current main

GitHub reported the anima-research#247 → anima-research#252 → anima-research#256 → anima-research#257 stack DIRTY against main
02adf27 (June-1784 and Basil-1786, room-203 #51425): the conflict sits at
the bottom, in anima-research#247. Integrated once here, by merge so every reviewed commit
keeps its hash, and propagated upward the same way (Tessa-974 #51438;
Basil-1007 reviews the resolutions, #51449).

main since 64c480b brings anima-research#236 (reject visibly-empty push and channel
content), anima-research#238 (zero-match pattern and feature-set diagnostics), anima-research#240 and
anima-research#242 (code-execution deadlines and inner results), anima-research#241 (silent heartbeat
turn rows) and anima-research#237 (lifecycle tool calls from code_execution scripts).

Two textual conflicts, both additive:
- src/mcpl/push-handler.ts imports: main's EmptyContentError and
  visible-content helpers beside anima-research#247's InboundSource type.
- src/framework.ts: main's new silentTurnRowMetadata() sits before
  recordProseSuppression(); that method keeps anima-research#247's doc comment ("suppressed
  without drafts (proseRouting=disabled)"), which is what its body does
  since anima-research#247 holds explicit-send-suppressed prose as drafts.

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 12, 2026
anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221) into fix/workspace-truthful-listings

Brings the branch up to main 956c6d2. GitHub reported the PR as
conflicting after anima-research#169's merge (6c07b6a), so CI didn't run on 2ae3554;
git's merge is clean, with no file conflicts.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
Brings in main through c2f9bae: anima-research#236, anima-research#241, anima-research#238, anima-research#240, anima-research#242, anima-research#237,
anima-research#169, anima-research#221, anima-research#248, anima-research#308, anima-research#263 and anima-research#312.

One conflict, in src/framework.ts's four MCPL handlers (push/event,
inference/request, channels/incoming and host/command). anima-research#263 wraps each
handler in answerOnce and refuseServerItem, and refuses the item after
the barrier wait once the queue has closed. This branch replaced each
handler's single-barrier wait with awaitDiscordAwarenessBarriers(),
called only while a generation is current. Resolved by keeping anima-research#263's
structure and putting this branch's wait where it waited on the barrier:

  if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers();

So the queue-closed check runs once every generation armed while the
handler waits has been waited out, and with no gate up an event still
reaches its handler in the turn it arrives.

Everything else merged without conflict.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants