Skip to content

fix(subagent): harden child failure routing - #106

Merged
delete-cloud merged 7 commits into
mainfrom
fix/adr-0018-subagent-hardening
May 5, 2026
Merged

delete-cloud merged 7 commits into
mainfrom
fix/adr-0018-subagent-hardening

Conversation

@delete-cloud

@delete-cloud delete-cloud commented May 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Add ADR-0018 PR2 task packet and acceptance checks for cancellation, failure, cleanup, and summary routing.
  • Route child adapter failures, interruption, and close failures through deterministic subagent summaries while preserving cancellation propagation.
  • Preserve owner-fencing fail-fast semantics through generic toolset and parallel batch execution.

Verification

  • uv run pytest tests/agentkit/tools/test_toolset.py tests/coding_agent/plugins/test_parallel_executor.py tests/coding_agent/tools/test_subagent.py tests/coding_agent/subagents/test_coordinator.py -v
  • uv run pytest tests/coding_agent/test_bootstrap.py tests/ui/test_session_manager_public_api.py tests/ui/test_session_manager_owner_checks.py -k "subagent or cloud" -v
  • uv run python -m compileall -q src tests/agentkit tests/coding_agent tests/ui
  • Manual QA driver covered run exception summary, interrupted summary, adapter close failure summary, cancellation cleanup, toolset owner conflict, and parallel owner conflict.

Review

  • Oracle bounded review found a P2 owner-fencing wrapper gap; fixed and retested.

Summary by CodeRabbit

  • Bug Fixes

    • More reliable cancellation and timeout handling for child tasks; deterministic, user-visible subagent summaries replace raw adapter errors
    • Ensures cleanup actions run and cleanup failures are surfaced as deterministic summaries
    • Fail-fast propagation for critical session/ownership failures during parallel tool execution
  • Tests

    • Added tests covering interruption, execution errors, cleanup failures, and fatal error propagation in parallel runs

delete-cloud and others added 4 commits May 5, 2026 01:37
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@coderabbitai

coderabbitai Bot commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@delete-cloud has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 47 minutes and 37 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e583e89f-7f0a-4582-8910-bbd68cba8e86

📥 Commits

Reviewing files that changed from the base of the PR and between 22c3b73 and 47fc18c.

📒 Files selected for processing (2)
  • coding-agent/src/coding_agent/tools/subagent.py
  • coding-agent/tests/coding_agent/tools/test_subagent.py
📝 Walkthrough

Walkthrough

This PR adds a FatalToolExecutionError type, makes session ownership conflicts fatal, updates parallel executor and toolset flows to re-raise fatal errors, and hardens subagent dispatch/cleanup to emit deterministic, model-visible summaries for interrupts, run exceptions, and adapter cleanup failures; tests and ADR docs are updated accordingly.

Changes

Fatal Error Propagation & Subagent Hardening

Layer / File(s) Summary
Exception Definition
coding-agent/src/agentkit/tools/toolset.py, coding-agent/src/agentkit/tools/__init__.py
Introduce FatalToolExecutionError and export it via __all__.
Toolset Execution Control Flow
coding-agent/src/agentkit/tools/toolset.py
_execute_batch, _execute_one_from_hook, and _envelope_raw_result detect and re-raise FatalToolExecutionError instead of wrapping it as a tool-result error.
Parallel Executor Integration
coding-agent/src/coding_agent/plugins/parallel_executor.py
Per-tool runner now re-raises FatalToolExecutionError (non-fatal exceptions remain JSON-wrapped); batch orchestration moved from asyncio.gather to asyncio.TaskGroup with except* FatalToolExecutionError propagation and index-keyed result collection.
Session Owner Exception Hierarchy
coding-agent/src/coding_agent/ui/session_owner_store.py
SessionOwnershipConflictError now subclasses FatalToolExecutionError, making owner-conflict a fail-fast fatal error.
Subagent Cleanup & Failure Summary
coding-agent/src/coding_agent/tools/subagent.py
_summarize_subagent_outcome adds explicit StopReason.INTERRUPTED handling. subagent_dispatch wraps run/cleanup with try/except/finally to capture consumer/adapter cleanup errors, combine them into deterministic TurnOutcome(ERROR, ...) (including timeout context), preserve CancelledError semantics while ensuring cleanup runs, and append hidden child tape entries with skip_context=True and subagent_child=True.
Tests & Documentation
coding-agent/tests/coding_agent/plugins/test_parallel_executor.py, coding-agent/tests/coding_agent/tools/test_subagent.py, coding-agent/.opencode/prompts/tasks/adr-0018-pr2-cancellation-failure-summary-routing.md, coding-agent/docs/adr/0018-owner-local-cloud-aware-subagent-orchestration.md
Add tests exercising fatal propagation (owner conflict) through Toolset/ParallelExecutor, sibling cancellation behavior on fatal errors, and multiple subagent summary scenarios (interrupted, run exception, adapter close failure, consumer-close-cancelled). ADR/doc acceptance criteria and test run instructions updated.

Sequence Diagram

sequenceDiagram
    participant Client as Client/Caller
    participant Toolset as Toolset
    participant Parallel as ParallelExecutor
    participant Subagent as Subagent/Tool
    participant Parent as Parent Session / Publisher
    Note over Client,Parent: FatalToolExecutionError propagation & deterministic subagent summaries

    Client->>Toolset: execute_tools(tool_calls)
    Toolset->>Parallel: dispatch batch
    Parallel->>Subagent: run tool (concurrent)
    
    alt Fatal (SessionOwnershipConflictError)
        Subagent-->>Parallel: raise FatalToolExecutionError
        Parallel-->>Toolset: re-raise fatal (taskgroup propagates)
        Toolset-->>Client: exception escapes (no wrapped result)
    else Non-fatal run error
        Subagent-->>Parallel: raises Exception
        Parallel-->>Parallel: capture -> json error result
        Parallel-->>Toolset: return error result
        Toolset-->>Client: wrapped error result
    end

    Note over Subagent,Parent: Subagent outcome publishing
    Subagent->>Subagent: run_turn / cleanup
    alt Interrupted or Run Exception
        Subagent->>Parent: publish deterministic "interrupted" / "failed" summary (model-visible)
        Subagent->>Parent: append hidden child tape entry (meta.skip_context/meta.subagent_child)
    else Cleanup Failure
        Subagent->>Parent: publish composed cleanup error summary (includes timeout context if any)
        Subagent->>Parent: append hidden child tape entry
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped through logs at break of day,
Fatal flags now clear the way—
Errors that must loudly shout,
Escape the wraps and sort things out.
Subagents tidy, summaries sing,
Clean closure left in springtime's ring.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(subagent): harden child failure routing' clearly and specifically summarizes the main change—improving how child subagent failures and error conditions are routed through deterministic summaries rather than leaking raw errors. It directly matches the primary intent of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/adr-0018-subagent-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
coding-agent/src/coding_agent/plugins/parallel_executor.py (1)

128-158: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

asyncio.gather does not cancel sibling tasks when FatalToolExecutionError propagates — other tools continue executing after an ownership conflict.

When return_exceptions=False (the default), the first raised exception is immediately propagated to the awaiting caller, but other awaitables in the sequence won't be cancelled and will continue to run.

This means when one _run_one coroutine raises SessionOwnershipConflictError, the execute_batch call sees the exception immediately, but the other coroutines in the same batch keep executing as background tasks. Since DependencyAnalyzer allows two file_write operations on different files in the same batch, a write-to-b.py can commit its side effects even after an ownership conflict was detected on the a.py write.

This contradicts the PR's stated "fail-fast" objective. asyncio.TaskGroup (Python 3.11+) is the recommended alternative and provides stronger safety guarantees: if any task raises, TaskGroup cancels the remaining scheduled tasks.

♻️ Proposed fix using `asyncio.TaskGroup`
-            batch_results = await asyncio.gather(
-                *[_run_one(idx, call) for idx, call in batch]
-            )
-            for idx, result in batch_results:
-                results_by_index[idx] = result
+            task_items = list(enumerate([_run_one(idx, call) for idx, call in batch]))
+            async with asyncio.TaskGroup() as tg:
+                tasks = [tg.create_task(coro) for _, coro in task_items]
+            for task, (_, _) in zip(tasks, task_items):
+                idx, result = task.result()
+                results_by_index[idx] = result

Note: TaskGroup raises ExceptionGroup on failure. If the callsite needs to unwrap FatalToolExecutionError from it, add an except* handler or unwrap in execute_batch.

If TaskGroup is not available (Python < 3.11), an alternative is to create explicit Task objects and cancel them on FatalToolExecutionError:

tasks = [asyncio.ensure_future(_run_one(idx, call)) for idx, call in batch]
try:
    batch_results = await asyncio.gather(*tasks)
except FatalToolExecutionError:
    for t in tasks:
        t.cancel()
    raise
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@coding-agent/src/coding_agent/plugins/parallel_executor.py` around lines 128
- 158, The current use of asyncio.gather inside the batch loop lets sibling
_run_one tasks continue running when one raises FatalToolExecutionError; change
execute_batch's batch execution so that sibling tasks are cancelled when a
FatalToolExecutionError occurs: preferably replace asyncio.gather with an
asyncio.TaskGroup (Python 3.11+) to let the group cancel remaining tasks on
first failure (handle ExceptionGroup/FatalToolExecutionError as needed), or if
TaskGroup isn't available, create explicit asyncio Tasks for each _run_one,
await them with asyncio.gather inside a try/except that on
FatalToolExecutionError iterates over the created tasks and calls cancel()
before re-raising; ensure the semantics around _semaphore and results_by_index
remain unchanged.
🧹 Nitpick comments (1)
coding-agent/tests/coding_agent/tools/test_subagent.py (1)

477-487: 💤 Low value

Consider tracking close() calls to document expected cleanup behavior for INTERRUPTED.

RaisingAdapter in the adjacent run_exception test explicitly verifies events == ["adapter-close"] to assert that close() is called after a thrown exception. InterruptedAdapter has no close() at all, so the test is silent on whether close is expected to run (or be skipped) for the INTERRUPTED path — an outcome that the PR explicitly adds cleanup handling for. Adding a minimal tracking stub would either confirm parity with the exception path or explicitly document that interruption skips close.

🔧 Suggested addition
+    events: list[str] = []
+
     class InterruptedAdapter:
         def __init__(self, **_kwargs: Any) -> None:
             pass

         async def run_turn(self, _goal: str) -> TurnOutcome:
             return TurnOutcome(
                 stop_reason=StopReason.INTERRUPTED,
                 error="Cancelled by user",
             )
+
+        async def close(self) -> None:
+            events.append("adapter-close")

Then add an assertion at the end of the test documenting the expected behaviour, e.g.:

# Assert close IS called (or assert events == [] if INTERRUPTED skips close)
assert events == ["adapter-close"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@coding-agent/tests/coding_agent/tools/test_subagent.py` around lines 477 -
487, The InterruptedAdapter in the test lacks a close() implementation so the
test doesn't assert whether adapter.close() is invoked for
StopReason.INTERRUPTED; add a minimal close(self) async or sync method on
InterruptedAdapter that appends e.g. "adapter-close" to the shared events list
(same mechanism used by RaisingAdapter), keep run_turn returning
StopReason.INTERRUPTED, and then add a final assertion (e.g. assert events ==
["adapter-close"] or assert events == [] depending on the intended behavior) to
explicitly document the expected cleanup behavior for InterruptedAdapter and
ensure parity with the run_exception test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@coding-agent/src/coding_agent/tools/subagent.py`:
- Around line 314-332: When closing resources in the finally block, if await
child_consumer.close() raises asyncio.CancelledError the code currently
re-raises immediately and skips awaiting
_close_adapter_if_supported(child_adapter); change the logic to catch
asyncio.CancelledError from child_consumer.close(), store the exception (e.g.,
saved_cancel = exc or saved_cancel = True), continue to run the second
try/except that awaits _close_adapter_if_supported(child_adapter) and aggregate
any adapter_close_error into cleanup_error as currently done, then after both
cleanup attempts re-raise the original CancelledError so both consumer and
adapter close are attempted but cancellation is still propagated; reference
child_consumer.close(), _close_adapter_if_supported(child_adapter),
cleanup_error and adapter_close_error when implementing this control-flow
change.

---

Outside diff comments:
In `@coding-agent/src/coding_agent/plugins/parallel_executor.py`:
- Around line 128-158: The current use of asyncio.gather inside the batch loop
lets sibling _run_one tasks continue running when one raises
FatalToolExecutionError; change execute_batch's batch execution so that sibling
tasks are cancelled when a FatalToolExecutionError occurs: preferably replace
asyncio.gather with an asyncio.TaskGroup (Python 3.11+) to let the group cancel
remaining tasks on first failure (handle ExceptionGroup/FatalToolExecutionError
as needed), or if TaskGroup isn't available, create explicit asyncio Tasks for
each _run_one, await them with asyncio.gather inside a try/except that on
FatalToolExecutionError iterates over the created tasks and calls cancel()
before re-raising; ensure the semantics around _semaphore and results_by_index
remain unchanged.

---

Nitpick comments:
In `@coding-agent/tests/coding_agent/tools/test_subagent.py`:
- Around line 477-487: The InterruptedAdapter in the test lacks a close()
implementation so the test doesn't assert whether adapter.close() is invoked for
StopReason.INTERRUPTED; add a minimal close(self) async or sync method on
InterruptedAdapter that appends e.g. "adapter-close" to the shared events list
(same mechanism used by RaisingAdapter), keep run_turn returning
StopReason.INTERRUPTED, and then add a final assertion (e.g. assert events ==
["adapter-close"] or assert events == [] depending on the intended behavior) to
explicitly document the expected cleanup behavior for InterruptedAdapter and
ensure parity with the run_exception test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c2abb41-cc8e-4308-a165-69c3fd2461f9

📥 Commits

Reviewing files that changed from the base of the PR and between 8d0b3cb and fa9eaee.

📒 Files selected for processing (9)
  • coding-agent/.opencode/prompts/tasks/adr-0018-pr2-cancellation-failure-summary-routing.md
  • coding-agent/docs/adr/0018-owner-local-cloud-aware-subagent-orchestration.md
  • coding-agent/src/agentkit/tools/__init__.py
  • coding-agent/src/agentkit/tools/toolset.py
  • coding-agent/src/coding_agent/plugins/parallel_executor.py
  • coding-agent/src/coding_agent/tools/subagent.py
  • coding-agent/src/coding_agent/ui/session_owner_store.py
  • coding-agent/tests/coding_agent/plugins/test_parallel_executor.py
  • coding-agent/tests/coding_agent/tools/test_subagent.py

delete-cloud and others added 2 commits May 5, 2026 12:19
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@coding-agent/src/coding_agent/tools/subagent.py`:
- Around line 310-313: The except block in Subagent.run_turn (inside
coding_agent/tools/subagent.py) is currently catching all Exceptions and
converting them into a TurnOutcome, which swallows fatal owner-fencing
conflicts; update the error handling so that SessionOwnershipConflictError is
explicitly re-raised (i.e., add an except SessionOwnershipConflictError: raise
before the generic except Exception) so the parent sees and fails fast, leaving
the existing TurnOutcome creation only for non-fencing exceptions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c52d27d-d673-42e5-aea8-e6e31f7d8e3f

📥 Commits

Reviewing files that changed from the base of the PR and between fa9eaee and 22c3b73.

📒 Files selected for processing (4)
  • coding-agent/src/coding_agent/plugins/parallel_executor.py
  • coding-agent/src/coding_agent/tools/subagent.py
  • coding-agent/tests/coding_agent/plugins/test_parallel_executor.py
  • coding-agent/tests/coding_agent/tools/test_subagent.py

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@delete-cloud
delete-cloud merged commit 9dd6f1f into main May 5, 2026
1 check passed
@delete-cloud
delete-cloud deleted the fix/adr-0018-subagent-hardening branch May 5, 2026 04:40
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.

1 participant