Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface PendingUserInputCardProps {
readonly onToggleCollapsed: () => void;
/** Renders a stop control on the collapsed bar, which replaces the composer. */
readonly onStopThread?: () => void;
/** Optimistic Stop feedback while the interrupt settles (#8618). */
readonly isStoppingThread?: boolean;
/**
* 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the
* collapsed bar (inside a clipping window) on the UI thread; the host
Expand Down Expand Up @@ -187,10 +189,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
</Pressable>
{props.onStopThread ? (
<ControlPill
accessibilityLabel="Stop"
accessibilityLabel={props.isStoppingThread ? "Stopping" : "Stop"}
icon="stop.fill"
variant="danger"
className="h-9 w-9"
disabled={props.isStoppingThread}
onPress={props.onStopThread}
/>
) : null}
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export interface ThreadComposerProps {
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
/** Optimistic Stop feedback: disables Stop while the interrupt settles (#8618). */
readonly isStoppingThread: boolean;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
Expand Down Expand Up @@ -716,9 +718,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
/>
{showStopAction ? (
<ComposerActionButton
accessibilityLabel="Stop agent"
accessibilityLabel={props.isStoppingThread ? "Stopping agent" : "Stop agent"}
icon="stop.fill"
variant="danger"
disabled={props.isStoppingThread}
onPress={props.onStopThread}
/>
) : (
Expand Down Expand Up @@ -807,9 +810,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
/>
{showStopAction ? (
<ComposerActionButton
accessibilityLabel="Stop agent"
accessibilityLabel={props.isStoppingThread ? "Stopping agent" : "Stop agent"}
icon="stop.fill"
variant="danger"
disabled={props.isStoppingThread}
onPress={props.onStopThread}
/>
) : voicePresentation.showsSend ? (
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export interface ThreadDetailScreenProps {
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
/** Optimistic Stop feedback: set on press, cleared when work ends (#8618). */
readonly isStoppingThread: boolean;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
Expand Down Expand Up @@ -780,6 +782,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
collapsed={userInputCollapsed}
onToggleCollapsed={handleToggleUserInputCollapsed}
onStopThread={props.onStopThread}
isStoppingThread={props.isStoppingThread}
cardProgress={userInputCardProgress}
cardCoverage={userInputCardCoverage}
onInputFocusChange={handleOwnedInputFocusChange}
Expand Down Expand Up @@ -820,6 +823,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onNativePasteImages={props.onNativePasteImages}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
isStoppingThread={props.isStoppingThread}
onSendMessage={handleSendMessage}
onReconnectEnvironment={props.onReconnectEnvironment}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
Expand Down
23 changes: 22 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,18 @@ function ThreadRouteContent(
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const [isStoppingThread, setIsStoppingThread] = useState(false);
const selectedThreadSessionStatus = selectedThread?.session?.status ?? null;
const selectedThreadIdentity = selectedThread
? scopedThreadKey(selectedThread.environmentId, selectedThread.id)
: null;
useEffect(() => {
// "Stopping" holds until the session leaves running/starting; per-thread
// so a switch never leaks the state onto another thread.
if (selectedThreadSessionStatus !== "running" && selectedThreadSessionStatus !== "starting") {
setIsStoppingThread(false);
}
}, [selectedThreadIdentity, selectedThreadSessionStatus]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stopping state leaks across threads

Medium Severity

isStoppingThread is a single boolean, and the reset effect only clears it when the newly selected session is not running or starting. Switching from a stopping thread onto another live thread leaves that thread’s Stop disabled and announced as Stopping.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c8b4b3f. Configure here.

const navigation = useNavigation();
const params = props.route.params;
const environmentIdRaw = firstRouteParam(params.environmentId);
Expand Down Expand Up @@ -486,14 +498,22 @@ function ThreadRouteContent(
) {
return;
}
return interruptThreadTurn({
// Optimistic stopping feedback for remote links: holds until the session
// leaves running/starting (reset effect below); a failed interrupt clears
// it immediately so Stop never sticks (#8618).
setIsStoppingThread(true);
void interruptThreadTurn({
environmentId: selectedThread.environmentId,
input: {
threadId: selectedThread.id,
...(selectedThread.session.activeTurnId
? { turnId: selectedThread.session.activeTurnId }
: {}),
},
}).then((result) => {
if (result._tag === "Failure") {
setIsStoppingThread(false);
}
});
}, [interruptThreadTurn, selectedThread]);

Expand Down Expand Up @@ -799,6 +819,7 @@ function ThreadRouteContent(
onRemoveDraftImage={composer.onRemoveDraftImage}
serverConfig={serverConfig}
onStopThread={handleStopThread}
isStoppingThread={isStoppingThread}
onSendMessage={composer.onSendMessage}
onReconnectEnvironment={handleReconnectEnvironment}
onUpdateThreadModelSelection={composer.onUpdateModelSelection}
Expand Down
93 changes: 93 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1839,6 +1839,99 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
}),
);

it.effect(
"marks the session-pinned turn interrupted when the interrupt request omits turnId",
() =>
Effect.gen(function* () {
const projectionPipeline = yield* OrchestrationProjectionPipeline;
const eventStore = yield* OrchestrationEventStore;
const sql = yield* SqlClient.SqlClient;
const now = "2026-01-01T00:00:00.000Z";
const threadId = ThreadId.make("thread-interrupt-no-turn-id");
const turnId = TurnId.make("turn-no-turn-id-1");

yield* eventStore.append({
type: "thread.created",
eventId: EventId.make("evt-nti1"),
aggregateKind: "thread",
aggregateId: threadId,
occurredAt: now,
commandId: CommandId.make("cmd-nti1"),
causationEventId: null,
correlationId: CorrelationId.make("cmd-nti1"),
metadata: {},
payload: {
threadId,
projectId: ProjectId.make("project-no-turn-id"),
title: "Interrupt without turn id",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt: now,
updatedAt: now,
},
});

yield* eventStore.append({
type: "thread.session-set",
eventId: EventId.make("evt-nti2"),
aggregateKind: "thread",
aggregateId: threadId,
occurredAt: "2026-01-01T00:00:01.000Z",
commandId: CommandId.make("cmd-nti2"),
causationEventId: null,
correlationId: CorrelationId.make("cmd-nti2"),
metadata: {},
payload: {
threadId,
session: {
threadId,
status: "running",
providerName: "codex",
runtimeMode: "full-access",
activeTurnId: turnId,
lastError: null,
updatedAt: "2026-01-01T00:00:01.000Z",
},
},
});

// Remote snapshots lag, so Stop can persist an interrupt request
// without a turnId — the pinned turn must still be settled (#8618).
yield* eventStore.append({
type: "thread.turn-interrupt-requested",
eventId: EventId.make("evt-nti3"),
aggregateKind: "thread",
aggregateId: threadId,
occurredAt: "2026-01-01T00:00:02.000Z",
commandId: CommandId.make("cmd-nti3"),
causationEventId: null,
correlationId: CorrelationId.make("cmd-nti3"),
metadata: {},
payload: {
threadId,
createdAt: "2026-01-01T00:00:02.000Z",
},
});

yield* projectionPipeline.bootstrap;

const rows = yield* sql<{
readonly state: string;
readonly completedAt: string | null;
}>`
SELECT state, completed_at AS "completedAt"
FROM projection_turns
WHERE thread_id = ${threadId} AND turn_id = ${turnId}
`;
assert.deepEqual(rows, [{ state: "interrupted", completedAt: "2026-01-01T00:00:02.000Z" }]);
}),
);

it.effect("settles a superseded running turn when a new turn becomes active", () =>
Effect.gen(function* () {
const projectionPipeline = yield* OrchestrationProjectionPipeline;
Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1472,12 +1472,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
}

case "thread.turn-interrupt-requested": {
if (event.payload.turnId === undefined) {
// Remote snapshots lag, so Stop often arrives without a turnId
// (see buildThreadTurnInterruptInput). Fall back to the session's
// pinned turn instead of dropping the interrupt on the floor — a
// turnId-less interrupt must still settle the running turn (#8618).
let effectiveTurnId = event.payload.turnId;
if (effectiveTurnId === undefined) {
const session = yield* projectionThreadSessionRepository.getByThreadId({
threadId: event.payload.threadId,
});
if (Option.isSome(session) && session.value.activeTurnId !== null) {
effectiveTurnId = session.value.activeTurnId;
}
}
if (effectiveTurnId === undefined) {
return;
}
const existingTurn = yield* projectionTurnRepository.getByTurnId({
threadId: event.payload.threadId,
turnId: event.payload.turnId,
turnId: effectiveTurnId,
});
if (Option.isSome(existingTurn)) {
yield* projectionTurnRepository.upsertByTurnId({
Expand All @@ -1490,7 +1503,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
return;
}
yield* projectionTurnRepository.upsertByTurnId({
turnId: event.payload.turnId,
turnId: effectiveTurnId,
threadId: event.payload.threadId,
pendingMessageId: null,
sourceProposedPlanThreadId: null,
Expand Down
Loading
Loading