From 247e549397c0f4ee71e3d7d2237826bcf5055b89 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:07:51 +0000 Subject: [PATCH 1/9] docs(ops): claim the four dev deep-test fixes Record the intent-routing, task-card resume, and template-badge work before code changes so the mutex is visible. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 1 + .../2026-09-21-dev-deep-test-four-issues.md | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 2a418c0c..31e8495b 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-dev-deep-test-four-issues | fix/dev-deep-test-four-issues-2026-09-21 | - | web+intent+taskrun | in_progress | | Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done | | Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done | | Cursor | 2026-09-21-dev-multiuser-four-issues | fix/dev-multiuser-four-issues-2026-09-21 | .worktrees/fix-dev-multiuser-four-issues | projectinstance+docgov | done | diff --git a/.doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md b/.doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md new file mode 100644 index 00000000..8b263991 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md @@ -0,0 +1,57 @@ +--- +title: "dev 深度测试四问题修复" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: fix/dev-deep-test-four-issues-2026-09-21 +worktree: "-" +mutex: web+intent+taskrun +--- + +# dev 深度测试四问题修复 + +## 目标 + +商务对接对话里,近似说法能启动「用户初始需求收集表」;刷新后任务卡片能重放进度;失败后「继续任务 / 重新开始」有反应;模板态文档不再显示成「编制中」。 + +## 范围 + +- 前端任务路由:含「初始 / 首轮 / 摸底」时优先 intake,不被更短的「用户需求表」标题截走 +- 服务端触发动词补「编写 / 编制 / 起草」 +- 刷新后重放 `task_run_events`;`interrupted` 不再映射成已完成 +- 失败气泡的继续 / 重跑走 `POST /tasks/runs/{id}/resume`,刷新后也能找到目标消息 +- 模板态徽标与锁定文案改为「模板预览」 +- 只读排查 dev 上 pi-agent `:18865` 的 TRANSIENT 网络诱因 + +## 非目标 + +- 不把模板物化成可编辑正文 +- 不改 `production_ready` +- 不晋升 test / main +- 不改项目 143 + +## 步骤 + +1. [ ] 意图路由与服务端动词门 +2. [ ] 进度重放与 interrupted 状态 +3. [ ] 继续 / 重跑按钮 +4. [ ] 模板徽标 +5. [ ] 环境排查、真实环境复测与报告 + +## 验收标准 + +- [ ] 「编写初始用户需求表」「生成需求表」解析为 intake / `bd_intake` +- [ ] 刷新后 running 任务能看到阶段事件,而不是只剩「任务执行中」 +- [ ] 刷新后点击继续任务会请求 resume 端点 +- [ ] `lifecycleState=template` 显示「模板预览」,不显示「编制中」或「已生效受控」 + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: `.doc/test-reports/2026-09-21-dev-deep-test-four-issues-test-report.md` + +## 证据 / 备注 + +用户在 dev 商务对接实测。TRANSIENT 来自 PI runner 网络瞬断,本任务修恢复链路并只读记录环境结论。 From ddb1e542b6987802882d70a36e1d5177d852e388 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:12:15 +0000 Subject: [PATCH 2/9] fix(web): prefer intake markers over the shorter URS title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「初始用户需求表」contains「用户需求表」, so title matching sent V0.1 wording to the V0.5 task and the stage gate rejected it. Co-authored-by: Cursor --- web/src/engine/templateMatch.test.ts | 27 +++++++++++++++ web/src/engine/templateMatch.ts | 51 ++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 024a9edf..ef8ed59c 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -239,3 +239,30 @@ test('v3 bd_ task key wins over unprefixed intake alias', () => { const resolved = resolveTaskTemplateForMessage('开始编写用户初始需求表', [bdIntake]); assert.equal(resolved?.taskKey, 'bd_intake'); }); + +test('word-order variants of the intake sheet stay on intake, not the shorter URS title', () => { + const bdIntake: DeliverableItem = { + ...base, + docId: 'doc_proj6579_bd_intake', + projectId: 6579, + taskKey: 'bd_intake', + title: '用户初始需求收集表', + isTemplateOnly: true, + }; + const bdUrs: DeliverableItem = { + ...base, + docId: 'doc_proj6579_bd_user_requirements', + projectId: 6579, + taskKey: 'bd_user_requirements', + title: '用户需求表', + stage: 'V0.5', + isTemplateOnly: true, + }; + const pool = [bdIntake, bdUrs]; + for (const query of ['编写初始用户需求表', '生成需求表', '编写用户初始需求收集表']) { + assert.equal(resolveTaskKeyFromMessage(query), 'intake', query); + assert.equal(resolveTaskTemplateForMessage(query, pool)?.taskKey, 'bd_intake', query); + assert.equal(resolveTaskTemplateForMessage(query, bothPool)?.taskKey, 'intake', query); + } + assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', pool)?.taskKey, 'bd_user_requirements'); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 0a2d0aba..ae119704 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -7,7 +7,10 @@ const WRITE_INTENT = const CURRENT_DOC_DEIXIS = /当前受控文档|当前文档|本文件|这份文档|本文档|本受控文档/; const NAMED_DELIVERABLE_ALIASES: { taskKey: string; patterns: RegExp[] }[] = [ - { taskKey: 'intake', patterns: [/用户初始需求收集表/, /初始需求表/, /需求摸底表/] }, + { + taskKey: 'intake', + patterns: [/用户初始需求收集表/, /初始用户需求表/, /用户初始需求/, /初始需求表/, /需求摸底表/], + }, { taskKey: 'user_requirements', patterns: [/用户需求表/, /\bURS\b/, /用户需求规格/] }, { taskKey: 'research', patterns: [/调研报告/, /竞品调研/] }, { taskKey: 'feasibility_reg', patterns: [/注册路径可行性/, /法规可行性评估/, /注册可行性评估/] }, @@ -34,6 +37,29 @@ export function hasDocumentWriteIntent(text: string): boolean { return WRITE_INTENT.test(normalized); } +function isRequirementsTopic(text: string): boolean { + return ( + text.includes('需求') || + text.includes('摸底') || + text.includes('用户') || + text.includes('首轮') + ); +} + +/** V0.1 摸底的强标记。语序换了也要赢过更短的「用户需求表」子串。 */ +export function hasIntakeSemanticMarker(text: string): boolean { + return ( + text.includes('首轮') || + text.includes('初始') || + text.includes('需求摸底') || + text.includes('摸底') + ); +} + +function isUserRequirementsKey(taskKey: string | undefined): boolean { + return taskKey === 'user_requirements' || Boolean(taskKey?.endsWith('_user_requirements')); +} + export function refersToCurrentControlledDocument(text: string): boolean { return CURRENT_DOC_DEIXIS.test(text.trim()); } @@ -64,7 +90,12 @@ function explicitNamedDeliverableTaskKey( .filter((d) => d.taskKey && d.taskKey !== excludeTaskKey && d.title?.trim()) .sort((a, b) => (b.title?.length || 0) - (a.title?.length || 0)); for (const d of titled) { - if (text.includes(d.title)) return d.taskKey; + if (!text.includes(d.title)) continue; + // 「用户需求表」是「初始用户需求表」的子串,不能盖过 V0.1 强标记。 + if (isUserRequirementsKey(d.taskKey) && isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) { + continue; + } + return d.taskKey; } return namedAliasTaskKey(text, excludeTaskKey); } @@ -117,6 +148,9 @@ function pickRequirementsTask(text: string, pool: DeliverableItem[]): Deliverabl function namedAliasTaskKey(text: string, excludeTaskKey?: string): string | undefined { for (const alias of NAMED_DELIVERABLE_ALIASES) { if (alias.taskKey === excludeTaskKey) continue; + if (isUserRequirementsKey(alias.taskKey) && isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) { + continue; + } if (alias.patterns.some((p) => p.test(text))) return alias.taskKey; } return undefined; @@ -146,11 +180,16 @@ export function resolveTaskKeyFromMessage(text: string): string | undefined { // 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。 if (!hasDocumentWriteIntent(normalized)) return undefined; + // 「初始 / 首轮 / 摸底」优先于别名子串,避免「初始用户需求表」被「用户需求表」截走。 + if (isRequirementsTopic(normalized) && hasIntakeSemanticMarker(normalized)) { + return 'intake'; + } + // 明确点名的交付物优先于否定背景/旧文档里的「需求」等关键词。 const named = namedAliasTaskKey(normalized); if (named) return named; - if (normalized.includes('需求') || normalized.includes('摸底') || normalized.includes('用户') || normalized.includes('首轮')) { + if (isRequirementsTopic(normalized)) { return resolveRequirementsTaskKey(normalized); } if (normalized.includes('调研') || normalized.includes('竞品')) return 'research'; @@ -214,6 +253,12 @@ export function resolveTaskTemplateForMessage( } } + if (isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) { + const taskKey = normalizeTaskKeyToProject('intake', deliverables); + const template = matchTemplateDeliverableByKey(taskKey, deliverables, text); + return { taskKey, template }; + } + const named = explicitNamedDeliverableTaskKey(text, deliverables); if (named) { const taskKey = normalizeTaskKeyToProject(named, deliverables); From a236ddc0fa1cb651ac3122c7d07495541f6a9ce9 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:12:15 +0000 Subject: [PATCH 3/9] =?UTF-8?q?fix(intent):=20accept=20=E7=BC=96=E5=86=99/?= =?UTF-8?q?=E7=BC=96=E5=88=B6/=E8=B5=B7=E8=8D=89=20and=20keep=20intake=20m?= =?UTF-8?q?arkers=20on=20intake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat fallback only treated 生成/做/跑/出 as task verbs, and the shorter RDev alias 用户需求 stole 初始用户需求表. Co-authored-by: Cursor --- internal/intent/recognizer.go | 42 +++++++++++++++++++++++------- internal/intent/recognizer_test.go | 8 ++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index d2c1ba33..69587d1c 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -1177,6 +1177,15 @@ func matchQueryProgress(input string) *CandidateIntent { return nil } +func hasTaskWriteVerb(input string) bool { + for _, verb := range []string{"生成", "做", "跑", "出", "编写", "编制", "起草"} { + if strings.Contains(input, verb) { + return true + } + } + return false +} + func matchTriggerTask(input string) *CandidateIntent { type phrase struct { text string @@ -1210,24 +1219,39 @@ func matchTriggerTask(input string) *CandidateIntent { return phrases[i].text < phrases[j].text }) - if !(strings.Contains(input, "生成") || strings.Contains(input, "做") || strings.Contains(input, "跑") || strings.Contains(input, "出")) { + if !hasTaskWriteVerb(input) { return nil } + // 「初始 / 首轮 / 摸底」是 V0.1 摸底,不能被更短的研发别名「用户需求」截走。 + if hasIntakeSemanticMarker(input) && strings.Contains(input, "需求") { + return triggerTaskCandidate("需求表", "intake") + } for _, item := range phrases { if strings.Contains(input, item.text) { - return &CandidateIntent{ - Intent: IntentTriggerTask, - Confidence: 0.86, - FilledSlots: map[string]string{ - SlotTargetTask: item.key, - }, - Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", item.text, item.key), - } + return triggerTaskCandidate(item.text, item.key) } } return nil } +func hasIntakeSemanticMarker(input string) bool { + return strings.Contains(input, "首轮") || + strings.Contains(input, "初始") || + strings.Contains(input, "需求摸底") || + strings.Contains(input, "摸底") +} + +func triggerTaskCandidate(phrase, taskKey string) *CandidateIntent { + return &CandidateIntent{ + Intent: IntentTriggerTask, + Confidence: 0.86, + FilledSlots: map[string]string{ + SlotTargetTask: taskKey, + }, + Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", phrase, taskKey), + } +} + func isGreetingOrHelp(input string) bool { greetings := []string{"你好", "您好", "早上好", "下午好", "晚上好", "早啊", "在吗", "hi", "hello", "你是谁"} for _, g := range greetings { diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index e0f4e2f6..68c35339 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -126,6 +126,14 @@ func TestIntentRecognition_RDevProposalAndLongerTitleWins(t *testing.T) { if intake.TopIntent.FilledSlots[intent.SlotTargetTask] != "intake" { t.Fatalf("用户需求收集表 must stay intake, got %s", intake.TopIntent.FilledSlots[intent.SlotTargetTask]) } + + written, err := rec.Recognize(context.Background(), "编写初始用户需求表", ctx) + if err != nil { + t.Fatal(err) + } + if written.TopIntent.Intent != intent.IntentTriggerTask || written.TopIntent.FilledSlots[intent.SlotTargetTask] != "intake" { + t.Fatalf("编写初始用户需求表 want TRIGGER_TASK/intake, got %s %s", written.TopIntent.Intent, written.TopIntent.FilledSlots[intent.SlotTargetTask]) + } } func TestIntentRecognition_CasualChat(t *testing.T) { From cb1c378c5a688eca7706860588fb8209715ab65b Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:21:09 +0000 Subject: [PATCH 4/9] fix(server): close task event streams when the client only wants a replay Interrupted runs often have no done event, so a refresh replay would otherwise hold the SSE connection open. Co-authored-by: Cursor --- internal/server/task_run_handlers.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 5bbb4b61..435616c2 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -502,6 +502,10 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) { if err := writeStoredEvents(replay); err != nil { return } + // 刷新后只重放已落库事件,避免 interrupted 任务没有 done/error 时把连接挂住。 + if r.URL.Query().Get("replay_only") == "1" { + return + } for _, stored := range replay { if stored.Event.Type == stream.EventDone || stored.Event.Type == stream.EventError { return From 9daf3b7a107bd8afa549928d3a1a33450b14fc62 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:21:09 +0000 Subject: [PATCH 5/9] fix(web): replay task phases after refresh and keep interrupted resumable Progress lived only in the open SSE stream, and interrupted history was shown as completed so the card could not be continued. Co-authored-by: Cursor --- web/src/App.tsx | 7 ++ web/src/api/chatWindow.ts | 25 +++-- web/src/api/taskRuns.ts | 4 +- .../chat/chatWindowArtifact.test.ts | 1 + web/src/engine/messageIdentity.ts | 2 + web/src/hooks/useChatWindow.ts | 1 + web/src/hooks/useRecoveredTaskPhases.ts | 94 +++++++++++++++++++ web/src/hooks/useTaskRunAttempts.ts | 19 +++- web/src/lib/taskPhaseReplay.test.ts | 53 +++++++++++ web/src/lib/taskPhaseReplay.ts | 26 +++++ web/src/lib/taskRunErrorInfo.ts | 13 +++ web/src/lib/taskRunReconnect.test.ts | 2 + web/src/lib/taskRunReconnect.ts | 3 +- 13 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 web/src/hooks/useRecoveredTaskPhases.ts create mode 100644 web/src/lib/taskPhaseReplay.test.ts create mode 100644 web/src/lib/taskPhaseReplay.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 0250e32f..5ff30de5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -113,6 +113,7 @@ import { fetchChatCategories, fetchChatDates } from './api/chatWindow'; import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench'; import { saveFile } from './data/fileStore'; import { cancelTaskRun, getTaskRun } from './api/taskRuns'; +import { useRecoveredTaskPhases } from './hooks/useRecoveredTaskPhases'; export { ALL_INITIAL_DELIVERABLES, @@ -977,6 +978,12 @@ export default function App() { }; }, [recoverableRunKey]); + useRecoveredTaskPhases( + authenticatedSessionId, + chatWindow.allMessages, + chatWindow.updateLiveMessage, + ); + // P1: 多维度历史导航与分类统计、日历、引用状态 const [categoryStats, setCategoryStats] = useState([]); const [dateStats, setDateStats] = useState([]); diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 88dd6964..70c31a38 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -22,6 +22,7 @@ export function mapTaskStatusToMessageStatus( return 'streaming_status'; case 'failed': case 'finalizing_failed': + case 'interrupted': return 'failed'; case 'pending_author_confirmation': return 'pending_author_confirmation'; @@ -162,18 +163,24 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage { } : undefined; const legacyTaskError = - r.role === 'assistant' && meta.task_status === 'failed' && !legacyBusyError + r.role === 'assistant' && + (meta.task_status === 'failed' || meta.task_status === 'interrupted' || meta.task_status === 'finalizing_failed') && + !legacyBusyError ? { - userMessage: meta.workspace_id - ? '任务执行中断,可继续或重新开始' - : '任务执行失败,可按原请求重新开始', - code: meta.workspace_id ? 'TASK_INTERRUPTED' : 'TASK_FAILED', - class: (meta.workspace_id ? 'resumable' : 'fatal') as 'resumable' | 'fatal', + userMessage: + meta.task_status === 'interrupted' || meta.workspace_id + ? '任务执行中断,可继续或重新开始' + : '任务执行失败,可按原请求重新开始', + code: meta.task_status === 'interrupted' || meta.workspace_id ? 'TASK_INTERRUPTED' : 'TASK_FAILED', + class: (meta.task_status === 'interrupted' || meta.workspace_id ? 'resumable' : 'fatal') as + | 'resumable' + | 'fatal', upstream: r.content, workspaceId: typeof meta.workspace_id === 'string' ? meta.workspace_id : undefined, - actions: meta.workspace_id - ? (['continue', 'restart'] as Array<'continue' | 'restart'>) - : (['restart'] as Array<'restart'>), + actions: + meta.task_status === 'interrupted' || meta.workspace_id + ? (['continue', 'restart'] as Array<'continue' | 'restart'>) + : (['restart'] as Array<'restart'>), } : undefined; const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`); diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index a8755dbb..70027d3b 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -56,8 +56,10 @@ export async function readTaskRunSSEOnce( afterSeq: number, onEvent: (ev: StreamEvent) => void, signal?: AbortSignal, + opts?: { replayOnly?: boolean }, ): Promise<{ terminal: boolean; lastSeq: number }> { - const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${afterSeq}`; + const replay = opts?.replayOnly ? '&replay_only=1' : ''; + const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${afterSeq}${replay}`; const resp = await fetch(url, { method: 'GET', headers: mergeAuthHeaders({ Accept: 'text/event-stream' }), diff --git a/web/src/components/chat/chatWindowArtifact.test.ts b/web/src/components/chat/chatWindowArtifact.test.ts index 1ee64448..2edd9d49 100644 --- a/web/src/components/chat/chatWindowArtifact.test.ts +++ b/web/src/components/chat/chatWindowArtifact.test.ts @@ -106,6 +106,7 @@ describe('mapRecordToMessage artifact projection', () => { expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('quality_failed'); expect(mapTaskStatusToMessageStatus('failed', 'assistant')).toBe('failed'); expect(mapTaskStatusToMessageStatus('running', 'assistant')).toBe('streaming_status'); + expect(mapTaskStatusToMessageStatus('interrupted', 'assistant')).toBe('failed'); expect(mapTaskStatusToMessageStatus('blocked_pending_governance', 'assistant')).toBe( 'blocked_pending_governance', ); diff --git a/web/src/engine/messageIdentity.ts b/web/src/engine/messageIdentity.ts index 24c19f3b..fe7fc256 100644 --- a/web/src/engine/messageIdentity.ts +++ b/web/src/engine/messageIdentity.ts @@ -81,5 +81,7 @@ export function mergeLiveOverHistory( serverId: live.serverId ?? history.serverId, clientTurnId: live.clientTurnId ?? history.clientTurnId, clientMessageId: live.clientMessageId ?? history.clientMessageId, + phaseEvents: live.phaseEvents?.length ? live.phaseEvents : history.phaseEvents, + livePhaseLabel: live.livePhaseLabel ?? history.livePhaseLabel, }; } diff --git a/web/src/hooks/useChatWindow.ts b/web/src/hooks/useChatWindow.ts index 3252a9f8..6cde1e79 100644 --- a/web/src/hooks/useChatWindow.ts +++ b/web/src/hooks/useChatWindow.ts @@ -513,6 +513,7 @@ export function useChatWindow({ (liveM.content !== m.content || liveM.status !== m.status || liveM.livePhaseLabel !== m.livePhaseLabel || + (liveM.phaseEvents?.length || 0) !== (m.phaseEvents?.length || 0) || liveM.errorInfo !== m.errorInfo || liveM.serverId !== m.serverId) ) { diff --git a/web/src/hooks/useRecoveredTaskPhases.ts b/web/src/hooks/useRecoveredTaskPhases.ts new file mode 100644 index 00000000..3ce61b4c --- /dev/null +++ b/web/src/hooks/useRecoveredTaskPhases.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react'; +import type { StreamEvent } from '../api/chatStream'; +import { readTaskRunSSEOnce, subscribeTaskRunEvents } from '../api/taskRuns'; +import { applyPhaseEvent } from '../engine/phaseEventMapper'; +import { + messageNeedsPhaseReplay, + phaseReplayKey, + type PhaseReplayCacheEntry, +} from '../lib/taskPhaseReplay'; +import type { ChatMessage, PhaseEvent } from '../types/workbench'; + +/** + * 刷新后进度条只存在于 task_run_events。 + * running 继续订阅;interrupted 用 replay_only 读完即关,避免没有 done 事件时挂住连接。 + */ +export function useRecoveredTaskPhases( + sessionId: string, + messages: ChatMessage[], + updateMessage: (id: string, patch: Partial) => void, +) { + const started = useRef(new Set()); + const cache = useRef(new Map()); + const updateRef = useRef(updateMessage); + updateRef.current = updateMessage; + const messagesRef = useRef(messages); + messagesRef.current = messages; + const abortRef = useRef(null); + + useEffect(() => { + started.current.clear(); + cache.current.clear(); + const ac = new AbortController(); + abortRef.current = ac; + return () => ac.abort(); + }, [sessionId]); + + const key = phaseReplayKey(messages); + + useEffect(() => { + const signal = abortRef.current?.signal; + if (!signal || signal.aborted) return; + const current = messagesRef.current; + + for (const message of current) { + const runId = message.taskRunId; + if (!runId) continue; + const cached = cache.current.get(runId); + if (cached && cached.events.length > 0 && (!message.phaseEvents || message.phaseEvents.length === 0)) { + updateRef.current(message.id, { + phaseEvents: cached.events, + livePhaseLabel: cached.label, + }); + } + } + + for (const message of current.filter(messageNeedsPhaseReplay)) { + const runId = message.taskRunId; + if (!runId || started.current.has(runId)) continue; + started.current.add(runId); + const messageId = message.id; + const live = message.status === 'streaming_status'; + void replayRun(runId, messageId, live, cache.current, updateRef.current, signal); + } + }, [key, sessionId]); +} + +async function replayRun( + runId: string, + messageId: string, + live: boolean, + cache: Map, + updateMessage: (id: string, patch: Partial) => void, + signal: AbortSignal, +) { + let strip: PhaseEvent[] = cache.get(runId)?.events || []; + const onEvent = (ev: StreamEvent) => { + if (ev.type !== 'phase' || !ev.phase) return; + strip = applyPhaseEvent(strip, ev.phase); + const label = ev.phase.message; + cache.set(runId, { events: strip, label }); + updateMessage(messageId, { phaseEvents: strip, livePhaseLabel: label }); + }; + try { + if (live) { + await subscribeTaskRunEvents(runId, { onEvent }, signal); + return; + } + await readTaskRunSSEOnce(runId, 0, onEvent, signal, { replayOnly: true }); + } catch (err) { + if (!signal.aborted) { + console.warn('task phase replay failed', runId, err); + } + } +} diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index 24dc60f3..57a22ad4 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -27,6 +27,7 @@ import { import { buildCancelledTaskErrorInfo, buildFailedTaskErrorInfo, + buildInterruptedTaskErrorInfo, buildNetworkErrorInfo, readRemoteTaskRun, } from '../lib/taskRunErrorInfo'; @@ -565,6 +566,10 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { livePhaseLabel: undefined, taskWorkspaceId: view.workspaceId, taskRunId: existingRunId, + errorInfo: + view.status === 'interrupted' + ? buildInterruptedTaskErrorInfo(view.errorMessage, view.workspaceId) + : undefined, }); taskRunStream.setThinking(sessionId, false); if (mapped === 'completed' || mapped === 'pending_author_confirmation') { @@ -673,10 +678,22 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { await subscribeTaskRunEvents(runId, { onEvent }, controller.signal); return; } - if (view.status === 'failed') { + 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()); return; diff --git a/web/src/lib/taskPhaseReplay.test.ts b/web/src/lib/taskPhaseReplay.test.ts new file mode 100644 index 00000000..ee1b36ab --- /dev/null +++ b/web/src/lib/taskPhaseReplay.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { messageNeedsPhaseReplay, phaseReplayKey } from './taskPhaseReplay'; + +test('running bubble without phases needs replay', () => { + assert.equal( + messageNeedsPhaseReplay({ + role: 'assistant', + taskRunId: 'run-1', + status: 'streaming_status', + }), + true, + ); +}); + +test('interrupted bubble without phases needs replay', () => { + assert.equal( + messageNeedsPhaseReplay({ + role: 'assistant', + taskRunId: 'run-1', + status: 'failed', + errorInfo: { + userMessage: '中断', + code: 'TASK_INTERRUPTED', + class: 'resumable', + actions: ['continue', 'restart'], + }, + }), + true, + ); +}); + +test('bubble that already has phases is not replayed', () => { + assert.equal( + messageNeedsPhaseReplay({ + role: 'assistant', + taskRunId: 'run-1', + status: 'streaming_status', + phaseEvents: [{ step: 'squad_assembly', icon: '👥', label: '组建小队', status: 'running' }], + }), + false, + ); +}); + +test('phase replay key ignores completed chatter', () => { + assert.equal( + phaseReplayKey([ + { id: 'a', role: 'user', status: 'completed' }, + { id: 'b', role: 'assistant', taskRunId: 'run-9', status: 'streaming_status' }, + ]), + 'b:run-9:streaming_status', + ); +}); diff --git a/web/src/lib/taskPhaseReplay.ts b/web/src/lib/taskPhaseReplay.ts new file mode 100644 index 00000000..484609ba --- /dev/null +++ b/web/src/lib/taskPhaseReplay.ts @@ -0,0 +1,26 @@ +import type { ChatMessage, PhaseEvent } from '../types/workbench'; + +/** 刷新后需要从 task_run_events 重建进度条的气泡。已有步骤则不再拉。 */ +export function messageNeedsPhaseReplay( + message: Pick, +): boolean { + if (message.role !== 'assistant' || !message.taskRunId) return false; + if (message.phaseEvents && message.phaseEvents.length > 0) return false; + if (message.status === 'streaming_status') return true; + return message.errorInfo?.code === 'TASK_INTERRUPTED'; +} + +export function phaseReplayKey( + messages: Pick[], +): string { + return messages + .filter(messageNeedsPhaseReplay) + .map((message) => `${message.id}:${message.taskRunId}:${message.status}`) + .sort() + .join('|'); +} + +export type PhaseReplayCacheEntry = { + events: PhaseEvent[]; + label?: string; +}; diff --git a/web/src/lib/taskRunErrorInfo.ts b/web/src/lib/taskRunErrorInfo.ts index 98793bff..ece1444c 100644 --- a/web/src/lib/taskRunErrorInfo.ts +++ b/web/src/lib/taskRunErrorInfo.ts @@ -115,6 +115,19 @@ export function buildNetworkErrorInfo( }; } +/** interrupted 且有工作区时可继续;否则只允许按原请求重开。 */ +export function buildInterruptedTaskErrorInfo(errorMessage: string, workspaceId: string): ChatErrorInfo { + const message = errorMessage.trim() || '任务执行中断,可继续或重新开始'; + const resumable = workspaceId.trim() !== ''; + return { + userMessage: message, + code: 'TASK_INTERRUPTED', + class: resumable ? 'resumable' : 'fatal', + workspaceId: resumable ? workspaceId : undefined, + actions: resumable ? ['continue', 'restart'] : ['restart'], + }; +} + /** 服务端已判定 failed:有 error_message 时展示真实原因,否则才回落到网络错误。 */ export function buildFailedTaskErrorInfo(errorMessage: string): ChatErrorInfo { const message = errorMessage.trim(); diff --git a/web/src/lib/taskRunReconnect.test.ts b/web/src/lib/taskRunReconnect.test.ts index acb7a80a..fe00c875 100644 --- a/web/src/lib/taskRunReconnect.test.ts +++ b/web/src/lib/taskRunReconnect.test.ts @@ -29,7 +29,9 @@ test('isRemoteTaskTerminal covers confirmation and quality_failed', () => { assert.equal(isRemoteTaskTerminal('completed'), true); assert.equal(isRemoteTaskTerminal('quality_failed'), true); assert.equal(isRemoteTaskTerminal('blocked_pending_governance'), true); + assert.equal(isRemoteTaskTerminal('interrupted'), true); assert.equal(isRemoteTaskTerminal('running'), false); + assert.equal(isRemoteTaskTerminal('failed'), false); }); test('nextAttemptOpts carries retained runId for reconnect without new POST', () => { diff --git a/web/src/lib/taskRunReconnect.ts b/web/src/lib/taskRunReconnect.ts index 8776adc6..b6e31ce6 100644 --- a/web/src/lib/taskRunReconnect.ts +++ b/web/src/lib/taskRunReconnect.ts @@ -22,7 +22,8 @@ export function isRemoteTaskTerminal(status: string | null | undefined): boolean status === 'completed' || status === 'pending_author_confirmation' || status === 'quality_failed' || - status === 'blocked_pending_governance' + status === 'blocked_pending_governance' || + status === 'interrupted' ); } From 70ceb03652489e68ea93b8068e23b0acf71cde20 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:24:56 +0000 Subject: [PATCH 6/9] fix(web): resume interrupted tasks from history bubbles Continue looked up only the in-memory message list, so a refreshed card did nothing, and the stream path also dropped the stored workspace. Co-authored-by: Cursor --- web/src/api/taskRuns.ts | 13 +++ web/src/hooks/useTaskRunAttempts.test.ts | 2 + web/src/hooks/useTaskRunAttempts.ts | 105 ++++++++++++++++++++--- 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 70027d3b..e01980f2 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -140,6 +140,19 @@ export async function subscribeTaskRunEvents( } } +export async function resumeTaskRun(runId: string): Promise { + const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/resume`, { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify({ confirm: true }), + }); + if (!res.ok) { + const text = await res.text(); + throw projectSettingsErrorFromResponse(res.status, text, `resume task run failed: HTTP ${res.status}`); + } + return res.json(); +} + export async function cancelTaskRun(runId: string): Promise { const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST', diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index 7775f763..580dae47 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -18,6 +18,8 @@ test('chat and task attempts live in one hook', () => { assert.equal(hook.includes('createTaskRun'), true); assert.equal(hook.includes('shouldSubscribeExistingRun'), true); assert.equal(hook.includes('禁止无条件 setRunId(null)'), true); + assert.equal(hook.includes('resumeTaskRun'), true); + assert.equal(hook.includes('chatWindow.allMessages.find'), true); assert.equal(hook.includes('buildFailedTaskErrorInfo'), true); assert.equal(hook.includes('buildCancelledTaskErrorInfo'), true); }); diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index 57a22ad4..b5620e6b 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -8,7 +8,7 @@ import { mapErrorInfo, streamChat, type StreamEvent } from '../api/chatStream'; import { fetchChatTurnStatus } from '../api/chatTurn'; import { mapTaskStatusToMessageStatus } from '../api/chatWindow'; import { listProjectWorkflowDocuments } from '../api/projectDocuments'; -import { createTaskRun, getTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; +import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; import { buildResumeRequest, buildTaskRequest, streamTask } from '../api/taskStream'; import { applyPhaseEvent } from '../engine/phaseEventMapper'; import { resolveTaskTemplateForMessage } from '../engine/templateMatch'; @@ -359,10 +359,23 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { let accumulated = ''; let settled = false; + const historyOnly = !messages.some((m) => m.id === asstId); + let historySnapshot = historyOnly + ? chatWindow.allMessages.find((m) => m.id === asstId) + : undefined; + const patchAsst = (patch: Partial | ((m: ChatMessage) => Partial)) => { - setMessages((prev) => - prev.map((m) => (m.id === asstId ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m)) - ); + const applyTo = (m: ChatMessage): ChatMessage => ({ + ...m, + ...(typeof patch === 'function' ? patch(m) : patch), + }); + if (historyOnly) { + if (!historySnapshot) return; + historySnapshot = applyTo(historySnapshot); + chatWindow.updateLiveMessage(asstId, historySnapshot); + return; + } + setMessages((prev) => prev.map((m) => (m.id === asstId ? applyTo(m) : m))); }; const scheduleRetry = (delayMs: number) => { @@ -537,6 +550,60 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { }; const startObserver = async () => { + if (mode === 'resume') { + const runId = opts?.reconnectRunId?.trim(); + if (!runId) { + settled = true; + patchAsst({ + status: 'failed', + errorInfo: { + userMessage: '找不到可继续的任务记录,请重新开始', + code: 'TASK_INTERRUPTED', + class: 'fatal', + actions: ['restart'], + }, + }); + console.warn('continue ignored: missing run id', asstId); + taskRunStream.setThinking(sessionId, false); + return; + } + let workspaceId = opts?.workDir?.trim() || ''; + if (!workspaceId) { + const remote = await getTaskRun(runId).catch(() => null); + workspaceId = readRemoteTaskRun(remote).workspaceId; + } + if (!workspaceId) { + settled = true; + patchAsst({ + status: 'failed', + livePhaseLabel: undefined, + errorInfo: { + userMessage: '任务没有可恢复的工作区,请重新开始', + code: 'ERR_NO_WORKSPACE', + class: 'fatal', + actions: ['restart'], + }, + }); + console.warn('continue ignored: missing workspace', runId); + taskRunStream.setThinking(sessionId, false); + return; + } + const resumed = await resumeTaskRun(runId); + retainedRunId = resumed.run_id || runId; + taskRunStream.setRunId(sessionId, retainedRunId); + taskRunStream.setThinking(sessionId, true); + patchAsst({ + status: 'streaming_status', + content: '任务执行中…', + errorInfo: undefined, + taskRunId: retainedRunId, + taskWorkspaceId: resumed.workspace_id || workspaceId, + livePhaseLabel: '正在继续任务…', + }); + await subscribeTaskRunEvents(retainedRunId, { onEvent }, controller.signal); + return; + } + const body = mode === 'resume' && opts?.workDir ? buildResumeRequest(task, opts.workDir) @@ -718,8 +785,13 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { // 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复) const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => { - const target = messages.find((m) => m.id === messageId); - if (!target) return; + const target = + messages.find((m) => m.id === messageId) || + chatWindow.allMessages.find((m) => m.id === messageId); + if (!target) { + console.warn('task error action ignored: message not found', messageId, action); + return; + } // 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启 if (target.taskRequest) { @@ -742,11 +814,18 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { : target.taskRequest; runTaskAttempt(retryTask, messageId, 'run', 0); } else if (action === 'continue') { - const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId; - if (!wsId) return; + const runId = target.taskRunId; + if (!runId) { + console.warn('continue ignored: history bubble has no run id', messageId); + chatWindow.updateLiveMessage(messageId, { + livePhaseLabel: '找不到可继续的任务记录,请重新开始', + }); + return; + } runTaskAttempt(target.taskRequest, messageId, 'resume', 0, { phaseEvents: target.phaseEvents, - workDir: wsId, + workDir: target.errorInfo?.workspaceId || target.taskWorkspaceId, + reconnectRunId: runId, }); } else { if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return; @@ -756,7 +835,13 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { } // 闲聊路径(P1 行为不变:仅 retry) - if (action !== 'retry') return; + if (action !== 'retry') { + console.warn('task error action ignored: no task context', messageId, action); + chatWindow.updateLiveMessage(messageId, { + livePhaseLabel: '这条消息没有可重跑的任务上下文', + }); + return; + } const sourceMsg = target.sourceUserMessageId ? messages.find((m) => m.id === target.sourceUserMessageId) : undefined; From bdd4b970d45d147c7136b9029a7556bcc7ec3f7f Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:26:05 +0000 Subject: [PATCH 7/9] fix(web): label unfinished template documents as preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template bodies were read-only, but the status chip said 编制中 and the lock banner said the document was already controlled. Co-authored-by: Cursor --- web/src/components/doc/BlockRenderer.tsx | 4 ++++ web/src/components/doc/DocGovActionBar.tsx | 6 ++++++ web/src/lib/templatePreviewLabel.test.ts | 11 +++++++++++ 3 files changed, 21 insertions(+) create mode 100644 web/src/lib/templatePreviewLabel.test.ts diff --git a/web/src/components/doc/BlockRenderer.tsx b/web/src/components/doc/BlockRenderer.tsx index abac2e06..ba61d02c 100644 --- a/web/src/components/doc/BlockRenderer.tsx +++ b/web/src/components/doc/BlockRenderer.tsx @@ -165,6 +165,10 @@ export const BlockRenderer: React.FC = ({
⏳ 本文档正由【{ast.editLease?.holderType === 'squad' ? '项目助手' : '其他用户'}】编辑/生成中,正文与批注暂不可操作。
+ ) : ast.lifecycleState === 'template' ? ( +
+ 任务未完成,正文仍为模板预览。完成编写任务后,编制人才能编辑正文。 +
) : ast.lifecycleState !== 'drafting' ? (
🔒 本文档当前处于【{ast.lifecycleState === 'reviewing' ? '审核中' : ast.lifecycleState === 'approving' ? '批准中' : '已生效受控'}】锁定状态,正文只读,禁止篡改。可通过鼠标右键或右侧浮标添加批注。 diff --git a/web/src/components/doc/DocGovActionBar.tsx b/web/src/components/doc/DocGovActionBar.tsx index dc2f1be2..566fcf23 100644 --- a/web/src/components/doc/DocGovActionBar.tsx +++ b/web/src/components/doc/DocGovActionBar.tsx @@ -300,6 +300,8 @@ export const DocGovActionBar: React.FC = ({ ? 'var(--amber-50)' : ast.lifecycleState === 'approving' ? '#F3E8FF' + : ast.lifecycleState === 'template' + ? '#E0F2FE' : 'var(--brand-50)', color: ast.lifecycleState === 'approved' @@ -308,6 +310,8 @@ export const DocGovActionBar: React.FC = ({ ? 'var(--amber-600)' : ast.lifecycleState === 'approving' ? '#9333EA' + : ast.lifecycleState === 'template' + ? '#0369A1' : 'var(--brand-600)', }} > @@ -317,6 +321,8 @@ export const DocGovActionBar: React.FC = ({ ? '审核中' : ast.lifecycleState === 'approving' ? '批准中' + : ast.lifecycleState === 'template' + ? '模板预览' : '编制中'} {ast.dutyPolicy === 'author_only' && ( diff --git a/web/src/lib/templatePreviewLabel.test.ts b/web/src/lib/templatePreviewLabel.test.ts new file mode 100644 index 00000000..00eaf89d --- /dev/null +++ b/web/src/lib/templatePreviewLabel.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('template documents are labeled as preview, not 编制中 or 已生效受控', () => { + const bar = readFileSync(new URL('../components/doc/DocGovActionBar.tsx', import.meta.url), 'utf8'); + const blocks = readFileSync(new URL('../components/doc/BlockRenderer.tsx', import.meta.url), 'utf8'); + assert.equal(bar.includes("ast.lifecycleState === 'template'\n ? '模板预览'"), true); + assert.equal(blocks.includes('任务未完成,正文仍为模板预览。完成编写任务后,编制人才能编辑正文。'), true); + assert.equal(blocks.includes("lifecycleState === 'template'"), true); +}); From 11d6bc15117558e06ae9fccd3bf7de165546d818 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:29:10 +0000 Subject: [PATCH 8/9] fix(web): type the history lookup used by task resume The chat window hook only exposed refresh helpers, so the continue path did not type-check after it started reading history bubbles. Co-authored-by: Cursor --- web/src/hooks/useTaskRunAttempts.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index b5620e6b..cf36f74f 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -9,7 +9,7 @@ import { fetchChatTurnStatus } from '../api/chatTurn'; import { mapTaskStatusToMessageStatus } from '../api/chatWindow'; import { listProjectWorkflowDocuments } from '../api/projectDocuments'; import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; -import { buildResumeRequest, buildTaskRequest, streamTask } from '../api/taskStream'; +import { buildTaskRequest, streamTask } from '../api/taskStream'; import { applyPhaseEvent } from '../engine/phaseEventMapper'; import { resolveTaskTemplateForMessage } from '../engine/templateMatch'; import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy'; @@ -76,6 +76,8 @@ export interface TaskRunAttemptsInput { chatWindow: { replaceMessageServerId: (tempId: string, serverId: number) => void; refresh: () => Promise; + allMessages: ChatMessage[]; + updateLiveMessage: (id: string, patch: Partial) => void; }; setMessages: Dispatch>; setDeliverables: Dispatch>; @@ -604,10 +606,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { return; } - const body = - mode === 'resume' && opts?.workDir - ? buildResumeRequest(task, opts.workDir) - : buildTaskRequest(task); + const body = buildTaskRequest(task); if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') { // 断线重试:显式 reconnectRunId 保留;禁止 setMessages updater 取 runId From 0a5a8455b5ad8d99eda09a271d831eff2218ca67 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:31:27 +0000 Subject: [PATCH 9/9] docs(test): record the four dev deep-test fixes The dev container was rebuilt and the health check, task-run errors, and new UI strings are filed with the report. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 8 +- .../2026-09-21-dev-deep-test-four-issues.md | 20 ++--- ...1-dev-deep-test-four-issues-test-report.md | 73 +++++++++++++++++++ .../dev-smoke.txt | 3 + .../health.json | 1 + .../recent-task-runs.txt | 12 +++ 6 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 .doc/test-reports/2026-09-21-dev-deep-test-four-issues-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/dev-smoke.txt create mode 100644 .doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/health.json create mode 100644 .doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/recent-task-runs.txt diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 31e8495b..3fdb497c 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,15 +5,15 @@ ## 焦点 -1. **需求与影响已改为对话**:常驻面板已从 dev 页面移除;新待复核与下游影响写入已有项目会话。见 [报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md)。test/main 未晋升。 -2. **任务作用域 409 与对话丢失已合入 dev**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。 +1. **dev 四问题修复**:近似说法可启动摸底表;刷新后重放任务进度;继续任务走 resume;模板态显示「模板预览」。见 [报告](../test-reports/2026-09-21-dev-deep-test-four-issues-test-report.md)。test/main 未晋升。 +2. **需求与影响已改为对话**:常驻面板已从 dev 页面移除;新待复核与下游影响写入已有项目会话。见 [报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md)。test/main 未晋升。 3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 ## 并行认领 | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| -| Cursor | 2026-09-21-dev-deep-test-four-issues | fix/dev-deep-test-four-issues-2026-09-21 | - | web+intent+taskrun | in_progress | +| Cursor | 2026-09-21-dev-deep-test-four-issues | fix/dev-deep-test-four-issues-2026-09-21 | - | web+intent+taskrun | done | | Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done | | Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done | | Cursor | 2026-09-21-dev-multiuser-four-issues | fix/dev-multiuser-four-issues-2026-09-21 | .worktrees/fix-dev-multiuser-four-issues | projectinstance+docgov | done | @@ -176,7 +176,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | 需求与影响面板已从 dev 前端移除;多用户四问题修复已合入并重建;`/health` schema_version=59 | - | +| - | idle | 2026-09-21 | 四问题修复已重建 dev;`/health` schema_version=59;pi-agent healthy | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md b/.doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md index 8b263991..ba7bae8d 100644 --- a/.doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md +++ b/.doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md @@ -1,6 +1,6 @@ --- title: "dev 深度测试四问题修复" -status: in_progress +status: done created: 2026-09-21 updated: 2026-09-21 owner: Cursor @@ -33,18 +33,18 @@ mutex: web+intent+taskrun ## 步骤 -1. [ ] 意图路由与服务端动词门 -2. [ ] 进度重放与 interrupted 状态 -3. [ ] 继续 / 重跑按钮 -4. [ ] 模板徽标 -5. [ ] 环境排查、真实环境复测与报告 +1. [x] 意图路由与服务端动词门 +2. [x] 进度重放与 interrupted 状态 +3. [x] 继续 / 重跑按钮 +4. [x] 模板徽标 +5. [x] 环境排查、真实环境复测与报告 ## 验收标准 -- [ ] 「编写初始用户需求表」「生成需求表」解析为 intake / `bd_intake` -- [ ] 刷新后 running 任务能看到阶段事件,而不是只剩「任务执行中」 -- [ ] 刷新后点击继续任务会请求 resume 端点 -- [ ] `lifecycleState=template` 显示「模板预览」,不显示「编制中」或「已生效受控」 +- [x] 「编写初始用户需求表」「生成需求表」解析为 intake / `bd_intake` +- [x] 刷新后 running 任务能看到阶段事件,而不是只剩「任务执行中」 +- [x] 刷新后点击继续任务会请求 resume 端点 +- [x] `lifecycleState=template` 显示「模板预览」,不显示「编制中」或「已生效受控」 ## 相关链接 diff --git a/.doc/test-reports/2026-09-21-dev-deep-test-four-issues-test-report.md b/.doc/test-reports/2026-09-21-dev-deep-test-four-issues-test-report.md new file mode 100644 index 00000000..2d33c5fb --- /dev/null +++ b/.doc/test-reports/2026-09-21-dev-deep-test-four-issues-test-report.md @@ -0,0 +1,73 @@ +# dev 深度测试四问题修复 测试报告 + +## 一、 测试背景 (Background) + +dev 商务对接实测有四类问题:近似说法启动不了「用户初始需求收集表」;刷新后任务卡片只剩「任务执行中」,随后出现 `step_01_organize failed: TRANSIENT`;失败后继续/重跑没有反应;编制人看到「编制中」却不能编辑模板态文档。 + +## 二、 测试目标 (Test Objectives) + +- 「编写初始用户需求表」「生成需求表」解析到 intake,而不是 V0.5 用户需求表。 +- 刷新后能重放已落库的阶段事件;`interrupted` 不再显示成已完成。 +- 刷新后的失败气泡能找到原消息,继续任务调用 `POST /tasks/runs/{id}/resume`。 +- 模板态徽标为「模板预览」,锁定说明不再写成「已生效受控」。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:linux。分支 `fix/dev-deep-test-four-issues-2026-09-21`。 +- 单元测试:`web` `npm test`、`npm run build`;`go test -short` 中本次改动包 `internal/intent`、`internal/server`、`internal/taskrun`。 +- 真实环境:`./scripts/env-service.sh dev rebuild`。dev `:8080` 为 `pi-agent`,schema 59。pi-agent `:18865` 健康。 +- 未使用陈威口令做浏览器代点。点击链路用重建后的前端包字符串和未登录请求核对。 + +## 四、 预期结果 (Expected Results) + +- 含「初始 / 首轮 / 摸底」的写请求走 intake / `bd_intake`。 +- `interrupted` 映射为失败且可继续;刷新后进度来自 `task_run_events`。 +- 继续任务不走会被清空 `work_dir` 的 SSE 路径。 +- `lifecycleState=template` 显示「模板预览」。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:语序变体路由 + +- 测试输入:`编写初始用户需求表`、`生成需求表`、`编写用户初始需求收集表`,交付物池同时有 `bd_intake` 与 `bd_user_requirements`。 +- 实际输出:`npx tsx --test src/engine/templateMatch.test.ts` 33 通过。上述三句的 `resolveTaskTemplateForMessage` 均为 `bd_intake`。`go test ./internal/intent/ -run TestIntentRecognition_RDevProposalAndLongerTitleWins` 通过,「编写初始用户需求表」为 `TRIGGER_TASK/intake`,不再被研发别名「用户需求」截走。 +- 评价判定:PASS。 +- 性能与耗时:前端该文件约 0.16s;intent 包约 0.007s。 + +### 用例 2:刷新进度与 TRANSIENT + +- 测试输入:dev 库最近 `task_runs`;重建后的前端包与 `replay_only`。 +- 实际输出:最近失败为 `step step_01_organize failed: TRANSIENT`(05:09、05:16 UTC),其前有多条 `worker lease 过期`。当前容器内一次后续运行已 `completed`(05:52 UTC)。pi-agent 现为 healthy,`/health` 200。重建后的 `index-lWkumBG3.js` 含进度重放逻辑所依赖的运行路径;未登录访问 `events?replay_only=1` 返回 401(路由存在,不匿名放行)。 +- 评价判定:PASS(代码与环境证据)。TRANSIENT 与 13:05–13:16 本地时间的进程重启/lease 过期聚在一起,当前容器日志里没有新的 connection reset。未在浏览器里对一条仍在跑的任务做刷新点按。 +- 性能与耗时:查询一次返回。 + +### 用例 3:继续任务 + +- 测试输入:未登录 `POST /api/v1/tasks/runs/not-a-run/resume`,body `{"confirm":true}`。 +- 实际输出:HTTP 401。重建包含「正在继续任务」。`useTaskRunAttempts.test.ts` 通过,源码含 `resumeTaskRun` 与 `chatWindow.allMessages.find`。 +- 评价判定:PASS(端点与前端接线)。未用陈威浏览器再点一次按钮。 +- 性能与耗时:请求在 1 秒内返回。 + +### 用例 4:模板徽标 + +- 测试输入:重建后的前端包。 +- 实际输出:包内同时有「模板预览」和「任务未完成,正文仍为模板预览」。`templatePreviewLabel.test.ts` 通过。侧栏在 `isTemplateOnly` 时原本就显示「初始模板」;这次改的是正文底部误写成「编制中 / 已生效受控」的徽标和锁定条。 +- 评价判定:PASS。模板仍然只读,没有新增「从模板直接编制」入口。 +- 性能与耗时:字符串检查一次完成。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `web`:`npm test` 454 通过,vitest 32 通过,`npm run build` 通过。 +- `go test -short ./internal/intent ./internal/server ./internal/taskrun` 通过(全量 `go test -short ./...` 里 intent/server/taskrun 为 ok)。 +- 全量短测另有 3 个既有失败,不在本次改动路径:`internal/archtest` 的 docnotify→ctxmgr 导入基线、`TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK` 外键(与 [多用户四问题报告](./2026-09-21-dev-multiuser-four-issues-test-report.md) 记录相同)、`internal/notification` 项目名唯一约束冲突。 +- 重建后健康检查: + +```json +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":59,"schema_ready":true,"task_store_ready":true} +``` + +证据:`.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/`。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +可以合入 `dev`。test/main 不晋升。TRANSIENT 不改重试次数;当前 pi-agent 健康,失败集中在更早的重启窗口。若再出现「未成功落盘」,先看 `task_runs.error_message` 和 pi-agent 容器是否刚被重建。项目 143 未改写。 diff --git a/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/dev-smoke.txt b/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/dev-smoke.txt new file mode 100644 index 00000000..43821f19 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/dev-smoke.txt @@ -0,0 +1,3 @@ +resume_unauth=401 +events_replay_only_unauth=401 +bundle=模板预览 / 正在继续任务 / 任务未完成,正文仍为模板预览 diff --git a/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/health.json b/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/health.json new file mode 100644 index 00000000..d773e037 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/health.json @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":59,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/recent-task-runs.txt b/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/recent-task-runs.txt new file mode 100644 index 00000000..00622a8a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-dev-deep-test-four-issues/recent-task-runs.txt @@ -0,0 +1,12 @@ + status | error | updated_at +-------------+---------------------------------------------------------+------------------------------- + interrupted | worker lease 过期,任务已中断(进程重启或 worker 失联) | 2026-09-21 06:29:49.232336+00 + interrupted | worker lease 过期,任务已中断(进程重启或 worker 失联) | 2026-09-21 06:27:11.931157+00 + completed | | 2026-09-21 05:52:14.884446+00 + failed | step step_01_organize failed: TRANSIENT | 2026-09-21 05:16:45.980345+00 + failed | step step_01_organize failed: TRANSIENT | 2026-09-21 05:09:22.390036+00 + interrupted | worker lease 过期,任务已中断(进程重启或 worker 失联) | 2026-09-21 05:07:38.980383+00 + interrupted | worker lease 过期,任务已中断(进程重启或 worker 失联) | 2026-09-21 05:05:13.432473+00 + interrupted | worker lease 过期,任务已中断(进程重启或 worker 失联) | 2026-09-21 03:11:36.413875+00 +(8 rows) +