diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 2a418c0c..3fdb497c 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,14 +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 | 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 | @@ -175,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 new file mode 100644 index 00000000..ba7bae8d --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-dev-deep-test-four-issues.md @@ -0,0 +1,57 @@ +--- +title: "dev 深度测试四问题修复" +status: done +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. [x] 意图路由与服务端动词门 +2. [x] 进度重放与 interrupted 状态 +3. [x] 继续 / 重跑按钮 +4. [x] 模板徽标 +5. [x] 环境排查、真实环境复测与报告 + +## 验收标准 + +- [x] 「编写初始用户需求表」「生成需求表」解析为 intake / `bd_intake` +- [x] 刷新后 running 任务能看到阶段事件,而不是只剩「任务执行中」 +- [x] 刷新后点击继续任务会请求 resume 端点 +- [x] `lifecycleState=template` 显示「模板预览」,不显示「编制中」或「已生效受控」 + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: `.doc/test-reports/2026-09-21-dev-deep-test-four-issues-test-report.md` + +## 证据 / 备注 + +用户在 dev 商务对接实测。TRANSIENT 来自 PI runner 网络瞬断,本任务修恢复链路并只读记录环境结论。 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) + 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) { 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 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..e01980f2 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' }), @@ -138,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/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/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/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/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); 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.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 24dc60f3..cf36f74f 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -8,8 +8,8 @@ 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 { buildResumeRequest, buildTaskRequest, streamTask } from '../api/taskStream'; +import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; +import { buildTaskRequest, streamTask } from '../api/taskStream'; import { applyPhaseEvent } from '../engine/phaseEventMapper'; import { resolveTaskTemplateForMessage } from '../engine/templateMatch'; import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy'; @@ -27,6 +27,7 @@ import { import { buildCancelledTaskErrorInfo, buildFailedTaskErrorInfo, + buildInterruptedTaskErrorInfo, buildNetworkErrorInfo, readRemoteTaskRun, } from '../lib/taskRunErrorInfo'; @@ -75,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>; @@ -358,10 +361,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) => { @@ -536,10 +552,61 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { }; const startObserver = async () => { - const body = - mode === 'resume' && opts?.workDir - ? buildResumeRequest(task, opts.workDir) - : buildTaskRequest(task); + 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 = buildTaskRequest(task); if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') { // 断线重试:显式 reconnectRunId 保留;禁止 setMessages updater 取 runId @@ -565,6 +632,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 +744,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; @@ -701,8 +784,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) { @@ -725,11 +813,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; @@ -739,7 +834,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; 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' ); } 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); +});