Skip to content

permission: pending requests destroyed on instance disposal without revocation event; dialog unanswerable, replies 404 forever #52846

Closed as not planned

Description

@bill277048-hash

Description

When a location instance is disposed (POST /global/dispose, any effective PATCH /global/config, SIGUSR2 TUI reload), pending permission requests are destroyed without emitting permission.replied or any revocation event, and the associated tool-call part is never finalized — it persists in the message store as status:"running", executed:false. Clients rebuild the dialog from that part on every load, so the user is stuck with a permission prompt that can never be answered: every Allow/Deny replies to an ID the server no longer knows → 404 Permission request not found: per_... → error toast, retried in a loop (observed: 178 × 404 in ~2.5 h, bursts of 109/min). In-flight tool calls in other windows die with {"type":"aborted","message":"Tool execution interrupted"}. Reload, closing/reopening the window, and even restarting the service do not clear the ghost dialog — the source is persisted data. This makes unattended/long-horizon multi-session work on a shared service structurally unsafe.

  • Plugins: one local plugin watching the global config dir (not required for repro)
  • OpenCode version: 2.0.18 (channel=latest)
  • OS: macOS (Darwin 25.6.0, arm64)
  • Terminal: shared background service (opencode serve --service), web client in Safari

Steps to reproduce

  1. With the shared service running, open a web UI session and trigger a tool call that resolves to ask (permission dialog waiting). Leave it unanswered.
  2. From a terminal, force a disposal: curl -X POST http://127.0.0.1:<port>/global/dispose (or make any effective PATCH /global/config change; or kill -USR2 <tui-pid>).
  3. Click Allow (or Deny) on the still-visible dialog → 请求失败 / Permission request not found: per_... toast; server log shows POST .../permission/per_.../reply → 404.
  4. GET /api/session/:id/permission returns [] while the dialog persists.
  5. Reload / reopen the window → dialog reappears, rebuilt from the persisted non-terminal tool part. No user action can clear it.

Expected

Disposal should be survivable or self-healing for clients: emit a terminal permission.replied (rejected/interrupted) per destroyed request, finalize the persisted tool part, and reconcile client dialogs against the pending list. A reply to an unknown request should not leave clients retrying forever (PermissionNotFoundError should be treated as terminal by the client).

Notes / code pointers (2.0.18)

  • permission/index.ts (~L50–59): the pending Map is instance-scoped state; the scope finalizer fails each pending deferred with RejectedError and clears the map without emitting any event, unlike the normal reply() path.
  • handlers/global.ts (~L80): config update with changed → disposeAllInstancesAndEmitGlobalDisposed(); disposal interrupts all fibers without quiescing in-flight sessions, so the session runner never writes a terminal state to the awaiting tool part.
  • Web event-reducer handles server.instance.disposed (directory resync) but does not reconcile dialogs rebuilt from persisted non-terminal parts against the empty pending list.
  • cli/cmd/run/stream.transport.ts (~L206, ~L1140): opencode run treats server.instance.disposed as a hard stream failure ("instance disposed") — headless/looped callers see an undifferentiated run error; same non-terminal part outcome when disposal is triggered by provider errors (Failed to drain Session → HTTP 400 / invalid key / rate limit).
  • Field timeline (sanitized): subagent permission per_A created 09:09 UTC, live-pending confirmed via API 09:12, mass disposal at 09:31:42 (±2 s of concurrent CLI gate-test activity), user clicks at 12:59 and 14:01 → 404 ×3; an older dialog produced the 178 × 404 storm. session_pending/event DB tables hold no trace of the destroyed requests — pending-permission state is memory-only while the tool part is persisted, and the two diverge permanently after disposal.
  • Screenshot: error dialog shows 请求失败 / Permission request not found: per_0fae....

Activity

  1. github-actions commented on Oct 2, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #29422: Web/Desktop permission prompts remain after the underlying request is interrupted or cleaned up, and replies return Permission request not found because no terminal event clears the stale prompt.
    • #52526: Reports the same stale-dialog and repeated 404 behavior after permission cleanup, including instance reload/cleanup.
  2. weixiongshang commented on Oct 5, 2026

    @weixiongshang

    Adding a field repro for the automatic disposal path — no manual /global/dispose or config change needed. The 60-minute location idle TTL evicts the location while a permission request is still pending, and the client is never told.

    Version 2.0.22, macOS arm64, shared background service, Desktop client:

    • T+0: a tool resolves to ask (read of a path outside the worktree → external_directory). The run blocks; nobody answers.
    • T+41m: last activity on the location (a queued shell-result message).
    • T+101m: exactly 60 minutes after that last activity, the server log shows location services evicted directory=<project> followed immediately by InterruptError: All fibers interrupted without error; the waiting tool part is finalized as aborted (Tool execution interrupted).
    • Next morning: the prompt is still visible. Clicking Allow/Deny returns 404 — server log shows 86 × POST /api/session/<ses>/permission/<per_>/reply → 404 while GET /api/session/<ses>/permission returns []. The composer stays blocked because a visible prompt replaces it.

    So disposal does not need to be forced for this failure mode: an unanswered prompt older than the idle TTL reproduces the same silent drop + 404 loop. The 60-minute sweep is also what #52908 measures for running shells; this is the same eviction hitting a pending PermissionV2 wait, which nothing in the TTL logic counts as a borrower.

    I opened #53272 for the client half — treat PermissionNotFoundError as terminal (drop the stale prompt locally, stop the auto-respond retry loop), mirroring the TUI fix #40960. The server half (publish permission.replied on cleanup) is #50595.

  3. Bliird commented on Oct 14, 2026

    @Bliird

    Reproduced on Windows, no plugins, no forced dispose.

    Env: OpenCode 2.0.28, desktop app (OPENCODE_CLIENT=desktop), Windows_NT 10.0.26300 x64
    Trigger: { "action": "shell", "resource": "*", "effect": "ask" }, prompt left unanswered several hours. No plugin, no subagent fan-out, no /global/dispose, no config change.
    Symptom: all three buttons (reject / always allow / allow once) -> "Permission request not found: per_139bcabe200117QMXM8G3Esxwj". Dialog stays, session stays blocked, no in-UI escape.
    Cleared by: full app quit only. Closing the window did not.
    Still broken in: 2.0.28 (V2).

    Matches the idle path @weixiongshang described - nothing forced it, time alone dropped the pending request.

    Fix status: #50595 (server: publish permission.replied on cleanup) closed Oct 10 as V1, never merged. #53272 (client: treat PermissionNotFoundError as terminal) closed. TUI equivalent is #40960.

    Question: is #50595 being ported to V2, or should it be re-opened against dev?

  4. dskajita commented on Oct 27, 2026

    @dskajita

    Another trigger, reproduced on a clean sandbox with a scripted fake model (no real provider), version 2.1.4, macOS arm64: interrupting a session that is waiting on a permission.

    Steps:

    1. Start opencode serve with an agent whose external_directory is ask.
    2. A parent session calls a sub-agent (child session) whose first tool call is read of a path outside the project.
    3. GET /api/session/<child>/permission lists the pending request (action: external_directory). The parent's list is empty, and GET /api/session/active just says running for both sessions.
    4. POST /api/session/<child>/interrupt.

    Result: the server drops the request (the child's permission list is empty, the tool call ends as aborted, the parent's sub-agent call ends as cancelled), but the event stream (GET /api/event) never publishes a permission.replied or any other permission event for that id. Order after permission.asked: session.tool.failed (aborted) -> session.step.failed -> session.execution.interrupted. A client that tracks pending prompts from events keeps the dialog, and every reply then fails with Permission request not found: per_... (seen in the desktop/web UI, Deny and Allow alike).

    So interrupt is one more cleanup path with the same gap as disposal and idle eviction: the request is removed without a closing event. Publishing the existing replied/rejected event (or a dedicated revoked event) from every path that removes a pending request would cover all of them. The closed #50595 did this for the cleanup paths.

    Side effect for headless use: a permission raised in a sub-agent session is not visible from the parent session or the active-sessions endpoint, so a run waits silently until someone opens the child session.

  5. opencodejr commented on Oct 27, 2026

    @opencodejr

    Thanks for the detailed report. I tried to reproduce this on 2.0.18 and couldn't.

    What I did: shared server, a simulated model, and a permission rule set to ask. The agent made a shell tool call, so a "Permission required" prompt was waiting, and GET /api/session/:id/permission listed the per_… request. Then I triggered instance disposal.

    Findings:

    • On v2, POST /global/dispose returns 405. That route, and disposeAllInstancesAndEmitGlobalDisposed in the notes, belong to the v1 server. The v2 equivalent is POST /api/location/reload, which shuts down and rebuilds every loaded location.
    • After the reload, the pending list was [].
    • The saved tool part was finished as status: "error" with Interaction cancelled because the location shut down. It did not stay running.
    • The prompt went away in the client and the session carried on.
    • In the v2.0.18 code, the permission service publishes permission.replied (reject) for each pending request when it shuts down. The web client already treats PermissionNotFoundError from a reply as final, and the web permission dialog is driven by the pending list, not by saved tool parts.

    If you still see this, it would help to have:

    • The output of opencode --version from the binary actually running the service, plus how it was installed. Please check that no v1 (opencode-ai) server is still running on that port.
    • The exact request that triggers the disposal on your side, and the server log lines around it.
    • The JSON for the stuck tool part, from GET /api/session/<id>/message, while the prompt is still showing.
    • Whether a fresh session on 2.0.18 shows the same thing, or only sessions created before an upgrade.
  6. opencodejr commented on Oct 30, 2026

    @opencodejr

    We haven't heard back on this, so I'm closing it as not planned for now.

    On 2.0.18, reloading every location with POST /api/location/reload (the v2 equivalent of /global/dispose, which returns 405 on v2) cleared the pending request. The tool part ended as status: "error" (Interaction cancelled because the location shut down), and the permission prompt went away. We couldn't get the 404 loop or the ghost prompt to happen.

    If it still happens, please comment here with:

    • opencode --version from the binary running the service
    • what triggers the disposal on your side
    • the server log lines around it
    • the JSON for the stuck tool part, from GET /api/session/<id>/message

    We'll reopen and look again. Thanks for the detailed write-up.

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

Metadata

Metadata

Assignees

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