fix(chat): heal task bubble pending after completed run

This commit is contained in:
weichen
2026-09-26 04:30:38 +00:00
parent a5ca6fdcfd
commit ccce44dfc0
37 changed files with 1164 additions and 202 deletions
+44 -1
View File
@@ -398,8 +398,51 @@ async function waitForSquadCompletion(page: Page, userPromptSnippet: string, tim
let verdict = await observe();
if (verdict.action === 'reject') fail(`squad observation rejected: ${verdict.reason}`);
if (verdict.action === 'accept') return Date.now() - startedAt;
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, { timeout: timeoutMs });
// Mid-wait heal: if steps look done but status stuck pending, reload to pick history task_status.
const healDeadline = Date.now() + Math.min(timeoutMs, 480_000);
while (Date.now() < healDeadline) {
try {
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
timeout: 60_000,
});
break;
} catch {
const status = (await assistant.getAttribute('data-chat-status')) || '';
const body = await page.locator('body').innerText().catch(() => '');
const phasesDone =
/质量门禁已完成|质量.*100|简报已完成|组建小队已完成/.test(body) ||
(await page.getByText(/质量门禁已完成|任务已完成/).count()) > 0;
if (status === 'pending' || status === 'streaming_status') {
if (phasesDone || Date.now() - startedAt > 240_000) {
await page.reload();
await expect(page.getByText(PROJECT_NAME, { exact: false }).first()).toBeVisible({
timeout: 30_000,
});
await openProject(page);
const again = turnAssistantLocator(page, userPromptSnippet);
await expect(again.assistant).toBeVisible({ timeout: 30_000 });
const healed = (await again.assistant.getAttribute('data-chat-status')) || '';
if (ANY_TERMINAL_STATUS_RE.test(healed)) {
return Date.now() - startedAt;
}
}
}
}
}
const finalAssistant = turnAssistantLocator(page, userPromptSnippet).assistant;
await expect(finalAssistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
timeout: Math.max(30_000, timeoutMs - (Date.now() - startedAt)),
});
verdict = await observe();
// Re-bind observe to final assistant
const status = (await finalAssistant.getAttribute('data-chat-status')) || '';
const hasVisibleError = await connErr.first().isVisible().catch(() => false);
verdict = evaluateSquadObservation(
{ userPromptMatched: true, assistantAdjacent: true, status, hasVisibleError },
{ requireInflight: false },
);
if (verdict.action === 'reject') fail(`squad observation rejected: ${verdict.reason}`);
if (verdict.action !== 'accept') fail(`squad terminal not accepted: ${verdict.reason}`);
return Date.now() - startedAt;
+12 -1
View File
@@ -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) {
+40
View File
@@ -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');
});
+37 -2
View File
@@ -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),
};
}
+83 -72
View File
@@ -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 {