fix(app): surface exhausted auto-approve permission requests (#61584)

This commit is contained in:
Luke Parker
2026-11-04 10:41:58 -06:00
committed by GitHub
Unverified
parent 7f24020fb2
commit e5f2dde373
5 changed files with 403 additions and 62 deletions
+2 -1
View File
@@ -249,7 +249,7 @@ function createServerController(
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const autoApprove = createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
// Projects whose repository has no root commit share the `global` ID; their name and icon stay local to the
@@ -356,6 +356,7 @@ function createServerController(
},
},
notification,
autoApprove,
}
}
@@ -0,0 +1,160 @@
import { describe, expect, test } from "bun:test"
import {
INITIAL_AUTO_APPROVE_STATE,
isDockVisible,
transitionAutoApprove,
type AutoApproveEffect,
type AutoApproveEvent,
type AutoApproveState,
type AutoApproveTransition,
} from "./auto-approve-machine"
const approve = (runId: string): AutoApproveEvent => ({
type: "approve",
sessionID: "ses_1",
projectID: "proj_1",
runId,
})
const replying = (runId: string, attempt: number): AutoApproveState => ({
status: "replying",
sessionID: "ses_1",
projectID: "proj_1",
runId,
attempt,
})
const waiting = (runId: string, attempt: number): AutoApproveState => ({
status: "waiting-retry",
sessionID: "ses_1",
projectID: "proj_1",
runId,
attempt,
})
const gaveUp: AutoApproveState = { status: "gave-up", projectID: "proj_1" }
function replay(events: AutoApproveEvent[], from = INITIAL_AUTO_APPROVE_STATE) {
return events.reduce<AutoApproveTransition[]>((steps, event) => {
steps.push(transitionAutoApprove(steps.at(-1)?.state ?? from, event))
return steps
}, [])
}
describe("transitionAutoApprove", () => {
test("approves once and settles on a successful reply", () => {
const steps = replay([approve("r1"), { type: "reply.succeeded", runId: "r1", attempt: 0 }])
expect(steps.map((step) => step.state)).toEqual([replying("r1", 0), { status: "replied" }])
expect(steps.map((step) => step.effects)).toEqual([
[{ type: "reply", sessionID: "ses_1", runId: "r1", attempt: 0 }],
[],
])
})
test("surfaces the request in the dock once every retry fails", () => {
const steps = replay([
approve("r1"),
{ type: "reply.failed", runId: "r1", attempt: 0 },
{ type: "retry.due", runId: "r1", attempt: 1 },
{ type: "reply.failed", runId: "r1", attempt: 1 },
{ type: "retry.due", runId: "r1", attempt: 2 },
{ type: "reply.failed", runId: "r1", attempt: 2 },
])
expect(steps.map((step) => step.state)).toEqual([
replying("r1", 0),
waiting("r1", 1),
replying("r1", 1),
waiting("r1", 2),
replying("r1", 2),
gaveUp,
])
expect(steps.map((step) => step.effects)).toEqual([
[{ type: "reply", sessionID: "ses_1", runId: "r1", attempt: 0 }],
[{ type: "schedule-retry", runId: "r1", attempt: 1, delayMs: 1000 }],
[{ type: "reply", sessionID: "ses_1", runId: "r1", attempt: 1 }],
[{ type: "schedule-retry", runId: "r1", attempt: 2, delayMs: 2000 }],
[{ type: "reply", sessionID: "ses_1", runId: "r1", attempt: 2 }],
[],
])
expect(steps.map((step) => isDockVisible(step.state, true))).toEqual([false, false, false, false, false, true])
})
test("recovers on a later attempt", () => {
const state = replay([
approve("r1"),
{ type: "reply.failed", runId: "r1", attempt: 0 },
{ type: "retry.due", runId: "r1", attempt: 1 },
{ type: "reply.succeeded", runId: "r1", attempt: 1 },
]).at(-1)?.state
expect(state).toEqual({ status: "replied" })
})
test.each<{ name: string; from: AutoApproveState }>([
{ name: "replying", from: replying("r1", 0) },
{ name: "waiting for a retry", from: waiting("r1", 1) },
{ name: "exhausted", from: gaveUp },
{ name: "replied", from: { status: "replied" } },
])("ignores another approval while $name", (input) => {
const result = transitionAutoApprove(input.from, approve("r2"))
expect(result.state).toBe(input.from)
expect(result.effects).toEqual([])
})
test("rejects results and timers from a superseded cycle or attempt", () => {
const state = replay([approve("r1"), { type: "cancel" }, approve("r2")]).at(-1)?.state ?? INITIAL_AUTO_APPROVE_STATE
expect(state).toEqual(replying("r2", 0))
const stale: AutoApproveEvent[] = [
{ type: "reply.failed", runId: "r1", attempt: 0 },
{ type: "reply.succeeded", runId: "r1", attempt: 0 },
{ type: "reply.failed", runId: "r2", attempt: 1 },
{ type: "retry.due", runId: "r2", attempt: 0 },
]
stale.forEach((event) => {
const result = transitionAutoApprove(state, event)
expect(result.state).toBe(state)
expect(result.effects).toEqual([])
})
const pausing = waiting("r2", 1)
expect(transitionAutoApprove(pausing, { type: "retry.due", runId: "r1", attempt: 1 }).state).toBe(pausing)
})
test.each<{ from: AutoApproveState; cancel: AutoApproveState; resolved: AutoApproveState; clears: boolean }>([
{
from: INITIAL_AUTO_APPROVE_STATE,
cancel: INITIAL_AUTO_APPROVE_STATE,
resolved: INITIAL_AUTO_APPROVE_STATE,
clears: false,
},
{ from: replying("r1", 0), cancel: INITIAL_AUTO_APPROVE_STATE, resolved: { status: "replied" }, clears: false },
{ from: waiting("r1", 1), cancel: INITIAL_AUTO_APPROVE_STATE, resolved: { status: "replied" }, clears: true },
{ from: gaveUp, cancel: INITIAL_AUTO_APPROVE_STATE, resolved: { status: "replied" }, clears: false },
{ from: { status: "replied" }, cancel: { status: "replied" }, resolved: { status: "replied" }, clears: false },
])("cancel and resolved from $from.status", (input) => {
const effects: AutoApproveEffect[] = input.clears ? [{ type: "cancel-retry" }] : []
expect(transitionAutoApprove(input.from, { type: "cancel" })).toEqual({ state: input.cancel, effects })
expect(transitionAutoApprove(input.from, { type: "resolved" })).toEqual({ state: input.resolved, effects })
})
})
test.each<{ state: AutoApproveState; on: boolean; off: boolean }>([
{ state: INITIAL_AUTO_APPROVE_STATE, on: false, off: true },
{ state: replying("r1", 0), on: false, off: true },
{ state: waiting("r1", 1), on: false, off: true },
{ state: gaveUp, on: true, off: true },
{ state: { status: "replied" }, on: false, off: false },
])("isDockVisible for $state.status", (input) => {
expect(isDockVisible(input.state, true)).toBe(input.on)
expect(isDockVisible(input.state, false)).toBe(input.off)
})
@@ -0,0 +1,119 @@
export const AUTO_APPROVE_RETRY_LIMIT = 2
export const AUTO_APPROVE_RETRY_DELAY_MS = 1000
// One permission request's auto-approve lifecycle. `runId` names one approval cycle and `attempt` one reply within
// it, so a reply or timer from a cancelled or resolved cycle can never move a later one.
export type AutoApproveState =
| { readonly status: "pending" }
| {
readonly status: "replying"
readonly sessionID: string
readonly projectID: string | undefined
readonly runId: string
readonly attempt: number
}
| {
readonly status: "waiting-retry"
readonly sessionID: string
readonly projectID: string | undefined
readonly runId: string
readonly attempt: number
}
| { readonly status: "replied" }
| { readonly status: "gave-up"; readonly projectID: string | undefined }
export type AutoApproveEvent =
| {
readonly type: "approve"
readonly sessionID: string
readonly projectID: string | undefined
readonly runId: string
}
| { readonly type: "reply.succeeded"; readonly runId: string; readonly attempt: number }
| { readonly type: "reply.failed"; readonly runId: string; readonly attempt: number }
| { readonly type: "retry.due"; readonly runId: string; readonly attempt: number }
| { readonly type: "cancel" }
| { readonly type: "resolved" }
export type AutoApproveEffect =
| { readonly type: "reply"; readonly sessionID: string; readonly runId: string; readonly attempt: number }
| { readonly type: "schedule-retry"; readonly runId: string; readonly attempt: number; readonly delayMs: number }
| { readonly type: "cancel-retry" }
export type AutoApproveTransition = {
readonly state: AutoApproveState
readonly effects: readonly AutoApproveEffect[]
}
export const INITIAL_AUTO_APPROVE_STATE: AutoApproveState = { status: "pending" }
export function transitionAutoApprove(state: AutoApproveState, event: AutoApproveEvent): AutoApproveTransition {
// A request nobody tried to approve has nothing to settle when another client answers it.
if (event.type === "resolved") {
if (state.status === "pending" || state.status === "replied") return unchanged(state)
return changed({ status: "replied" }, state.status === "waiting-retry" ? [{ type: "cancel-retry" }] : [])
}
if (event.type === "cancel") {
if (state.status === "pending" || state.status === "replied") return unchanged(state)
return changed(INITIAL_AUTO_APPROVE_STATE, state.status === "waiting-retry" ? [{ type: "cancel-retry" }] : [])
}
// Store scans and sweeps approve repeatedly; only a request no cycle owns starts one. An exhausted request stays
// surfaced until someone answers it or auto-approve is turned off and on again.
if (event.type === "approve") {
if (state.status !== "pending") return unchanged(state)
return changed(
{ status: "replying", sessionID: event.sessionID, projectID: event.projectID, runId: event.runId, attempt: 0 },
[{ type: "reply", sessionID: event.sessionID, runId: event.runId, attempt: 0 }],
)
}
if (event.type === "reply.succeeded") {
if (state.status !== "replying" || !current(state, event)) return unchanged(state)
return changed({ status: "replied" })
}
if (event.type === "reply.failed") {
if (state.status !== "replying" || !current(state, event)) return unchanged(state)
if (state.attempt >= AUTO_APPROVE_RETRY_LIMIT) return changed({ status: "gave-up", projectID: state.projectID })
const attempt = state.attempt + 1
return changed({ ...state, status: "waiting-retry", attempt }, [
{ type: "schedule-retry", runId: state.runId, attempt, delayMs: AUTO_APPROVE_RETRY_DELAY_MS * attempt },
])
}
if (state.status !== "waiting-retry" || !current(state, event)) return unchanged(state)
return changed({ ...state, status: "replying" }, [
{ type: "reply", sessionID: state.sessionID, runId: state.runId, attempt: state.attempt },
])
}
// Whether the permission dock shows a pending request. Auto-approve hides it only while a cycle can still answer it.
export function isDockVisible(state: AutoApproveState, autoApprove: boolean) {
if (state.status === "replied") return false
if (state.status === "gave-up") return true
return !autoApprove
}
function current(state: { runId: string; attempt: number }, event: { runId: string; attempt: number }) {
return state.runId === event.runId && state.attempt === event.attempt
}
function changed(state: AutoApproveState, effects: readonly AutoApproveEffect[] = []): AutoApproveTransition {
return { state, effects }
}
function unchanged(state: AutoApproveState): AutoApproveTransition {
return { state, effects: [] }
}
+113 -56
View File
@@ -1,14 +1,22 @@
import { createEffect, onCleanup } from "solid-js"
import { createEffect, onCleanup, untrack } from "solid-js"
import { createStore, produce } from "solid-js/store"
import type { PermissionRequest } from "@opencode/client/promise"
import type { Data } from "@opencode/client/solid"
import { abort } from "@opencode/util/abort"
import { uuid } from "@opencode/util/uuid"
import type { ServerSDK } from "@/runtime/server/client"
import { projectSettingsKey, useSettings } from "@/settings/model"
import {
AUTO_APPROVE_RETRY_DELAY_MS,
AUTO_APPROVE_RETRY_LIMIT,
INITIAL_AUTO_APPROVE_STATE,
transitionAutoApprove,
type AutoApproveEffect,
type AutoApproveEvent,
type AutoApproveState,
} from "./auto-approve-machine"
const respondedLimit = 1000
const retryLimit = 2
const retryDelayMs = 1000
const repliedLimit = 1000
// Auto-approves permission requests on one server connection for the projects
// the client-local settings cover: the app-wide default, unless a project overrides it.
@@ -19,24 +27,34 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
const covers = (projectID: string | undefined) =>
permissions.autoApproves(projectSettingsKey(input.sdk.scope, projectID))
const state = { disposed: false, generation: 0, responded: new Set<string>() }
// Requests in the initial `pending` state have no entry.
const [requests, setRequests] = createStore<Record<string, AutoApproveState | undefined>>({})
const timers = new Map<string, ReturnType<typeof setTimeout>>()
const scope = new AbortController()
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (enabled()) consider(event.data)
})
const unsubscribe = [
input.sdk.event.on("permission.asked", (event) => {
if (enabled()) consider(event.data, scope.signal)
}),
input.sdk.event.on("permission.replied", (event) => dispatch(event.data.requestID, { type: "resolved" })),
]
onCleanup(() => {
state.disposed = true
unsubscribe()
abort(scope)
unsubscribe.forEach((stop) => stop())
// Back to pending, so replies still in flight settle into nothing and no retry is scheduled.
untrack(() => Object.keys(requests)).forEach((id) => dispatch(id, { type: "cancel" }))
})
// The event stream does not replay requests asked while this client was
// disconnected, and requests may already be pending before the setting turns
// on, so sweep on every connect while the setting is on.
// on, so sweep on every connect while the setting is on. A newer connect or
// a change of the setting aborts the sweep and its scheduled retries.
createEffect(() => {
if (!enabled() || input.sdk.connection.status() !== "connected") return
const generation = ++state.generation
void sweepWithRetry(generation, 0)
const controller = new AbortController()
onCleanup(() => abort(controller))
void sweepWithRetry(controller.signal, 0)
})
// Approves pending requests that reach the local store, which is how a
@@ -51,20 +69,78 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
}
})
// An incomplete sweep leaves pending requests hidden with no later trigger
// to recover them, so retry it a bounded number of times. A newer sweep
// supersedes scheduled retries.
async function sweepWithRetry(generation: number, attempt: number) {
const complete = await sweep()
// Turning auto-approve off for a request's project stops its cycle, so a
// scheduled retry never answers for the user and turning it on again starts over.
createEffect(() => {
Object.entries(requests).forEach(([id, state]) => {
if (!state || state.status === "pending" || state.status === "replied") return
if (complete || attempt >= retryLimit) return
setTimeout(() => {
if (state.disposed || !enabled() || generation !== state.generation) return
void sweepWithRetry(generation, attempt + 1)
}, retryDelayMs * (attempt + 1))
if (!covers(state.projectID)) dispatch(id, { type: "cancel" })
})
})
function dispatch(requestID: string, event: AutoApproveEvent) {
const state = untrack(() => requests[requestID]) ?? INITIAL_AUTO_APPROVE_STATE
const result = transitionAutoApprove(state, event)
if (result.state !== state) {
setRequests({ [requestID]: result.state.status === "pending" ? undefined : result.state })
if (result.state.status === "replied") pruneReplied()
}
result.effects.forEach((effect) => execute(requestID, effect))
}
async function sweep() {
function execute(requestID: string, effect: AutoApproveEffect) {
if (effect.type === "reply") {
// A request another client already answered counts as replied.
void input.data.session.permission.reply({ sessionID: effect.sessionID, requestID, decision: "once" }).then(
() => dispatch(requestID, { type: "reply.succeeded", runId: effect.runId, attempt: effect.attempt }),
() => dispatch(requestID, { type: "reply.failed", runId: effect.runId, attempt: effect.attempt }),
)
return
}
clearTimeout(timers.get(requestID))
timers.delete(requestID)
if (effect.type === "cancel-retry") return
timers.set(
requestID,
setTimeout(() => {
timers.delete(requestID)
dispatch(requestID, { type: "retry.due", runId: effect.runId, attempt: effect.attempt })
}, effect.delayMs),
)
}
function pruneReplied() {
const replied = untrack(() =>
Object.entries(requests).flatMap(([id, state]) => (state?.status === "replied" ? [id] : [])),
)
if (replied.length <= repliedLimit) return
setRequests(produce((draft) => replied.slice(0, replied.length - repliedLimit).forEach((id) => delete draft[id])))
}
// An incomplete sweep leaves pending requests hidden with no later trigger
// to recover them, so retry it a bounded number of times.
async function sweepWithRetry(signal: AbortSignal, attempt: number) {
const complete = await sweep(signal)
if (complete || attempt >= AUTO_APPROVE_RETRY_LIMIT || signal.aborted) return
const timer = setTimeout(
() => void sweepWithRetry(signal, attempt + 1),
AUTO_APPROVE_RETRY_DELAY_MS * (attempt + 1),
)
signal.addEventListener("abort", () => clearTimeout(timer), { once: true })
}
async function sweep(signal: AbortSignal) {
const inventory = await sweepLocations()
const listed = await Promise.all(
@@ -72,7 +148,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
input.sdk.api.permission.request
.list({ location: { directory: location.directory } })
.then((pending) => {
if (!state.disposed) pending.data.forEach((request) => consider(request))
if (!signal.aborted) pending.data.forEach((request) => consider(request, signal))
return true
})
@@ -128,42 +204,23 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
// A request from a session this client has not loaded needs that session's
// project before a per-project choice can apply to it.
function consider(permission: PermissionRequest) {
function consider(permission: PermissionRequest, signal: AbortSignal) {
if (covers(undefined) || input.data.session.get(permission.sessionID)) return approve(permission)
void input.sdk.api.session
.get({ sessionID: permission.sessionID })
.then((session) => approve(permission, session.projectID))
.then((session) => {
if (!signal.aborted) approve(permission, session.projectID)
})
.catch(() => undefined)
}
function approve(
permission: PermissionRequest,
projectID = input.data.session.get(permission.sessionID)?.projectID,
attempt = 0,
) {
// covers() guards the retry timer path: the user may disable the setting
// between a failed reply and its scheduled retry.
if (state.disposed || !covers(projectID) || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, decision: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
// of times. Later sweeps retry it after that.
state.responded.delete(permission.id)
if (state.disposed || attempt >= retryLimit) return
setTimeout(() => approve(permission, projectID, attempt + 1), retryDelayMs * (attempt + 1))
})
function approve(permission: PermissionRequest, projectID = input.data.session.get(permission.sessionID)?.projectID) {
if (!covers(projectID)) return
dispatch(permission.id, { type: "approve", sessionID: permission.sessionID, projectID, runId: uuid() })
}
function remember(id: string) {
state.responded.add(id)
for (const oldest of state.responded) {
if (state.responded.size <= respondedLimit) break
state.responded.delete(oldest)
}
return {
/** The auto-approve lifecycle of one permission request. Reactive. */
state: (requestID: string) => requests[requestID] ?? INITIAL_AUTO_APPROVE_STATE,
}
}
+9 -5
View File
@@ -11,13 +11,15 @@ import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/
import { createWebSearchRequest } from "./websearch"
import { createSessionBackground } from "@/session/requests/background"
import { createSteerBackground } from "@/session/requests/steer-background"
import { useData } from "@/runtime/server/current"
import { useData, useServer } from "@/runtime/server/current"
import { isDockVisible } from "./auto-approve-machine"
export function createSessionRequestModel() {
const params = useParams()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const data = useData()
const autoApprove = useServer().ctx.autoApprove
const language = useLanguage()
const settings = useSettings()
createEffect(() => {
@@ -107,11 +109,13 @@ export function createSessionRequestModel() {
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const id = params.id
const project = projectSettingsKey(serverSDK.scope, id ? data.session.get(id)?.projectID : undefined)
const enabled = settings.permissions.autoApproves(
projectSettingsKey(serverSDK.scope, id ? data.session.get(id)?.projectID : undefined),
)
if (settings.permissions.autoApproves(project)) return
return sessionPermissionRequest(data.session.list(), data.session.permission.list, id)
return sessionPermissionRequest(data.session.list(), data.session.permission.list, id, (item) =>
isDockVisible(autoApprove.state(item.id), enabled),
)
})
const blocked = createMemo(() => {