Skip to content

Commit 94bf0c0

Browse files
Add Pop up when Plan Approval session is end and also handle old plan cancellation
1 parent a346194 commit 94bf0c0

10 files changed

Lines changed: 230 additions & 29 deletions

File tree

src/App/src/api/httpClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class HttpClient {
2020
private responseInterceptors: ResponseInterceptor[] = [];
2121
private timeout: number;
2222

23-
constructor(baseUrl = '', timeout = 30000) {
23+
constructor(baseUrl = '', timeout = 180000) {
2424
this.baseUrl = baseUrl;
2525
this.timeout = timeout;
2626
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import {
3+
Dialog,
4+
DialogSurface,
5+
DialogTitle,
6+
DialogContent,
7+
DialogBody,
8+
DialogActions,
9+
Button,
10+
} from '@fluentui/react-components';
11+
import { Warning20Regular } from '@fluentui/react-icons';
12+
import "../../styles/Panel.css";
13+
14+
interface PlanTimeoutDialogProps {
15+
isOpen: boolean;
16+
onGoHome: () => void;
17+
onCancel: () => void;
18+
}
19+
20+
const PlanTimeoutDialog: React.FC<PlanTimeoutDialogProps> = ({
21+
isOpen,
22+
onGoHome,
23+
onCancel,
24+
}) => {
25+
return (
26+
<Dialog open={isOpen}>
27+
<DialogSurface>
28+
<DialogBody>
29+
<DialogTitle>
30+
<div className="plan-cancellation-dialog-title">
31+
<Warning20Regular className="plan-cancellation-warning-icon" />
32+
Session Timed Out
33+
</div>
34+
</DialogTitle>
35+
<DialogContent>
36+
The plan approval request has timed out because no action was taken.
37+
Please go to the Home page and create a new task.
38+
</DialogContent>
39+
<DialogActions>
40+
<Button
41+
appearance="secondary"
42+
onClick={onCancel}
43+
>
44+
Cancel
45+
</Button>
46+
<Button
47+
appearance="primary"
48+
onClick={onGoHome}
49+
>
50+
Go To Home Page
51+
</Button>
52+
</DialogActions>
53+
</DialogBody>
54+
</DialogSurface>
55+
</Dialog>
56+
);
57+
};
58+
59+
export default PlanTimeoutDialog;

src/App/src/hooks/usePlanWebSocket.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
approvalRequestReceived,
1919
planCompletedFinal,
2020
planFailedFinal,
21+
setShowTimeoutDialog,
2122
} from '@/store/slices/planSlice';
2223
import {
2324
setSubmittingChatDisableInput,
@@ -242,6 +243,17 @@ export function usePlanWebSocket({
242243
const c = errorMessage.trim();
243244
if (c.length > 0) errorContent = c;
244245
}
246+
247+
// Detect timeout-specific error → show popup dialog instead of inline error
248+
const isTimeout = errorContent.toLowerCase().includes('timed out');
249+
if (isTimeout) {
250+
dispatch(planFailedFinal());
251+
dispatch(setShowBufferingText(false));
252+
dispatch(setShowTimeoutDialog(true));
253+
webSocketService.disconnect();
254+
return;
255+
}
256+
245257
const errorAgent: AgentMessageData = {
246258
agent: 'system',
247259
agent_type: AgentMessageType.SYSTEM_AGENT,

src/App/src/pages/PlanPage.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ import {
2828
selectLoadingMessage,
2929
selectReloadLeftList,
3030
selectWaitingForPlan,
31+
selectShowTimeoutDialog,
3132
setReloadLeftList,
3233
setProcessingApproval,
3334
setShowProcessingPlanSpinner,
3435
setShowCancellationDialog,
3536
setCancellingPlan,
37+
setShowTimeoutDialog,
3638
setLoadingMessage,
3739
setErrorLoading,
3840
planApprovalAccepted,
@@ -73,6 +75,7 @@ import { useInlineToaster } from '../components/toast/InlineToaster';
7375
import Octo from '../commonComponents/imports/Octopus.png';
7476
import LoadingMessage, { loadingMessages } from '../commonComponents/components/LoadingMessage';
7577
import PlanCancellationDialog from '../components/common/PlanCancellationDialog';
78+
import PlanTimeoutDialog from '../components/common/PlanTimeoutDialog';
7679
import '../styles/PlanPage.css';
7780

7881
// Singleton API service
@@ -99,6 +102,7 @@ const PlanPage: React.FC = () => {
99102
const showProcessingPlanSpinner = useAppSelector(selectShowProcessingPlanSpinner);
100103
const showCancellationDialog = useAppSelector(selectShowCancellationDialog);
101104
const cancellingPlan = useAppSelector(selectCancellingPlan);
105+
const showTimeoutDialog = useAppSelector(selectShowTimeoutDialog);
102106
const loadingMessage = useAppSelector(selectLoadingMessage);
103107
const reloadLeftList = useAppSelector(selectReloadLeftList);
104108
const waitingForPlan = useAppSelector(selectWaitingForPlan);
@@ -388,6 +392,15 @@ const PlanPage: React.FC = () => {
388392
onCancel={handleCancelDialog}
389393
loading={cancellingPlan}
390394
/>
395+
396+
<PlanTimeoutDialog
397+
isOpen={showTimeoutDialog}
398+
onGoHome={() => {
399+
dispatch(setShowTimeoutDialog(false));
400+
navigate('/');
401+
}}
402+
onCancel={() => dispatch(setShowTimeoutDialog(false))}
403+
/>
391404
</CoralShellColumn>
392405
);
393406
};

src/App/src/store/slices/planSlice.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export interface PlanState {
5656
showCancellationDialog: boolean;
5757
/** Is a cancellation API call in progress? */
5858
cancellingPlan: boolean;
59+
/** Show timeout popup when plan approval times out */
60+
showTimeoutDialog: boolean;
5961
/** Loading message for spinners */
6062
loadingMessage: string;
6163
}
@@ -74,6 +76,7 @@ const initialState: PlanState = {
7476
reloadLeftList: true,
7577
showCancellationDialog: false,
7678
cancellingPlan: false,
79+
showTimeoutDialog: false,
7780
loadingMessage: '',
7881
};
7982

@@ -120,6 +123,9 @@ const planSlice = createSlice({
120123
setCancellingPlan(state, action: PayloadAction<boolean>) {
121124
state.cancellingPlan = action.payload;
122125
},
126+
setShowTimeoutDialog(state, action: PayloadAction<boolean>) {
127+
state.showTimeoutDialog = action.payload;
128+
},
123129
setLoadingMessage(state, action: PayloadAction<string>) {
124130
state.loadingMessage = action.payload;
125131
},
@@ -231,6 +237,7 @@ export const {
231237
setReloadLeftList,
232238
setShowCancellationDialog,
233239
setCancellingPlan,
240+
setShowTimeoutDialog,
234241
setLoadingMessage,
235242
markPlanCompleted,
236243
planApprovalAccepted,
@@ -254,6 +261,7 @@ export const selectContinueWithWebsocketFlow = (s: RootState) => s.plan.continue
254261
export const selectReloadLeftList = (s: RootState) => s.plan.reloadLeftList;
255262
export const selectShowCancellationDialog = (s: RootState) => s.plan.showCancellationDialog;
256263
export const selectCancellingPlan = (s: RootState) => s.plan.cancellingPlan;
264+
export const selectShowTimeoutDialog = (s: RootState) => s.plan.showTimeoutDialog;
257265
export const selectLoadingMessage = (s: RootState) => s.plan.loadingMessage;
258266
export const selectPlanStatus = (s: RootState) => s.plan.planData?.plan?.overall_status ?? null;
259267
export const selectPlanApproved = (s: RootState) => s.plan.planApproved;

src/backend/v4/api/router.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,10 @@ async def process_request(
369369
raise HTTPException(status_code=500, detail="Failed to create plan") from e
370370

371371
try:
372+
# Cancel any stale pending approvals from previous plans for this user.
373+
# This ensures old background tasks (still waiting for approval) terminate
374+
# silently instead of sending timeout errors to the user's current WebSocket.
375+
orchestration_config.cancel_pending_approvals_for_user(user_id)
372376

373377
async def run_orchestration_task():
374378
try:

src/backend/v4/config/settings.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,20 +97,26 @@ def __init__(self):
9797
self._approval_events: Dict[str, asyncio.Event] = {}
9898
self._clarification_events: Dict[str, asyncio.Event] = {}
9999

100+
# Track which user each pending plan belongs to, and which plans are superseded
101+
self._plan_to_user: Dict[str, str] = {} # plan_id -> user_id
102+
self._superseded_plans: set = set() # plan IDs cancelled by a new task
103+
100104
# Default timeout (seconds) for waiting operations
101105
self.default_timeout: float = 300.0
102106

103107
def get_current_orchestration(self, user_id: str) -> Any:
104108
"""Get existing orchestration workflow instance for user_id."""
105109
return self.orchestrations.get(user_id, None)
106110

107-
def set_approval_pending(self, plan_id: str) -> None:
111+
def set_approval_pending(self, plan_id: str, user_id: str = None) -> None:
108112
"""Mark approval pending and create/reset its event."""
109113
self.approvals[plan_id] = None
110114
if plan_id not in self._approval_events:
111115
self._approval_events[plan_id] = asyncio.Event()
112116
else:
113117
self._approval_events[plan_id].clear()
118+
if user_id:
119+
self._plan_to_user[plan_id] = user_id
114120

115121
def set_approval_result(self, plan_id: str, approved: bool) -> None:
116122
"""Set approval decision and trigger its event."""
@@ -214,6 +220,30 @@ def cleanup_approval(self, plan_id: str) -> None:
214220
"""Remove approval tracking data and event."""
215221
self.approvals.pop(plan_id, None)
216222
self._approval_events.pop(plan_id, None)
223+
self._plan_to_user.pop(plan_id, None)
224+
self._superseded_plans.discard(plan_id)
225+
226+
def cancel_pending_approvals_for_user(self, user_id: str) -> None:
227+
"""Cancel all pending approvals for a user (called when a new task starts).
228+
229+
Wakes up any blocking wait_for_approval calls so they return immediately.
230+
The plan is marked as superseded so the orchestration can terminate silently
231+
without sending error messages to the user's current WebSocket.
232+
"""
233+
plans_to_cancel = [
234+
pid for pid, uid in self._plan_to_user.items()
235+
if uid == user_id and pid in self.approvals and self.approvals[pid] is None
236+
]
237+
for plan_id in plans_to_cancel:
238+
logger.info("Superseding stale pending approval: %s (user: %s)", plan_id, user_id)
239+
self._superseded_plans.add(plan_id)
240+
self.approvals[plan_id] = False
241+
if plan_id in self._approval_events:
242+
self._approval_events[plan_id].set() # wake up the blocked wait
243+
244+
def is_plan_superseded(self, plan_id: str) -> bool:
245+
"""Check if a plan was superseded by a newer task from the same user."""
246+
return plan_id in self._superseded_plans
217247

218248
def cleanup_clarification(self, request_id: str) -> None:
219249
"""Remove clarification tracking data and event."""
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Custom exceptions for orchestration module."""
2+
3+
4+
class PlanSupersededError(Exception):
5+
"""Raised when a plan's approval wait is cancelled because the user started a new task."""
6+
7+
def __init__(self, plan_id: str):
8+
self.plan_id = plan_id
9+
super().__init__(f"Plan {plan_id} was superseded by a new task")
10+
11+
12+
class PlanTimeoutError(Exception):
13+
"""Raised when user does not approve/reject the plan within the timeout window."""
14+
15+
def __init__(self, plan_id: str, timeout_seconds: float = 0):
16+
self.plan_id = plan_id
17+
self.timeout_seconds = timeout_seconds
18+
super().__init__(
19+
f"Plan {plan_id} approval timed out after {timeout_seconds}s"
20+
)

src/backend/v4/orchestration/human_approval_manager.py

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from v4.config.settings import connection_config, orchestration_config
2121
from v4.models.models import MPlan
22+
from v4.orchestration.exceptions import PlanSupersededError, PlanTimeoutError
2223
from v4.orchestration.helper.plan_to_mplan_converter import PlanToMPlanConverter
2324

2425
logger = logging.getLogger(__name__)
@@ -330,43 +331,31 @@ async def _wait_for_user_approval(
330331
logger.error("No plan ID provided for approval")
331332
return messages.PlanApprovalResponse(approved=False, m_plan_id=m_plan_id)
332333

333-
orchestration_config.set_approval_pending(m_plan_id)
334+
orchestration_config.set_approval_pending(m_plan_id, user_id=self.current_user_id)
334335

335336
try:
336337
approved = await orchestration_config.wait_for_approval(m_plan_id)
338+
339+
# Check if this plan was superseded by a new task from the same user
340+
if orchestration_config.is_plan_superseded(m_plan_id):
341+
logger.info(
342+
"Plan %s was superseded by a new task - terminating silently",
343+
m_plan_id,
344+
)
345+
orchestration_config.cleanup_approval(m_plan_id)
346+
raise PlanSupersededError(m_plan_id)
347+
337348
logger.info("Approval received for plan %s: %s", m_plan_id, approved)
338349
return messages.PlanApprovalResponse(approved=approved, m_plan_id=m_plan_id)
339350

340351
except asyncio.TimeoutError:
341-
logger.debug(
342-
"Approval timeout for plan %s - notifying user and terminating process",
352+
logger.info(
353+
"Approval timeout for plan %s after %ss",
343354
m_plan_id,
355+
orchestration_config.default_timeout,
344356
)
345-
346-
timeout_message = messages.TimeoutNotification(
347-
timeout_type="approval",
348-
request_id=m_plan_id,
349-
message=f"Plan approval request timed out after {orchestration_config.default_timeout} seconds. Please try again.",
350-
timestamp=asyncio.get_event_loop().time(),
351-
timeout_duration=orchestration_config.default_timeout,
352-
)
353-
354-
try:
355-
await connection_config.send_status_update_async(
356-
message=timeout_message,
357-
user_id=self.current_user_id,
358-
message_type=messages.WebsocketMessageType.TIMEOUT_NOTIFICATION,
359-
)
360-
logger.info(
361-
"Timeout notification sent to user %s for plan %s",
362-
self.current_user_id,
363-
m_plan_id,
364-
)
365-
except Exception as e:
366-
logger.error("Failed to send timeout notification: %s", e)
367-
368357
orchestration_config.cleanup_approval(m_plan_id)
369-
return None
358+
raise PlanTimeoutError(m_plan_id, orchestration_config.default_timeout)
370359

371360
except KeyError as e:
372361
logger.debug("Plan ID not found: %s - terminating process silently", e)
@@ -377,6 +366,10 @@ async def _wait_for_user_approval(
377366
orchestration_config.cleanup_approval(m_plan_id)
378367
return None
379368

369+
except (PlanSupersededError, PlanTimeoutError):
370+
# Let these propagate to orchestration_manager for proper handling
371+
raise
372+
380373
except Exception as e:
381374
logger.debug(
382375
"Unexpected error waiting for approval: %s - terminating process silently",

0 commit comments

Comments
 (0)