Skip to content

[Bug]: OpenCodeAdapter leaks pending permission requests on session teardown — thread becomes permanently unsettleable #7113

Description

@simonechecchia

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Summary

OpenCodeAdapter never settles its pending permission requests when a session tears down. Any approval still open when the session stops is stranded in projection_pending_approvals with status = 'pending' forever, so projection_threads.pending_approval_count stays above zero and the thread can never be settled:

This thread still needs attention. Resolve or interrupt it first, then try again.

There is nothing left to resolve. The session is stopped, the turns that requested the approvals are long dead, and no UI surface can clear them — the only way out is hand-editing state.sqlite.

This is the OpenCode counterpart of #5119 (ClaudeAdapter leaking pending user inputs on teardown), which was fixed. The same parity gap exists here for approvals: CursorAdapter and GrokAdapter both call settlePendingApprovalsAsCancelled on teardown, and OpenCodeAdapter has no equivalent.

It is made much easier to hit by #4795 — permissions that map to requestType: "unknown" render no approval UI, so the user never sees the request in the first place and the turn dies with it still open. But the leak is a separate defect: it strands any pending permission on teardown, and fixing the UI mapping alone would not clear the rows that are already stuck.

Steps to reproduce

  1. Start a thread on the OpenCode provider in auto runtime mode.
  2. Get the agent to use a tool whose permission kind is outside bash / read / editgrep and task both do it. In auto mode these are guaranteed to prompt (see the permission arrays below), and none of them render an approval UI ([Bug] T3 Code silently drops OpenCode skill permission requests — approval never shown, turn aborts #4795), so the request opens invisibly and is never answered.
  3. Let the session end — the turn erroring out does it, and so does interrupting or a server restart.
  4. Try to settle the thread → refused with the message above, with no pending approval visible anywhere in the UI.

Auto mode is what makes this reliably reproducible. The permission array T3 sends to OpenCode differs by runtime mode, and only full-access appends a catch-all allow:

// auto — ends here, so the leading `*: ask` governs every unlisted permission kind
[{"permission":"*","pattern":"*","action":"ask"}, ..., {"permission":"question","pattern":"*","action":"allow"}]

// full-access — same array plus a trailing catch-all
[..., {"permission":"question","pattern":"*","action":"allow"}, {"permission":"*","pattern":"*","action":"allow"}]

In my thread every one of the six permission requests was raised while in auto mode; after switching the same thread to full-access, OpenCode raised none at all.

Verify in ~/.t3/userdata/state.sqlite:

SELECT request_id, turn_id, status, created_at FROM projection_pending_approvals WHERE status = 'pending';
SELECT thread_id, pending_approval_count FROM projection_threads;

Expected behavior

Tearing down an OpenCode session settles its pending permission requests — emitting request.resolved with a cancel decision for each, exactly as CursorAdapter and GrokAdapter do, and as ClaudeAdapter now does for user inputs after #5119. The projection count drops to zero and the thread becomes settleable.

Actual behavior

The rows stay pending forever. canSettle refuses while hasPendingApprovals is true, and no user action can clear it. The thread is permanently stuck in the inbox.

Impact

Blocks work completely

Version or commit

0.0.33 — T3 Code (Alpha), headless t3 serve under a systemd user unit

Environment

Ubuntu 24.04.4 LTS, aarch64, T3 Code 0.0.33 (headless server, systemd t3code.service), provider: OpenCode 1.18.18 (opencode-go/glm-5.3, agent build), runtime mode auto when the requests leaked (thread later switched to full-access). Thread driven from the mobile client.

Logs or stack traces

# ~/.t3/userdata/logs/provider/events.<thread-id>.log — request opened, never resolved
[2026-08-15T14:31:00.040Z] NTIVE: {"type":"permission.asked","turnId":"opencode-turn-44264957-...","payload":{"properties":{
  "id":"per_005d54cc0001f1q32zDJCj2PCI","sessionID":"ses_<redacted>",
  "permission":"grep","patterns":["<search-term>"],"metadata":{"pattern":"<search-term>","include":"*.vue"}}}}

State left behind after the session stopped — four approvals from two turns that died hours earlier, against a thread whose latest turn had already completed:

projection_thread_sessions: status=stopped, active_turn_id=null
projection_threads:         pending_approval_count=4, pending_user_input_count=0

request_id                      turn                             created_at                status
per_005cc6b90001wptm4ESDlQ0wlm  opencode-turn-b7ddb8d6-...       2026-08-15T14:21:18.104Z  pending
per_005d54cc0001f1q32zDJCj2PCI  opencode-turn-44264957-...       2026-08-15T14:31:00.040Z  pending
per_005d54d0d001bmo190FFDXuhCI  opencode-turn-44264957-...       2026-08-15T14:31:00.117Z  pending
per_005d54df8001xr5TstpGd1gKVD  opencode-turn-44264957-...       2026-08-15T14:31:00.350Z  pending

Both of those turns are in state: error in projection_turns, and the thread's latest turn completed successfully at 15:51 — so the thread looks idle and healthy in every way except for the counter.

The split between what surfaced and what leaked lines up exactly with mapPermissionToRequestType. All six requests came from the same thread in the same auto-mode window:

Requested permission Mapped requestType UI shown Outcome
14:21:01 bash command_execution_approval yes answered acceptForSession in 4s
14:21:01 bash command_execution_approval yes answered acceptForSession in 6s
14:21:18 task unknown no stranded
14:31:00 grep unknown no stranded
14:31:00 grep unknown no stranded
14:31:00 grep unknown no stranded

Worth noting for #4795, which is filed against permission: "skill": the gap is not specific to skills. task, grep, codesearch, webfetch, websearch, external_directory and doom_loop all fall through to unknown, and in auto mode OpenCode is configured to ask for every one of them.

Root cause

apps/server/src/provider/Layers/OpenCodeAdapter.tscontext.pendingPermissions is populated on permission.asked and drained only on permission.replied:

case "permission.asked": {
  context.pendingPermissions.set(event.properties.id, event.properties);   // ~:942
  yield* emit({ ...base, type: "request.opened", payload: { ... } });
}

case "permission.replied": {
  context.pendingPermissions.delete(event.properties.requestID);           // ~:964
  yield* emit({ ...base, type: "request.resolved", payload: { ... } });
}

stopOpenCodeContext (~:541) — the single teardown path, reached from stopSession and stopAll — aborts the remote session and closes the scope, but never touches that map and never emits request.resolved:

const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* (context) {
  if (yield* Ref.getAndSet(context.stopped, true)) return false;
  yield* runOpenCodeSdk("session.abort", () =>
    context.client.session.abort({ sessionID: context.openCodeSessionId }),
  ).pipe(Effect.ignore({ log: true }));
  yield* Scope.close(context.sessionScope, Exit.void);
  return true;
});

Since request.resolved is what clears the projection row, the row outlives the session. context.pendingQuestions looks like it has the same exposure.

Adapter parity:

Adapter Settles approvals on teardown
CursorAdapter.ts settlePendingApprovalsAsCancelled
GrokAdapter.ts settlePendingApprovalsAsCancelled
ClaudeAdapter.ts ✅ drains pendingApprovals in stopSessionInternal
OpenCodeAdapter.ts

Two things worth considering alongside the adapter fix, since users who already hit this are stuck:

  1. A recovery path for existing rows. Migration 025_CleanupInvalidProjectionPendingApprovals only deletes approvals with no backing approval.requested activity; these have one, so they survive it. I had to stop the service and clear them by hand (status='resolved', decision='cancel', then recompute pending_approval_count).
  2. A server-side guard. An approval whose turn is terminal and whose session is stopped can never be answered, so arguably it should not hold hasPendingApprovals true regardless of which adapter leaked it.

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