Skip to content
36 changes: 31 additions & 5 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ import {
type ThreadUserInputQuestion,
} from "@t3tools/client-runtime/state/thread-requests";
import {
BACKGROUND_WORK_STOP_RETRY_AFTER_MS,
type BackgroundWorkStopRequest,
isBackgroundWorkStopPending,
presentPendingBackgroundWork,
presentProviderGoal,
} from "@t3tools/client-runtime/state/thread-execution";
Expand Down Expand Up @@ -669,8 +672,19 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false });
const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false });
const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null);
const statusPending = statusRequest?.threadKey === selectedThreadKey;
const [statusRequest, setStatusRequest] = useState<BackgroundWorkStopRequest | null>(null);
const statusPending = isBackgroundWorkStopPending(
statusRequest,
selectedThreadKey,
props.selectedThread.pendingBackgroundTasks,
);
useEffect(() => {
if (statusRequest?.accepted !== true) return;
const timer = setTimeout(() => {
setStatusRequest((current) => (current === statusRequest ? null : current));
}, BACKGROUND_WORK_STOP_RETRY_AFTER_MS);
return () => clearTimeout(timer);
}, [statusRequest]);
const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString());
useFocusEffect(
useCallback(() => {
Expand Down Expand Up @@ -707,7 +721,14 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
});
const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => {
if (statusPending || !props.canOperateThread) return;
const request = { threadKey: selectedThreadKey };
const request: BackgroundWorkStopRequest = {
threadKey: selectedThreadKey,
taskIds:
action === "stop"
? props.selectedThread.pendingBackgroundTasks.map((task) => task.taskId)
: [],
accepted: false,
};
setStatusRequest(request);
const target = {
environmentId: props.environmentId,
Expand Down Expand Up @@ -742,8 +763,13 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
...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));
setStatusRequest((current) =>
current !== request
? current
: action === "stop" && result._tag !== "Failure"
? { ...request, accepted: true }
: null,
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Dialog.alert(
Expand Down
41 changes: 33 additions & 8 deletions apps/web/src/components/chat/useThreadLifecycle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import {
RuntimeMode,
} from "@t3tools/contracts";
import { effectiveSnoozed, 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 @@ -386,21 +391,41 @@ 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 [backgroundWorkStop, setBackgroundWorkStop] = useState<BackgroundWorkStopRequest | null>(
null,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const isStoppingBackgroundWork = isBackgroundWorkStopPending(
backgroundWorkStop,
`${environmentId}:${activeThreadId}`,
pendingBackgroundTasks,
);
useEffect(() => {
if (backgroundWorkStop?.accepted !== true) return;
const timer = setTimeout(() => {
setBackgroundWorkStop((current) => (current === backgroundWorkStop ? null : current));
}, BACKGROUND_WORK_STOP_RETRY_AFTER_MS);
return () => clearTimeout(timer);
}, [backgroundWorkStop]);
const handleStopBackgroundWork = async () => {
if (!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),
accepted: false,
};
setBackgroundWorkStop(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));
setBackgroundWorkStop((current) =>
current !== request
? current
: result._tag === "Failure"
? null
: { ...request, accepted: true },
);
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Expand Down
22 changes: 22 additions & 0 deletions packages/client-runtime/src/state/threadExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
deriveThreadRuntime,
isBackgroundWorkStopPending,
threadRuntimeHasInterruptibleRun,
} from "./threadExecution.ts";

Expand Down Expand Up @@ -393,3 +394,24 @@ describe("provider-reported model selection", () => {
).toBeNull();
});
});

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

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

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

it("only applies to the thread that sent the Stop", () => {
expect(isBackgroundWorkStopPending(null, "env:thread", tasks)).toBe(false);
expect(isBackgroundWorkStopPending(sent, "env:other", tasks)).toBe(false);
});
});
19 changes: 19 additions & 0 deletions packages/client-runtime/src/state/threadExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,25 @@ 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 accepted: boolean;
}

/** 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">>,
): boolean {
if (request === null || request.threadKey !== threadKey) return false;
return !request.accepted || tasks.some((task) => request.taskIds.includes(task.taskId));
}

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