Repository navigation
permissions: parallel Code Mode asks for the same tool orphan the second request #51224
Description
Activity
I looked at this on
v2and the shape of the bug is visible inPermissionV2.ask()creates a distinct pending entry per call (create(value, input.agent), then keyed byrequest.id), andreply()resolves the pending entry for therequestIDit is handed. Two parallel Code Mode calls that hit the same ask-gated tool therefore enqueue two separate asks, and approving the first one leaves the sibling deferred untouched, so the second call waits until it is interrupted.I would like to work on this. Plan: make one approval cover the sibling asks the same decision applies to (same session, tool and pattern), or have the second ask join the existing pending entry rather than creating a sibling one, then add a case to
packages/core/test/permission.test.tswith two parallel asks for one tool where a single approval resolves both. I will confirm which of those two shapes matches the intended semantics before sending the PR.I set out to fix this and wrote the service level case as a test before changing any code. I could not reproduce an orphan for the shape this report describes, so before I send anything speculative, here is what I measured.
What I ran against the
v2PermissionV2service, mirroringtool/mcp.ts(which asks withaction: name(server, tool),resources: ["*"],save: ["*"]and no explicit id, so each call gets its own generated id):- two parallel
assertcalls, same session, same action, same resources, same save - wait for both
permission.askedevents, so both are genuinely pending reply({ requestID: first.id, reply: "always" })
Result: the sibling is released. After
replyreturns,list()is empty and both assert fibers join. That is the sweep at the end ofreply, which re-evaluates every remaining pending request once the saved rule is written.The rest of the shape is also consistent. The sweep only runs for
alwaystogether with a non emptysave, and the TUI offersalwaysonly whenrequest.save?.length(routes/session/permission.tsx), so those two agree.Where a sibling does stay pending is when re-evaluating it cannot produce
allow:reply: "once"never sweeps, so the sibling stays pending- a missing Session or a configured deny skips it
I reproduced the
oncecase the same way (the sibling stays inlist()), and the existing suite already pins the other two as intended, so those skips are deliberate rather than a defect.So the hang needs the sibling to be pending, with no saved rule covering it at reply time, and invisible. The invisibility part looks like the same surface as #51223: the session route renders only
data.session.permission.list(sessionID)?.[0](routes/session/index.tsx:2614) and matches an inline ask to a tool part bysource.id, so a second concurrent ask for the session has no surface of its own.Which semantics do you want here? Either one ask covers both concurrent identical calls, which means deduplicating at
createinstead of queueing a sibling, or every ask is surfaced independently. The first changes permission semantics for all callers, so I would rather ask than guess, and I am not going to send a change I cannot reproduce.I am stepping back from my earlier offer to work on this until that is settled. Happy to pick it up again once the intended behaviour is clear.
- two parallel
Thanks for the deep analysis. As the reporter, my preference: dedupe concurrent identical asks (same session, tool, pattern → one ask covers all). Surfacing each independently is the cleaner semantics but requires the TUI to render more than
list()?.[0]; until #51223 (invisibility) is fixed, any sibling ask that can silently go unsurfaced is a deadlock waiting to happen, so coalescing is the safer default.One detail that may explain your repro gap: in the original incident the user replied "always" and the grant was persisted — yet the sibling hung ~20 more minutes. The grant was stored under the project of the TUI's working directory, while the hung session belonged to the legacy
globalproject, so the sweep's re-evaluation couldn't produceallowfor it. That lands in your "sibling stays pending when re-evaluation can't produce allow" bucket, but via a project-scoping mismatch rather than a deny. Happy to share exact timestamps from the session data if useful.
Summary
When two parallel Code Mode (
execute) calls in the same assistant turn both invoke the same ask-gated MCP tool, resolving/approving one permission ask orphans the second: it never resolves, even after the permission is fully granted, and the second call hangs until the user interrupts it.Environment
opencode serve --service){ "action": "hyprland_*", "resource": "*", "effect": "ask" }Reproduction
ask(see above).executecalls that both invoke the same tool, e.g. both runawait tools.hyprland.clipboard_copy({ text: "x" }).Expected Behavior
Either one ask covers both pending calls, or each ask is independently surfaced and resolved. Granting the permission (
allowrule / saved approval) should release every pending call gated on it.Actual Behavior
From session data (all times UTC):
executefeat: compact and other improvements #1 and Roadmap & Existing Issues #2, both callinghyprland.clipboard_copy).hyprland_clipboard_copyallow grant is persisted; call feat: compact and other improvements #1 completes at that moment (blocked 278 s on the ask).{"type":"aborted","message":"Tool execution interrupted"}.So the second (duplicate) ask appears to be superseded/orphaned by the first and is not re-evaluated when the permission state changes.
Additional Context
Possibly related to the same permission-ask pipeline as the Code Mode asks that never surface in the TUI (filed separately). Workaround: never issue parallel
executecalls to the same ask-gated tool; issue them sequentially, or pre-allowthe tool in config. Full investigation report with timeline available on request.