@@ -98,6 +98,7 @@ const AUTO_RETRY_MAX_ATTEMPTS = 2;
9898const AUTO_RETRY_DELAY_MS = 10_000 ;
9999const AUTH_RESTORE_MAX_RETRY_WAITS = 6 ;
100100const MAX_SUPERSEDED_RUN_IDS = 100 ;
101+ const MAX_RESPONDED_PERMISSION_REQUEST_IDS = 500 ;
101102/**
102103 * Streamed events are buffered and flushed on this cadence so a burst of tokens
103104 * coalesces into one processing pass (and roughly one render) instead of one
@@ -607,6 +608,13 @@ export class SessionService {
607608 private cloudLogGapReconciler : CloudLogGapReconciler ;
608609 /** Maps toolCallId → cloud requestId for routing permission responses */
609610 private cloudPermissionRequestIds = new Map < string , string > ( ) ;
611+ /**
612+ * Cloud permission requestIds the user has already responded to this app
613+ * session. A stale snapshot (a resolved marker not yet flushed to storage)
614+ * or a replayed stream frame can re-deliver an answered request; without
615+ * this guard it would re-surface as a fresh pending card.
616+ */
617+ private respondedCloudPermissionRequestIds = new Set < string > ( ) ;
610618 private liveTurnContent = new Map <
611619 string ,
612620 { startedAtTs : number ; agentTextChunks : number ; agentOutputEvents : number }
@@ -2167,6 +2175,15 @@ export class SessionService {
21672175 return ;
21682176 }
21692177
2178+ if ( this . respondedCloudPermissionRequestIds . has ( update . requestId ) ) {
2179+ this . d . log . debug ( "Skipping already-answered cloud permission request" , {
2180+ taskRunId,
2181+ requestId : update . requestId ,
2182+ toolCallId : update . toolCall . toolCallId ,
2183+ } ) ;
2184+ return ;
2185+ }
2186+
21702187 if (
21712188 isPermissionRequestAlreadySurfaced (
21722189 session . pendingPermissions ,
@@ -3180,6 +3197,7 @@ export class SessionService {
31803197 session : AgentSession ,
31813198 permission : PermissionRequest | undefined ,
31823199 toolCallId : string ,
3200+ requestId : string | undefined ,
31833201 optionId : string ,
31843202 customInput ?: string ,
31853203 answers ?: Record < string , string > ,
@@ -3192,23 +3210,88 @@ export class SessionService {
31923210 customInput ,
31933211 answers ,
31943212 ) ;
3195- if ( ! answerPrompt ) {
3213+ if ( answerPrompt ) {
3214+ await this . sendCloudPrompt (
3215+ { ...session , cloudStatus : cloudStatus ?? session . cloudStatus } ,
3216+ answerPrompt ,
3217+ ) ;
3218+ this . d . log . info ( "Permission answer resumed terminal cloud run" , {
3219+ taskId : session . taskId ,
3220+ toolCallId,
3221+ optionId,
3222+ } ) ;
3223+ } else {
31963224 this . d . log . info ( "Dropped permission response for terminal cloud run" , {
31973225 taskId : session . taskId ,
31983226 toolCallId,
31993227 optionId,
32003228 } ) ;
3201- return ;
32023229 }
3203- await this . sendCloudPrompt (
3204- { ...session , cloudStatus : cloudStatus ?? session . cloudStatus } ,
3205- answerPrompt ,
3206- ) ;
3207- this . d . log . info ( "Permission answer resumed terminal cloud run" , {
3208- taskId : session . taskId ,
3230+ await this . persistCloudPermissionResolution (
3231+ session . taskId ,
3232+ permission ?. taskRunId ?? session . taskRunId ,
32093233 toolCallId ,
3234+ requestId ,
32103235 optionId ,
3211- } ) ;
3236+ ) ;
3237+ }
3238+
3239+ /**
3240+ * Record a response to a permission request whose sandbox is gone. A live
3241+ * sandbox writes `_posthog/permission_resolved` to the run log when it
3242+ * resolves a request, but a request answered after its run terminalized has
3243+ * no sandbox left to do that — without this record the request stays
3244+ * pending in the persisted log forever, and every future derivation (app
3245+ * restart, another device, a session rebuild) re-surfaces the
3246+ * already-answered question as a fresh card.
3247+ */
3248+ private async persistCloudPermissionResolution (
3249+ taskId : string ,
3250+ taskRunId : string ,
3251+ toolCallId : string ,
3252+ requestId : string | undefined ,
3253+ optionId : string ,
3254+ ) : Promise < void > {
3255+ if ( ! requestId ) return ;
3256+ this . markCloudPermissionResponded ( requestId ) ;
3257+
3258+ const client = await this . d . getAuthenticatedClient ( ) ;
3259+ if ( ! client ) return ;
3260+ try {
3261+ await client . appendTaskRunLog ( taskId , taskRunId , [
3262+ {
3263+ type : "notification" ,
3264+ timestamp : new Date ( ) . toISOString ( ) ,
3265+ notification : {
3266+ method : POSTHOG_NOTIFICATIONS . PERMISSION_RESOLVED ,
3267+ params : { requestId, toolCallId, optionId } ,
3268+ } ,
3269+ } ,
3270+ ] ) ;
3271+ } catch ( error ) {
3272+ this . d . log . warn ( "Failed to persist permission resolution to run log" , {
3273+ taskId,
3274+ taskRunId,
3275+ toolCallId,
3276+ error,
3277+ } ) ;
3278+ }
3279+ }
3280+
3281+ private markCloudPermissionResponded ( requestId : string ) : void {
3282+ this . respondedCloudPermissionRequestIds . add ( requestId ) ;
3283+ // add() grows the set by at most one, so one eviction restores the cap.
3284+ if (
3285+ this . respondedCloudPermissionRequestIds . size >
3286+ MAX_RESPONDED_PERMISSION_REQUEST_IDS
3287+ ) {
3288+ const oldest = this . respondedCloudPermissionRequestIds
3289+ . values ( )
3290+ . next ( ) . value ;
3291+ if ( oldest !== undefined ) {
3292+ this . respondedCloudPermissionRequestIds . delete ( oldest ) ;
3293+ }
3294+ }
32123295 }
32133296
32143297 // --- Permissions ---
@@ -3270,6 +3353,7 @@ export class SessionService {
32703353 session ,
32713354 permission ,
32723355 toolCallId ,
3356+ cloudRequestId ,
32733357 optionId ,
32743358 customInput ,
32753359 answers ,
@@ -3293,6 +3377,7 @@ export class SessionService {
32933377 session ,
32943378 permission ,
32953379 toolCallId ,
3380+ cloudRequestId ,
32963381 optionId ,
32973382 customInput ,
32983383 answers ,
@@ -3302,6 +3387,10 @@ export class SessionService {
33023387 }
33033388 throw error ;
33043389 }
3390+ // The live sandbox persists its own resolved marker; remember the
3391+ // response locally so a snapshot fetched before that marker flushes
3392+ // to storage cannot re-surface the question.
3393+ this . markCloudPermissionResponded ( cloudRequestId ) ;
33053394 } else {
33063395 await this . d . trpc . agent . respondToPermission . mutate ( {
33073396 taskRunId : session . taskRunId ,
@@ -3353,8 +3442,16 @@ export class SessionService {
33533442 try {
33543443 if ( session . isCloud && isTerminalStatus ( session . cloudStatus ) ) {
33553444 // The run is over — the card was resolved locally above and there is no
3356- // live permission promise left to reject.
3445+ // live permission promise left to reject. Persist the dismissal so the
3446+ // request is not re-derived as pending from the run log later.
33573447 this . cloudPermissionRequestIds . delete ( toolCallId ) ;
3448+ await this . persistCloudPermissionResolution (
3449+ session . taskId ,
3450+ permission ?. taskRunId ?? session . taskRunId ,
3451+ toolCallId ,
3452+ cloudRequestId ,
3453+ "cancelled" ,
3454+ ) ;
33583455 return ;
33593456 }
33603457 if ( session . isCloud && cloudRequestId ) {
@@ -3364,6 +3461,7 @@ export class SessionService {
33643461 optionId : "reject_with_feedback" ,
33653462 customInput : "User cancelled the permission request." ,
33663463 } ) ;
3464+ this . markCloudPermissionResponded ( cloudRequestId ) ;
33673465 } else {
33683466 await this . d . trpc . agent . cancelPermission . mutate ( {
33693467 taskRunId : session . taskRunId ,
0 commit comments