Skip to content

Non-interactive (headless/reminder/webhook) sub-agent hangs forever on an approval-gated tool instead of failing closed #1615

Description

@Aaronontheweb

Summary

A sub-agent spawned from a non-interactive session (headless chat -p, a scheduled reminder, or a webhook) that invokes an approval-gated tool hangs indefinitely waiting on an approval that can never be answered — instead of failing closed, which is the documented contract for non-interactive callers.

Expected (per the current policy contract)

ToolAccessPolicy.CheckApprovalGate documents (ToolAccessPolicy.cs:291-295):

The approval policy is authoritative for every channel — there is no safe-list auto-grant for non-interactive callers. A non-interactive caller (reminder, webhook, sub-agent without an approval bridge) that hits an approval-gated tool fails closed unless the patterns are already in the persistent approval store.

ChannelType.SupportsInteractiveApproval() (ChannelType.cs:38-46) returns true only for Slack/Discord/Mattermost/Tui/SignalR; everything else — including Headless — is _ => false. So an approval-gated tool call in a headless/reminder/webhook run should be denied (fail closed).

Actual

The sub-agent parks on the approval bridge forever. Its last log line is awaiting human approval and the run never returns. The timeoutSeconds/prefillTimeoutSeconds watchdogs do not fire, and the parent that spawned it is also wedged waiting on the child. Observed hang length: 2+ hours, until the client was killed.

Root cause

The fail-closed guarantee is written for a sub-agent without an approval bridge — but the sub-agent is handed the parent's bridge unconditionally, regardless of whether the parent channel can service approvals:

  1. SubAgentSpawner.cs:195ApprovalBridge = context.ApprovalBridge (no gate on channel capability).
  2. SubAgentActor.cs:261_toolExecutionContext.SupportsInteractiveApproval = _approvalBridge is not null — capability is inferred from bridge presence, not the real channel. So a headless sub-agent believes it can prompt and skips the fail-closed path.
  3. It then waits with no finite timeout:
    • ParentSessionApprovalBridge.cs:69_channel.WaitForApprovalAsync(parentCallId, Timeout.InfiniteTimeSpan, ct)
    • IApprovalChannel.cs:106-109Task.WhenAny(tcs, Task.Delay(Timeout.InfiniteTimeSpan), Task.Delay(Timeout.InfiniteTimeSpan, ct)). In a headless run nothing consumes the emitted ToolInteractionRequest, so tcs can never complete and none of the three tasks ever finishes.
  4. Every recovery mechanism is disabled around the wait:
    • SubAgentActor.cs:560/568 — the processing watchdog (which enforces timeoutSeconds/prefillTimeoutSeconds) is stopped when SubAgentApprovalWaitStarted fires (:1133), and stale ticks are suppressed while WaitingForParentApproval (:529).
    • SubAgentSpawner.cs:213 — the parent's Ask on the child uses timeout: Timeout.InfiniteTimeSpan, so a wedged child wedges the parent.

Reproduction

  1. Run a workflow in a headless session (netclaw chat -p …), or a scheduled reminder, that spawns a sub-agent.
  2. Have the sub-agent invoke an approval-gated tool (e.g. a shell_execute whose verb/cwd is not already in the persistent approval store).
  3. Observed: the sub-agent's session log ends on awaiting human approval; there is no open outbound connection to any backend; the session hangs indefinitely; no watchdog fires.

Impact

Any unattended run (reminder/webhook/headless) whose sub-agent hits an un-granted approval-required tool deadlocks indefinitely and silently — child and parent both hang, and the configured sub-agent watchdogs never recover it. The symptom is easily mistaken for a model/backend hang (idle backend, session that looks open).

Suggested fix (ordered)

  1. Match the implementation to the contract: do not pass an approval bridge to a sub-agent when the parent channel !SupportsInteractiveApproval() — gate SubAgentSpawner.cs:195. With no bridge, the sub-agent throws ParentApprovalUnavailableException and the tool fails closed exactly as ToolAccessPolicy.cs:293 promises. Also derive SubAgentActor.cs:261's SupportsInteractiveApproval from the real channel capability, not bridge presence.
  2. Backstop — finite approval timeout for non-interactive callers: the seam already exists — WaitForApprovalAsync supports a finite timeout and returns ApprovalDecision.TimedOut, which SubAgentActor.cs:1173 already degrades to approval_timed_out gracefully. Replace Timeout.InfiniteTimeSpan at ParentSessionApprovalBridge.cs:69 (and the parent Ask at SubAgentSpawner.cs:213) with a finite ceiling.
  3. Defense in depth: keep an absolute hard-cap watchdog armed even during WaitingForParentApproval, so no sub-agent state can run unbounded.

Verified anchors

ToolAccessPolicy.cs:152,291-295 · ChannelType.cs:38-46 · SubAgentSpawner.cs:195,213 · SubAgentActor.cs:261,529,560,568,1133,1173,1182 · ParentSessionApprovalBridge.cs:69 · IApprovalChannel.cs:106-109,118

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsessionsLLM session actor, turn lifecycle, pipelinessubagentsspawn_agent, SubAgentActor, definition loader, discovery context layer, and related featurestoolsIssues related to agent tools: file_read, web_search, shell_execute, image processing, etc.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions