Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/App/src/components/common/PlanTimeoutDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import {
Dialog,
DialogSurface,
DialogTitle,
DialogContent,
DialogBody,
DialogActions,
Button,
} from '@fluentui/react-components';
import { Warning20Regular } from '@fluentui/react-icons';
import "../../styles/Panel.css";

interface PlanTimeoutDialogProps {
isOpen: boolean;
onGoHome: () => void;
onCancel: () => void;
}

const PlanTimeoutDialog: React.FC<PlanTimeoutDialogProps> = ({
isOpen,
onGoHome,
onCancel,
}) => {
return (
<Dialog open={isOpen}>
Comment thread
NirajC-Microsoft marked this conversation as resolved.
<DialogSurface>
<DialogBody>
<DialogTitle>
<div className="plan-cancellation-dialog-title">
<Warning20Regular className="plan-cancellation-warning-icon" />
Session Timed Out
</div>
</DialogTitle>
<DialogContent>
The plan approval request has timed out because no action was taken.
Please go to the Home page and create a new task.
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={onCancel}
>
Cancel
</Button>
<Button
appearance="primary"
onClick={onGoHome}
>
Go To Home Page
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
};

export default PlanTimeoutDialog;
12 changes: 12 additions & 0 deletions src/App/src/hooks/usePlanWebSocket.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
approvalRequestReceived,
planCompletedFinal,
planFailedFinal,
setShowTimeoutDialog,
} from '@/store/slices/planSlice';
import {
setSubmittingChatDisableInput,
Expand Down Expand Up @@ -242,6 +243,17 @@ export function usePlanWebSocket({
const c = errorMessage.trim();
if (c.length > 0) errorContent = c;
}

// Detect timeout-specific error → show popup dialog instead of inline error
const isTimeout = errorContent.toLowerCase().includes('timed out');
if (isTimeout) {
dispatch(planFailedFinal());
dispatch(setShowBufferingText(false));
dispatch(setShowTimeoutDialog(true));
webSocketService.disconnect();
return;
}
Comment thread
NirajC-Microsoft marked this conversation as resolved.

const errorAgent: AgentMessageData = {
agent: 'system',
agent_type: AgentMessageType.SYSTEM_AGENT,
Expand Down
13 changes: 13 additions & 0 deletions src/App/src/pages/PlanPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ import {
selectLoadingMessage,
selectReloadLeftList,
selectWaitingForPlan,
selectShowTimeoutDialog,
setReloadLeftList,
setProcessingApproval,
setShowProcessingPlanSpinner,
setShowCancellationDialog,
setCancellingPlan,
setShowTimeoutDialog,
setLoadingMessage,
setErrorLoading,
planApprovalAccepted,
Expand Down Expand Up @@ -73,6 +75,7 @@ import { useInlineToaster } from '../components/toast/InlineToaster';
import Octo from '../commonComponents/imports/Octopus.png';
import LoadingMessage, { loadingMessages } from '../commonComponents/components/LoadingMessage';
import PlanCancellationDialog from '../components/common/PlanCancellationDialog';
import PlanTimeoutDialog from '../components/common/PlanTimeoutDialog';
import '../styles/PlanPage.css';

// Singleton API service
Expand All @@ -99,6 +102,7 @@ const PlanPage: React.FC = () => {
const showProcessingPlanSpinner = useAppSelector(selectShowProcessingPlanSpinner);
const showCancellationDialog = useAppSelector(selectShowCancellationDialog);
const cancellingPlan = useAppSelector(selectCancellingPlan);
const showTimeoutDialog = useAppSelector(selectShowTimeoutDialog);
const loadingMessage = useAppSelector(selectLoadingMessage);
const reloadLeftList = useAppSelector(selectReloadLeftList);
const waitingForPlan = useAppSelector(selectWaitingForPlan);
Expand Down Expand Up @@ -388,6 +392,15 @@ const PlanPage: React.FC = () => {
onCancel={handleCancelDialog}
loading={cancellingPlan}
/>

<PlanTimeoutDialog
isOpen={showTimeoutDialog}
onGoHome={() => {
dispatch(setShowTimeoutDialog(false));
navigate('/');
}}
onCancel={() => dispatch(setShowTimeoutDialog(false))}
/>
</CoralShellColumn>
);
};
Expand Down
8 changes: 8 additions & 0 deletions src/App/src/store/slices/planSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface PlanState {
showCancellationDialog: boolean;
/** Is a cancellation API call in progress? */
cancellingPlan: boolean;
/** Show timeout popup when plan approval times out */
showTimeoutDialog: boolean;
/** Loading message for spinners */
loadingMessage: string;
}
Expand All @@ -74,6 +76,7 @@ const initialState: PlanState = {
reloadLeftList: true,
showCancellationDialog: false,
cancellingPlan: false,
showTimeoutDialog: false,
loadingMessage: '',
};

Expand Down Expand Up @@ -120,6 +123,9 @@ const planSlice = createSlice({
setCancellingPlan(state, action: PayloadAction<boolean>) {
state.cancellingPlan = action.payload;
},
setShowTimeoutDialog(state, action: PayloadAction<boolean>) {
state.showTimeoutDialog = action.payload;
},
setLoadingMessage(state, action: PayloadAction<string>) {
state.loadingMessage = action.payload;
},
Expand Down Expand Up @@ -231,6 +237,7 @@ export const {
setReloadLeftList,
setShowCancellationDialog,
setCancellingPlan,
setShowTimeoutDialog,
setLoadingMessage,
markPlanCompleted,
planApprovalAccepted,
Expand All @@ -254,6 +261,7 @@ export const selectContinueWithWebsocketFlow = (s: RootState) => s.plan.continue
export const selectReloadLeftList = (s: RootState) => s.plan.reloadLeftList;
export const selectShowCancellationDialog = (s: RootState) => s.plan.showCancellationDialog;
export const selectCancellingPlan = (s: RootState) => s.plan.cancellingPlan;
export const selectShowTimeoutDialog = (s: RootState) => s.plan.showTimeoutDialog;
export const selectLoadingMessage = (s: RootState) => s.plan.loadingMessage;
export const selectPlanStatus = (s: RootState) => s.plan.planData?.plan?.overall_status ?? null;
export const selectPlanApproved = (s: RootState) => s.plan.planApproved;
Expand Down
4 changes: 4 additions & 0 deletions src/backend/v4/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,10 @@ async def process_request(
raise HTTPException(status_code=500, detail="Failed to create plan") from e

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

async def run_orchestration_task():
try:
Expand Down
32 changes: 31 additions & 1 deletion src/backend/v4/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,20 +97,26 @@ def __init__(self):
self._approval_events: Dict[str, asyncio.Event] = {}
self._clarification_events: Dict[str, asyncio.Event] = {}

# Track which user each pending plan belongs to, and which plans are superseded
self._plan_to_user: Dict[str, str] = {} # plan_id -> user_id
self._superseded_plans: set = set() # plan IDs cancelled by a new task

# Default timeout (seconds) for waiting operations
self.default_timeout: float = 300.0

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

def set_approval_pending(self, plan_id: str) -> None:
def set_approval_pending(self, plan_id: str, user_id: str = None) -> None:
"""Mark approval pending and create/reset its event."""
self.approvals[plan_id] = None
if plan_id not in self._approval_events:
self._approval_events[plan_id] = asyncio.Event()
else:
self._approval_events[plan_id].clear()
if user_id:
self._plan_to_user[plan_id] = user_id

def set_approval_result(self, plan_id: str, approved: bool) -> None:
"""Set approval decision and trigger its event."""
Expand Down Expand Up @@ -214,6 +220,30 @@ def cleanup_approval(self, plan_id: str) -> None:
"""Remove approval tracking data and event."""
self.approvals.pop(plan_id, None)
self._approval_events.pop(plan_id, None)
self._plan_to_user.pop(plan_id, None)
self._superseded_plans.discard(plan_id)

def cancel_pending_approvals_for_user(self, user_id: str) -> None:
"""Cancel all pending approvals for a user (called when a new task starts).

Wakes up any blocking wait_for_approval calls so they return immediately.
The plan is marked as superseded so the orchestration can terminate silently
without sending error messages to the user's current WebSocket.
"""
plans_to_cancel = [
pid for pid, uid in self._plan_to_user.items()
if uid == user_id and pid in self.approvals and self.approvals[pid] is None
]
for plan_id in plans_to_cancel:
logger.info("Superseding stale pending approval: %s (user: %s)", plan_id, user_id)
self._superseded_plans.add(plan_id)
self.approvals[plan_id] = False
if plan_id in self._approval_events:
self._approval_events[plan_id].set() # wake up the blocked wait

def is_plan_superseded(self, plan_id: str) -> bool:
"""Check if a plan was superseded by a newer task from the same user."""
return plan_id in self._superseded_plans
Comment thread
NirajC-Microsoft marked this conversation as resolved.

def cleanup_clarification(self, request_id: str) -> None:
"""Remove clarification tracking data and event."""
Expand Down
20 changes: 20 additions & 0 deletions src/backend/v4/orchestration/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Custom exceptions for orchestration module."""


class PlanSupersededError(Exception):
"""Raised when a plan's approval wait is cancelled because the user started a new task."""

def __init__(self, plan_id: str):
self.plan_id = plan_id
super().__init__(f"Plan {plan_id} was superseded by a new task")


class PlanTimeoutError(Exception):
"""Raised when user does not approve/reject the plan within the timeout window."""

def __init__(self, plan_id: str, timeout_seconds: float = 0):
self.plan_id = plan_id
self.timeout_seconds = timeout_seconds
super().__init__(
f"Plan {plan_id} approval timed out after {timeout_seconds}s"
)
47 changes: 20 additions & 27 deletions src/backend/v4/orchestration/human_approval_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from v4.config.settings import connection_config, orchestration_config
from v4.models.models import MPlan
from .exceptions import PlanSupersededError, PlanTimeoutError
from v4.orchestration.helper.plan_to_mplan_converter import PlanToMPlanConverter
Comment thread
NirajC-Microsoft marked this conversation as resolved.

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

orchestration_config.set_approval_pending(m_plan_id)
orchestration_config.set_approval_pending(m_plan_id, user_id=self.current_user_id)

Comment thread
NirajC-Microsoft marked this conversation as resolved.
try:
approved = await orchestration_config.wait_for_approval(m_plan_id)

# Check if this plan was superseded by a new task from the same user
if orchestration_config.is_plan_superseded(m_plan_id):
logger.info(
"Plan %s was superseded by a new task - terminating silently",
m_plan_id,
)
orchestration_config.cleanup_approval(m_plan_id)
raise PlanSupersededError(m_plan_id)
Comment thread
NirajC-Microsoft marked this conversation as resolved.

logger.info("Approval received for plan %s: %s", m_plan_id, approved)
return messages.PlanApprovalResponse(approved=approved, m_plan_id=m_plan_id)

except asyncio.TimeoutError:
logger.debug(
"Approval timeout for plan %s - notifying user and terminating process",
logger.info(
"Approval timeout for plan %s after %ss",
m_plan_id,
orchestration_config.default_timeout,
)

timeout_message = messages.TimeoutNotification(
timeout_type="approval",
request_id=m_plan_id,
message=f"Plan approval request timed out after {orchestration_config.default_timeout} seconds. Please try again.",
timestamp=asyncio.get_event_loop().time(),
timeout_duration=orchestration_config.default_timeout,
)

try:
await connection_config.send_status_update_async(
message=timeout_message,
user_id=self.current_user_id,
message_type=messages.WebsocketMessageType.TIMEOUT_NOTIFICATION,
)
logger.info(
"Timeout notification sent to user %s for plan %s",
self.current_user_id,
m_plan_id,
)
except Exception as e:
logger.error("Failed to send timeout notification: %s", e)

orchestration_config.cleanup_approval(m_plan_id)
return None
raise PlanTimeoutError(m_plan_id, orchestration_config.default_timeout)

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

except (PlanSupersededError, PlanTimeoutError):
# Let these propagate to orchestration_manager for proper handling
raise

except Exception as e:
logger.debug(
"Unexpected error waiting for approval: %s - terminating process silently",
Expand Down
Loading