Skip to content
31 changes: 26 additions & 5 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 @@ -578,8 +582,13 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
);
}
};
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,
useDeadlinePassed(statusRequest?.retryAt ?? null),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString());
useFocusEffect(
useCallback(() => {
Expand Down Expand Up @@ -611,7 +620,14 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
});
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)
: [],
retryAt: null,
};
setStatusRequest(request);
const target = {
environmentId: props.environmentId,
Expand Down Expand Up @@ -646,8 +662,13 @@ 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));
setStatusRequest((current) =>
current !== request
? current
: action === "stop" && result._tag !== "Failure"
? { ...request, retryAt: Date.now() + BACKGROUND_WORK_STOP_RETRY_AFTER_MS }
: null,
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Dialog.alert(
Expand Down
43 changes: 33 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 @@ -383,21 +388,39 @@ 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,
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,
};
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, 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 @@ -25,6 +25,7 @@ import {
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
deriveThreadRuntime,
isBackgroundWorkStopPending,
presentRunStop,
threadRuntimeHasInterruptibleRun,
} from "./threadExecution.ts";
Expand Down Expand Up @@ -462,3 +463,32 @@ describe("provider-reported model selection", () => {
).toBeNull();
});
});

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);
});
});
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 @@ -361,6 +361,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