From d01b0658da7654d23922a2cb1f453bf048de969d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 01:08:00 +0200 Subject: [PATCH 1/2] fix(ui): drain yolo permissions outside shell render YOLO auto-accept was driven by an InstanceShell render effect, so queued permissions could wait on heavy UI rendering even though the permission request was already available. Move auto-accept draining into the permission queue flow, trigger it when permissions sync/enqueue and when YOLO is enabled, and keep per-session once-only behavior unchanged. Retry state is bounded and cleaned when permissions or sessions leave the pending queue. Validation: git diff --check; npm run typecheck --workspace @codenomad/ui. --- .../components/instance/instance-shell2.tsx | 31 +---- .../shell/right-panel/tabs/StatusTab.tsx | 5 +- packages/ui/src/stores/instances.ts | 23 ++++ .../ui/src/stores/permission-auto-accept.ts | 120 ++++++++++++++++-- 4 files changed, 134 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index bdfed7f3b..1f857591d 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -36,7 +36,7 @@ import { serverApi } from "../../lib/api-client" import { loadBackgroundProcesses } from "../../stores/background-processes" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { useI18n } from "../../lib/i18n" -import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances" +import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" @@ -58,13 +58,7 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure" import { useDrawerResize } from "./shell/useDrawerResize" import { useSessionCache } from "./shell/useSessionCache" import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" -import { getPermissionSessionId } from "../../types/permission" -import { - canAutoRespondPermission, - finishAutoRespondPermission, - getPermissionAutoAcceptInFlightVersion, - isPermissionAutoAcceptEnabled, -} from "../../stores/permission-auto-accept" +import { isPermissionAutoAcceptEnabled } from "../../stores/permission-auto-accept" const log = getLogger("session") const OPEN_SESSION_SEARCH_EVENT = "codenomad:open-session-search" @@ -269,8 +263,6 @@ const InstanceShell2: Component = (props) => { return permissions + questions > 0 }) - const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id)) - const activePromptInputApi = createMemo(() => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") return null @@ -284,25 +276,6 @@ const InstanceShell2: Component = (props) => { })) } - createEffect(() => { - getPermissionAutoAcceptInFlightVersion() - - for (const permission of permissionQueue()) { - const sessionId = getPermissionSessionId(permission) - if (!sessionId) continue - if (!permission?.id) continue - if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue - - void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once") - .catch((error) => { - log.error("Failed to auto-accept permission", error) - }) - .finally(() => { - finishAutoRespondPermission(props.instance.id, sessionId, permission.id) - }) - } - }) - const yoloModeEnabled = createMemo(() => { const session = activeSessionForInstance() if (!session) return false diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index f618bf03e..7be3284c4 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -13,7 +13,8 @@ import type { Session } from "../../../../../types/session" import ContextUsagePanel from "../../../../session/context-usage-panel" import { TodoListView } from "../../../../tool-call/renderers/todo" import InstanceServiceStatus from "../../../../instance-service-status" -import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept" +import { togglePermissionAutoAcceptForSession } from "../../../../../stores/instances" +import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -63,7 +64,7 @@ const StatusTab: Component = (props) => { color="warning" size="small" inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }} - onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)} + onChange={() => togglePermissionAutoAcceptForSession(props.instanceId, session.id)} /> diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index c6b494827..4dc83de56 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -32,6 +32,7 @@ import { setSessionPendingPermission, setSessionPendingQuestion } from "./sessio import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge" +import { clearAutoAcceptPermission, drainAutoAcceptPermissions, isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "./permission-auto-accept" import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" @@ -207,6 +208,7 @@ async function syncPendingPermissions(instanceId: string): Promise { addPermissionToQueue(instanceId, permission) upsertPermissionV2(instanceId, permission) } + drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) } catch (error) { log.warn("Failed to sync pending permissions", { instanceId, error }) } @@ -615,6 +617,10 @@ function getPermissionQueueLength(instanceId: string): number { return getPermissionQueue(instanceId).length } +function hasPendingPermission(instanceId: string, permissionId: string): boolean { + return getPermissionQueue(instanceId).some((permission) => permission.id === permissionId) +} + function getQuestionQueue(instanceId: string): QuestionRequest[] { const queue = questionQueues().get(instanceId) if (!queue) { @@ -796,6 +802,8 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL } byPermissionId.set(permission.id, slug) } + + drainAutoAcceptPermissions(instanceId, [permission], sendPermissionResponse, hasPendingPermission) } function removePermissionFromQueue(instanceId: string, permissionId: string): void { @@ -832,13 +840,27 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo permissionWorktreeSlugByInstance.get(instanceId)?.delete(permissionId) const removedSessionId = getPermissionSessionId(removed) if (removedSessionId) { + clearAutoAcceptPermission(instanceId, removedSessionId, permissionId) const remaining = decrementSessionPendingCount(instanceId, removedSessionId) setSessionPendingPermission(instanceId, removedSessionId, remaining > 0) } } } +function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void { + const willEnable = !isPermissionAutoAcceptEnabled(instanceId, sessionId) + togglePermissionAutoAccept(instanceId, sessionId) + if (!willEnable) return + drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) +} + function clearPermissionQueue(instanceId: string): void { + for (const permission of getPermissionQueue(instanceId)) { + const sessionId = getPermissionSessionId(permission) + if (sessionId) { + clearAutoAcceptPermission(instanceId, sessionId, permission.id) + } + } setPermissionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -1130,6 +1152,7 @@ export { getPermissionQueueLength, addPermissionToQueue, removePermissionFromQueue, + togglePermissionAutoAcceptForSession, clearPermissionQueue, sendPermissionResponse, setActivePermissionIdForInstance, diff --git a/packages/ui/src/stores/permission-auto-accept.ts b/packages/ui/src/stores/permission-auto-accept.ts index 6607d3a8b..4999416b0 100644 --- a/packages/ui/src/stores/permission-auto-accept.ts +++ b/packages/ui/src/stores/permission-auto-accept.ts @@ -1,6 +1,16 @@ import { createSignal } from "solid-js" +import type { PermissionReply, PermissionRequestLike } from "../types/permission" +import { getPermissionSessionId } from "../types/permission" +import { getLogger } from "../lib/logger" const STORAGE_KEY = "codenomad:permission-auto-accept:v1" +const RETRY_BASE_DELAY_MS = 1_000 +const RETRY_MAX_DELAY_MS = 10_000 + +const log = getLogger("api") + +type AutoAcceptResponder = (instanceId: string, sessionId: string, requestId: string, reply: PermissionReply) => Promise +type PendingPermissionChecker = (instanceId: string, requestId: string) => boolean function makeKey(instanceId: string, sessionId: string) { return `${instanceId}:${sessionId}` @@ -34,9 +44,10 @@ function persist(next: Map) { } const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState()) -const [inFlightVersion, setInFlightVersion] = createSignal(0) const inFlight = new Set() +const retryAttempts = new Map() +const retryTimers = new Map>() export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) { return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false @@ -54,28 +65,109 @@ export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: st persist(next) return next }) + if (!enabled) { + clearAutoAcceptSession(instanceId, sessionId) + } } export function togglePermissionAutoAccept(instanceId: string, sessionId: string) { setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId)) } -export function canAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) { - const key = makeKey(instanceId, sessionId) - if (!autoAcceptState().get(key)) return false - const requestKey = `${key}:${requestId}` - if (inFlight.has(requestKey)) return false - inFlight.add(requestKey) - return true +function makeRequestKey(instanceId: string, sessionId: string, requestId: string) { + return `${makeKey(instanceId, sessionId)}:${requestId}` } -export function getPermissionAutoAcceptInFlightVersion() { - return inFlightVersion() +function clearRetry(requestKey: string) { + const timer = retryTimers.get(requestKey) + if (timer) { + clearTimeout(timer) + retryTimers.delete(requestKey) + } + retryAttempts.delete(requestKey) } -export function finishAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) { - if (!inFlight.delete(`${makeKey(instanceId, sessionId)}:${requestId}`)) { - return +export function clearAutoAcceptPermission(instanceId: string, sessionId: string, requestId: string) { + const requestKey = makeRequestKey(instanceId, sessionId, requestId) + inFlight.delete(requestKey) + clearRetry(requestKey) +} + +export function clearAutoAcceptSession(instanceId: string, sessionId: string) { + const prefix = `${makeKey(instanceId, sessionId)}:` + for (const requestKey of Array.from(inFlight)) { + if (requestKey.startsWith(prefix)) { + inFlight.delete(requestKey) + } + } + for (const requestKey of Array.from(retryTimers.keys())) { + if (requestKey.startsWith(prefix)) { + clearRetry(requestKey) + } + } + for (const requestKey of Array.from(retryAttempts.keys())) { + if (requestKey.startsWith(prefix)) { + retryAttempts.delete(requestKey) + } + } +} + +function scheduleRetry( + instanceId: string, + permission: PermissionRequestLike, + responder: AutoAcceptResponder, + isPending: PendingPermissionChecker, + requestKey: string, +) { + if (retryTimers.has(requestKey)) return + const attempt = (retryAttempts.get(requestKey) ?? 0) + 1 + retryAttempts.set(requestKey, attempt) + const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS) + const timer = setTimeout(() => { + retryTimers.delete(requestKey) + drainAutoAcceptPermission(instanceId, permission, responder, isPending) + }, delay) + retryTimers.set(requestKey, timer) +} + +export function drainAutoAcceptPermission( + instanceId: string, + permission: PermissionRequestLike, + responder: AutoAcceptResponder, + isPending: PendingPermissionChecker, +) { + const sessionId = getPermissionSessionId(permission) + if (!sessionId || !permission?.id) return + if (!isPermissionAutoAcceptEnabled(instanceId, sessionId)) return + if (!isPending(instanceId, permission.id)) return + + const requestKey = makeRequestKey(instanceId, sessionId, permission.id) + if (inFlight.has(requestKey) || retryTimers.has(requestKey)) return + + inFlight.add(requestKey) + + void responder(instanceId, sessionId, permission.id, "once") + .then(() => { + clearRetry(requestKey) + }) + .catch((error) => { + log.error("Failed to auto-accept permission", error) + if (isPending(instanceId, permission.id) && isPermissionAutoAcceptEnabled(instanceId, sessionId)) { + scheduleRetry(instanceId, permission, responder, isPending, requestKey) + } + }) + .finally(() => { + inFlight.delete(requestKey) + }) +} + +export function drainAutoAcceptPermissions( + instanceId: string, + permissions: PermissionRequestLike[], + responder: AutoAcceptResponder, + isPending: PendingPermissionChecker, +) { + for (const permission of permissions) { + drainAutoAcceptPermission(instanceId, permission, responder, isPending) } - setInFlightVersion((value) => value + 1) } From 3147ae00d45c92bcd39482e08d36ad9993f0bf7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 16:00:09 +0200 Subject: [PATCH 2/2] fix(ui): simplify yolo auto-accept drain Remove retry scheduling from YOLO permission auto-accept because replies target the local server and retry state adds complexity without a clear recovery benefit. The queue-driven drain still guards duplicate sends with in-flight tracking and cleans up when permissions or sessions leave the pending queue. Also remove dead queue state left in the permission removal path. --- packages/ui/src/stores/instances.ts | 2 - .../ui/src/stores/permission-auto-accept.ts | 50 +------------------ 2 files changed, 1 insertion(+), 51 deletions(-) diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 4dc83de56..b4d8878d6 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -830,8 +830,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo return next }) - const updatedQueue = getPermissionQueue(instanceId) - recomputeActiveInterruption(instanceId) const removed = removedPermission diff --git a/packages/ui/src/stores/permission-auto-accept.ts b/packages/ui/src/stores/permission-auto-accept.ts index 4999416b0..1b1ea402c 100644 --- a/packages/ui/src/stores/permission-auto-accept.ts +++ b/packages/ui/src/stores/permission-auto-accept.ts @@ -4,8 +4,6 @@ import { getPermissionSessionId } from "../types/permission" import { getLogger } from "../lib/logger" const STORAGE_KEY = "codenomad:permission-auto-accept:v1" -const RETRY_BASE_DELAY_MS = 1_000 -const RETRY_MAX_DELAY_MS = 10_000 const log = getLogger("api") @@ -46,8 +44,6 @@ function persist(next: Map) { const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState()) const inFlight = new Set() -const retryAttempts = new Map() -const retryTimers = new Map>() export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) { return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false @@ -78,19 +74,9 @@ function makeRequestKey(instanceId: string, sessionId: string, requestId: string return `${makeKey(instanceId, sessionId)}:${requestId}` } -function clearRetry(requestKey: string) { - const timer = retryTimers.get(requestKey) - if (timer) { - clearTimeout(timer) - retryTimers.delete(requestKey) - } - retryAttempts.delete(requestKey) -} - export function clearAutoAcceptPermission(instanceId: string, sessionId: string, requestId: string) { const requestKey = makeRequestKey(instanceId, sessionId, requestId) inFlight.delete(requestKey) - clearRetry(requestKey) } export function clearAutoAcceptSession(instanceId: string, sessionId: string) { @@ -100,34 +86,6 @@ export function clearAutoAcceptSession(instanceId: string, sessionId: string) { inFlight.delete(requestKey) } } - for (const requestKey of Array.from(retryTimers.keys())) { - if (requestKey.startsWith(prefix)) { - clearRetry(requestKey) - } - } - for (const requestKey of Array.from(retryAttempts.keys())) { - if (requestKey.startsWith(prefix)) { - retryAttempts.delete(requestKey) - } - } -} - -function scheduleRetry( - instanceId: string, - permission: PermissionRequestLike, - responder: AutoAcceptResponder, - isPending: PendingPermissionChecker, - requestKey: string, -) { - if (retryTimers.has(requestKey)) return - const attempt = (retryAttempts.get(requestKey) ?? 0) + 1 - retryAttempts.set(requestKey, attempt) - const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS) - const timer = setTimeout(() => { - retryTimers.delete(requestKey) - drainAutoAcceptPermission(instanceId, permission, responder, isPending) - }, delay) - retryTimers.set(requestKey, timer) } export function drainAutoAcceptPermission( @@ -142,19 +100,13 @@ export function drainAutoAcceptPermission( if (!isPending(instanceId, permission.id)) return const requestKey = makeRequestKey(instanceId, sessionId, permission.id) - if (inFlight.has(requestKey) || retryTimers.has(requestKey)) return + if (inFlight.has(requestKey)) return inFlight.add(requestKey) void responder(instanceId, sessionId, permission.id, "once") - .then(() => { - clearRetry(requestKey) - }) .catch((error) => { log.error("Failed to auto-accept permission", error) - if (isPending(instanceId, permission.id) && isPermissionAutoAcceptEnabled(instanceId, sessionId)) { - scheduleRetry(instanceId, permission, responder, isPending, requestKey) - } }) .finally(() => { inFlight.delete(requestKey)