Skip to content

Commit f874d69

Browse files
committed
fix(cloud-task): restore pending permission request on renderer reload
A cloud task that is awaiting a permission/plan approval keeps that request only as a transient SSE `permission_request` event — it is never persisted to session_logs. When the renderer reloads (or a second subscriber attaches) the watcher re-emits a snapshot built from session_logs alone, so the pending request is dropped and the approve/ deny UI never returns. The user is then forced to cancel and type a message instead of answering, effectively bypassing the gate (see #1833). Track unanswered permission requests on the main-process watcher (which survives a renderer reload) and replay them right after each `emitCurrentSnapshot`, so a reconnecting renderer rebuilds its pending- permission state. The handler keys on toolCallId, so replay is idempotent. Entries are cleared when the request is answered (permission_response), when the turn is cancelled, and when the run reaches a terminal status. Scope: this covers renderer reload / re-subscribe, where the main process stays alive. A full app restart tears down the watcher, so restoring after restart still requires the backend to re-surface the pending request on a fresh stream — tracked separately.
1 parent 64256f2 commit f874d69

2 files changed

Lines changed: 149 additions & 0 deletions

File tree

apps/code/src/main/services/cloud-task/service.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,109 @@ describe("CloudTaskService", () => {
459459
await waitFor(() => getWatcherEmittedEntryCount() === 0);
460460
});
461461

462+
it("replays a pending permission request on snapshot until it is answered", async () => {
463+
const updates: unknown[] = [];
464+
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
465+
466+
const runResponse = {
467+
id: "run-1",
468+
status: "in_progress",
469+
stage: "agent",
470+
output: null,
471+
error_message: null,
472+
branch: "main",
473+
updated_at: "2026-01-01T00:00:00Z",
474+
};
475+
const permissionEvent = {
476+
type: "permission_request",
477+
requestId: "req-1",
478+
toolCall: { toolCallId: "tool-1", title: "Approve?", kind: "other" },
479+
options: [],
480+
};
481+
// Route by URL so the test is robust to call ordering: run-state, session
482+
// logs (always empty here), and command responses.
483+
mockNetFetch.mockImplementation(async (input: string | Request) => {
484+
const url = typeof input === "string" ? input : input.url;
485+
if (url.includes("/session_logs/")) {
486+
return createJsonResponse([], 200, { "X-Has-More": "false" });
487+
}
488+
if (url.includes("/command/")) {
489+
return createJsonResponse({ result: {} });
490+
}
491+
return createJsonResponse(runResponse);
492+
});
493+
494+
mockStreamFetch.mockResolvedValueOnce(
495+
createOpenSseResponse(
496+
`id: 1\ndata: ${JSON.stringify(permissionEvent)}\n\n`,
497+
),
498+
);
499+
500+
const permissionUpdates = () =>
501+
updates.filter(
502+
(u) =>
503+
typeof u === "object" &&
504+
u !== null &&
505+
(u as { kind?: string }).kind === "permission_request",
506+
);
507+
508+
service.watch({
509+
taskId: "task-1",
510+
runId: "run-1",
511+
apiHost: "https://app.example.com",
512+
teamId: 2,
513+
});
514+
515+
// Live permission request surfaces once over the stream.
516+
await waitFor(() => permissionUpdates().length === 1);
517+
518+
// A renderer reload re-subscribes to the still-alive watcher; the pending
519+
// permission must be replayed so the approve/deny UI comes back.
520+
service.watch({
521+
taskId: "task-1",
522+
runId: "run-1",
523+
apiHost: "https://app.example.com",
524+
teamId: 2,
525+
});
526+
527+
await waitFor(() => permissionUpdates().length === 2);
528+
expect(permissionUpdates()[1]).toEqual({
529+
taskId: "task-1",
530+
runId: "run-1",
531+
kind: "permission_request",
532+
requestId: "req-1",
533+
toolCall: permissionEvent.toolCall,
534+
options: [],
535+
});
536+
537+
// Answering the request clears it, so later re-subscribes do not replay it.
538+
const result = await service.sendCommand({
539+
taskId: "task-1",
540+
runId: "run-1",
541+
apiHost: "https://app.example.com",
542+
teamId: 2,
543+
method: "permission_response",
544+
params: { requestId: "req-1", optionId: "allow" },
545+
});
546+
expect(result.success).toBe(true);
547+
548+
const snapshotCount = () =>
549+
updates.filter((u) => (u as { kind?: string }).kind === "snapshot")
550+
.length;
551+
const snapshotsBefore = snapshotCount();
552+
553+
service.watch({
554+
taskId: "task-1",
555+
runId: "run-1",
556+
apiHost: "https://app.example.com",
557+
teamId: 2,
558+
});
559+
560+
// Wait for the re-subscribe snapshot, then confirm no permission replay.
561+
await waitFor(() => snapshotCount() > snapshotsBefore);
562+
expect(permissionUpdates().length).toBe(2);
563+
});
564+
462565
it("ignores keepalive SSE events while keeping the stream open", async () => {
463566
const updates: unknown[] = [];
464567
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));

apps/code/src/main/services/cloud-task/service.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ interface WatcherState {
102102
lastErrorMessage: string | null;
103103
lastBranch: string | null;
104104
lastStatusUpdatedAt: string | null;
105+
pendingPermissionRequests: Map<string, PermissionRequestEventData>;
105106
isBootstrapping: boolean;
106107
hasEmittedSnapshot: boolean;
107108
bufferedLogBatches: StoredLogEntry[][];
@@ -375,6 +376,8 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
375376
method: input.method,
376377
});
377378

379+
this.clearPendingPermissionForCommand(input);
380+
378381
return { success: true, result: data.result };
379382
} catch (error) {
380383
const errorMessage =
@@ -388,6 +391,21 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
388391
}
389392
}
390393

394+
private clearPendingPermissionForCommand(input: SendCommandInput): void {
395+
const watcher = this.watchers.get(watcherKey(input.taskId, input.runId));
396+
if (!watcher) return;
397+
398+
if (input.method === "permission_response") {
399+
const requestId = (input.params as { requestId?: string } | undefined)
400+
?.requestId;
401+
if (requestId) {
402+
watcher.pendingPermissionRequests.delete(requestId);
403+
}
404+
} else if (input.method === "cancel") {
405+
watcher.pendingPermissionRequests.clear();
406+
}
407+
}
408+
391409
@preDestroy()
392410
unwatchAll(): void {
393411
for (const key of [...this.watchers.keys()]) {
@@ -419,6 +437,7 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
419437
lastErrorMessage: null,
420438
lastBranch: null,
421439
lastStatusUpdatedAt: null,
440+
pendingPermissionRequests: new Map(),
422441
isBootstrapping: false,
423442
hasEmittedSnapshot: false,
424443
bufferedLogBatches: [],
@@ -789,6 +808,7 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
789808
}
790809

791810
if (isPermissionRequestEvent(event.data)) {
811+
watcher.pendingPermissionRequests.set(event.data.requestId, event.data);
792812
this.emit(CloudTaskEvent.Update, {
793813
taskId: watcher.taskId,
794814
runId: watcher.runId,
@@ -975,6 +995,28 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
975995
errorMessage: watcher.lastErrorMessage,
976996
branch: watcher.lastBranch,
977997
});
998+
999+
this.replayPendingPermissionRequests(watcher);
1000+
}
1001+
1002+
private replayPendingPermissionRequests(watcher: WatcherState): void {
1003+
if (
1004+
watcher.pendingPermissionRequests.size === 0 ||
1005+
isTerminalStatus(watcher.lastStatus)
1006+
) {
1007+
return;
1008+
}
1009+
1010+
for (const pending of watcher.pendingPermissionRequests.values()) {
1011+
this.emit(CloudTaskEvent.Update, {
1012+
taskId: watcher.taskId,
1013+
runId: watcher.runId,
1014+
kind: "permission_request" as const,
1015+
requestId: pending.requestId,
1016+
toolCall: pending.toolCall,
1017+
options: pending.options,
1018+
});
1019+
}
9781020
}
9791021

9801022
private failWatcher(key: string, error: CloudTaskConnectionError): void {
@@ -1234,6 +1276,10 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
12341276
watcher.lastStatusUpdatedAt = updatedAt;
12351277
}
12361278

1279+
if (isTerminalStatus(watcher.lastStatus)) {
1280+
watcher.pendingPermissionRequests.clear();
1281+
}
1282+
12371283
return changed;
12381284
}
12391285

0 commit comments

Comments
 (0)