Fix hidden Codex subagents and stuck parent sessions (#2068)

* fix(server): prevent hidden Codex subagents and stuck turns

Codex can announce native children only through its mirrored lifecycle stream, while rejected interrupts previously looked like successful local cancellation. Preserve those child announcements and keep manager state active until the provider acknowledges cancellation.

* fix(server): block actions after rejected cancellation

A provider can accept a turn before publishing the turn ID needed to interrupt it. Keep that interval non-cancelable, and prevent reload, replacement, or rewind from proceeding without an acknowledged cancellation.

* fix(server): surface rejected agent cancellations

Replacement prompts and Stop requests could appear accepted after the provider kept ownership of the active turn. Complete replacement cancellation before detaching the stream, and return cancellation failures through the client response.

* fix(server): handle turn completion during cancellation

* fix: finish cancellation lifecycle handling

* fix(server): close remaining cancellation races

* fix(server): settle autonomous cancellations

* fix: honor cancellation failures at call sites

* refactor(server): centralize agent run state

* fix(server): keep pending runs isolated from stale events

* fix: surface remaining cancellation failures

* fix(server): narrow cancellation error handling
This commit is contained in:
Mohamed Boudra
2026-07-15 00:01:58 +02:00
committed by GitHub
parent 50ed0d0ab1
commit d706c4339b
33 changed files with 1597 additions and 432 deletions
+20
View File
@@ -227,6 +227,7 @@ describe("cancelComposerAgent", () => {
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
onCancelFailed: (error: unknown) => void;
} {
const canceledIds: string[] = [];
return {
@@ -240,6 +241,7 @@ describe("cancelComposerAgent", () => {
isAgentRunning: true,
isCancellingAgent: false,
isConnected: true,
onCancelFailed: () => undefined,
};
}
@@ -250,6 +252,24 @@ describe("cancelComposerAgent", () => {
expect(input.client.canceledIds).toEqual(["agent"]);
});
it("reports a rejected cancel so the composer can leave its canceling state", async () => {
const cancellationError = new Error("Provider rejected the interrupt");
const failures: unknown[] = [];
const input = baseInput();
input.client.cancelAgent = async () => {
throw cancellationError;
};
const result = cancelComposerAgent({
...input,
onCancelFailed: (error: unknown) => failures.push(error),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(result).toBe(true);
expect(failures).toEqual([cancellationError]);
});
it("does nothing when the agent is not running", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
+7 -1
View File
@@ -142,12 +142,18 @@ export interface CancelComposerAgentInput {
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
onCancelFailed: (error: unknown) => void;
}
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
if (!input.isAgentRunning || input.isCancellingAgent) return false;
if (!input.isConnected || !input.client) return false;
void input.client.cancelAgent(input.agentId);
try {
void Promise.resolve(input.client.cancelAgent(input.agentId)).catch(input.onCancelFailed);
} catch (error) {
input.onCancelFailed(error);
return false;
}
return true;
}
+9 -2
View File
@@ -416,7 +416,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
);
}
function resolveVoiceStartErrorMessage(error: unknown): string | null {
function resolveErrorMessage(error: unknown): string | null {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return null;
@@ -438,7 +438,7 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
if (voice.isVoiceModeForAgent(serverId, agentId)) return;
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[Composer] Failed to start voice mode", error);
const message = resolveVoiceStartErrorMessage(error);
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
@@ -1466,6 +1466,13 @@ export function Composer({
isAgentRunning,
isCancellingAgent,
isConnected,
onCancelFailed: (error) => {
setIsCancellingAgent(false);
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
},
});
if (!didCancel) return;
setIsCancellingAgent(true);
+18 -32
View File
@@ -66,7 +66,12 @@ import {
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
} from "./labels";
import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state";
import {
computeCanStartDictation,
runAlternateSendAction,
runDefaultSendAction,
stopRealtimeVoice,
} from "./state";
const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];
@@ -929,31 +934,6 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis
await ctx.startDictation();
}
interface StopRealtimeVoiceContext {
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
isRealtimeVoiceForCurrentAgent: boolean;
isAgentRunning: boolean;
client: { cancelAgent: (agentId: string) => Promise<unknown> } | null;
voiceAgentId: string | undefined;
}
async function stopRealtimeVoiceImpl(ctx: StopRealtimeVoiceContext): Promise<void> {
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
const tasks: Promise<unknown>[] = [];
if (ctx.isAgentRunning && ctx.client && ctx.voiceAgentId) {
tasks.push(ctx.client.cancelAgent(ctx.voiceAgentId));
}
tasks.push(ctx.voice.stopVoice());
const results = await Promise.allSettled(tasks);
results.forEach((result) => {
if (result.status === "rejected") {
console.error("[MessageInput] Failed to stop realtime voice", result.reason);
}
});
}
interface VoicePressContext {
isRealtimeVoiceForCurrentAgent: boolean;
voice: { toggleMute: () => void } | null | undefined;
@@ -1502,17 +1482,23 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
discardFailedDictation();
}, [discardFailedDictation]);
const handleStopRealtimeVoice = useCallback(
() =>
stopRealtimeVoiceImpl({
const handleStopRealtimeVoice = useCallback(async () => {
try {
await stopRealtimeVoice({
voice,
isRealtimeVoiceForCurrentAgent,
isAgentRunning,
client,
voiceAgentId,
}),
[client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId],
);
});
} catch (error) {
console.error("[MessageInput] Failed to stop realtime voice", error);
const message = extractErrorMessage(error);
if (message && message.trim().length > 0) {
toast.error(message);
}
}
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]);
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
toggleRealtimeVoiceImpl({
+49 -2
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state";
import { describe, expect, it, vi } from "vitest";
import {
computeCanStartDictation,
runAlternateSendAction,
runDefaultSendAction,
stopRealtimeVoice,
} from "./state";
const connected = { isConnected: true } as never;
const disconnected = { isConnected: false } as never;
@@ -149,3 +154,45 @@ describe("composer send behavior", () => {
expect(alternateAction.calls).toEqual(["send"]);
});
});
describe("stopRealtimeVoice", () => {
it("keeps voice mode active when the running agent refuses cancellation", async () => {
const cancellationError = new Error("active run cancellation was not acknowledged");
const cancelAgent = vi.fn().mockRejectedValue(cancellationError);
const stopVoice = vi.fn().mockResolvedValue(undefined);
await expect(
stopRealtimeVoice({
voice: { stopVoice },
isRealtimeVoiceForCurrentAgent: true,
isAgentRunning: true,
client: { cancelAgent },
voiceAgentId: "agent-1",
}),
).rejects.toBe(cancellationError);
expect(stopVoice).not.toHaveBeenCalled();
});
it("stops voice mode after the running agent acknowledges cancellation", async () => {
const calls: string[] = [];
await stopRealtimeVoice({
voice: {
stopVoice: async () => {
calls.push("stop voice");
},
},
isRealtimeVoiceForCurrentAgent: true,
isAgentRunning: true,
client: {
cancelAgent: async () => {
calls.push("cancel agent");
},
},
voiceAgentId: "agent-1",
});
expect(calls).toEqual(["cancel agent", "stop voice"]);
});
});
+21
View File
@@ -3,6 +3,14 @@ import type { MessagePayload } from "@/composer/types";
export type SendBehavior = "interrupt" | "queue";
interface StopRealtimeVoiceContext {
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
isRealtimeVoiceForCurrentAgent: boolean;
isAgentRunning: boolean;
client: { cancelAgent: (agentId: string) => Promise<unknown> } | null;
voiceAgentId: string | undefined;
}
interface SendActionContext {
defaultSendBehavior: SendBehavior;
isAgentRunning: boolean;
@@ -41,3 +49,16 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
ctx.handleQueueMessage();
}
}
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
if (ctx.isAgentRunning) {
if (!ctx.client || !ctx.voiceAgentId) {
throw new Error("Cannot stop the running voice agent while the host is unavailable");
}
await ctx.client.cancelAgent(ctx.voiceAgentId);
}
await ctx.voice.stopVoice();
}
@@ -476,6 +476,15 @@ function applyToolErrorToMessages(
);
}
function notifyVoiceAbortFailure(
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
notifyError: (message: string) => void,
): void {
if (data.type === "error" && data.metadata?.voiceAbortFailed === true) {
notifyError(data.content);
}
}
interface SessionProviderSharedProps {
children: ReactNode;
serverId: string;
@@ -1570,6 +1579,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setMessages(serverId, applyToolError);
}
notifyVoiceAbortFailure(data, toast.error);
let activityType: "system" | "info" | "success" | "error" = "info";
if (data.type === "error") activityType = "error";
@@ -1805,6 +1816,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyWorkspaceSetupProgress,
applyTimelineResponse,
updateSessionServerInfo,
toast,
voiceRuntime,
voiceAudioEngine,
]);