fix(chat): heal task bubble pending after completed run
This commit is contained in:
+12
-1
@@ -108,6 +108,7 @@ export async function readTaskRunSSEOnce(
|
||||
|
||||
/**
|
||||
* 订阅任务 SSE:意外 EOF / 不完整 chunked 时按最后 seq 重订阅同一 run,禁止调用方据此 POST 新 run。
|
||||
* 重连前可经 onGap 轮询 run 终态:若已 completed 等则停止订阅(避免 done 丢失后永久 pending)。
|
||||
*/
|
||||
export async function subscribeTaskRunEvents(
|
||||
runId: string,
|
||||
@@ -117,6 +118,8 @@ export async function subscribeTaskRunEvents(
|
||||
maxReconnects?: number;
|
||||
reconnectDelayMs?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Return true to stop reconnecting (caller already settled from GET /runs). */
|
||||
onGap?: (ctx: { afterSeq: number; attempt: number }) => Promise<boolean>;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -131,12 +134,20 @@ export async function subscribeTaskRunEvents(
|
||||
const { terminal, lastSeq } = await readTaskRunSSEOnce(runId, afterSeq, handlers.onEvent, signal);
|
||||
afterSeq = Math.max(afterSeq, lastSeq);
|
||||
if (terminal) return;
|
||||
// 意外 EOF:未见到 done/error,按 after_seq 重连
|
||||
// 意外 EOF:未见到 done/error — 先问 onGap,再按 after_seq 重连
|
||||
if (handlers.onGap) {
|
||||
const stop = await handlers.onGap({ afterSeq, attempt });
|
||||
if (stop) return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
if (err instanceof TaskRunHttpError && isNonRetryableStatus(err.status)) {
|
||||
throw err;
|
||||
}
|
||||
if (handlers.onGap) {
|
||||
const stop = await handlers.onGap({ afterSeq, attempt }).catch(() => false);
|
||||
if (stop) return;
|
||||
}
|
||||
if (attempt >= maxReconnects) throw err;
|
||||
}
|
||||
if (attempt >= maxReconnects) {
|
||||
|
||||
@@ -46,3 +46,43 @@ test('mergeLiveOverHistory preserves server id from live', () => {
|
||||
assert.equal(merged.id, 'msg-101');
|
||||
assert.equal(merged.serverId, 101);
|
||||
});
|
||||
|
||||
test('mergeLiveOverHistory does not let inflight live mask terminal history', () => {
|
||||
const history = base({
|
||||
id: 'msg-243',
|
||||
role: 'assistant',
|
||||
serverId: 243,
|
||||
status: 'completed',
|
||||
content: '任务摘要',
|
||||
livePhaseLabel: undefined,
|
||||
});
|
||||
const live = base({
|
||||
id: 'ast-tmp',
|
||||
role: 'assistant',
|
||||
serverId: 243,
|
||||
status: 'pending',
|
||||
content: '任务执行中…',
|
||||
livePhaseLabel: '任务执行中…',
|
||||
});
|
||||
const merged = mergeLiveOverHistory(history, live);
|
||||
assert.equal(merged.id, 'msg-243');
|
||||
assert.equal(merged.status, 'completed');
|
||||
assert.equal(merged.livePhaseLabel, undefined);
|
||||
});
|
||||
|
||||
test('mergeLiveOverHistory keeps live terminal over history inflight', () => {
|
||||
const history = base({
|
||||
id: 'msg-10',
|
||||
role: 'assistant',
|
||||
serverId: 10,
|
||||
status: 'pending',
|
||||
});
|
||||
const live = base({
|
||||
id: 'ast-10',
|
||||
role: 'assistant',
|
||||
serverId: 10,
|
||||
status: 'completed',
|
||||
});
|
||||
const merged = mergeLiveOverHistory(history, live);
|
||||
assert.equal(merged.status, 'completed');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ChatMessage } from '../types/workbench';
|
||||
import type { AssistantMessageStatus, ChatMessage } from '../types/workbench';
|
||||
import {
|
||||
isErrorTerminalChatStatus,
|
||||
isInflightChatStatus,
|
||||
isSuccessTerminalChatStatus,
|
||||
} from '../lib/squadCompletionObservation';
|
||||
|
||||
/** 收集消息的所有可对账身份键 */
|
||||
export function getMessageKeys(m: ChatMessage): string[] {
|
||||
@@ -66,6 +71,31 @@ export function filterPendingLiveMessages(
|
||||
return liveMessages.filter((m) => !isLiveMessageInHistory(m, historyMessages));
|
||||
}
|
||||
|
||||
function isTerminalMessageStatus(status: string | undefined): boolean {
|
||||
if (!status) return false;
|
||||
return (
|
||||
isSuccessTerminalChatStatus(status) ||
|
||||
isErrorTerminalChatStatus(status) ||
|
||||
status === 'cancelled'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer history terminal status when live is still inflight.
|
||||
* Otherwise a completed task_status from chat window refresh is masked by a
|
||||
* stale pending/streaming_status bubble (SSE done lost / reconnect gap).
|
||||
*/
|
||||
export function resolveMergedMessageStatus(
|
||||
historyStatus: string | undefined,
|
||||
liveStatus: string | undefined,
|
||||
): AssistantMessageStatus | undefined {
|
||||
const live = liveStatus || '';
|
||||
if (isInflightChatStatus(live) && isTerminalMessageStatus(historyStatus)) {
|
||||
return historyStatus as AssistantMessageStatus | undefined;
|
||||
}
|
||||
return (liveStatus ?? historyStatus) as AssistantMessageStatus | undefined;
|
||||
}
|
||||
|
||||
/** 用 live 流状态覆盖历史快照中的同一条消息 */
|
||||
export function mergeLiveOverHistory(
|
||||
history: ChatMessage,
|
||||
@@ -74,6 +104,8 @@ export function mergeLiveOverHistory(
|
||||
if (!isSameMessage(history, live)) {
|
||||
return history;
|
||||
}
|
||||
const preferHistoryTerminal =
|
||||
isInflightChatStatus(live.status || '') && isTerminalMessageStatus(history.status);
|
||||
return {
|
||||
...history,
|
||||
...live,
|
||||
@@ -82,8 +114,11 @@ export function mergeLiveOverHistory(
|
||||
clientTurnId: live.clientTurnId ?? history.clientTurnId,
|
||||
clientMessageId: live.clientMessageId ?? history.clientMessageId,
|
||||
phaseEvents: live.phaseEvents?.length ? live.phaseEvents : history.phaseEvents,
|
||||
livePhaseLabel: live.livePhaseLabel ?? history.livePhaseLabel,
|
||||
livePhaseLabel: preferHistoryTerminal
|
||||
? undefined
|
||||
: live.livePhaseLabel ?? history.livePhaseLabel,
|
||||
taskDurationMs: live.taskDurationMs ?? history.taskDurationMs,
|
||||
phaseSnapshot: live.phaseSnapshot ?? history.phaseSnapshot,
|
||||
status: resolveMergedMessageStatus(history.status, live.status),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -573,6 +573,82 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
}
|
||||
};
|
||||
|
||||
const settleFromRemoteIfTerminal = async (runId: string): Promise<boolean> => {
|
||||
if (settled || controller.signal.aborted) return settled;
|
||||
const remote = await getTaskRun(runId).catch(() => null);
|
||||
if (!remote) return false;
|
||||
const view = readRemoteTaskRun(remote);
|
||||
if (view.status === 'quality_failed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'quality_failed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return true;
|
||||
}
|
||||
if (isBlockedGovernanceStatus(view.status)) {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'blocked_pending_governance',
|
||||
content: GOVERNANCE_BLOCKED_HINT,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return true;
|
||||
}
|
||||
if (view.status === 'completed' || view.status === 'pending_author_confirmation') {
|
||||
settled = true;
|
||||
const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant') || 'completed';
|
||||
patchAsst({
|
||||
status: mapped,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
if (view.status === 'failed' || view.status === 'finalizing_failed') {
|
||||
failOrRetry(buildFailedTaskErrorInfo(view.errorMessage));
|
||||
return true;
|
||||
}
|
||||
if (view.status === 'interrupted') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'failed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
errorInfo: buildInterruptedTaskErrorInfo(view.errorMessage, view.workspaceId),
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return true;
|
||||
}
|
||||
if (view.status === 'cancelled') {
|
||||
failOrRetry(buildCancelledTaskErrorInfo());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const subscribeWithGapHeal = async (runId: string) => {
|
||||
await subscribeTaskRunEvents(
|
||||
runId,
|
||||
{
|
||||
onEvent,
|
||||
onGap: async () => settleFromRemoteIfTerminal(runId),
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
};
|
||||
|
||||
const startObserver = async () => {
|
||||
if (mode === 'resume') {
|
||||
const runId = opts?.reconnectRunId?.trim();
|
||||
@@ -624,7 +700,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
taskWorkspaceId: resumed.workspace_id || workspaceId,
|
||||
livePhaseLabel: '正在继续任务…',
|
||||
});
|
||||
await subscribeTaskRunEvents(retainedRunId, { onEvent }, controller.signal);
|
||||
await subscribeWithGapHeal(retainedRunId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -643,7 +719,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
retainedRunId = existingRunId;
|
||||
taskRunStream.setRunId(sessionId, existingRunId);
|
||||
patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' });
|
||||
await subscribeTaskRunEvents(existingRunId, { onEvent }, controller.signal);
|
||||
await subscribeWithGapHeal(existingRunId);
|
||||
return;
|
||||
}
|
||||
if (remote && isRemoteTaskTerminal(view.status)) {
|
||||
@@ -700,7 +776,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal);
|
||||
await subscribeWithGapHeal(created.run_id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -717,82 +793,17 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
const runId = retainedRunId || taskRunStream.activeTaskRunId || undefined;
|
||||
if (runId) {
|
||||
try {
|
||||
if (await settleFromRemoteIfTerminal(runId)) {
|
||||
return;
|
||||
}
|
||||
const remote = await getTaskRun(runId);
|
||||
const view = readRemoteTaskRun(remote);
|
||||
if (view.status === 'quality_failed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'quality_failed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
if (isBlockedGovernanceStatus(view.status)) {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'blocked_pending_governance',
|
||||
content: GOVERNANCE_BLOCKED_HINT,
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
if (view.status === 'completed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'completed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
return;
|
||||
}
|
||||
if (view.status === 'pending_author_confirmation') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'pending_author_confirmation',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (shouldSubscribeExistingRun(view.status)) {
|
||||
// 原 run 仍在跑:重订阅,不 POST 新 run
|
||||
settled = false;
|
||||
retainedRunId = runId;
|
||||
patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId });
|
||||
await subscribeTaskRunEvents(runId, { onEvent }, controller.signal);
|
||||
return;
|
||||
}
|
||||
if (view.status === 'failed' || view.status === 'finalizing_failed') {
|
||||
failOrRetry(buildFailedTaskErrorInfo(view.errorMessage));
|
||||
return;
|
||||
}
|
||||
if (view.status === 'interrupted') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'failed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: view.workspaceId,
|
||||
taskRunId: runId,
|
||||
errorInfo: buildInterruptedTaskErrorInfo(view.errorMessage, view.workspaceId),
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
if (view.status === 'cancelled') {
|
||||
failOrRetry(buildCancelledTaskErrorInfo());
|
||||
await subscribeWithGapHeal(runId);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user