fix(agent): end a HITL park turn gracefully so Approve/Deny complete (F-040)#4869
Conversation
…(F-040)
On the /messages HITL path a parked permission gate left session.prompt()
blocked forever: Claude-over-ACP does not end a turn on an unanswered gate, so
the parked turn never terminated. That leaked the runner sandbox (the finally
never ran), the egress never emitted a finish frame (the SSE stream hung), and
the AI-SDK resume errored out as "The agent run failed".
Make a park END the /run turn. When the responder returns park, an onPark
callback cancels the in-flight prompt via sandbox.destroySession (the
sandbox-agent package's managed cancel: it resolves the pending permission with
{outcome:"cancelled"} — not a reject, so no F-024 clobber — and sends
session/cancel so prompt() returns). The orchestration races the prompt against
a parked signal and returns stopReason "paused", so the finally disposes the
sandbox (no leak) and the egress drains to a clean finish (paused -> AI-SDK
other). The resume cold-replays as a fresh turn and the stored decision resolves
the gate: Approve runs the tool and completes; Deny returns a clean denial and
the model continues.
Live-verified on Claude+haiku with a gated github tool and an Ask rule (local
runner, no Daytona): both park turns logged stopReason=paused, zero leaked
sandbox dirs, Approve completed with a real answer, Deny ended in a graceful
denial (not "agent run failed"), and the ACP write/fetch-failed errors are gone.
Tests: park emits a terminal paused result even when the prompt hangs; no leak;
park terminates even if the managed cancel rejects; the egress drains a parked
stream to a finish.
Claude-Session: https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Railway Preview Environment
Updated at 2026-06-26T01:47:42.309Z |
Context
HITL is a headline agent capability, and on Claude it was broken end to end (QA finding F-040). On the
/messagespath, when a tool needs human approval the responder returnsparkand sends no ACPrespondPermission, then the runnerawaitssession.prompt(). But Claude-over-ACP does not end a turn on an unanswered permission gate, sosession.prompt()blocked forever. Three things followed:finallynever ran),finishframe and the SSE stream hung,The net user experience: Approve hung ~5 min then
ERR_ABORTED; Deny ended in a red "The agent run failed" instead of a clean denial.What changed
A
parknow ENDS the/runturn gracefully instead of holding the ACP connection open.engines/sandbox_agent/permissions.ts—attachPermissionRespondergains anonParkcallback; ondecision === "park"it firesonPark()(still sends norespondPermission, so no F-024 clobber).engines/sandbox_agent.ts— on the first park,onParkcallssandbox.destroySession(session.id), the sandbox-agent package's managed cancel. It resolves the pending permission RPC with{outcome:"cancelled"}(not a reject) and sendssession/cancel, so the in-flightprompt()returns. The orchestration races the prompt against aparkedSignaland returnsstopReason:"paused", so thefinallydisposes the sandbox (no leak) and the egress drains to a cleanfinish.sdks/python/.../vercel/stream.py— mappaused/cancelledto the AI-SDKotherfinish reason (it is intentional state, not an unknown model reason).The resume then cold-replays as a fresh turn and the stored decision resolves the gate via the name+args anchor (#4854): Approve runs the tool and completes; Deny returns a clean "User refused permission" tool-error and the model continues. The FE resume already carries the
{approved}envelope (#4859); verified, unchanged.Scope / risk
protocol.ts/wire.pychange, no golden/wire-contract change (stopReasonis a free-form string;pausedis a new value of an existing field). No FE change./invokeis unchanged. It never parks (no human surface), so the new path is dead code there.ok:falsethrough the outer catch (the side.catch()on the orphaned prompt only suppresses a late rejection after a park).How to QA
Prerequisites: a committed Agent app with Claude Code + haiku, a gated github gateway tool, and an Ask rule (
harness_kwargs.claude.permissions.ask), plus an Anthropic key in the project vault. Local runner (no Daytona). Restart the runner so it loads the change.ERR_ABORTED).docker exec <runner> sh -lc 'ls -d /tmp/agenta-sandbox-agent-* | wc -l'→ stays0(no leak). The runner log showsprompt stopReason=pausedfor the park turn.Expected: park terminates (
stopReason=paused), Approve completes, Deny is a clean denial, zero leaked sandboxes, and nounhandledRejection/ACP write error: other side closed/fetch failedin the runner log.Test command:
cd services/agent && pnpm test && pnpm run typecheckcd sdks/python && python -m pytest oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py oss/tests/pytest/unit/agents/ -n0 -qEdge cases covered by tests: the prompt never resolving (the real Claude case), the managed cancel itself rejecting (the local park signal still ends the turn), and the egress draining a parked stream to a
finish.https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc