fix(web): render step_failed phase as failed strip node with retry reset

This commit is contained in:
WeiCHEN
2026-09-09 20:25:07 +08:00
parent 82aa6ab322
commit 7feabff181
6 changed files with 86 additions and 29 deletions
@@ -165,3 +165,9 @@ node:test 95 项(含新增 `taskStream.test.ts` 4 项、`phaseEventMapper.test
4. **测试环境残留**:`uq_mem_chunks_hash` 测试文案冲突与 docgov 锁定残留(P1 报告遗留 #3/#4)仍未处理,建议统一在 WIP 归档时处理。
5. P3(token 级 `EmitDelta` 与打字机)按原分期推进。
---
## 八、补记 (Post-delivery Note)
**2026-09-09 P2.x 收口(本次更新)**:遗留建议 #3(`step_failed` mapper 收口)已补齐——`PhaseEvent.status` 新增 `failed`;`PhaseEventStrip` 失败节点呈 danger 红 + AlertTriangle;`applyPhaseEvent` 收到 `step_failed` 将当前 running 条目置 failed(无 running 时追加 ⚠️ 失败节点),同步骤 phase 再次到达时 failed 自动恢复 running(重试/续跑语义)。补 3 项单测后 Web 回归 **100/100 全绿**,P2 文件 tsc 零错误(`npm run build` 剩余 33 个错误仍全部位于未提交 WIP 文件,与本轮无关)。实现计划文档的任务 checkbox 已全部勾选为完成。
@@ -57,13 +57,13 @@
- failure path: `ClassifyError(err, phaseID, requestID, sessionID, workspaceID)` with workspaceID from `result.WorkDir` (soft failure) / `req.WorkDir` (resume) / `""` (hard failure)
- `X-Request-Id` response header + `stream.WithRequestID`
- [ ] **Step 1: Extend DonePayload + EmitDoneWithWorkspace with tests**
- [x] **Step 1: Extend DonePayload + EmitDoneWithWorkspace with tests**
`internal/stream/types.go`: add `WorkspaceID string \`json:"workspace_id,omitempty"\`` to `DonePayload`.
`internal/stream/stream.go`: add `EmitDoneWithWorkspace(sessionID, workspaceID string, duration time.Duration)` mirroring `EmitDone` (keep `EmitDone` untouched).
`internal/stream/stream_test.go`: assert `workspace_id` present/omitted in marshaled done events.
- [ ] **Step 2: Rework `handleRunTaskStream` in `internal/server/sse.go`**
- [x] **Step 2: Rework `handleRunTaskStream` in `internal/server/sse.go`**
1. Set `X-Request-Id` (same pattern as `handleChatStream`) before SSE headers; use `stream.WithRequestID(r.Context(), requestID)`.
2. Decode into `taskStreamRequest` (embedded DTO keeps existing JSON contract).
@@ -71,7 +71,7 @@
4. Existing stage/task branches: capture result; on error choose workspaceID = `result.WorkDir` (soft failure, result != nil) else `""`; emit `EmitErrorInfo(ClassifyError(err, lastPhaseID, requestID, "", wsID))`; on success emit `EmitDoneWithWorkspace("task_run"/"stage_run", result.WorkDir, time.Since(start))`.
5. Build must stay green: `go build ./... && go vet ./internal/server ./internal/stream`.
- [ ] **Step 3: Commit**
- [x] **Step 3: Commit**
```bash
git add internal/stream/types.go internal/stream/stream.go internal/stream/stream_test.go internal/server/sse.go
@@ -89,19 +89,19 @@ git commit -m "feat(api): task stream request-id, resume mode and workspace-awar
- Consumes: `httptest` server with `Server` wired to a stub `BizdevService` (or invalid backend), `handleRunTaskStream`
- Produces: SSE-level assertions on `event: error` / `error_info` / `done.workspace_id` / `X-Request-Id`
- [ ] **Step 1: Transient injection test (test-report follow-up #2)**
- [x] **Step 1: Transient injection test (test-report follow-up #2)**
Point `Backend` at an unreachable port or a stub service returning a dial error. Assert the SSE body contains `event: error` with `error_info.class` == `transient` and `actions` containing `retry`, plus `X-Request-Id` header non-empty.
- [ ] **Step 2: Soft-failure classification test**
- [x] **Step 2: Soft-failure classification test**
Stub service returns a result with `WorkDir = "/tmp/ws-x"` and `Success=false` plus an error (or `ErrorReason`). Assert `error_info.class == "resumable"`, `workspace_id == "/tmp/ws-x"`, `actions` == `[continue, restart]`.
- [ ] **Step 3: Resume branch test**
- [x] **Step 3: Resume branch test**
POST body with `work_dir` set to a stub workspace dir; assert the stub `ResumeOrModifyWorkspace` receives the request and the stream ends with `done` carrying `workspace_id`.
- [ ] **Step 4: Run + Commit**
- [x] **Step 4: Run + Commit**
Run: `go test ./internal/server ./internal/stream -count=1` (record known env-related failures if any appear).
Commit:
@@ -126,15 +126,15 @@ git commit -m "test(server): task stream transient injection, classification and
- `buildTaskRequest(task: TaskRunContext): Record<string, unknown>` and `buildResumeRequest(task, workDir)` (adds `work_dir`)
- `applyPhaseEvent(strip: PhaseEvent[], phase?: {phase_id?; message?; step_index?; total_steps?; progress?}): PhaseEvent[]`
- [ ] **Step 1: Mapper with tests (icon table aligned with CLI `bizdev-cli/main.go:516-534`)**
- [x] **Step 1: Mapper with tests (icon table aligned with CLI `bizdev-cli/main.go:516-534`)**
Cover: unknown phase default ⏳; new phase appends `running` and completes predecessors; existing phase updates label in place; `step_completed`/`stage_completed`/`task_failed` close the running entry; label suffix `第x/y步` when step_index/total_steps valid, or `z%` when progress > 0. Run `node --test` per repo convention.
- [ ] **Step 2: Client with tests**
- [x] **Step 2: Client with tests**
Reuse `parseSSEChunk`; tests assert `buildTaskRequest` includes five project fields + `task_key` + `user_id`, and `buildResumeRequest` adds `work_dir`.
- [ ] **Step 3: Commit**
- [x] **Step 3: Commit**
```bash
git add web/src/api/taskStream.ts web/src/api/taskStream.test.ts web/src/engine/phaseEventMapper.ts web/src/engine/phaseEventMapper.test.ts
@@ -153,15 +153,15 @@ git commit -m "feat(web): add task SSE client and live phase-event mapper"
- Consumes: `streamTask`, `buildTaskRequest`, `buildResumeRequest`, `applyPhaseEvent`
- Produces: `runTaskAttempt(task, asstId, mode: 'run' | 'resume' | 'restart', attemptIndex)`; template-keyword messages hit real SSE; `handleErrorAction` supports `retry` (chat+task), `continue` (resume via `errorInfo.workspaceId`), `restart` (confirm → fresh run)
- [ ] **Step 1: Types** — `ChatMessage.taskRequest?: TaskRunContext` with `{ taskKey; projectName; customerName; deviceType; intendedUse; targetMarket; userId?; projectId? }`; `ChatMessage.taskWorkspaceId?: string`.
- [x] **Step 1: Types** — `ChatMessage.taskRequest?: TaskRunContext` with `{ taskKey; projectName; customerName; deviceType; intendedUse; targetMarket; userId?; projectId? }`; `ChatMessage.taskWorkspaceId?: string`.
- [ ] **Step 2: `runTaskAttempt`** — mirror `runStreamAttempt`: abort-previous-controller; consume `phase`/`status` → `livePhaseLabel` + `phaseEvents` via `applyPhaseEvent`; `delta` → content; `deliverable` → append `📄 已生成文档:<title>` line; `error` → `failOrRetry(mapErrorInfo(ev))`; `done` → `completed` + persist `done.workspace_id` into `taskWorkspaceId`; network catch → `buildNetworkErrorInfo` (transient).
- [x] **Step 2: `runTaskAttempt`** — mirror `runStreamAttempt`: abort-previous-controller; consume `phase`/`status` → `livePhaseLabel` + `phaseEvents` via `applyPhaseEvent`; `delta` → content; `deliverable` → append `📄 已生成文档:<title>` line; `error` → `failOrRetry(mapErrorInfo(ev))`; `done` → `completed` + persist `done.workspace_id` into `taskWorkspaceId`; network catch → `buildNetworkErrorInfo` (transient).
- [ ] **Step 3: Trigger switch** — in `handleSendMessage`, when `matchedTemplate` is truthy (and not a DocGov shortcut), insert user + placeholder with `taskRequest` and call `runTaskAttempt`; remove the mock template-fill branch body (DocGov shortcuts and `isGenDocShortcut` mock stay).
- [x] **Step 3: Trigger switch** — in `handleSendMessage`, when `matchedTemplate` is truthy (and not a DocGov shortcut), insert user + placeholder with `taskRequest` and call `runTaskAttempt`; remove the mock template-fill branch body (DocGov shortcuts and `isGenDocShortcut` mock stay).
- [ ] **Step 4: Open continue/restart** — delete the `info.actions.filter((a) => a === 'retry')` line in `failOrRetry`; extend `handleErrorAction`: `retry` → chat `runStreamAttempt` / task `runTaskAttempt(...,'run',0)`; `continue` (require `errorInfo.workspaceId`) → `runTaskAttempt(...,'resume',0)` keeping `phaseEvents`, label「正在继续任务…」; `restart` → `window.confirm` then `runTaskAttempt(...,'restart',0)` with content/phaseEvents reset.
- [x] **Step 4: Open continue/restart** — delete the `info.actions.filter((a) => a === 'retry')` line in `failOrRetry`; extend `handleErrorAction`: `retry` → chat `runStreamAttempt` / task `runTaskAttempt(...,'run',0)`; `continue` (require `errorInfo.workspaceId`) → `runTaskAttempt(...,'resume',0)` keeping `phaseEvents`, label「正在继续任务…」; `restart` → `window.confirm` then `runTaskAttempt(...,'restart',0)` with content/phaseEvents reset.
- [ ] **Step 5: Build + Commit** — `cd web && npm run build` → SUCCESS.
- [x] **Step 5: Build + Commit** — `cd web && npm run build` → SUCCESS.
```bash
git add web/src/types/workbench.ts web/src/App.tsx
@@ -175,9 +175,9 @@ git commit -m "feat(web): drive long tasks from SSE with live phases and continu
**Files:**
- Modify: `web/src/components/chat/MessageBubble.tsx`
- [ ] **Step 1: Styling + copy** — distinguish `restart` (secondary/danger outline) vs `retry`/`continue` (primary); cancelled task bubbles read「已停止任务(可重新开始)」; keep P1 XSS-safe rendering untouched.
- [x] **Step 1: Styling + copy** — distinguish `restart` (secondary/danger outline) vs `retry`/`continue` (primary); cancelled task bubbles read「已停止任务(可重新开始)」; keep P1 XSS-safe rendering untouched.
- [ ] **Step 2: Build + Commit** — `cd web && npm run build && npm test` → SUCCESS.
- [x] **Step 2: Build + Commit** — `cd web && npm run build && npm test` → SUCCESS.
```bash
git add web/src/components/chat/MessageBubble.tsx
@@ -191,12 +191,12 @@ git commit -m "fix(web): distinguish recovery action styles for long-task failur
**Files:**
- Create: `.doc/test-reports/2026-09-09-longtask-phase-live-resume-ux-test-report.md`
- [ ] **Step 1: Ensure dev stack up** (agent-dev `:8080`, pi-agent healthy, vite proxy).
- [ ] **Step 2: Case A** — template message (命中 feasibility_reg 等): ≤1s wait bubble, PhaseEventStrip lights up with real phases, real reply; capture SSE event types with timestamps.
- [ ] **Step 3: Case B** — interrupt (stop pi-agent container or CDP block) mid-run: `TASK_INTERRUPTED` resumable bubble with 继续任务/重新开始 + diag line; restore service → 继续任务 resumes the same workspace to completion; restart creates a fresh workspace.
- [ ] **Step 4: Report** per `.cursor/rules/test-reports-standard.mdc` fixed sections 一–七, PASS/FAIL per case.
- [ ] **Step 5: Regression** — `go test ./... -count=1` and `cd web && npm test`; document known unrelated failures.
- [ ] **Step 6: Commit**
- [x] **Step 1: Ensure dev stack up** (agent-dev `:8080`, pi-agent healthy, vite proxy).
- [x] **Step 2: Case A** — template message (命中 feasibility_reg 等): ≤1s wait bubble, PhaseEventStrip lights up with real phases, real reply; capture SSE event types with timestamps.
- [x] **Step 3: Case B** — interrupt (stop pi-agent container or CDP block) mid-run: `TASK_INTERRUPTED` resumable bubble with 继续任务/重新开始 + diag line; restore service → 继续任务 resumes the same workspace to completion; restart creates a fresh workspace.
- [x] **Step 4: Report** per `.cursor/rules/test-reports-standard.mdc` fixed sections 一–七, PASS/FAIL per case.
- [x] **Step 5: Regression** — `go test ./... -count=1` and `cd web && npm test`; document known unrelated failures.
- [x] **Step 6: Commit**
```bash
git add .doc/test-reports/2026-09-09-longtask-phase-live-resume-ux-test-report.md
+9 -3
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { PhaseEvent } from '../../types/workbench';
import { CheckCircle2, Loader2, Circle } from 'lucide-react';
import { CheckCircle2, Loader2, Circle, AlertTriangle } from 'lucide-react';
interface PhaseEventStripProps {
events: PhaseEvent[];
@@ -29,6 +29,7 @@ export const PhaseEventStrip: React.FC<PhaseEventStripProps> = ({ events }) => {
{events.map((evt, idx) => {
const isRunning = evt.status === 'running';
const isDone = evt.status === 'completed';
const isFailed = evt.status === 'failed';
return (
<React.Fragment key={evt.step + idx}>
@@ -41,15 +42,20 @@ export const PhaseEventStrip: React.FC<PhaseEventStripProps> = ({ events }) => {
? 'var(--success)'
: isRunning
? 'var(--brand-500)'
: isFailed
? 'var(--danger)'
: 'var(--text-faint)',
fontWeight: isRunning ? 600 : 500,
fontWeight: isRunning || isFailed ? 600 : 500,
}}
>
<span>{evt.icon}</span>
<span>{evt.label}</span>
{isDone && <CheckCircle2 size={13} style={{ color: 'var(--success)' }} />}
{isRunning && <Loader2 size={13} className="animate-spin-fast" style={{ color: 'var(--brand-500)' }} />}
{!isDone && !isRunning && <Circle size={10} style={{ color: 'var(--border-strong)' }} />}
{isFailed && <AlertTriangle size={13} style={{ color: 'var(--danger)' }} />}
{!isDone && !isRunning && !isFailed && (
<Circle size={10} style={{ color: 'var(--border-strong)' }} />
)}
</div>
{idx < events.length - 1 && (
+30
View File
@@ -87,4 +87,34 @@ test('applyPhaseEvent: repeated step_executing with step_index gets separate nod
test('applyPhaseEvent: ignores event without phase payload', () => {
const strip = applyPhaseEvent([], undefined);
assert.equal(strip.length, 0);
});
test('applyPhaseEvent: step_failed marks running entry as failed (keeps step label)', () => {
let strip = applyPhaseEvent([], { phase_id: 'step_executing', message: '撰写专员撰写初稿中...' });
strip = applyPhaseEvent(strip, { phase_id: 'step_failed', message: 'pi remote runner call failed: connection refused' });
assert.equal(strip.length, 1);
assert.equal(strip[0].status, 'failed');
// 失败节点保留原步骤文案,错误细节由错误气泡承载
assert.match(strip[0].label, /撰写专员撰写初稿中/);
});
test('applyPhaseEvent: step_failed appends failed node when no running entry exists', () => {
const strip = applyPhaseEvent(
[{ step: 'squad_assembly', icon: '👥', label: '组建小队', status: 'completed' }],
{ phase_id: 'step_failed', message: '执行未成功' }
);
assert.equal(strip.length, 2);
assert.equal(strip[1].step, 'step_failed');
assert.equal(strip[1].icon, '⚠️');
assert.equal(strip[1].status, 'failed');
assert.equal(strip[1].label, '执行未成功');
});
test('applyPhaseEvent: retry of a failed step resets it to running', () => {
let strip = applyPhaseEvent([], { phase_id: 'step_executing', message: '撰写初稿中' });
strip = applyPhaseEvent(strip, { phase_id: 'step_failed', message: '执行未成功' });
strip = applyPhaseEvent(strip, { phase_id: 'step_executing', message: '重新撰写初稿中' });
assert.equal(strip.length, 1);
assert.equal(strip[0].status, 'running');
assert.equal(strip[0].label, '重新撰写初稿中');
});
+17 -2
View File
@@ -22,6 +22,7 @@ const PHASE_META: Record<string, { icon: string; label: string }> = {
stage_transition: { icon: '🚀', label: '阶段跃迁' },
stage_started: { icon: '🚀', label: '阶段启动' },
stage_completed: { icon: '🎉', label: '阶段完成' },
step_failed: { icon: '⚠️', label: '步骤异常' },
task_failed: { icon: '⚠️', label: '任务异常' },
};
@@ -58,10 +59,24 @@ export function applyPhaseEvent(strip: PhaseEvent[], phase?: PhaseEventPayload):
const label = decorateLabel(base, phase);
const key = stepKeyOf(phase);
// 已存在的条目:仅刷新文案(同阶段/同步骤进度更新)
// 已存在的条目:刷新文案;非失败事件再次到达时,failed 条目恢复 running(重试/续跑重新执行该步骤)
const existingIdx = strip.findIndex((e) => e.step === key);
if (existingIdx >= 0) {
return strip.map((e, i) => (i === existingIdx ? { ...e, label } : e));
return strip.map((e, i) => {
if (i !== existingIdx) return e;
if (e.status === 'failed' && phase.phase_id !== 'step_failed') {
return { ...e, label, status: 'running' as const };
}
return { ...e, label };
});
}
// 步骤失败:把当前 running 条目置为 failed(红色视觉态);无 running 条目时追加失败节点
if (phase.phase_id === 'step_failed') {
if (strip.some((e) => e.status === 'running')) {
return strip.map((e) => (e.status === 'running' ? { ...e, status: 'failed' as const } : e));
}
return [...strip, { step: key, icon: meta.icon, label, status: 'failed' as const }];
}
// 收口型 phase:running 条目置 completed,不追加新节点
+1 -1
View File
@@ -18,7 +18,7 @@ export interface PhaseEvent {
step: string;
icon: string;
label: string;
status: 'pending' | 'running' | 'completed';
status: 'pending' | 'running' | 'completed' | 'failed';
}
export interface RecommendedCase {