fix(orchestrator): interrupt-proof teardown so a timeout can't drop task.json#29
Conversation
…h; interrupt-proof teardown Two gaps surfaced by a lost nightly (a preserved workspace's dangling .venv/bin/python symlinks broke the run-dir artifact publish): 1. Only capture_to filtered _WORKSPACE_CAPTURE_IGNORE. preserve_to (MOVE_ON_WRITE) moved the raw workspace and DIRECT_WRITE — the docker driver's default — never copies, so agent-created .venv/node_modules (sandbox-only symlinks) and credential-store names persisted verbatim in run_dir/artifacts. All three paths now prune the same ignore set. 2. A task-timeout CancelledError delivered while _run_post_run_commands awaits aborted run()'s finally block wholesale: _cleanup() was skipped (tempdir leaked, workspace never pruned/granted) and _finalize_result() was skipped (task.json lost). Teardown now catches the interrupt, completes cleanup + finalize, then re-raises it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vation-prune Preservation-path pruning is covered downstream by coder_eval_uipath #57 and isn't reachable on current configs; keep this PR to the teardown fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The .venv/publishing framing conflated this fix with pipeline #57; _cleanup() does not remove the .venv. Comment now reflects what interrupt-proofing fixes: tempdir leak + lost task.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @bai-uipath's task in 1m 18s —— View job Code Review In Progress
|
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:29
Scope: pr:29 · branch bai/prune-preserved-workspaces · d31f46f · 2026-07-17T22:53Z · workflow variant
Change class: complex — reworks run()'s teardown control flow to catch a task-timeout watchdog interrupt (CancelledError) or exception during post-run, complete cleanup + finalize, then re-raise; touches asyncio cancellation and error-handling semantics on the orchestrator hot path
coder_eval is in excellent shape (9.6/10, clean on type safety, security, error handling, and API surface), and the sole real risk is a single confirmed correctness gap in the timeout-teardown fix: the watchdog interrupt guard covers only post-run commands, so default empty-post_run tasks can still drop task.json on cancellation — silently changing metric denominators for identical agent output — with the remaining items being complexity/cohesion and test-coverage debt around that same code.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 9 / 10 | 0 | 1 | 0 | 0 | Orchestrator.run cyclomatic complexity crosses the >20 hot-module threshold (CC 21, grade D) |
| 2. Type Safety | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 3. Test Health | 8.9 / 10 | 0 | 1 | 0 | 1 | Teardown tests only cover post-run interrupt; the cleanup-phase / empty-post_run interrupt path the fix leaves broken is untested |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 9.9 / 10 | 0 | 0 | 0 | 1 | Interrupt-capture teardown inlined into run()'s finally instead of a cohesive _teardown() helper |
| 6. Error Handling & Resilience | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 7. API Surface & Maintainability | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 8. Evaluation Harness Quality | 9 / 10 | 0 | 1 | 0 | 0 | Interrupt guard covers only post-run, not _cleanup()/_finalize_result(); default empty-post_run tasks still drop task.json on watchdog cancel |
Overall Score: 9.6 / 10 · Weakest Axis: Test Health at 8.9 / 10
Totals: 🔴 0 · 🟠 3 · 🟡 0 · 🔵 2 across 8 axes.
Blockers
- [Axis 1] Orchestrator.run cyclomatic complexity crosses the >20 hot-module threshold (CC 21, grade D) (
src/coder_eval/orchestrator.py:399) — The added nestedtry/exceptin run()'s finally block pushedOrchestrator.runfrom CC 19 to CC 21 (verified:radon ccreportsM 399:4 Orchestrator.run - D (21)on the PR head), matching the Axis-1 High anchor 'cyclomatic complexity > 20 in a hot module (orchestrator)'. Extract the whole finally-block teardown sequence (_refresh_runtime_tool_versions->_run_post_run_commands->_cleanup-> log-tail capture ->_finalize_result-> re-raise) into oneasync def _teardown(self, start_time, log_tail) -> Nonehelper and call it from the finally. This drops run() back below the threshold and, if the helper wraps ALL teardown steps, also closes the gap in finding #2. - [Axis 3] Teardown tests only cover post-run interrupt; the cleanup-phase / empty-post_run interrupt path the fix leaves broken is untested (
tests/test_teardown_interrupt.py:62) — Both tests inject the watchdog interrupt only at the post-run raise site and mock cleanup away — line 61patch.object(orch, "_run_post_run_commands", AsyncMock(side_effect=asyncio.CancelledError()))+ line 62patch.object(orch, "_cleanup", cleanup)(mirrored at lines 82-83). But in orchestrator.py the new try/except guards ONLY_refresh_runtime_tool_versions()+await self._run_post_run_commands()(pr-29 orchestrator.py lines 570-575);await self._cleanup()at line 582 is OUTSIDE the guard, and_cleanupperforms multiple real await points (await self.agent.stop(),await asyncio.to_thread(self.sandbox.capture_to/preserve_to/grant_read_access)at orchestrator.py:2091,2107,2111,2124). The task-timeout watchdog fires on a wall-clock timer independent of execution position: if post-run finishes first (or there are no post-run commands), the singletask.cancel()lands during_cleanup()instead, so_finalize_result(start_time)(orchestrator.py:596) is skipped and task.json is dropped — the EXACT failure mode the PR claims to fix. The fix comment's assumption 'the watchdog cancels exactly once, so the teardown awaits below run normally' only holds when the cancel lands in post-run, not when it lands in the longer, I/O-heavy cleanup region. Add a test that raisesasyncio.CancelledErrorfrom_cleanup(leaving_run_post_run_commandssucceeding) and asserts_finalize_resultstill ran (orch.result.completed_at is not None); it will fail against the current code, exposing that the guard must be extended to wrap_cleanup(and the log-tail/finalize block) too, not just post-run. - [Axis 8] Interrupt guard covers only post-run, not _cleanup()/_finalize_result(); default empty-post_run tasks still drop task.json on watchdog cancel (
src/coder_eval/orchestrator.py:582) — The new try/except wraps onlyself._refresh_runtime_tool_versions()+await self._run_post_run_commands()(lines 573-574).await self._cleanup()(line 582) andself._finalize_result(start_time)(line 596) remain OUTSIDE the guard. Trigger:post_rundefaults to an empty list (models/tasks.py:395default_factory=list), so for the common task_run_post_run_commands->_run_command_listhitsif not commands ...: return(orchestrator.py:1975) and never suspends. The watchdog delivers cancellation vialoop.call_soon_threadsafe(task.cancel)(agents/watchdog.py:100-101); a cancel requested at the eval-loop boundary but not yet delivered therefore lands on the FIRST real suspension inside_cleanup()—await self.agent.stop()orawait asyncio.to_thread(self.sandbox.capture_to, ...).CancelledErroris a BaseException, so_cleanup()'sexcept Exception(orchestrator.py:2091) does not catch it; it escapes the finally block, skipping theerror_log_tailcapture (line 588-595) AND_finalize_result()(line 596), which is the sole writer of task.json (atomic write at ~line 715). The row then silently vanishes from the run, changing metric denominators for identical agent output — exactly the Axis-8 scoring-correctness class. The PR's own commit message ('interrupt-proof teardown so a timeout can't drop task.json') and code comment overclaim: the fix closes the post-run path but leaves the DEFAULT-config path (empty post_run) unguarded. Not a regression (baseline dropped task.json on any finally-block interrupt), hence High rather than Critical, but the fix is incomplete on the exact path it targets. Fix: bring_cleanup()and_finalize_result()under the same interrupt-accumulation pattern — e.g. wrapawait self._cleanup()in its own try/except that folds a caught(Exception, asyncio.CancelledError)intoteardown_interrupt, so_finalize_result(start_time)always runs before the final re-raise.
Non-blocking, but please consider before merge
None.
Nits
- [Axis 3] Cancellation test asserts only the exception type, not that the SAME instance is re-raised unchanged (
tests/test_teardown_interrupt.py:64) — The module docstring states the contract is to 're-raise the original exception unchanged', and orchestrator.py:597-598 doesraise teardown_interrupton the captured instance. The cancellation test asserts only the class viapytest.raises(asyncio.CancelledError)(line 64) and never binds the injected instance, so a regression that swallowed the original and raised a freshCancelledError()would pass. Bind the injected exception to a variable and assertexcinfo.value is <that instance>(the plain-exception test at line 79 already pins the message viamatch="post-run blew up", so only the cancellation case is weak on identity). - [Axis 5] Interrupt-capture teardown inlined into run()'s finally instead of a cohesive _teardown() helper (
src/coder_eval/orchestrator.py:559) — The fix is correct, but it deepens an already-~200-line hot method (run()spans lines 399-600) rather than extracting the teardown into a helper. The finally block (lines 559-598) now performs a full seven-step teardown sequence inline: interrupt-guarded post-run (teardown_interrupt: BaseException | None = None/try: self._refresh_runtime_tool_versions(); await self._run_post_run_commands() except (Exception, asyncio.CancelledError) as e: teardown_interrupt = e), thenawait self._cleanup(), the log-tail allowlist block,self._finalize_result(start_time), and finallyif teardown_interrupt is not None: raise teardown_interrupt. This nested try/except-plus-deferred-reraise is what droveOrchestrator.runCC from 19 to 21 (C->D per the routed radon signal). Extracting this coherent unit into anasync def _teardown(self, start_time) -> BaseException | Nonehelper (returning the captured interrupt for the caller to re-raise) would restore run()'s finally to a couple of lines, keep the interrupt-proofing logic testable in isolation, and pull the method back under the CC-20 line for the orchestrator hot module. This is a cohesion nit only — the current code is correct and the divergence is not painful today (per the Axis-5 Low anchor: 'method that could be split for cohesion but isn't painful today').
What's Missing
Tests:
- 🟠 No test exercises the cleanup-phase interrupt the fix leaves broken: both new tests inject the failure at
_run_post_run_commands(lines 61/82) and mock_cleanupaway (62/83), so the branch where a watchdog cancel lands inside_cleanup()— the common default empty-post_runcase — and skips_finalize_resultis never run. Add a test that raisesasyncio.CancelledErrorfrom_cleanupwith post-run succeeding and asserts_finalize_resultstill ran (orch.result.completed_at is not None); it will fail against current code. (trigger: tests/test_teardown_interrupt.py) (restates: Axis 3: Teardown tests only cover post-run interrupt; cleanup-phase path untested)
Parallel paths:
- 🟡 The parallel
DockerRunnerpath guarantees thetask.jsoncontract with_write_synthetic_task_json(writes a synthetic ERROR record when the container dies before finalizing), but the in-processOrchestrator.run()finally being hardened here has no equivalent fallback — so on the still-unguarded_cleanup()-interrupt path the in-process row produces notask.jsonon disk at all. The PR hardened only in-process teardown and did not bring it to parity with the driver it mirrors. (trigger: src/coder_eval/orchestrator.py) _(restates: Axis 8: Interrupt guard covers only post-run, not _cleanup()/finalize_result())
Downstream consumers:
- 🟡 Disk-based re-aggregation consumers assume a
task.jsonexists for every attempted row:recover_task_results/aggregate_run_dir(rglob overtask.json) and the--resumealready-complete check both skip a row with notask.json. On the incomplete-guard path batch.py still folds the escapedCancelledErrorinto an in-memory ERRORTaskResult(post-gather BaseException handler), so the live run.json disagrees with what a later disk re-aggregation sees — the row silently drops from the recomputed denominators (and--resumewould re-run it). (trigger: src/coder_eval/orchestrator.py) _(restates: Axis 8: Interrupt guard covers only post-run, not _cleanup()/finalize_result())
Daily/nightly:
- 🟠 The change edits the production run path (the finally that writes
task.json) and is motivated by a task-timeout scenario, but the PR never states the nightly blast radius: with the default emptypost_run(the common nightly task) the guard does not cover_cleanup(), so an in-process nightly still dropstask.jsonon atask_timeoutwatchdog cancel, and the cross-repo consumer (coder-eval-uipath / eval-runner reading per-rowtask.json) loses that row. Docker-driver nightlies are shielded by the host-side synthetic fallback; in-process nightlies are not — call this out explicitly. (trigger: src/coder_eval/orchestrator.py) _(restates: Axis 8: Interrupt guard covers only post-run, not _cleanup()/finalize_result())
Harness & Lint Improvements
Static checks (lint / type):
- [ruff] Enable ruff's mccabe complexity gate on the hot modules to make CC>20 fail in
make check/CI before review. In pyproject addC901to[tool.ruff.lint] selectand set[tool.ruff.lint.mccabe] max-complexity = 20. Because several existing orchestrator methods already exceed 20 (_simulation_dialog_loopCC40,_finalize_resultCC26,_check_run_limitsCC24,_evaluation_loopCC23), grandfather them with an explicit# noqa: C901(or per-file-ignore) at each site — the noqa list itself makes the pre-existing debt visible and, crucially, any NEW crossing (likeOrchestrator.rungoing 19→21) fails the build because a fresh function has no grandfathering. If per-function noqa grandfathering is undesirable, encode the same intent as a custom CE025 rule modeled on CE022: an AST/radon-based per-function CC ratchet overorchestrator.py/checker.py/sandbox.pywith a baseline allowlist of the current over-threshold functions, firing when any other function crosses CC 20 or a listed one worsens. Prevents: Finding #1 (Orchestrator.run CC 21 > 20 in a hot module) and the complexity-driver half of Finding #4 (the inlined interrupt-capture teardown that pushed run() from CC 19 to 21) — both would have failed the gate at the moment run() crossed 20, forcing the_teardown()extraction before merge.
Harness improvements (not statically reachable):
- Extend tests/test_teardown_interrupt.py from a single post-run injection site into a parametrized test that injects
asyncio.CancelledError(and a plain Exception) at EVERY teardown seam in run()'s finally —_run_post_run_commands,_cleanup(with post-run succeeding), the log-tail block, and_finalize_result— and, without mocking_cleanup/_finalize_resultaway, asserts for each seam thatorch.result.completed_at is not Noneand that task.json was actually written. Against current code the_cleanupcase fails (completed_at stays None), which is exactly what exposes that the interrupt guard must wrap_cleanup+log-tail+_finalize_result, not just post-run. Why not static: The defect only manifests at runtime: it requires an already-delivered watchdog cancel landing on the first suspendingawaitinside_cleanup(reachable precisely because the DEFAULT emptypost_runnever suspends) and then asserting a persisted filesystem artifact (task.json) still exists. Which teardown steps must always run under interrupt is a semantic property of the finally block, not a grep-/AST-detectable shape. Prevents: Finding #2 (cleanup-phase / empty-post_run interrupt path is untested) and Finding #5 (guard covers only post-run, so a watchdog cancel during _cleanup skips _finalize_result and silently drops task.json, changing metric denominators for identical agent output). - Strengthen the cancellation case in tests/test_teardown_interrupt.py to pin re-raise IDENTITY, not just the exception class: bind the injected
CancelledErrorinstance, wrap the call inpytest.raises(asyncio.CancelledError) as excinfo, and assertexcinfo.value is <injected instance>. The plain-exception test already pins its message viamatch=; only the cancellation path is identity-weak. Why not static: The contract ('re-raise the ORIGINAL exception unchanged' per the module docstring) is semantic — a regression that swallows the original and raises a freshCancelledError()is type-identical, so no type or AST check distinguishes it. A lint rule flagging everypytest.raises(X)used without value-binding/match=would be far too noisy (most type-only assertions are legitimate) to be worth enforcing. Prevents: Finding #3 (cancellation test asserts only the exception type, so a swallow-and-re-raise-fresh regression of the teardown re-raise contract would pass unnoticed).
Top 5 Priority Actions
- Extend the interrupt guard so it also wraps
await self._cleanup()(src/coder_eval/orchestrator.py:582) andself._finalize_result(start_time)(line 596) — fold a caught(Exception, asyncio.CancelledError)intoteardown_interruptbefore the final re-raise — so a watchdog cancel landing in cleanup (the common empty-post_run path) still writes task.json instead of dropping the row and skewing metrics. - Add a regression test that raises
asyncio.CancelledErrorfrom_cleanupwhile post-run succeeds and asserts_finalize_resultstill ran (orch.result.completed_at is not None) at tests/test_teardown_interrupt.py:62 — it fails against current code and pins the cleanup-phase path the two existing tests mock away. - Extract the seven-step finally teardown sequence into one
async def _teardown(self, start_time)helper (src/coder_eval/orchestrator.py:399/559), which dropsOrchestrator.runback below the CC-20 hot-module threshold (currently D/21) and resolves the Axis-5 cohesion nit in the same change while making the interrupt logic unit-testable in isolation. - Strengthen the cancellation test to bind the injected exception and assert
excinfo.value is <that instance>(tests/test_teardown_interrupt.py:64) so a regression that swallows the original and raises a freshCancelledError()is caught, matching the module's 're-raise unchanged' contract. - Follow up on the higher-CC sibling hot methods flagged in the same module —
_simulation_dialog_loopE(40),_finalize_resultD(26),_check_run_limitsD(24),_evaluation_loopD(23) in src/coder_eval/orchestrator.py — as a maintainability backlog since they already exceed the threshold that run() only marginally crossed.
Stats: 0 🔴 · 3 🟠 · 0 🟡 · 2 🔵 across 8 axes reviewed.
mohsen-uipath
left a comment
There was a problem hiding this comment.
LGTM — reviewed the diff and verified the load-bearing assumption.
What it does: Previously a task-timeout CancelledError thrown during post-run flew straight out of the finally, so _cleanup() and _finalize_result() were skipped entirely (leaked tempdir + lost task.json, dropping the task from the rollup denominator). Now the interrupt is caught and held, teardown runs to completion, and the same exception is re-raised — cleanup/finalize always execute, and callers observe the same outcome.
Verified assumption: The fix relies on the watchdog cancelling exactly once. Confirmed in agents/watchdog.py::_fire — single-shot threading.Timer guarded by _fired under a lock, so task.cancel is scheduled at most once; the awaits after the catch then run normally. It also hard-kills the subprocess before cancelling, so the now-guaranteed _cleanup() never waits on a live process.
Tests: Both interrupt kinds (cancellation + plain exception) covered — cleanup awaited, result finalized.
Two non-blocking notes: (1) no task.uncancel(), which is fine because the fix re-raises rather than swallows the cancellation (it's honored, not hijacked); (2) teardown now always runs with no deadline of its own — a hanging _cleanup() couldn't be rescued by the already-spent watchdog, but that's low risk since the subprocess is already dead before cleanup starts.
Good call narrowing scope to the teardown fix and deferring the .venv/symlink hygiene to coder_eval_uipath #57.

The bug
The task-timeout watchdog cancels the running asyncio task, delivering a
CancelledErrorat the nextawait. When that lands inside_run_post_run_commands(post-run commands do awaited I/O), the cancellation used to abortrun()'sfinallyblock wholesale:_finalize_result()skipped → the task's task.json is never written, so it silently drops out of the run and out of the rollup denominator_cleanup()skipped → its tempdir leaksFix
run()'s teardown is now interrupt-proof: an exception (includingCancelledError) raised during the post-run phase is held, the full teardown completes (cleanup → log-tail → finalize), then the original exception is re-raised so callers observe the same outcome as before. The watchdog cancels exactly once, so the awaits after the catch run normally.Scope
Deliberately narrow — just the teardown correctness fix. An earlier revision of this branch also pruned capture-ignored entries (
.venv/node_modules/credential stores) on thepreserve_to/DIRECT_WRITE preservation paths; that has been dropped and deferred to its own hygiene PR, as it's defense-in-depth for a workspace layout that isn't currently reachable.🤖 Generated with Claude Code