fix(e2e): accept already-completed squad turns without waiting inflight

UI-11 can finish before the waiter sees pending. Treat success terminals
as done, keep upload/correspondence/error checks, and cover both timelines.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 20:43:33 +00:00
co-authored by Cursor
parent 339e871242
commit f0ab254ed1
10 changed files with 361 additions and 29 deletions
@@ -0,0 +1,47 @@
---
title: "UI-11 快速 completed 被验收脚本误判为失败"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
severity: high
---
# UI-11 快速 completed 被验收脚本误判为失败
## 现象
第14轮真实 UI(schema 30,`dev@339e871`)在 **UI-11** 失败:页面上传附件并发送「请将此附件归档」后,相邻助手卡片已快速处于 `completed`(文案:已收录补充材料并关联黑板)。
`waitForSquadCompletion` 默认 `requireInflight=true`,用 Playwright `toHaveAttribute` 硬等 `pending|streaming_status|auto_retrying` 60 秒。收到的属性已是 `completed`,被当成 unexpected value,60s 后 FAIL。产品终态成功,验收误判。
## 根因分析
| 层 | 问题 | 后果 |
|---|---|---|
| 验收契约 | `requireInflight !== false` 默认为必须先看到处理中 | 快路径成功被 60s 超时打死 |
| Playwright 匹配 | `toHaveAttribute` 对已是 `completed` 的节点持续重试同一错误匹配 | 日志里 123 次 resolved 到 completed 仍等 inflight |
| UI-03 先例 | 已用 `{ requireInflight: false }` 避开同类竞态 | UI-11 未沿用,默认仍硬等 |
| 真实性 | 现场助手无错误、用户句与相邻卡片对应、上传已走 freeform | 不得放宽上传/对应关系/终态/无错误,只改观测顺序 |
证据 `error-context.md`:Locator 命中 `data-chat-status="completed"`,Expected `/pending|streaming_status|auto_retrying/`。
## 严重度
`high`
真实成功轮次被脚本误杀,阻断后续 UI-12+。
## 关联整改
- Remediation: [../remediation/2026-09-13-document-revision-fast-completion-observation.md](../remediation/2026-09-13-document-revision-fast-completion-observation.md)
- Short-term: [../ops/short-term/2026-09-13-document-revision-fast-completion-observation.md](../ops/short-term/2026-09-13-document-revision-fast-completion-observation.md)
- 分支: `fix/document-revision-fast-completion-observation`
## 证据
- 失败证据(只读,不得改/移/删/提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-fast-completion-observation-failure/`
## 开放问题
- 本轮不重跑真实 UI-11;不得标 E2E 通过或任务 `done`
- 处理中状态仅作可选观测,不作为成功必要条件
+8
View File
@@ -31,6 +31,14 @@
### 最新专项审计(2026-09-13)
**文件**: [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md)
**结论**: 第14轮 UI-11 上传归档后助手已 `completed`;验收默认硬等 inflight 60s,把成功终态误判为失败。
**整改**: [快速完成观测契约整改](../remediation/2026-09-13-document-revision-fast-completion-observation.md)
### 质量修复子工作区契约(2026-09-13)
**文件**: [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md)
**结论**: 第13轮 UI-03 质量门禁正确判缺「阻塞信息/非阻塞信息」;repair TASK 引导 `../../output.md`,模型越界改根文件,本地目标缺失,manifest 停在 70。
+6 -4
View File
@@ -1,13 +1,13 @@
# 当前焦点
**更新**: 2026-09-13
**production_ready**: no(DOCREV:schema **30**;第13轮 UI-03 质量修复子工作区契约;不执行 env-service 重建 / 不声称 E2E 通过)
**production_ready**: no(DOCREV:schema **30**;第14轮 UI-11 快速完成观测契约;不执行 env-service 重建 / 不声称 E2E 通过)
## 焦点
1. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(`fix/document-revision-quality-repair-workspace-contract`;第13轮 UI-03 越界改根 output.md;不声称 E2E 通过)
2. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06)
3. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录)
1. **DOCREV-RT**: [UI-11 快速完成观测契约](short-term/2026-09-13-document-revision-fast-completion-observation.md)(`fix/document-revision-fast-completion-observation`;第14轮成功终态被硬等 inflight 误杀;不声称 E2E 通过)
2. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(已合入 `dev@339e871`;仍 `in_progress`,待占槽重跑 UI-03)
3. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06)
## 并行认领
@@ -30,6 +30,7 @@
| Cursor-Auto | document-revision-prelim-output | fix/document-revision-pi-runner-module | - | document-revision-prelim-output | in_progress |
| Cursor-Auto | document-revision-editor-hydration-race | fix/document-revision-editor-hydration-race | - | document-revision-editor-hydration-race | in_progress |
| Cursor-Auto | document-revision-quality-repair-workspace-contract | fix/document-revision-quality-repair-workspace-contract | - | document-revision-quality-repair-workspace-contract | in_progress |
| Cursor-Auto | document-revision-fast-completion-observation | fix/document-revision-fast-completion-observation | - | document-revision-fast-completion-observation | in_progress |
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
@@ -77,3 +78,4 @@
| UI-11 prelim_design 缺 output.md 审计 | [../audit/2026-09-13-document-revision-prelim-output.md](../audit/2026-09-13-document-revision-prelim-output.md) |
| UI-06 编辑弹窗 hydration 竞态审计 | [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md) |
| UI-03 质量修复子工作区契约审计 | [../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md) |
| UI-11 快速完成观测误判审计 | [../audit/2026-09-13-document-revision-fast-completion-observation.md](../audit/2026-09-13-document-revision-fast-completion-observation.md) |
@@ -0,0 +1,54 @@
---
title: "UI-11 快速完成观测契约"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
owner: Cursor-Auto
branch: fix/document-revision-fast-completion-observation
worktree: "-"
mutex: document-revision-fast-completion-observation
---
# UI-11 快速完成观测契约
## 目标
堵住第14轮 UI-11:助手已 `completed`,验收脚本仍硬等处理中 60s,把成功终态误判为失败。只改测试契约,不降真实性。
## 范围
- `web/src/lib/squadCompletionObservation.ts` 与单测
- `web/e2e/document-revision-runtime.spec.ts` 的 `waitForSquadCompletion`
- audit / remediation / CURRENT
## 非目标
- 不改产品运行时、不放宽上传/对应关系/终态/无错误
- 不碰证据目录;不合 dest;不启动/重启容器;不 E2E;不 push
- 不把 stub 或伪造页面当验收
## 步骤
1. [x] 从 `dev@339e871` 开分支并登记 mutex
2. [x] 观测谓词 + 直接 completed / 经历 inflight 测试
3. [x] 接线 `waitForSquadCompletion`,保留 UI-11 上传断言
4. [x] 跑相关单测并原子提交
## 验收标准
- [x] 已 completed 直接接受,不要求 inflight
- [x] 经历 inflight 再到终态仍接受
- [x] 错误终态与错位消息拒绝
- [ ] 现场 UI-11(未跑)
## 相关链接
- Audit: [../../audit/2026-09-13-document-revision-fast-completion-observation.md](../../audit/2026-09-13-document-revision-fast-completion-observation.md)
- Remediation: [../../remediation/2026-09-13-document-revision-fast-completion-observation.md](../../remediation/2026-09-13-document-revision-fast-completion-observation.md)
## 证据 / 备注
- mutex:`document-revision-fast-completion-observation`(新 mutex)
- 基线:`dev@339e871`
- 验证:`npx tsx --test src/lib/squadCompletionObservation.test.ts` PASS;`cd web && npm test` PASS(306 + 29)
- **未跑真实 UI-11,不得标 done**;**不得 merge / rebuild / E2E / push**
@@ -0,0 +1,48 @@
---
title: "UI-11 快速完成观测契约整改"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
---
# UI-11 快速完成观测契约整改
## 背景
链到 [../audit/2026-09-13-document-revision-fast-completion-observation.md](../audit/2026-09-13-document-revision-fast-completion-observation.md)。
只修验收脚本与测试契约,不改产品运行时,不降低真实性。
## 目标
- 定位到该用户 prompt 的相邻助手卡片已是成功终态(`completed` / `pending_author_confirmation`)时直接接受
- 仍须校验:消息对应关系、成功终态、无错误;UI-11 另须页面上传证据(chip / freeform 2xx / `doc-free-*` pane)
- 处理中状态只作可选观测,不得硬等 60s
- 两类可重复契约:直接 `completed`、经历 inflight 再到终态
## 分波次计划
| 波次 | 内容 | 验收 |
|------|------|------|
| Wave 0 | 抽取观测谓词 + 改 `waitForSquadCompletion` + 单测 | 直接 completed 接受;经历 inflight 接受;错误终态拒绝;UI-11 上传断言保留 |
| Wave 1 | 占槽后重跑真实 UI-11(本轮不做) | 快完成不再被误判 |
## 设计
1. **先读当前 `data-chat-status`**:成功终态 + 无可见错误 → 立即接受,即使从未看到 inflight。
2. **仍在处理中**:再等终端态;不把「必须先看到 pending」当作门闩。
3. **失败终态 / 可见错误 / 找不到相邻助手**:FAIL,不得假通过。
4. **`requireInflight` 默认关闭**;即使显式打开,已 completed 仍直接接受。
## 验收标准
- [x] 快照已是 completed → accept,`sawInflight=false`
- [x] pending → completed → accept,`sawInflight=true`
- [x] failed / 无对应关系 / 可见错误 → reject
- [x] UI-11 上传与 freeform 断言不删不降
- [ ] 真实 UI-11(本轮不做)
## 相关链接
- Audit / Short-term:同 slug `2026-09-13-document-revision-fast-completion-observation`
- 证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-fast-completion-observation-failure/`
+1
View File
@@ -3,6 +3,7 @@
| 文档 | 说明 |
|------|------|
| [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT |
| [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md) | UI-11 快完成被硬等 inflight 误杀,只改验收观测契约 |
| [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md) | 质量修复子工作区越界改根 output.md,本地目标缺失 |
| [2026-09-13-document-revision-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md) | 编辑弹窗打开期间 hydration 换块 ID,保存静默丢稿 |
| [2026-09-13-document-revision-runtime-remediation.md](./2026-09-13-document-revision-runtime-remediation.md) | 账本 PG 持久化、bootstrap、保存/overlay/确定性 change report |
+1 -1
View File
@@ -25,7 +25,7 @@
| 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` |
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录 | `ci` |
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[UI-11 快完成观测](../audit/2026-09-13-document-revision-fast-completion-observation.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录;UI-11 验收不得把已 completed 误判为缺 inflight | `ci` |
---
+37 -24
View File
@@ -6,6 +6,10 @@ import {
BLOCK_EDITOR_DIALOG_LABEL,
BLOCK_EDITOR_SAVE_NAME,
} from '../src/lib/blockEditorDialog';
import {
ANY_TERMINAL_STATUS_RE,
evaluateSquadObservation,
} from '../src/lib/squadCompletionObservation';
/**
* Document revision runtime — real UI acceptance (UI-01..UI-16).
@@ -243,8 +247,8 @@ function turnAssistantLocator(page: Page, userPromptSnippet: string) {
}
/**
* Wait for this-turn assistant terminal. Optionally require observing inflight first.
* UI-03 may skip inflight (already seen in UI-02) to avoid fast-complete race.
* Wait for this-turn assistant terminal.
* Inflight is optional: if the adjacent card is already a success terminal, accept it.
*/
async function waitForSquadCompletion(
page: Page,
@@ -253,35 +257,43 @@ async function waitForSquadCompletion(
opts?: { requireInflight?: boolean },
) {
const startedAt = Date.now();
const requireInflight = opts?.requireInflight !== false;
const requireInflight = opts?.requireInflight === true;
const { userBubble, assistant } = turnAssistantLocator(page, userPromptSnippet);
await expect(userBubble).toBeVisible({ timeout: 30_000 });
await expect(assistant).toBeVisible({ timeout: 30_000 });
if (requireInflight) {
await expect(assistant).toHaveAttribute(
'data-chat-status',
/pending|streaming_status|auto_retrying/,
{ timeout: 60_000 },
const connErr = page.locator('text=连接异常');
const observe = async () => {
const status = (await assistant.getAttribute('data-chat-status')) || '';
const hasVisibleError = await connErr.first().isVisible().catch(() => false);
return evaluateSquadObservation(
{
userPromptMatched: true,
assistantAdjacent: true,
status,
hasVisibleError,
},
{ requireInflight },
);
};
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',
/completed|pending_author_confirmation|failed|cancelled|quality_failed/,
{ timeout: timeoutMs },
);
const terminal = (await assistant.getAttribute('data-chat-status')) || '';
if (
terminal === 'failed' ||
terminal === 'cancelled' ||
terminal === 'quality_failed'
) {
fail(`squad terminal status is ${terminal}`);
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
timeout: timeoutMs,
});
verdict = await observe();
if (verdict.action === 'reject') {
fail(`squad observation rejected: ${verdict.reason}`);
}
if (terminal !== 'completed' && terminal !== 'pending_author_confirmation') {
fail(`squad terminal status unexpected: ${terminal}`);
if (verdict.action !== 'accept') {
fail(`squad terminal not accepted: ${verdict.reason}`);
}
return Date.now() - startedAt;
}
@@ -1142,7 +1154,8 @@ test.describe.serial('document-revision-runtime UI acceptance', () => {
fail(`UI-11 must not leave doc-upload orphan active: ${uploadedDocId}`);
}
// 发送会拉起普通 assistant:离开 UI-11 前等本轮终态,避免污染 UI-12+。
// 本轮助手可能直接 completed(归档快路径)。仍须:上传证据、相邻卡片、成功终态、无错误。
// inflight 只作可选观测,不得因错过 pending 而把 completed 判失败。
await waitForSquadCompletion(page, '请将此附件归档', 180_000);
await shot(page, 'UI-11-multi-doc.png');
@@ -0,0 +1,62 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { reduceSquadObservation } from './squadCompletionObservation';
const turn = {
userPromptMatched: true,
assistantAdjacent: true,
hasVisibleError: false,
};
describe('squadCompletionObservation', () => {
it('accepts a directly completed adjacent assistant without requiring inflight', () => {
const verdict = reduceSquadObservation([{ ...turn, status: 'completed' }], {
requireInflight: true,
});
assert.equal(verdict.action, 'accept');
assert.equal(verdict.reason, 'already_completed');
assert.equal(verdict.sawInflight, false);
});
it('accepts a turn that passed through inflight then completed', () => {
const verdict = reduceSquadObservation(
[
{ ...turn, status: 'pending' },
{ ...turn, status: 'streaming_status' },
{ ...turn, status: 'completed' },
],
{ requireInflight: true },
);
assert.equal(verdict.action, 'accept');
assert.equal(verdict.reason, 'completed_after_inflight');
assert.equal(verdict.sawInflight, true);
});
it('rejects error terminals, missing correspondence, and visible errors', () => {
assert.equal(
reduceSquadObservation([{ ...turn, status: 'failed' }]).action,
'reject',
);
assert.equal(
reduceSquadObservation([{ ...turn, userPromptMatched: false, status: 'completed' }])
.action,
'reject',
);
assert.equal(
reduceSquadObservation([{ ...turn, assistantAdjacent: false, status: 'completed' }])
.action,
'reject',
);
assert.equal(
reduceSquadObservation([{ ...turn, hasVisibleError: true, status: 'completed' }])
.action,
'reject',
);
});
it('keeps waiting while still inflight and does not treat inflight as success', () => {
const verdict = reduceSquadObservation([{ ...turn, status: 'pending' }]);
assert.equal(verdict.action, 'wait');
assert.equal(verdict.sawInflight, true);
});
});
+97
View File
@@ -0,0 +1,97 @@
export const INFLIGHT_CHAT_STATUSES = ['pending', 'streaming_status', 'auto_retrying'] as const;
export const SUCCESS_TERMINAL_CHAT_STATUSES = ['completed', 'pending_author_confirmation'] as const;
export const ERROR_TERMINAL_CHAT_STATUSES = ['failed', 'cancelled', 'quality_failed'] as const;
export const SUCCESS_TERMINAL_STATUS_RE =
/^(completed|pending_author_confirmation)$/;
export const ERROR_TERMINAL_STATUS_RE = /^(failed|cancelled|quality_failed)$/;
export const ANY_TERMINAL_STATUS_RE =
/^(completed|pending_author_confirmation|failed|cancelled|quality_failed)$/;
export type SquadObservationFrame = {
userPromptMatched: boolean;
assistantAdjacent: boolean;
status: string;
hasVisibleError?: boolean;
};
export type SquadObservationVerdict = {
action: 'accept' | 'wait' | 'reject';
reason: string;
sawInflight: boolean;
};
export function isInflightChatStatus(status: string): boolean {
return (INFLIGHT_CHAT_STATUSES as readonly string[]).includes(status);
}
export function isSuccessTerminalChatStatus(status: string): boolean {
return (SUCCESS_TERMINAL_CHAT_STATUSES as readonly string[]).includes(status);
}
export function isErrorTerminalChatStatus(status: string): boolean {
return (ERROR_TERMINAL_CHAT_STATUSES as readonly string[]).includes(status);
}
/**
* Decide whether a single observed assistant card is done, still running, or invalid.
* Inflight is optional: a success terminal is accepted even if requireInflight is true
* and no pending/streaming frame was seen (UI-11 fast-complete).
*/
export function evaluateSquadObservation(
frame: SquadObservationFrame,
ctx?: { requireInflight?: boolean; sawInflight?: boolean },
): SquadObservationVerdict {
const sawInflight = Boolean(ctx?.sawInflight) || isInflightChatStatus(frame.status);
if (!frame.userPromptMatched) {
return { action: 'reject', reason: 'missing_user_prompt', sawInflight };
}
if (!frame.assistantAdjacent) {
return { action: 'reject', reason: 'missing_adjacent_assistant', sawInflight };
}
if (frame.hasVisibleError) {
return { action: 'reject', reason: 'visible_error', sawInflight };
}
if (isErrorTerminalChatStatus(frame.status)) {
return { action: 'reject', reason: `error_terminal:${frame.status}`, sawInflight };
}
if (isSuccessTerminalChatStatus(frame.status)) {
return {
action: 'accept',
reason: sawInflight && !isInflightChatStatus(frame.status)
? 'completed_after_inflight'
: 'already_completed',
sawInflight: Boolean(ctx?.sawInflight),
};
}
if (isInflightChatStatus(frame.status)) {
return { action: 'wait', reason: 'inflight', sawInflight: true };
}
return { action: 'wait', reason: 'wait_terminal', sawInflight: Boolean(ctx?.sawInflight) };
}
/** Replay a status timeline. Used by unit tests for direct-completed vs inflight paths. */
export function reduceSquadObservation(
frames: SquadObservationFrame[],
opts?: { requireInflight?: boolean },
): SquadObservationVerdict {
let sawInflight = false;
let last: SquadObservationVerdict = {
action: 'wait',
reason: 'empty_timeline',
sawInflight: false,
};
for (const frame of frames) {
if (isInflightChatStatus(frame.status)) {
sawInflight = true;
}
last = evaluateSquadObservation(frame, {
requireInflight: opts?.requireInflight,
sawInflight,
});
if (last.action !== 'wait') {
return last;
}
}
return last;
}