Skip to content

permissions: parallel Code Mode asks for the same tool orphan the second request #51224

Description

@rissanssi

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 version: 2.0.16
  • OS: Linux 7.2.6-1-omarchy-rc3 (x64, Arch-based Omarchy)
  • Terminal: ghostty (TERM=xterm-ghostty, COLORTERM=truecolor)
  • Shell: /bin/bash
  • Install/channel: latest (v2.0.16, background service via opencode serve --service)
  • Active plugins: superpowers (git+https://github.com/obra/superpowers.git)
  • MCP: local stdio server with a tool gated by { "action": "hyprland_*", "resource": "*", "effect": "ask" }

Reproduction

  1. Gate an MCP tool with ask (see above).
  2. 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" }).
  3. 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.

Actual Behavior

From session data (all times UTC):

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.

Activity

  1. argszero commented on Sep 28, 2026

    @argszero
    Contributor

    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.

  2. argszero commented on Sep 28, 2026

    @argszero
    Contributor

    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 v2 PermissionV2 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.

  3. rissanssi commented on Sep 28, 2026

    @rissanssi
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions