Agent task execution runtime
Concurrent execution, timeouts, cancellation and process group cleanup for agent tool calls. A runtime experiment driven by minimal reproduction → root cause → fix → combined fault injection and mutation testing.
- Status
- Active
- Published
- Oct 10, 2026
- Updated
- Oct 29, 2026
- Tech
- Python · asyncio · Linux · pytest
- Visibility
- Source not public
A task executor built as a foundation for agent runtimes / harnesses, focused on concurrency control, timeouts, cancellation propagation, child process group cleanup and pipe deadlocks when an agent calls local commands and tools.
The project is developed in collaboration with AI: I own the semantic boundaries, the acceptance matrix, failure reproduction and part of the root-cause analysis (and wrote the combined fault injection regression tests myself); a coding agent does most of the implementation and built the mutation testing tool.
Six core problems solved
- Pressing Esc twice, the second cancellation doesn’t take effect (about 1.7 seconds
of extra waiting)
- Found: in the acceptance matrix the design intent expected an exit in 0.6 seconds, but it actually took 2.31 seconds.
- Root cause: once Python’s
asyncio.TaskGroupsets_abortingon the first cancellation, it no longer forwards later cancellations to child tasks; the existing unit tests called the low-level function directly and bypassed the batch composition layer. - Fix: the batch executor now awaits child tasks itself and forwards every external cancellation (without forwarding again on an internal fail-fast); after the fix it measured 0.62 seconds.
- After one timeout an indexing task never runs again (a stale lock file)
- Root cause: on timeout
SIGKILLwas sent straight away, so the tool’s cleanup that deletes its lock file never got to run. - Fix: send
SIGTERMto the whole process group first, return as soon as the group is empty within the grace period, and sendSIGKILLto whatever is still running when it expires.
- Root cause: on timeout
- Esc shows “cancelled”, but the background process keeps writing
- Root cause: the cancellation exception is raised directly at the
await, and the original code only caughtTimeoutError, so all cleanup was skipped. - Fix: process group cleanup runs in a separate task protected by
asyncio.shield; if another cancellation arrives during cleanup the grace period is cut short and escalated toSIGKILL; the cancellation is re-raised only after the cleanup result is written to the record and handed to the caller.
- Root cause: the cancellation exception is raised directly at the
- A failed build recorded as a success, and cleanup status misreported
- Root cause: the scheduling layer and the executor each kept their own timers and did their own cleanup, and only checked whether an exception was raised, not the child process’s return code.
- Fix: the executor is solely responsible for timing and cleanup, and the
scheduling layer only translates results; the task result (
state) and the resource cleanup outcome (cleanup) are recorded as two independent dimensions, and a backstop timer identifies a hang in the executor itself.
- Deadlock on large output, or a background descendant making the read wait forever
for EOF
- Root cause: waiting for the process to exit before reading its output lets the
OS pipe fill up and deadlock; for commands like
sh -c "sleep 20 & echo ok", the background child inherits the write end of the stdout pipe, so EOF never arrives. - Fix: the direct child’s exit marks the end of the command; output is read
continuously during execution, keeping only the last 64 KiB; the whole process
group is cleaned up afterwards; if a descendant escapes the process group with
setsidand holds the pipe, reading is abandoned after at most 0.5 seconds andcleanupis honestly reported asINCOMPLETE.
- Root cause: waiting for the process to exit before reading its output lets the
OS pipe fill up and deadlock; for commands like
- How do you prove the tests actually catch bugs?
- Combined fault injection: tests built from “never-seen combinations of
failures” — for example a tool with slow cleanup releasing an escaping process
during cleanup (F1), and 12 tasks that ignore
SIGTERMqueued in three waves with Esc pressed twice midway through the second wave (F2). - Mutation testing: a self-built, targeted mutation testing script runs all 114 tests under Python 3.14, and all 45 code mutants are detected (0 survive).
- Sandboxed code review: a small local model (
qwen2.5-coder 7B) wrote cleanup logic from the first draft of the requirements; it was run in auser + PID namespacesandbox and reviewed by hand to find logic holes the tests did not cover.
- Combined fault injection: tests built from “never-seen combinations of
failures” — for example a tool with slow cleanup releasing an escaping process
during cleanup (F1), and 12 tasks that ignore
Current status and known limits
- Status: wrapping up
v0.1(private repository; the full source and ADR records can be shared for interviews or conversations). - Known limits:
- descendants that escape the process group with
setsidcan currently only be reported honestly asINCOMPLETE; Linuxcgroup v2is planned for forced cleanup; - pure Python coroutine tools cannot be force-terminated the way subprocesses can (untrusted tools must run as subprocesses);
- task records are kept in memory for now; SQLite persistence and restart recovery
are planned for
v0.2.
- descendants that escape the process group with