Skip to content
51 changes: 44 additions & 7 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ import {
type ApprovalResponder,
type ThreadUserInputQuestion,
} from "@t3tools/client-runtime/state/thread-requests";
import { useDeadlinePassed } from "../../lib/clock";
import {
BACKGROUND_WORK_STOP_RETRY_AFTER_MS,
type BackgroundWorkStopRequest,
isBackgroundWorkStopPending,
presentPendingBackgroundWork,
presentProviderGoal,
} from "@t3tools/client-runtime/state/thread-execution";
Expand Down Expand Up @@ -353,15 +357,27 @@ const RECONNECT_ERROR_DELAY_MS = 5_000;
// Anchors, the submitted message, follow state and pending scrolls belong to
// one thread, so the body remounts when the thread changes.
export function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const [statusRequests, setStatusRequests] = useState<
Record<string, BackgroundWorkStopRequest | undefined>
>({});
return (
<ThreadDetailBody
key={scopedThreadKey(props.environmentId, props.selectedThread.id)}
{...props}
statusRequests={statusRequests}
setStatusRequests={setStatusRequests}
/>
);
}

function ThreadDetailBody(props: ThreadDetailScreenProps) {
function ThreadDetailBody(
props: ThreadDetailScreenProps & {
statusRequests: Record<string, BackgroundWorkStopRequest | undefined>;
setStatusRequests: React.Dispatch<
React.SetStateAction<Record<string, BackgroundWorkStopRequest | undefined>>
>;
},
) {
const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
const { layout } = useAdaptiveWorkspaceLayout();
const deviceType = useDeviceType();
Expand Down Expand Up @@ -590,8 +606,13 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
);
}
};
const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null);
const statusPending = statusRequest?.threadKey === selectedThreadKey;
const statusRequest = props.statusRequests[selectedThreadKey] ?? null;
const statusPending = isBackgroundWorkStopPending(
statusRequest,
selectedThreadKey,
props.selectedThread.pendingBackgroundTasks,
useDeadlinePassed(statusRequest?.retryAt ?? null),
);
const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString());
useFocusEffect(
useCallback(() => {
Expand Down Expand Up @@ -623,8 +644,15 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
});
const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => {
if (statusPending || !props.canOperateThread) return;
const request = { threadKey: selectedThreadKey };
setStatusRequest(request);
const request: BackgroundWorkStopRequest = {
threadKey: selectedThreadKey,
taskIds:
action === "stop"
? props.selectedThread.pendingBackgroundTasks.map((task) => task.taskId)
: [],
retryAt: null,
};
props.setStatusRequests((current) => ({ ...current, [request.threadKey]: request }));
const target = {
environmentId: props.environmentId,
input: { threadId: props.selectedThread.id },
Expand Down Expand Up @@ -658,8 +686,17 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
...target,
input: { ...target.input, visitedAt: wokeAt ?? lifecycleNow },
});
// An accepted interrupt can leave tasks running. Release the action for retry.
setStatusRequest((current) => (current === request ? null : current));
props.setStatusRequests((current) =>
current[request.threadKey] !== request
? current
: {
...current,
[request.threadKey]:
action === "stop" && result._tag !== "Failure"
? { ...request, retryAt: Date.now() + BACKGROUND_WORK_STOP_RETRY_AFTER_MS }
: undefined,
},
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Dialog.alert(
Expand Down
63 changes: 54 additions & 9 deletions apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Cause from "effect/Cause";
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";
Expand All @@ -20,7 +21,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({

vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({
...(await importOriginal()),
useOrchestrationCommand: (() => async () => ({ _tag: "Success", value: undefined })) as never,
useOrchestrationCommand: (() => (input: unknown) => state.switchRef(input)) as never,
}));

vi.mock(import("../../hooks/useThreadActions"), async (importOriginal) => ({
Expand Down Expand Up @@ -77,7 +78,7 @@ function RestoreBranchHarness(props: {
serverConfig: null,
runtimeMode: "full-access" as never,
isWorking: false,
pendingBackgroundTasks: [],
pendingBackgroundTasks: [{ taskId: "background-command", kind: "command" }] as never,
turnItems: [],
activePendingApproval: null as never,
activePendingUserInput: null as never,
Expand All @@ -95,13 +96,16 @@ function RestoreBranchHarness(props: {
persistThreadSettingsForNextTurn: async () => ({ _tag: "Success", value: undefined }) as never,
});
return (
<button
type="button"
disabled={lifecycle.isRestoringThreadBranch}
onClick={lifecycle.handleSwitchCheckoutToThreadStable}
>
{lifecycle.isRestoringThreadBranch ? "Restoring..." : "Restore branch"}
</button>
<>
{lifecycle.workNow?.actions}
<button
type="button"
disabled={lifecycle.isRestoringThreadBranch}
onClick={lifecycle.handleSwitchCheckoutToThreadStable}
>
{lifecycle.isRestoringThreadBranch ? "Restoring..." : "Restore branch"}
</button>
</>
);
}

Expand Down Expand Up @@ -181,3 +185,44 @@ it.each(["a", "b", "navigation"] as const)(
expect(focusComposer).toHaveBeenCalledTimes(scenario === "navigation" ? 0 : 1);
},
);

it.each(["Success", "Failure"] as const)(
"keeps background stop requests per thread after %s",
async (outcome) => {
setup();
const completions: Array<(result: unknown) => void> = [];
state.switchRef = vi.fn(() => new Promise((resolve) => completions.push(resolve)));
const threadA = threadInput("thread:a", "feature-a");
const threadB = threadInput("thread:b", "feature-b");
const currentRouteThreadKeyRef = { current: threadA.routeThreadKey };
const harness = (thread: ThreadInput) => (
<RestoreBranchHarness
thread={thread}
currentRouteThreadKeyRef={currentRouteThreadKeyRef}
refreshGitStatus={() => undefined}
focusComposer={() => undefined}
/>
);
await using screen = await render(harness(threadA));
await page.getByRole("button", { name: "Stop", exact: true }).click();
await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled();
await screen.rerender(harness(threadB));
await page.getByRole("button", { name: "Stop", exact: true }).click();
await screen.rerender(harness(threadA));
await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled();
completions[0]!(
outcome === "Success"
? { _tag: "Success", value: undefined }
: { _tag: "Failure", cause: Cause.fail(new Error("stop failed")) },
);
if (outcome === "Success") {
await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled();
} else {
await expect.element(page.getByRole("button", { name: "Stop", exact: true })).toBeEnabled();
}
await screen.rerender(harness(threadB));
await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled();
expect(state.switchRef).toHaveBeenCalledTimes(2);
completions[1]!({ _tag: "Success", value: undefined });
},
);
48 changes: 38 additions & 10 deletions apps/web/src/components/chat/useThreadLifecycle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
makeThreadErrorSetter,
} from "../ChatView.logic";
import { useStableCallback } from "../../hooks/useStableCallback";
import { useNowAfter } from "../../hooks/useDeadline";
import { useDeadlinePassed, useNowAfter } from "../../hooks/useDeadline";
import type { usePendingThreadRequests } from "./usePendingThreadRequests";
import {
AuthOrchestrationOperateScope,
Expand All @@ -28,6 +28,11 @@ import {
snoozeWakeLabel,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
BACKGROUND_WORK_STOP_RETRY_AFTER_MS,
type BackgroundWorkStopRequest,
isBackgroundWorkStopPending,
} from "@t3tools/client-runtime/state/thread-execution";
import { useAcknowledgeThreadWoke, useThreadActions } from "../../hooks/useThreadActions";
import { settleAndAdvance } from "../../settleAndAdvance";
import type { ChatShortcutCommands } from "./useChatShortcuts";
Expand Down Expand Up @@ -400,21 +405,44 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) {
// The interrupt path also accepts a completed run while its provider still
// has background work.
const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : [];
const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState<string | null>(null);
const isStoppingBackgroundWork =
stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`;
const [backgroundWorkStops, setBackgroundWorkStops] = useState<
Record<string, BackgroundWorkStopRequest | undefined>
>({});
const backgroundWorkStop = backgroundWorkStops[`${environmentId}:${activeThreadId}`] ?? null;
const isStoppingBackgroundWork = isBackgroundWorkStopPending(
backgroundWorkStop,
`${environmentId}:${activeThreadId}`,
pendingBackgroundTasks,
useDeadlinePassed(backgroundWorkStop?.retryAt ?? null),
);
const handleStopBackgroundWork = async () => {
if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
if (
isStoppingBackgroundWork ||
!activeThread ||
!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
)
return;
const requestKey = `${environmentId}:${activeThread.id}`;
setStoppingBackgroundWorkKey(requestKey);
const request: BackgroundWorkStopRequest = {
threadKey: `${environmentId}:${activeThread.id}`,
taskIds: pendingBackgroundTasks.map((task) => task.taskId),
retryAt: null,
};
setBackgroundWorkStops((current) => ({ ...current, [request.threadKey]: request }));
const result = await interruptThreadTurn({
environmentId,
input: { threadId: activeThread.id },
});
// Acceptance does not confirm termination. Allow retry while the provider
// finishes stopping the tasks or reports a failure.
setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current));
setBackgroundWorkStops((current) =>
current[request.threadKey] !== request
? current
: {
...current,
[request.threadKey]:
result._tag === "Failure"
? undefined
: { ...request, retryAt: Date.now() + BACKGROUND_WORK_STOP_RETRY_AFTER_MS },
},
);
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Expand Down
30 changes: 30 additions & 0 deletions packages/client-runtime/src/state/threadExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
deriveThreadRuntime,
isBackgroundWorkStopPending,
presentPendingBackgroundWork,
presentProviderGoal,
presentRunStop,
Expand Down Expand Up @@ -472,6 +473,35 @@ describe("provider-reported model selection", () => {
});
});

describe("isBackgroundWorkStopPending", () => {
const tasks = [{ taskId: "dev-server" }];
const sent = { threadKey: "env:thread", taskIds: ["dev-server"], retryAt: null };
const accepted = { ...sent, retryAt: 10_000 };

it("stays pending after acceptance while a stopped task is still listed", () => {
expect(isBackgroundWorkStopPending(sent, "env:thread", tasks, false)).toBe(true);
expect(isBackgroundWorkStopPending(accepted, "env:thread", tasks, false)).toBe(true);
});

it("ends once the stopped tasks leave, even if new work starts", () => {
expect(isBackgroundWorkStopPending(accepted, "env:thread", [], false)).toBe(false);
expect(isBackgroundWorkStopPending(accepted, "env:thread", [{ taskId: "next" }], false)).toBe(
false,
);
});

it("allows retry at the deadline even if a stopped task remains", () => {
const request = { ...accepted, retryAt: 10_000 };
expect(isBackgroundWorkStopPending(request, "env:thread", tasks, false)).toBe(true);
expect(isBackgroundWorkStopPending(request, "env:thread", tasks, true)).toBe(false);
});

it("only applies to the thread that sent the Stop", () => {
expect(isBackgroundWorkStopPending(null, "env:thread", tasks, false)).toBe(false);
expect(isBackgroundWorkStopPending(sent, "env:other", tasks, false)).toBe(false);
});
});

describe("presentProviderGoal", () => {
it("rolls token counts that round to 1000k over to 1m", () => {
const usage = (tokensUsed: number) =>
Expand Down
20 changes: 20 additions & 0 deletions packages/client-runtime/src/state/threadExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,26 @@ function joinWithAnd(parts: ReadonlyArray<string>): string {
return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
}

// The Claude adapter forces a stuck query closed after 10 seconds.
export const BACKGROUND_WORK_STOP_RETRY_AFTER_MS = 10_000;

export interface BackgroundWorkStopRequest {
readonly threadKey: string;
readonly taskIds: ReadonlyArray<string>;
readonly retryAt: number | null;
}

/** Acceptance does not end the tasks, so an accepted Stop stays pending while they are listed. */
export function isBackgroundWorkStopPending(
request: BackgroundWorkStopRequest | null,
threadKey: string,
tasks: ReadonlyArray<Pick<OrchestrationV2PendingBackgroundTask, "taskId">>,
retryExpired: boolean,
): boolean {
if (request === null || request.threadKey !== threadKey || retryExpired) return false;
return request.retryAt === null || tasks.some((task) => request.taskIds.includes(task.taskId));
}

export function presentPendingBackgroundWork(
tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>,
): PendingBackgroundWorkPresentation | null {
Expand Down
Loading