From 8034db5a493fa54dd276f75a9237493b8b0532d9 Mon Sep 17 00:00:00 2001 From: satyalyadav Date: Thu, 10 Sep 2026 17:31:48 -0700 Subject: [PATCH 1/3] fix(web): recover stuck background work stop state --- .../web/src/components/ChatView.logic.test.ts | 29 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 6 ++++ apps/web/src/components/ChatView.tsx | 13 ++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 06fcce22ee3f..c2f64b5648e5 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -60,6 +60,8 @@ import { threadShellHasStarted, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, + scheduleStopBackgroundWorkTimeout, + STOP_BACKGROUND_WORK_TIMEOUT_MS, startNewThreadForProject, codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, @@ -673,6 +675,33 @@ describe("shouldReleaseTimelineAnchorForToolActivity", () => { }); }); +describe("stop background work timeout", () => { + afterEach(() => vi.useRealTimers()); + + it("resets the stopping state after the timeout window", () => { + vi.useFakeTimers(); + const onTimeout = vi.fn(); + + scheduleStopBackgroundWorkTimeout(onTimeout); + vi.advanceTimersByTime(STOP_BACKGROUND_WORK_TIMEOUT_MS - 1); + expect(onTimeout).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(onTimeout).toHaveBeenCalledOnce(); + }); + + it("cancels the timeout when liveness clears before the grace window", () => { + vi.useFakeTimers(); + const onTimeout = vi.fn(); + + const cancel = scheduleStopBackgroundWorkTimeout(onTimeout); + cancel(); + vi.advanceTimersByTime(STOP_BACKGROUND_WORK_TIMEOUT_MS); + + expect(onTimeout).not.toHaveBeenCalled(); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 66214df385e8..b2125666c3b3 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -61,6 +61,7 @@ export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +export const STOP_BACKGROUND_WORK_TIMEOUT_MS = 5_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -284,6 +285,11 @@ export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () return () => globalThis.clearTimeout(timeoutId); } +export function scheduleStopBackgroundWorkTimeout(onTimeout: () => void): () => void { + const timeoutId = globalThis.setTimeout(onTimeout, STOP_BACKGROUND_WORK_TIMEOUT_MS); + return () => globalThis.clearTimeout(timeoutId); +} + export function hasEnvironmentReconnectWarningGraceElapsed( activeEnvironmentId: EnvironmentId | null, elapsedEnvironmentId: EnvironmentId | null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 283e85365667..18e4f0446a04 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -389,6 +389,7 @@ import { hasEnvironmentReconnectWarningGraceElapsed, latestTurnStartFailureId, scheduleEnvironmentReconnectWarning, + scheduleStopBackgroundWorkTimeout, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldDockDraftHeroForSubmission, @@ -5730,8 +5731,18 @@ export default function ChatView(props: ChatViewProps) { // returning only means the request was accepted. if (activeBackgroundLiveness === null) { setIsStoppingBackgroundWork(false); + return; + } + if (!isStoppingBackgroundWork) { + return; } - }, [activeBackgroundLiveness]); + // A missed task completion event or delayed provider interrupt must not + // freeze the Stop affordance permanently on "Stopping...". Re-enable Stop + // after the timeout window so the user is never wedged. + return scheduleStopBackgroundWorkTimeout(() => { + setIsStoppingBackgroundWork(false); + }); + }, [activeBackgroundLiveness, isStoppingBackgroundWork]); useEffect(() => { // Per-thread state: switching threads while A's stop is pending must not // disable B's Stop button (review finding). From 1810875279d448768f853ed49bf309b3dea4e726 Mon Sep 17 00:00:00 2001 From: satyalyadav Date: Thu, 10 Sep 2026 17:53:30 -0700 Subject: [PATCH 2/3] fix(web): stop background work through session teardown --- .../web/src/components/ChatView.logic.test.ts | 29 ----------- apps/web/src/components/ChatView.logic.ts | 6 --- apps/web/src/components/ChatView.tsx | 50 ++++++++----------- 3 files changed, 20 insertions(+), 65 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c2f64b5648e5..06fcce22ee3f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -60,8 +60,6 @@ import { threadShellHasStarted, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, - scheduleStopBackgroundWorkTimeout, - STOP_BACKGROUND_WORK_TIMEOUT_MS, startNewThreadForProject, codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, @@ -675,33 +673,6 @@ describe("shouldReleaseTimelineAnchorForToolActivity", () => { }); }); -describe("stop background work timeout", () => { - afterEach(() => vi.useRealTimers()); - - it("resets the stopping state after the timeout window", () => { - vi.useFakeTimers(); - const onTimeout = vi.fn(); - - scheduleStopBackgroundWorkTimeout(onTimeout); - vi.advanceTimersByTime(STOP_BACKGROUND_WORK_TIMEOUT_MS - 1); - expect(onTimeout).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1); - expect(onTimeout).toHaveBeenCalledOnce(); - }); - - it("cancels the timeout when liveness clears before the grace window", () => { - vi.useFakeTimers(); - const onTimeout = vi.fn(); - - const cancel = scheduleStopBackgroundWorkTimeout(onTimeout); - cancel(); - vi.advanceTimersByTime(STOP_BACKGROUND_WORK_TIMEOUT_MS); - - expect(onTimeout).not.toHaveBeenCalled(); - }); -}); - describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b2125666c3b3..66214df385e8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -61,7 +61,6 @@ export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; -export const STOP_BACKGROUND_WORK_TIMEOUT_MS = 5_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -285,11 +284,6 @@ export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () return () => globalThis.clearTimeout(timeoutId); } -export function scheduleStopBackgroundWorkTimeout(onTimeout: () => void): () => void { - const timeoutId = globalThis.setTimeout(onTimeout, STOP_BACKGROUND_WORK_TIMEOUT_MS); - return () => globalThis.clearTimeout(timeoutId); -} - export function hasEnvironmentReconnectWarningGraceElapsed( activeEnvironmentId: EnvironmentId | null, elapsedEnvironmentId: EnvironmentId | null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 18e4f0446a04..660d701ccd81 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -381,7 +381,6 @@ import { buildLocalDraftThread, buildLoadingThreadFromShell, buildRunningThreadTurnInterruptInput, - buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, @@ -389,7 +388,6 @@ import { hasEnvironmentReconnectWarningGraceElapsed, latestTurnStartFailureId, scheduleEnvironmentReconnectWarning, - scheduleStopBackgroundWorkTimeout, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldDockDraftHeroForSubmission, @@ -1446,6 +1444,9 @@ export default function ChatView(props: ChatViewProps) { const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { + reportFailure: false, + }); const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false, }); @@ -5720,55 +5721,44 @@ export default function ChatView(props: ChatViewProps) { ]); // Background work (subagent fleets, workflow runs, watch loops) can outlive // the turn; once it settles, the composer stop button is gone, so this - // banner is the only visible stop affordance. Stop routes through the - // stop-everything interrupt: it kills every live background task before - // interrupting, and works by session, so no active turn is needed. + // banner is the only visible stop affordance. Stop ends the provider session, + // which tears down its background work and emits the liveness update. const activeBackgroundLiveness = !isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null; - const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false); + const [stoppingBackgroundWorkThreadId, setStoppingBackgroundWorkThreadId] = + useState(null); + const isStoppingBackgroundWork = activeThread?.id === stoppingBackgroundWorkThreadId; useEffect(() => { - // "Stopping..." holds until the liveness clears; the interrupt command - // returning only means the request was accepted. + // The provider session stop is the hard boundary. Keep "Stopping..." + // visible until the existing liveness signal reports that it has cleared. if (activeBackgroundLiveness === null) { - setIsStoppingBackgroundWork(false); - return; - } - if (!isStoppingBackgroundWork) { - return; + setStoppingBackgroundWorkThreadId(null); } - // A missed task completion event or delayed provider interrupt must not - // freeze the Stop affordance permanently on "Stopping...". Re-enable Stop - // after the timeout window so the user is never wedged. - return scheduleStopBackgroundWorkTimeout(() => { - setIsStoppingBackgroundWork(false); - }); - }, [activeBackgroundLiveness, isStoppingBackgroundWork]); + }, [activeBackgroundLiveness]); useEffect(() => { // Per-thread state: switching threads while A's stop is pending must not // disable B's Stop button (review finding). - setIsStoppingBackgroundWork(false); + setStoppingBackgroundWorkThreadId(null); }, [activeThreadId]); const handleStopBackgroundWork = useCallback(async () => { if (!activeThread) return; - setIsStoppingBackgroundWork(true); - const result = await interruptThreadTurn({ + const threadId = activeThread.id; + setStoppingBackgroundWorkThreadId(threadId); + const result = await stopThreadSession({ environmentId, - input: buildThreadTurnInterruptInput(activeThread), + input: { threadId }, }); if (result._tag === "Failure") { - // Every failure clears the pending state — an interrupted command - // never reached the server, so liveness would hold "Stopping..." - // forever. Only real failures toast. - setIsStoppingBackgroundWork(false); + setStoppingBackgroundWorkThreadId((current) => (current === threadId ? null : current)); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setThreadError( - activeThread.id, + threadId, error instanceof Error ? error.message : "Failed to stop background work.", ); } } - }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); + }, [activeThread, environmentId, setThreadError, stopThreadSession]); const backgroundLivenessBannerItem = useMemo(() => { if (activeBackgroundLiveness === null || !activeThread) { return null; From c8b8382e31974c5a31271e497249979cc0e3b98f Mon Sep 17 00:00:00 2001 From: satyalyadav Date: Thu, 10 Sep 2026 21:46:07 -0700 Subject: [PATCH 3/3] fix(web): hide stale liveness after session stop --- apps/web/src/components/ChatView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 660d701ccd81..0614eeaec567 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5724,7 +5724,9 @@ export default function ChatView(props: ChatViewProps) { // banner is the only visible stop affordance. Stop ends the provider session, // which tears down its background work and emits the liveness update. const activeBackgroundLiveness = - !isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null; + !isWorking && activeThread && activeThreadShell?.session?.status !== "stopped" + ? (activeThreadShell?.backgroundLiveness ?? null) + : null; const [stoppingBackgroundWorkThreadId, setStoppingBackgroundWorkThreadId] = useState(null); const isStoppingBackgroundWork = activeThread?.id === stoppingBackgroundWorkThreadId;