diff --git a/.doc/test-reports/2026-09-09-longtask-phase-live-resume-ux-test-report.md b/.doc/test-reports/2026-09-09-longtask-phase-live-resume-ux-test-report.md index 4463145e..89a60f60 100644 --- a/.doc/test-reports/2026-09-09-longtask-phase-live-resume-ux-test-report.md +++ b/.doc/test-reports/2026-09-09-longtask-phase-live-resume-ux-test-report.md @@ -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 已全部勾选为完成。 + diff --git a/docs/superpowers/plans/2026-09-09-longtask-phase-live-resume-ux.md b/docs/superpowers/plans/2026-09-09-longtask-phase-live-resume-ux.md index 65f84ee7..40a70c51 100644 --- a/docs/superpowers/plans/2026-09-09-longtask-phase-live-resume-ux.md +++ b/docs/superpowers/plans/2026-09-09-longtask-phase-live-resume-ux.md @@ -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` 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 `📄 已生成文档:` 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 diff --git a/web/src/components/chat/PhaseEventStrip.tsx b/web/src/components/chat/PhaseEventStrip.tsx index 4e96475d..c42d93f4 100644 --- a/web/src/components/chat/PhaseEventStrip.tsx +++ b/web/src/components/chat/PhaseEventStrip.tsx @@ -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 && ( diff --git a/web/src/engine/phaseEventMapper.test.ts b/web/src/engine/phaseEventMapper.test.ts index e46e897b..739747bf 100644 --- a/web/src/engine/phaseEventMapper.test.ts +++ b/web/src/engine/phaseEventMapper.test.ts @@ -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, '重新撰写初稿中'); }); \ No newline at end of file diff --git a/web/src/engine/phaseEventMapper.ts b/web/src/engine/phaseEventMapper.ts index f81d8715..08fb3b82 100644 --- a/web/src/engine/phaseEventMapper.ts +++ b/web/src/engine/phaseEventMapper.ts @@ -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,不追加新节点 diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index b6ec4f57..a632988d 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -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 {