mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[eric] agents: an outage parks the turn and resumes when the provider answers; OAuth connects survive a backend restart
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wtspwSFzZmjCx9UNPAorQ
This commit is contained in:
co-authored by
Claude Opus 5
parent
d5934b1da1
commit
58e376496e
@@ -69,7 +69,7 @@ import { isShowUiPair, isAskUiPair, extractPendingAskUi, callToolUseId, resultTo
|
||||
import { composerPlaceholder } from './composerPlaceholder';
|
||||
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
|
||||
import ForceStopAgentBar from './ForceStopAgentBar';
|
||||
import { ProviderRetryPill, RateLimitPill } from './shell/RateLimitPill';
|
||||
import { ProviderRetryPill, RateLimitPill, ReconnectWaitPill } from './shell/RateLimitPill';
|
||||
import { ContextRecoveredPill } from './shell/ContextRecoveredPill';
|
||||
import ChatInput, { ChatInputHandle } from './ChatInput';
|
||||
import FollowupChips from './FollowupChips';
|
||||
@@ -2111,6 +2111,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
)}
|
||||
|
||||
<RateLimitPill sessionId={session.id} />
|
||||
<ReconnectWaitPill sessionId={session.id} />
|
||||
<ProviderRetryPill sessionId={session.id} />
|
||||
<ContextRecoveredPill sessionId={session.id} />
|
||||
|
||||
|
||||
@@ -99,3 +99,46 @@ export const RateLimitPill: React.FC<{ sessionId: string }> = ({ sessionId }) =>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
/** The connection dropped for longer than the in-turn retry ladder covers, so the turn is PARKED
|
||||
* rather than finished: it wakes itself on a widening schedule and continues where it left off.
|
||||
* This is the one pill that must NOT auto-clear on a short timer, because the wait it describes can
|
||||
* be fifteen minutes; an agent sitting silent that long is exactly what makes people force-quit and
|
||||
* lose the task. It clears when the next turn actually lands. */
|
||||
export const ReconnectWaitPill: React.FC<{ sessionId: string }> = ({ sessionId }) => {
|
||||
const c = useClaudeTokens();
|
||||
const rw = useAppSelector((s) => s.agents.sessions[sessionId]?.reconnect_wait);
|
||||
|
||||
const label = (() => {
|
||||
const secs = rw?.retry_in_s ?? 0;
|
||||
if (!secs) return 'Connection lost, retrying';
|
||||
const mins = Math.round(secs / 60);
|
||||
return mins >= 1 ? `Connection lost, retrying in ~${mins} min` : 'Connection lost, retrying shortly';
|
||||
})();
|
||||
const lastLabel = useRef(label);
|
||||
if (rw) lastLabel.current = label;
|
||||
|
||||
return (
|
||||
<Fade in={!!rw} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
title="Your work is saved. The agent is waiting for the connection and will pick up where it left off, with nothing for you to click."
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.6,
|
||||
alignSelf: 'flex-start',
|
||||
mx: 2,
|
||||
mb: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: 999,
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
}}
|
||||
>
|
||||
<AutorenewIcon sx={{ fontSize: 14, animation: 'osw-retry-spin 1.6s linear infinite', '@keyframes osw-retry-spin': { to: { transform: 'rotate(360deg)' } } }} />
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500 }}>{lastLabel.current}</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -118,6 +118,8 @@ export interface AgentSession {
|
||||
framework_overhead_tokens?: number;
|
||||
context_overflow?: { reason: string; message: string; at: string } | null;
|
||||
rate_limited?: { retry_after_s: number | null; at: string } | null;
|
||||
// Parked waiting for the connection back; unlike the pills above this can last minutes, so the UI has to say so.
|
||||
reconnect_wait?: { retry_in_s: number | null; attempt: number | null; at: string } | null;
|
||||
provider_retrying?: { attempt: number | null; delay_ms: number | null; at: string } | null;
|
||||
context_recovered?: { at: string } | null;
|
||||
// Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts.
|
||||
@@ -1051,6 +1053,25 @@ const agentsSlice = createSlice({
|
||||
if (session) session.rate_limited = null;
|
||||
},
|
||||
|
||||
setReconnectWait(
|
||||
state,
|
||||
action: PayloadAction<{ sessionId: string; retryInS: number | null; attempt: number | null }>
|
||||
) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) {
|
||||
session.reconnect_wait = {
|
||||
retry_in_s: action.payload.retryInS,
|
||||
attempt: action.payload.attempt,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
clearReconnectWait(state, action: PayloadAction<{ sessionId: string }>) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) session.reconnect_wait = null;
|
||||
},
|
||||
|
||||
setAppDepsChanged(state, action: PayloadAction<{ sessionId: string }>) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) session.app_deps_changed = true;
|
||||
@@ -1543,6 +1564,8 @@ export const {
|
||||
setContextOverflow,
|
||||
setRateLimited,
|
||||
clearRateLimited,
|
||||
setReconnectWait,
|
||||
clearReconnectWait,
|
||||
setProviderRetrying,
|
||||
clearProviderRetrying,
|
||||
setContextRecovered,
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
updateSessionContext,
|
||||
setContextOverflow,
|
||||
setRateLimited,
|
||||
setReconnectWait,
|
||||
clearReconnectWait,
|
||||
setProviderRetrying,
|
||||
setContextRecovered,
|
||||
setAppDepsChanged,
|
||||
@@ -474,6 +476,8 @@ class WebSocketManager {
|
||||
}
|
||||
if (data.status === 'running' && session_id) {
|
||||
store.dispatch(trackAgentNotification(session_id));
|
||||
// The parked-for-reconnect pill describes a wait that just ended; leaving it up outlives the recovery it was announcing.
|
||||
store.dispatch(clearReconnectWait({ sessionId: session_id }));
|
||||
}
|
||||
// Native OS notification when an agent finishes while the user is elsewhere: workflows already had this; long chat tasks deserve the same "it's done" tap on both platforms. Sub-agents stay silent (their parent's finish is the story).
|
||||
if (data.status === 'completed' && session_id && document.hidden) {
|
||||
@@ -715,6 +719,17 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:reconnect_wait':
|
||||
// The turn is PARKED, not over: it retries itself on a widening schedule, and an agent that looks idle for fifteen minutes reads as broken.
|
||||
if (session_id) {
|
||||
store.dispatch(setReconnectWait({
|
||||
sessionId: session_id,
|
||||
retryInS: typeof data.retry_in_s === 'number' ? data.retry_in_s : null,
|
||||
attempt: typeof data.attempt === 'number' ? data.attempt : null,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:context_recovered':
|
||||
// The backend hit a context-overflow crash mid-turn, rebuilt from its local copy, and retried on its own. Transient muted pill so the recovery is visible without reading like an error.
|
||||
if (session_id) {
|
||||
|
||||
Reference in New Issue
Block a user