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:
SubAgentSpawner.cs:195 — ApprovalBridge = context.ApprovalBridge (no gate on channel capability).
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.
- It then waits with no finite timeout:
ParentSessionApprovalBridge.cs:69 — _channel.WaitForApprovalAsync(parentCallId, Timeout.InfiniteTimeSpan, ct)
IApprovalChannel.cs:106-109 — Task.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.
- 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
- Run a workflow in a headless session (
netclaw chat -p …), or a scheduled reminder, that spawns a sub-agent.
- 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).
- 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)
- 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.
- 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.
- 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
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.CheckApprovalGatedocuments (ToolAccessPolicy.cs:291-295):ChannelType.SupportsInteractiveApproval()(ChannelType.cs:38-46) returnstrueonly for Slack/Discord/Mattermost/Tui/SignalR; everything else — includingHeadless— 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 approvaland the run never returns. ThetimeoutSeconds/prefillTimeoutSecondswatchdogs 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:
SubAgentSpawner.cs:195—ApprovalBridge = context.ApprovalBridge(no gate on channel capability).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.ParentSessionApprovalBridge.cs:69—_channel.WaitForApprovalAsync(parentCallId, Timeout.InfiniteTimeSpan, ct)IApprovalChannel.cs:106-109—Task.WhenAny(tcs, Task.Delay(Timeout.InfiniteTimeSpan), Task.Delay(Timeout.InfiniteTimeSpan, ct)). In a headless run nothing consumes the emittedToolInteractionRequest, sotcscan never complete and none of the three tasks ever finishes.SubAgentActor.cs:560/568— the processing watchdog (which enforcestimeoutSeconds/prefillTimeoutSeconds) is stopped whenSubAgentApprovalWaitStartedfires (:1133), and stale ticks are suppressed whileWaitingForParentApproval(:529).SubAgentSpawner.cs:213— the parent'sAskon the child usestimeout: Timeout.InfiniteTimeSpan, so a wedged child wedges the parent.Reproduction
netclaw chat -p …), or a scheduled reminder, that spawns a sub-agent.shell_executewhose verb/cwd is not already in the persistent approval store).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)
!SupportsInteractiveApproval()— gateSubAgentSpawner.cs:195. With no bridge, the sub-agent throwsParentApprovalUnavailableExceptionand the tool fails closed exactly asToolAccessPolicy.cs:293promises. Also deriveSubAgentActor.cs:261'sSupportsInteractiveApprovalfrom the real channel capability, not bridge presence.WaitForApprovalAsyncsupports a finite timeout and returnsApprovalDecision.TimedOut, whichSubAgentActor.cs:1173already degrades toapproval_timed_outgracefully. ReplaceTimeout.InfiniteTimeSpanatParentSessionApprovalBridge.cs:69(and the parentAskatSubAgentSpawner.cs:213) with a finite ceiling.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