mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 14:38:23 -10:00
fix(web-shell): keep turn settlement daemon-authoritative
This commit is contained in:
1 parent
2a7d82fec1
commit
246d9cbe13
4 files changed
+13
-165
No files matched your search
@@ -1292,7 +1292,8 @@ export interface WebShellProps {
|
||||
onSessionChange?: (event: SessionChangeEvent) => void;
|
||||
/**
|
||||
* Called for authoritative terminals observed live, or replayed for a prompt
|
||||
* this provider admitted.
|
||||
* this provider admitted. Multiple mounted providers can report the same
|
||||
* `(sessionId, promptId)`, so hosts should deduplicate by that key.
|
||||
*/
|
||||
onAssistantTurnSettled?: (event: WebShellAssistantTurnSettledEvent) => void;
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,17 @@ function getSettledAssistantMessage(
|
||||
)
|
||||
.map((block) => block.id),
|
||||
);
|
||||
if (
|
||||
promptBlockIds.size === 0 ||
|
||||
blocks.some(
|
||||
(block) =>
|
||||
block.kind === 'assistant' &&
|
||||
promptBlockIds.has(block.id) &&
|
||||
block.streaming,
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const messages = transcriptBlocksToDaemonMessages(blocks, {
|
||||
includeSourceIdentity: true,
|
||||
});
|
||||
|
||||
@@ -10487,103 +10487,6 @@ describe('DaemonSessionProvider', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes a failed retirement when an epoch reset destroys the terminal', async () => {
|
||||
// A cold restore emits only `session_update` chunks, never terminal
|
||||
// events. The admitted prompt's terminal is gone; the host must still hear
|
||||
// a `failed` settlement instead of waiting forever.
|
||||
const resyncGate = createDeferred<void>();
|
||||
const reloaded = createDeferred<void>();
|
||||
const firstSession = createMockSession({
|
||||
sessionId: 'session-retire-epoch',
|
||||
submitPrompt: vi.fn(async () => ({
|
||||
promptId: 'prompt-1',
|
||||
lastEventId: 9,
|
||||
})),
|
||||
events: async function* epochResetThenResync(
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
) {
|
||||
await Promise.race([
|
||||
resyncGate.promise,
|
||||
new Promise<void>((resolve) =>
|
||||
opts.signal?.addEventListener('abort', () => resolve(), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
if (opts.signal?.aborted) return;
|
||||
yield {
|
||||
id: 10,
|
||||
v: 1,
|
||||
type: 'state_resync_required',
|
||||
data: { reason: 'epoch_reset' },
|
||||
} satisfies DaemonEvent;
|
||||
},
|
||||
});
|
||||
const reloadedSession = createMockSession({
|
||||
sessionId: 'session-retire-epoch',
|
||||
hasActivePrompt: false,
|
||||
events: createPendingEvents(reloaded),
|
||||
replaySnapshot: {
|
||||
compactedReplay: [
|
||||
{
|
||||
id: 11,
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'answer before the crash' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
liveJournal: [],
|
||||
},
|
||||
});
|
||||
sdkMocks.sessions.push(firstSession, reloadedSession);
|
||||
const settlements: DaemonPromptSettledEvent[] = [];
|
||||
let actions: DaemonUiSessionActions | undefined;
|
||||
|
||||
function Harness() {
|
||||
actions = useDaemonActions();
|
||||
useDaemonPromptSettled((event) => {
|
||||
settlements.push(event);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />, {
|
||||
autoConnect: true,
|
||||
reconnectDelayMs: 1,
|
||||
maxReconnectDelayMs: 1,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const prompt = requireActions(actions).sendPrompt('hello');
|
||||
void prompt.catch(() => {});
|
||||
await flushPromises();
|
||||
});
|
||||
expect(settlements).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
resyncGate.resolve();
|
||||
await reloaded.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(settlements).toEqual([
|
||||
{
|
||||
sessionId: 'session-retire-epoch',
|
||||
promptId: 'prompt-1',
|
||||
outcome: 'failed',
|
||||
error: {
|
||||
message: 'Prompt terminal lost across daemon epoch reset',
|
||||
code: 'epoch_reset',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes a replayed cancelled terminal after the prompt was cancelled', async () => {
|
||||
// `cancel()` removes the ActivePrompt (deletes it in `finally`) but the
|
||||
// admission key survives; a `turn_complete{stopReason:'cancelled'}` that
|
||||
|
||||
@@ -1092,14 +1092,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
const activePromptsRef = useRef<Map<string, ActivePrompt>>(new Map());
|
||||
const settledPromptsRef = useRef<Map<string, SettledPrompt>>(new Map());
|
||||
const locallyBoundPromptKeysRef = useRef(new Set<string>());
|
||||
// A locally bound prompt whose terminal an epoch reset may have destroyed.
|
||||
// Recorded when `requestEpochResetReload` discards the binding, consumed
|
||||
// after the reload's replay injection to publish a `failed` retirement when
|
||||
// the fresh snapshot carried no terminal for it (a cold restore emits only
|
||||
// `session_update` chunks). Cleared on use so unrelated reloads stay silent.
|
||||
const epochResetBoundPromptRef = useRef<
|
||||
{ sessionId: string; promptId: string } | undefined
|
||||
>(undefined);
|
||||
const promptSettlementListenersRef = useRef<Set<DaemonPromptSettledListener>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -1132,26 +1124,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Publish a `failed` settlement for a still-bound prompt whose terminal was
|
||||
// destroyed without ever being observed (session died, auth/terminal error,
|
||||
// missing session). Stays inside the public outcome union and carries the
|
||||
// same (sessionId, promptId) key so a late real terminal is deduped.
|
||||
const retireAbandonedPrompt = useCallback(
|
||||
(sessionId: string, code: string) => {
|
||||
const active = activePromptsRef.current.get(sessionId);
|
||||
if (!active?.promptId) return;
|
||||
publishPromptSettlement({
|
||||
sessionId,
|
||||
promptId: active.promptId,
|
||||
outcome: 'failed',
|
||||
error: {
|
||||
message: 'Prompt terminal lost before delivery',
|
||||
code,
|
||||
},
|
||||
});
|
||||
},
|
||||
[publishPromptSettlement],
|
||||
);
|
||||
const pendingSessionLoadRef = useRef<PendingSessionLoad | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -2890,33 +2862,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
publishPromptSettlement(replaySettlement);
|
||||
}
|
||||
}
|
||||
// An epoch reset may have destroyed the terminal of a prompt this
|
||||
// provider had bound: if the replay above did not settle it (the
|
||||
// admission key is still present, so no terminal arrived), retire
|
||||
// it as `failed` rather than leaving a host keyed on
|
||||
// `onAssistantTurnSettled` waiting forever.
|
||||
const epochResetBoundPrompt = epochResetBoundPromptRef.current;
|
||||
epochResetBoundPromptRef.current = undefined;
|
||||
if (
|
||||
epochResetBoundPrompt &&
|
||||
epochResetBoundPrompt.sessionId === activeSession.sessionId &&
|
||||
locallyBoundPromptKeysRef.current.has(
|
||||
getPromptSettledKey(
|
||||
epochResetBoundPrompt.sessionId,
|
||||
epochResetBoundPrompt.promptId,
|
||||
),
|
||||
)
|
||||
) {
|
||||
publishPromptSettlement({
|
||||
sessionId: epochResetBoundPrompt.sessionId,
|
||||
promptId: epochResetBoundPrompt.promptId,
|
||||
outcome: 'failed',
|
||||
error: {
|
||||
message: 'Prompt terminal lost across daemon epoch reset',
|
||||
code: 'epoch_reset',
|
||||
},
|
||||
});
|
||||
}
|
||||
setConnection((c) => ({ ...c, catchingUp: undefined }));
|
||||
// Release the raw snapshot only after the injection above
|
||||
// completed: if normalization/dispatch threw, the recovery path
|
||||
@@ -3268,16 +3213,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
const active = activePromptsRef.current.get(
|
||||
activeSession.sessionId,
|
||||
);
|
||||
if (active?.promptId) {
|
||||
// The reset may have destroyed the turn's terminal (a cold
|
||||
// restore emits only `session_update` chunks, never terminals).
|
||||
// Remember the bound prompt so the reload can retire it as
|
||||
// `failed` if the fresh snapshot carries no terminal for it.
|
||||
epochResetBoundPromptRef.current = {
|
||||
sessionId: activeSession.sessionId,
|
||||
promptId: active.promptId,
|
||||
};
|
||||
}
|
||||
active?.controller.abort();
|
||||
activePromptsRef.current.delete(activeSession.sessionId);
|
||||
if (restoredActivePrompt) {
|
||||
@@ -3925,7 +3860,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
const isAuthFailure = isAuthFailureHttpError(error);
|
||||
const isTerminal = isTerminalSessionHttpError(error);
|
||||
if (failedSessionId && (isAuthFailure || isTerminal)) {
|
||||
retireAbandonedPrompt(failedSessionId, 'session_error');
|
||||
const active = activePromptsRef.current.get(failedSessionId);
|
||||
active?.controller.abort();
|
||||
activePromptsRef.current.delete(failedSessionId);
|
||||
@@ -4357,7 +4291,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
errorStatus,
|
||||
);
|
||||
}
|
||||
retireAbandonedPrompt(deadSessionId, 'session_missing');
|
||||
const active = activePromptsRef.current.get(deadSessionId);
|
||||
active?.controller.abort();
|
||||
activePromptsRef.current.delete(deadSessionId);
|
||||
|
||||
Reference in new issue
Block a user