You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
permissions: parallel Code Mode asks for the same tool orphan the second request #51224
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 version: 2.0.16
OS: Linux 7.2.6-1-omarchy-rc3 (x64, Arch-based Omarchy)
MCP: local stdio server with a tool gated by { "action": "hyprland_*", "resource": "*", "effect": "ask" }
Reproduction
Gate an MCP tool with ask (see above).
Produce an assistant turn with two parallel execute calls that both invoke the same tool, e.g. both run await tools.hyprland.clipboard_copy({ text: "x" }).
Observe: one call completes (after the ask is granted), the other hangs indefinitely.
Expected Behavior
Either one ask covers both pending calls, or each ask is independently surfaced and resolved. Granting the permission (allow rule / saved approval) should release every pending call gated on it.
18:28:53 — an hyprland_clipboard_copy allow grant is persisted; call feat: compact and other improvements #1 completes at that moment (blocked 278 s on the ask).
Call Roadmap & Existing Issues #2 never resolves despite the permission now being granted; it stays pending 1462 s until 18:48:39, when the user's next message aborts it with {"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 execute calls to the same ask-gated tool; issue them sequentially, or pre-allow the tool in config. Full investigation report with timeline available on request.
I looked at this on v2 and the shape of the bug is visible in PermissionV2.
ask() creates a distinct pending entry per call (create(value, input.agent), then keyed by request.id), and reply() resolves the pending entry for the requestID it 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.ts with 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 v2PermissionV2 service, mirroring tool/mcp.ts (which asks with action: name(server, tool), resources: ["*"], save: ["*"] and no explicit id, so each call gets its own generated id):
two parallel assert calls, same session, same action, same resources, same save
wait for both permission.asked events, so both are genuinely pending
reply({ requestID: first.id, reply: "always" })
Result: the sibling is released. After reply returns, list() is empty and both assert fibers join. That is the sweep at the end of reply, 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 always together with a non empty save, and the TUI offers always only when request.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 once case the same way (the sibling stays in list()), 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 by source.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 create instead 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.
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 global project, so the sweep's re-evaluation couldn't produce allow for 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.
Thanks for the detailed timeline. I tried to reproduce this on 2.0.16 and couldn't:
Setup: a minimal local stdio MCP server with one tool gated by { "action": "hypr_*", "resource": "*", "effect": "ask" }, and one assistant turn with two parallel execute calls that both call the same tool.
Allow once on the first ask: the first call completed, then the TUI showed the second ask, and confirming it completed the second call.
Always allow on the first ask: both calls completed straight away, with no second prompt.
So parallel asks for the same tool on their own don't seem to leave the second call stuck. Each call gets its own ask, and "always" releases the other pending ones.
What I couldn't recreate is the project-scope situation you described: a session in the legacy global project, with the "always" grant saved under a newer project for the same directory. If you see this again, these would help:
whether the session that hangs was created before upgrading or in a project that was migrated, or whether it also happens in a fresh session;
while the second call is stuck, the output of GET /api/session/<id>/permission, to see whether the second ask is still pending;
whether a permission prompt is on screen at that moment.
If the ask is pending but never shown, it is most likely the same problem as #51223 (Code Mode asks never appearing in the TUI).
Closing since this can't be reproduced: with two parallel asks for the same tool, "once" resolves the first and then shows the second, and "always" releases every pending ask that the saved rule now allows. The remaining theory is a session in the legacy global project whose "always" grant was saved under a different project. If you hit it again on the latest release, please reopen with the output of GET /api/session/<id>/permission while the call is stuck.
Closing this for now, since we haven't heard back. On 2.0.16 I couldn't reproduce the second call being left hanging. With two parallel execute calls to the same ask-gated MCP tool, Allow once brought up the second ask and both calls finished. Always allow released both calls at once.
The case I couldn't test is the legacy global project one, where the "always" grant is saved under a different project from the session's. If this happens again, please comment or open a new issue with:
whether the session was created before an upgrade or migration;
the output of GET /api/session/<id>/permission while the call is stuck;
whether a permission prompt is on screen at that moment.
If the ask is pending but never shown, #51223 is probably the issue to follow.
Activity
argszero commentedon Sep 28, 2026
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.argszero commentedon Sep 28, 2026
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):assertcalls, same session, same action, same resources, same savepermission.askedevents, so both are genuinely pendingreply({ 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 pendingI 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.
rissanssi commentedon Sep 28, 2026
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.opencodejr commentedon Oct 28, 2026
Thanks for the detailed timeline. I tried to reproduce this on 2.0.16 and couldn't:
{ "action": "hypr_*", "resource": "*", "effect": "ask" }, and one assistant turn with two parallelexecutecalls that both call the same tool.So parallel asks for the same tool on their own don't seem to leave the second call stuck. Each call gets its own ask, and "always" releases the other pending ones.
What I couldn't recreate is the project-scope situation you described: a session in the legacy
globalproject, with the "always" grant saved under a newer project for the same directory. If you see this again, these would help:GET /api/session/<id>/permission, to see whether the second ask is still pending;If the ask is pending but never shown, it is most likely the same problem as #51223 (Code Mode asks never appearing in the TUI).
rekram1-node commentedon Oct 29, 2026
Closing since this can't be reproduced: with two parallel asks for the same tool, "once" resolves the first and then shows the second, and "always" releases every pending ask that the saved rule now allows. The remaining theory is a session in the legacy
globalproject whose "always" grant was saved under a different project. If you hit it again on the latest release, please reopen with the output ofGET /api/session/<id>/permissionwhile the call is stuck.opencodejr commentedon Oct 31, 2026
Closing this for now, since we haven't heard back. On 2.0.16 I couldn't reproduce the second call being left hanging. With two parallel
executecalls to the same ask-gated MCP tool, Allow once brought up the second ask and both calls finished. Always allow released both calls at once.The case I couldn't test is the legacy
globalproject one, where the "always" grant is saved under a different project from the session's. If this happens again, please comment or open a new issue with:GET /api/session/<id>/permissionwhile the call is stuck;If the ask is pending but never shown, #51223 is probably the issue to follow.