merge: fix/dev-deep-test-four-issues-2026-09-21
This commit is contained in:
+4
-3
@@ -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`
|
||||
|
||||
|
||||
@@ -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 网络瞬断,本任务修恢复链路并只读记录环境结论。
|
||||
@@ -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 未改写。
|
||||
@@ -0,0 +1,3 @@
|
||||
resume_unauth=401
|
||||
events_replay_only_unauth=401
|
||||
bundle=模板预览 / 正在继续任务 / 任务未完成,正文仍为模板预览
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ChatCategoryStat[]>([]);
|
||||
const [dateStats, setDateStats] = useState<ChatDateStat[]>([]);
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
+16
-1
@@ -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<CreateTaskRunResponse> {
|
||||
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<void> {
|
||||
const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -165,6 +165,10 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
||||
<div style={{ marginTop: '8px', padding: '6px 10px', background: 'var(--amber-50)', borderRadius: 'var(--r-sm)', fontSize: '11.5px', color: 'var(--amber-600)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>⏳ 本文档正由【{ast.editLease?.holderType === 'squad' ? '项目助手' : '其他用户'}】编辑/生成中,正文与批注暂不可操作。</span>
|
||||
</div>
|
||||
) : ast.lifecycleState === 'template' ? (
|
||||
<div style={{ marginTop: '8px', padding: '6px 10px', background: '#E0F2FE', borderRadius: 'var(--r-sm)', fontSize: '11.5px', color: '#0369A1', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>任务未完成,正文仍为模板预览。完成编写任务后,编制人才能编辑正文。</span>
|
||||
</div>
|
||||
) : ast.lifecycleState !== 'drafting' ? (
|
||||
<div style={{ marginTop: '8px', padding: '6px 10px', background: 'var(--amber-50)', borderRadius: 'var(--r-sm)', fontSize: '11.5px', color: 'var(--amber-600)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>🔒 本文档当前处于【{ast.lifecycleState === 'reviewing' ? '审核中' : ast.lifecycleState === 'approving' ? '批准中' : '已生效受控'}】锁定状态,正文只读,禁止篡改。可通过鼠标右键或右侧浮标添加批注。</span>
|
||||
|
||||
@@ -300,6 +300,8 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
|
||||
? '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<DocGovActionBarProps> = ({
|
||||
? 'var(--amber-600)'
|
||||
: ast.lifecycleState === 'approving'
|
||||
? '#9333EA'
|
||||
: ast.lifecycleState === 'template'
|
||||
? '#0369A1'
|
||||
: 'var(--brand-600)',
|
||||
}}
|
||||
>
|
||||
@@ -317,6 +321,8 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
|
||||
? '审核中'
|
||||
: ast.lifecycleState === 'approving'
|
||||
? '批准中'
|
||||
: ast.lifecycleState === 'template'
|
||||
? '模板预览'
|
||||
: '编制中'}
|
||||
</span>
|
||||
{ast.dutyPolicy === 'author_only' && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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<ChatMessage>) => void,
|
||||
) {
|
||||
const started = useRef(new Set<string>());
|
||||
const cache = useRef(new Map<string, PhaseReplayCacheEntry>());
|
||||
const updateRef = useRef(updateMessage);
|
||||
updateRef.current = updateMessage;
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
const abortRef = useRef<AbortController | null>(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<string, PhaseReplayCacheEntry>,
|
||||
updateMessage: (id: string, patch: Partial<ChatMessage>) => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
allMessages: ChatMessage[];
|
||||
updateLiveMessage: (id: string, patch: Partial<ChatMessage>) => void;
|
||||
};
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
|
||||
@@ -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<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => {
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ChatMessage, PhaseEvent } from '../types/workbench';
|
||||
|
||||
/** 刷新后需要从 task_run_events 重建进度条的气泡。已有步骤则不再拉。 */
|
||||
export function messageNeedsPhaseReplay(
|
||||
message: Pick<ChatMessage, 'role' | 'taskRunId' | 'phaseEvents' | 'status' | 'errorInfo'>,
|
||||
): 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<ChatMessage, 'id' | 'role' | 'taskRunId' | 'phaseEvents' | 'status' | 'errorInfo'>[],
|
||||
): string {
|
||||
return messages
|
||||
.filter(messageNeedsPhaseReplay)
|
||||
.map((message) => `${message.id}:${message.taskRunId}:${message.status}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export type PhaseReplayCacheEntry = {
|
||||
events: PhaseEvent[];
|
||||
label?: string;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user