Skip to content

fix(tui): stop the subagent contention gate from falsely refusing child shell calls - #5732

Merged
Hmbown merged 1 commit into
mainfrom
fix/subagent-scope-gate-false-positive-20260830
Aug 30, 2026
Merged

fix(tui): stop the subagent contention gate from falsely refusing child shell calls#5732
Hmbown merged 1 commit into
mainfrom
fix/subagent-scope-gate-false-positive-20260830

Conversation

@Hmbown

@Hmbown Hmbown commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Dogfood repro: a delegated child's bash was refused with "another child is writing in this shared checkout" even for read-only commands and even when the peer was long finished; the parent burned turns, cancelled the child, and redid the work in the foreground.

Root causes fixed (claim/liveness layer)

  • Gate ignored read-only evidence the execution envelope had already computed — a proven mutation-free shell run (ls, git status) is now carved out of the contention refusal.
  • Panicked child never went terminal (spawn_supervised caught the panic, the terminal commit never ran), staying Running and gating peers until heartbeat auto-cancel — indefinitely with a lingering shell. The task body now runs under a guard that commits a crash terminal result through the existing arbitration, then resumes unwinding.
  • Headless WaitingForUser worker records (no paired agent entry — no user can ever answer) counted as live claimants forever; the predicate now excludes them.
  • active_coordination_owners duplicated and drifted from the liveness predicate — it now delegates, so admission, the peer gate, and agents/coordinate action=release share one definition and release can actually clear a leaked claim.
  • The refusal is recoverable: it names the blocking peer ids and the concrete remediations.

Tests

Four regression tests, each proven red on its bug signature pre-fix and green post-fix; 169/169 claim/contention/coordination families; full lib suite 11530/0 on post-#5724 main; fmt clean. (One pre-existing main clippy lint at runtime_threads.rs:2562 is untouched and reproduced with this change stashed.)

No-Issue: dogfood-reported defect, tracked in the Ops takeover plan (Lane 1).


Devin Review

…ld shell calls

Dogfood repro: a delegated child's bash was refused with "another child is
writing in this shared checkout" even for read-only commands and even when
the peer was long finished; the parent burned turns, cancelled the child,
and redid the work in the foreground.

Root causes and repairs, all in the claim/liveness layer:

- The gate ignored the read-only evidence the execution envelope had already
  computed. A shell run the read-only classifier proves mutation-free can
  never collide with a peer's writes, so the gate now carves it out
  (proven_readonly_shell_run shares the classifier, not its role
  restriction — the envelope grant is role-scoped because it grants shell to
  inspection roles; the gate grants nothing and only asks about collision).
- A panicked child never reached its terminal commit (spawn_supervised
  caught the panic and returned), so it stayed Running and gated peers until
  heartbeat auto-cancel — indefinitely while a lingering shell kept the
  heartbeat fresh. run_subagent_task now runs its body under a guard that
  commits a crash terminal result through the same finish_terminal_result
  arbitration, then resumes unwinding so the supervisor still logs and dumps.
- A headless worker record parked at WaitingForUser (no paired agent entry,
  so no user can ever answer it) counted as a live claimant forever. The
  liveness predicate now excludes it; a paired waiting child stays live.
- active_coordination_owners duplicated the liveness predicate and had
  drifted from it; it now delegates, so claim admission, the peer gate, and
  stale-claim release share one definition — and agents/coordinate
  action=release can actually clear a leaked claim again.
- The refusal names the blocking peer ids and the concrete remediations
  (wait, parent-cancel, release a stale claim, relaunch with worktree
  isolation) instead of dead-ending the child.

Regression tests (all fail on their bug signature pre-fix, pass post-fix):
contended_shared_writers_keep_proven_readonly_shell,
contended_shared_writer_refusal_names_blocking_peer_and_remediation,
panicked_child_task_terminalizes_and_stops_gating_peers,
waiting_for_user_headless_worker_is_not_a_live_coordination_owner.

Verified: 4/4 new tests; 169/169 claim/contention/coordination families;
full lib suite 11530 passed / 0 failed on post-#5724 main; fmt clean.
Pre-existing clippy too_many_arguments at runtime_threads.rs:2562 is on main
and untouched by this change (reproduced with this change stashed).

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-30T09:16:31.383185Z bc094ab PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 3m 31s —— View job


Review complete

  • Gather context (PR body, changed files)
  • Read crates/tui/src/tools/subagent/mod.rs diff
  • Read crates/tui/src/tools/subagent/tests.rs diff
  • Check for correctness bugs / panics / race conditions
  • Check provider/model/route safety implications (N/A — no model/provider selection code touched)
  • Check reuse/simplification/dead code
  • Check test coverage
  • Post final review

Note: cargo invocations (check/test) required approval that wasn't granted in this session, so this review is based on static reading only, not a compiled/executed verification. I traced the call graph by hand instead (see below).

Summary

This is a well-scoped fix touching exactly the claim/liveness layer described in the PR body. I traced each of the four claimed root causes against the code and they hold up:

  1. Read-only carve-out (crates/tui/src/tools/subagent/mod.rs:15404-15407, is_unbounded_shell_run/proven_readonly_shell_run at :15515-15531): proven_readonly_shell_run short-circuits on is_unbounded_shell_run before calling agent_readonly_bash_input, so it can't panic on non-shell input shapes, and it only ever excludes actual exec_shell calls from the gate — it can't accidentally widen the carve-out to other write/network capabilities. Good reuse: it calls the same crate::tools::shell::agent_readonly_bash_input classifier already used by bounded_readonly_bash_evidence/BashTool::execute, rather than reimplementing readonly detection a third time. The doc comment explaining why it deliberately skips the role restriction that bounded_readonly_bash_evidence applies is a good call-out of a subtle difference.

  2. Panic guard (supervise_subagent_task_body, :10271-10295): confirmed run_subagent_task(task) is exactly the future passed into spawn_supervised at :6762-6766, so the inner catch_unwind → commit terminal result → resume_unwind composes correctly with the outer spawn_supervised catch_unwind (which still logs + writes the crash dump exactly as before). finish_terminal_result's existing arbitration (claim_terminal_delivery requires status == Running && !completion_claimed) means a panic racing a concurrent cancel/natural-completion correctly becomes a no-op rather than double-committing — matches the doc claim.

  3. Headless WaitingForUser exclusion (is_live_coordination_owner, :4926-4941): verified AgentWorkerStatus::WaitingForUser is not in is_terminal()'s match arms, so this really was an infinite-liveness leak pre-fix. The added condition correctly distinguishes headless (!self.agents.contains_key(id)) from paired workers, preserving the interactive case.

  4. active_coordination_owners delegating to is_live_coordination_owner (:4950-4957): a genuine simplification — removes ~25 lines of hand-duplicated prior-session logic that had already drifted once (issue [Bug Report] Stale write-claims lock sub-agents out of command execution; verifier role contradicts its own description #5562 per the doc comment) in favor of one predicate reused by admission, the peer gate, and release_stale_write_claims (:4121). Confirmed no other caller of the old has_peer_shared_write_claim name remains.

Nothing incorrect found. The blocking-peer names in the refusal message (live_peer_shared_write_claim_owners) are a real usability improvement — the old message gave no way to tell which peer to cancel.

Tests

The four new tests each map 1:1 to a root cause and match the described "proven red pre-fix" methodology:

  • contended_shared_writers_keep_proven_readonly_shell — exercises the exact ls scenario from the dogfood repro through two live disjoint-claim writers.
  • contended_shared_writer_refusal_names_blocking_peer_and_remediation — confirms the refusal still fires for a real mutating command, and asserts the message contains the peer id and remediation text (also asserts the file wasn't actually created — good, checks the refusal is enforced, not just worded).
  • panicked_child_task_terminalizes_and_stops_gating_peers — drives supervise_subagent_task_body directly with a panicking future and checks both the SubAgentStatus::Failed result and that is_live_coordination_owner flips off.
  • waiting_for_user_headless_worker_is_not_a_live_coordination_owner — covers headless vs. paired, and additionally exercises release_stale_write_claims end-to-end to confirm the remediation named in the refusal message actually works.

Signatures used in the new tests (new_with_owner, insert_test_running_agent, register_worker_with_coordination, make_write_worker_spec, WriteScopeClaim/register_claim) all match their existing definitions in mod.rs, so the tests should compile against current helpers.

Minor observations (non-blocking)

@devin-ai-integration devin-ai-integration 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.

Devin Review found 3 potential issues.

Devin Review

body: impl std::future::Future<Output = ()> + Send,
) {
use futures_util::FutureExt;
let panicked = std::panic::AssertUnwindSafe(body).catch_unwind().await;

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.

🟡 Panics release the parent barrier early

When a child panics, catch_unwind drops its foreground registration before the crash result is committed. The parent can settle without receiving the failure.

Prompt for agents
In crates/tui/src/tools/subagent/mod.rs, supervise_subagent_task_body catches a panic around run_subagent_task_inner(task), but the inner future owns SubAgentTask and therefore its ForegroundChildRegistration. Panic unwinding drops that registration before supervise_subagent_task_body calls finish_terminal_result. Keep the foreground registration alive in the outer supervisor until terminal failure delivery and commit finish, while preserving normal access to its parking signal and the existing single-winner terminal arbitration. Add a regression test that blocks or observes terminal delivery and proves the foreground registry does not settle before the panic result is committed.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +15404 to +15407
// A shell run the read-only classifier proves mutation-free cannot
// collide with the peer's writes no matter how contended the
// checkout is, so the gate below does not apply to it.
&& !proven_readonly_shell_run(name, &input)

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.

📝 Info: Read-only carve-out stays bounded

proven_readonly_shell_run runs after all authority checks and shares the executor’s classifier. It bypasses only contention, not shell or mutation policy.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +4938 to +4939
&& !(record.status == AgentWorkerStatus::WaitingForUser
&& !self.agents.contains_key(id))

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.

📝 Info: Interactive waiting claims remain live

The exclusion requires no paired agent record. Paired children remain live through their waiting worker record, even when their agent status is interrupted.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc094abe1f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +15404 to +15407
// A shell run the read-only classifier proves mutation-free cannot
// collide with the peer's writes no matter how contended the
// checkout is, so the gate below does not apply to it.
&& !proven_readonly_shell_run(name, &input)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the read-only bypass inside the hardened shell path

When two write-capable children are contended, this skips coordination for commands such as git diff, but builders execute Bash under ShellPolicy::Full, not the hardened read-only path. Consequently the command still runs shell hooks and honors Git helpers such as a configured external diff command, either of which can mutate the shared checkout while the peer is writing. Only bypass the contention gate if execution is also forced through the read-only environment/sandbox, rather than relying solely on the input classifier.

Useful? React with 👍 / 👎.

manager.live_peer_shared_write_claim_owners(&self.owner_agent_id);
if !blocking_peers.is_empty() {
return Err(anyhow!(
"Tool {name} cannot prove a bounded file target, and another child is writing in this shared checkout (blocking peers: {}). Wait for the peer to finish, ask the parent to cancel it, run agents/coordinate action=release to clear a stale claim, or relaunch the children with worktree isolation.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Point contention recovery at an available model tool

When this refusal occurs, it tells the child to run agents/coordinate action=release, but that legacy tool is explicitly retired from the model catalog and AgentToolAction exposes no release action. The child therefore cannot perform the advertised stale-claim recovery and may retry or remain blocked; expose recovery through agent or direct the request to a genuinely reachable operator path.

AGENTS.md reference: AGENTS.md:L89-L91

Useful? React with 👍 / 👎.

Comment on lines +10286 to +10293
result.status =
SubAgentStatus::Failed(format!("sub-agent task panicked: {message}"));
result.result = None;
result.needs_input = None;
// Arbitrated exactly like the natural terminal commit: when a
// cancel or another terminal outcome already won, this is a
// no-op rather than a second result.
manager.finish_terminal_result(&agent_id, result, false, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop owned shell jobs before releasing a panicked writer

If the child panics while one of its background Bash jobs is still running, this marks the owner terminal without cancelling that job. is_live_coordination_owner then ignores the child's claim, so another writer can be admitted while the orphaned process is still mutating the shared checkout—the exact lingering-shell case described by the new panic guard. Cancel or otherwise retain coordination ownership for running shell jobs before committing the failed terminal state.

Useful? React with 👍 / 👎.

body: impl std::future::Future<Output = ()> + Send,
) {
use futures_util::FutureExt;
let panicked = std::panic::AssertUnwindSafe(body).catch_unwind().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retain the foreground barrier until panic fan-in completes

For a turn-owned child, unwinding body drops the SubAgentTask and its ForegroundChildRegistration as this awaited catch_unwind completes, before the handler acquires the manager lock and publishes the terminal result. If the parent is settling concurrently, its barrier can therefore observe zero children, flush, and emit TurnComplete before the panic receipt is delivered. Keep the registration outside the caught body, or otherwise release it only after finish_terminal_result completes.

Useful? React with 👍 / 👎.

Comment on lines +10281 to +10287
let message = crate::utils::panic_message(&*panic);
{
let mut manager = manager_handle.write().await;
match manager.get_result(&agent_id) {
Ok(mut result) => {
result.status =
SubAgentStatus::Failed(format!("sub-agent task panicked: {message}"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep raw panic payloads out of model-visible results

When a panic payload contains user data—for example, Rust slicing and assertion panics can embed the entire offending string—this copies that payload into SubAgentStatus::Failed. Terminal delivery then places the error in the mailbox and subagent.failed sentinel, so it can be appended to the parent transcript and sent in the next provider request; previously the detailed panic was confined to local logging and the crash dump. Publish a bounded generic failure here and retain the raw message only in the local diagnostic path.

Useful? React with 👍 / 👎.

@Hmbown
Hmbown merged commit 016ecec into main Aug 30, 2026
31 checks passed
@Hmbown
Hmbown deleted the fix/subagent-scope-gate-false-positive-20260830 branch August 30, 2026 17:05
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