merge: fix/dev-deep-test-four-issues-2026-09-21

This commit is contained in:
weichen
2026-09-21 06:31:33 +00:00
28 changed files with 643 additions and 42 deletions
+4 -3
View File
@@ -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)
+33 -9
View File
@@ -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 {
+8
View File
@@ -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) {
+4
View File
@@ -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
+7
View File
@@ -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[]>([]);
+16 -9
View File
@@ -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
View File
@@ -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',
);
+4
View File
@@ -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' && (
+2
View File
@@ -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,
};
}
+27
View File
@@ -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');
});
+48 -3
View File
@@ -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);
+1
View File
@@ -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)
) {
+94
View File
@@ -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);
}
}
}
+2
View File
@@ -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);
});
+117 -16
View File
@@ -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;
+53
View File
@@ -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',
);
});
+26
View File
@@ -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;
};
+13
View File
@@ -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();
+2
View File
@@ -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', () => {
+2 -1
View File
@@ -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'
);
}
+11
View File
@@ -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);
});