fix(chat): preserve intent and task runtime state

This commit is contained in:
weichen
2026-09-13 03:51:18 +00:00
parent 995e099026
commit 95ab2badf6
28 changed files with 710 additions and 68 deletions
@@ -0,0 +1,66 @@
---
title: "真实会话被调试探针污染、查询误触发写任务与刷新状态退化"
status: done
created: 2026-09-13
updated: 2026-09-13
severity: critical
---
# 真实会话被调试探针污染、查询误触发写任务与刷新状态退化
## 现象
- 用户在“对话协同会话”中看到 `test` 等非本人业务输入,多次任务失败。
- “总结一下用户初始需求表”没有读取/总结已有文档,而是启动 `intake` 写任务。
- 实时失败卡片带恢复动作;刷新后只剩错误正文,重试/继续/重新开始能力消失。
- 文档被智能体小队编辑期间,页面没有持续、醒目的占用状态,用户继续工作后才从失败中得知冲突。
## 根因分析
### 1. 诊断请求写入了真实用户会话
dev PostgreSQL 的 `sess-p143-u9` 在 03:17–03:32 记录了 `debug ping`、`test task`、`test`、`验证租约修复`。这些请求带有 `debug-turn-*` 等诊断标识,并与真实用户消息混排。结论不是“意图模型把输入改成 test”,而是调试过程直接复用了真实会话和项目 143,污染了审计记录与 UI。
### 2. 前端用名词命中代替动作意图识别
`web/src/engine/templateMatch.ts` 的 `resolveTaskKeyFromMessage` 只要文本包含“需求/用户”等名词就返回 `intake` 或 `user_requirements`。`App.tsx` 在聊天请求进入后端意图识别前先执行该匹配,并直接调用 `/api/v1/tasks/runs`。因此查询句“总结一下用户初始需求表”被确定性误判为写任务。
这不是模型偶发误判,而是客户端关键词捷径绕过了服务端意图合同;现有测试只覆盖“开始编写”,没有任何“总结/查看/当前状态/谁在编辑”等只读负例。
### 3. 结构化失败状态只存在于实时 SSE
实时路径把 `error_info`、`taskRequest`、`phaseEvents` 保存在 React 内存消息中;失败投影到 `chat_messages` 时只持久化 `run_id/workspace_id/task_status` 和错误正文。`mapRecordToMessage` 刷新恢复时也只依据 `task_status` 还原 `failed`,没有恢复 `errorInfo.actions`、原始任务请求或事件进度,导致 `MessageBubble` 无法渲染重试/继续/重新开始按钮。
### 4. 编辑租约存在,但不是可恢复的页面状态
`task_run_events` 已保存 `lease_acquired/lease_released`,`document_records` 也保存 `lease_holder_type/id/expires_at/heartbeat_at`。但 `App.tsx` 的任务事件处理只消费 `phase/status/delta/deliverable/error/done`,忽略 `governance`;刷新历史也不回放运行中任务事件。租约提示只可能短暂出现在内部数据,不能驱动会话、文档列表和编辑器的统一 busy 状态。
### 5. 文档忙被错误分型为系统内部错误
并发请求因有效租约拒绝时,事件被分类为 `INTERNAL_ERROR/fatal/actions=[restart]`,用户看到“系统内部错误,请重新开始会话”。实际是可预期的 `DOC_BUSY` 状态,应禁止重复启动并展示持有人、进度与预计状态,而不是建议重启会话。
## 严重度
`critical`:真实会话污染破坏可审计性;只读请求触发写入违反用户意图;刷新丢失恢复能力;后台编辑不可见导致重复劳动和冲突。
## 关联整改
- Remediation: [../remediation/2026-09-13-conversation-intent-runtime-state-remediation.md](../remediation/2026-09-13-conversation-intent-runtime-state-remediation.md)
- Short-term: [../ops/short-term/2026-09-13-conversation-intent-runtime-state.md](../ops/short-term/2026-09-13-conversation-intent-runtime-state.md)
## 证据
- dev PostgreSQL:`chat_sessions.id=138/session_id=sess-p143-u9/project_id=143/user_id=9`
- `chat_messages.id=554..567`:诊断探针、误路由与失败投影
- `task_runs.run_id=run_1789269755258510844`:03:22–03:32 持有租约;并发请求均因 busy 失败
- `task_runs.run_id=run_1789270446838831656`:由“总结一下用户初始需求表”启动的新写任务
- `task_run_events`:租约获取/释放与 `INTERNAL_ERROR` 分型
- `web/src/engine/templateMatch.ts`
- `web/src/App.tsx`
- `web/src/api/chatWindow.ts`
- `internal/taskrun/manager.go`
## 开放问题
- 已污染的四条诊断消息是否由项目创建者确认后做可审计软删除;本任务不擅自修改用户数据。
- 误启动的最新任务是否保留其产物;本任务不擅自取消或删除。
+8
View File
@@ -15,6 +15,14 @@
### 最新专项审计(2026-09-13)
**文件**: [2026-09-13-conversation-intent-runtime-state.md](./2026-09-13-conversation-intent-runtime-state.md)
**结论**: 真实会话被诊断探针污染;客户端名词匹配令只读总结请求误启动写任务;错误动作与文档租约状态刷新后无法恢复。
**整改**: [对话意图、可恢复任务状态与文档占用可见性整改](../remediation/2026-09-13-conversation-intent-runtime-state-remediation.md)
### 刷新历史专项审计(2026-09-13)
**文件**: [2026-09-13-chat-history-refresh-disappears.md](./2026-09-13-chat-history-refresh-disappears.md)
**结论**: 消息仍在数据库;文档任务与激活前消息缺阶段作用域,被刷新后的窗口过滤器剔除,而统计仍返回未过滤数量。
+2 -1
View File
@@ -7,13 +7,14 @@
1. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录)
2. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录
3. **前端**: 版本冲突刷新、终止 preview 人工抽检
3. **INTENT/WEB**: [查询误触发写任务与任务状态退化](short-term/2026-09-13-conversation-intent-runtime-state.md)(发布 blocker)
## 并行认领
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor | chat-history-refresh-remediation | fix/chat-history-refresh | - | chat | in_progress |
| Codex | conversation-intent-runtime-state | fix/conversation-intent-state | .worktrees/fix-conversation-intent-state | intent | in_progress |
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
@@ -0,0 +1,53 @@
---
title: "修复查询误触发写任务与任务状态刷新退化"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
owner: Codex
branch: fix/conversation-intent-state
worktree: ".worktrees/fix-conversation-intent-state"
mutex: intent
---
# 修复查询误触发写任务与任务状态刷新退化
## 目标
修复只读查询误启动文档、刷新丢失任务卡片能力及文档编辑状态不可见,并建立诊断数据隔离门禁。
## 范围
- 模板任务动作识别及负例测试
- 任务错误/进度/恢复上下文持久化与历史重建
- 文档租约/运行态的 UI 展示和重复启动前置拦截
- 隔离 fixture 验收与真实 PI 刷新 E2E
## 非目标
- 未经项目创建者确认,不软删除已污染消息或删除误启动产物
- 不放宽文档租约与工作流权限规则
## 步骤
1. [x] 核对真实会话、任务运行、事件和文档租约证据
2. [x] 补只读意图与刷新恢复失败测试
3. [x] 收紧写任务动作识别并修复 busy 错误分型
4. [x] 实现任务/租约状态刷新恢复与统一提示
5. [ ] 执行前端、Go、PG 与真实 PI 双标签页验收
## 验收标准
- [ ] [整改方案](../../remediation/2026-09-13-conversation-intent-runtime-state-remediation.md) 全部验收项有证据
- [ ] `CHAT-INTENT-STATE` 由 FAIL 更新为 PASS
- [ ] test-report 归档并回链
## 相关链接
- Checklist: 本任务验收标准
- Handoff: 与 `fix/chat-history-refresh` 合并前复核 `App.tsx/chatWindow.ts`
- Test report: [../../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md](../../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md)
## 证据 / 备注
- Audit: [../../audit/2026-09-13-conversation-intent-runtime-state.md](../../audit/2026-09-13-conversation-intent-runtime-state.md)
- Remediation: [../../remediation/2026-09-13-conversation-intent-runtime-state-remediation.md](../../remediation/2026-09-13-conversation-intent-runtime-state-remediation.md)
@@ -0,0 +1,44 @@
---
title: "对话意图、可恢复任务状态与文档占用可见性整改"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
---
# 对话意图、可恢复任务状态与文档占用可见性整改
## 背景
见 [专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md)。本问题不是单一意图模型错误,而是客户端写操作捷径、任务状态投影、租约展示和诊断数据隔离四处合同不一致。
## 目标
- 只读表达永不直接触发文档写任务;写操作必须具有明确动作语义。
- 失败、运行中、质量失败和恢复动作刷新后等价恢复。
- 文档被人工或智能体编辑时,在会话、文档列表和编辑器持续显示同一个服务端状态,并在发送前阻止重复写任务。
- 自动化/诊断请求只能使用隔离 fixture,不得写入真实用户会话。
## 分波次计划
| 波次 | 内容 | 验收 |
|------|------|------|
| Wave 0 | 禁止真实会话诊断探针;补污染审计与只读负例 | “总结/查看/状态/谁在编辑”均不命中写任务 |
| Wave 1 | 将客户端模板匹配收紧为“动作 + 目标”;其余请求交给服务端聊天/意图识别 | 明确“编写/生成/更新”触发;名词查询不触发 |
| Wave 2 | 持久化/重建 `error_info`、task request、进度与恢复动作 | 刷新前后失败卡片语义和按钮一致 |
| Wave 3 | 增加运行态/租约查询与统一 busy UI,消费 governance 事件 | 运行中刷新仍显示文档、持有人、阶段、开始时间和禁止编辑提示 |
| Wave 4 | 将 busy 分型为策略状态,执行真实 PI + 刷新 + 双标签页 E2E | 重复请求不会创建第二 run;状态可恢复;完成后自动解锁 |
## 验收标准
- [ ] “总结一下用户初始需求表”返回已有文档摘要,不创建 `task_runs`。
- [ ] “开始编写用户初始需求表”只创建一个 run;运行中再次写请求不创建失败 run。
- [ ] 任务失败后刷新,错误码、诊断摘要及允许动作与刷新前一致。
- [ ] 运行中刷新,聊天占位、阶段进度、文档 busy 标识和输入保护均恢复。
- [ ] 租约释放/任务终态后 UI 自动解除 busy,无需用户手动刷新。
- [ ] 测试使用专用项目/会话并在 teardown 清理;禁止 `debug/test` 探针进入非 fixture 会话。
## 相关链接
- Audit: [../audit/2026-09-13-conversation-intent-runtime-state.md](../audit/2026-09-13-conversation-intent-runtime-state.md)
- Short-term: [../ops/short-term/2026-09-13-conversation-intent-runtime-state.md](../ops/short-term/2026-09-13-conversation-intent-runtime-state.md)
- gap-matrix 行: `CHAT-INTENT-STATE`
+1
View File
@@ -3,6 +3,7 @@
| 文档 | 说明 |
|------|------|
| [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT |
| [2026-09-13-conversation-intent-runtime-state-remediation.md](./2026-09-13-conversation-intent-runtime-state-remediation.md) | 对话写意图、刷新可恢复任务状态、文档占用可见性与诊断隔离 |
| [2026-09-13-chat-history-refresh-remediation.md](./2026-09-13-chat-history-refresh-remediation.md) | 对话刷新恢复、消息作用域与授权统计一致性 |
| [2026-09-12-comprehensive-audit-and-remediation-plan.md](./2026-09-12-comprehensive-audit-and-remediation-plan.md) | 综合缺陷与整改 |
| [2026-09-12-remediation-tasks-and-acceptance-checklist.md](./2026-09-12-remediation-tasks-and-acceptance-checklist.md) | 任务拆分与验收 |
+1
View File
@@ -58,6 +58,7 @@
| ID | 描述 | 状态 | 证据 | 计划动作 |
|---|---|---|---|---|
| CHAT-HISTORY-REFRESH | 工作流激活后刷新,对话正文被阶段过滤器清空,但分类数量与锚点仍按全量返回 | **PASS** | [test-report](../test-reports/2026-09-13-chat-history-refresh-test-report.md);代码:`message_scope.go`、`chat_history_auth.go`、goose 00026/00027 | 真实 PI 刷新 E2E 待 dev 容器补录(自动化/PG 已 PASS) |
| CHAT-INTENT-STATE | 真实会话被诊断探针污染;只读查询误启动文档写任务;错误动作与编辑状态刷新后丢失 | **FAIL** | [audit](../audit/2026-09-13-conversation-intent-runtime-state.md);dev `sess-p143-u9` / task run / lease 现场 | [remediation](./2026-09-13-conversation-intent-runtime-state-remediation.md) |
---
+2 -2
View File
@@ -15,13 +15,13 @@
| 模块 | 能力 | 状态 | 证据 | 缺口/后续 | mutex |
|------|------|------|------|-----------|-------|
| 意图识别 L1 | 18 类 Intent + 三分支消解 | done | [system-design/intent-recognition](../system-design/intent-recognition/);[test-report 2026-09-07](../test-reports/2026-09-07-three-branch-intent-framework-test-report.md) | — | `intent` |
| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 客户端关键词捷径绕过意图合同,只读文档查询会触发写任务 | `intent` |
| 对话 / ctxmgr | 多轮上下文、记忆、摘要 | partial | [system-design/multi-turn-dialogue](../system-design/multi-turn-dialogue/);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | `CHAT-HISTORY-REFRESH`:作用域写入、同集授权统计、历史回填;语义摘要(D5 backlog) | `chat` |
| 智能体小队 | 角色-任务-工作区-黑板 | done | [system-design/agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6 backlog) | `squad` |
| 工作流治理 | 三正式工作流 + 阶段门禁 | done | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md) | Phase2 设计器 partial | `workflow` |
| IAM / 鉴权 | 项目级 API 鉴权 + RBAC | partial | [audit/authorization](../audit/authorization-matrix-v2.md);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | 历史读取端点补统一会话 owner + 阶段授权;旧 API 410 标注(BUG-11 partial) | `authz` |
| 文档治理 | 法定文档生命周期 + 租约 | done | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md) | — | `docgov` |
| 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md) | 人工抽检待完成 | `web` |
| 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` |
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录 | `ci` |
@@ -0,0 +1,79 @@
# 对话意图与运行态可恢复性 测试报告
## 一、 测试背景 (Background)
真实会话出现诊断探针污染、只读“总结”误触发文档写任务、刷新后错误动作退化、文档被编辑时无持续状态提示。本报告验证本轮代码整改;真实 PI 浏览器验收须在合入 dev 并重建容器后补录。
## 二、 测试目标 (Test Objectives)
- 文档名词和只读动作不触发 `task_runs`
- 明确写动作仍正确映射 `intake/user_requirements`
- 错误信息、恢复动作与任务请求可从历史消息重建
- 文档租约可经项目文档 API 投影并显示为编辑中
- 文档忙分型为 `DOC_BUSY`,不提供误导性重试动作
- Go 与前端全量基础门禁通过
## 三、 测试方案与环境 (Test Plan & Environment)
- 日期:2026-09-13 UTC
- 分支/worktree:`fix/conversation-intent-state` / `.worktrees/fix-conversation-intent-state`
- 后端:Go 单元与 `-short` 全量套件;未启动 worktree 容器
- 前端:Node test + Vitest + TypeScript/Vite production build
- 现场取证:dev PostgreSQL 只读查询;未修改用户消息、任务或文档
## 四、 预期结果 (Expected Results)
| 场景 | 预期 |
|------|------|
| `总结一下用户初始需求表` | 不匹配写任务,进入普通对话/服务端意图链 |
| `请开始编写用户初始需求表` | 映射 `intake` |
| 失败消息刷新 | 恢复 `errorInfo.actions` 与 `taskRequest` |
| 文档有有效租约 | API 返回 holder/到期/心跳;正文和抽屉显示编辑中 |
| 并发写命中租约 | `DOC_BUSY`,不创建误导性重试动作 |
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 用例 A:只读负例与明确写入正例
- **Input**: `tsx --test src/engine/templateMatch.test.ts`
- **Actual Output**: 12 tests PASS;含 5 个只读负例与 2 个明确写入正例
- **Evaluation**: PASS
### 用例 B:刷新后错误/任务上下文恢复
- **Input**: `vitest run ...chatWindowArtifact.test.ts`
- **Actual Output**: 结构化错误动作、原请求与 legacy busy 分型测试 PASS
- **Evaluation**: PASS
### 用例 C:租约服务端投影与前端合并
- **Input**: `go test -short ./internal/docgov`;`vitest run ...deliverableSync.test.ts`
- **Actual Output**: active lease holder/expiry/heartbeat 投影与前端映射 PASS
- **Evaluation**: PASS
### 用例 D:真实现场状态核对
- **Input**: 只读查询 `sess-p143-u9`、`task_runs`、`task_run_events`、`document_records`
- **Actual Output**: 误启动 run `run_1789270446838831656` 于 03:42 UTC 完成,质量 100;租约已释放;用户数据未被本轮修改
- **Evaluation**: PASS(根因证据);不代表修复后 E2E
### 用例 E:真实 PI + 浏览器刷新/双标签页
- **Input**: 待合入 dev 后执行
- **Actual Output**: 未执行
- **Evaluation**: DEFERRED
## 六、 自动化测试套件汇总 (Test Suite Execution)
```text
go vet ./... PASS
go test -short ./... PASS(全部 Go 包)
cd web && npm test PASS(Node 185 + Vitest 24)
cd web && npm run build PASS(Vite 生产构建)
git diff --check PASS
硬编码 localhost 调试上报扫描 PASS(0 命中)
```
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
代码级与构建门禁通过,达到进入集成槽位的条件;尚不能标记发布完成。合入 dev 后必须使用隔离 fixture 执行真实 PI、刷新恢复、双标签页租约可见性与“不新增第二 run”验收。真实会话中的诊断消息与误启动产物只记录证据,不在未获确认时删除或回滚。
+36 -25
View File
@@ -13,25 +13,29 @@ import (
// WorkflowDocumentSummary 项目工作流受控文档列表项。
type WorkflowDocumentSummary struct {
DocID string `json:"doc_id"`
ProjectID int64 `json:"project_id"`
TaskKey string `json:"task_key"`
Title string `json:"title"`
LifecycleState DocumentState `json:"lifecycle_state"`
IsTemplateOnly bool `json:"is_template_only"`
ContentSHA256 string `json:"content_sha256"`
WorkDir string `json:"work_dir,omitempty"`
Version int `json:"version"`
UpdatedAt string `json:"updated_at,omitempty"`
DocID string `json:"doc_id"`
ProjectID int64 `json:"project_id"`
TaskKey string `json:"task_key"`
Title string `json:"title"`
LifecycleState DocumentState `json:"lifecycle_state"`
IsTemplateOnly bool `json:"is_template_only"`
ContentSHA256 string `json:"content_sha256"`
WorkDir string `json:"work_dir,omitempty"`
Version int `json:"version"`
UpdatedAt string `json:"updated_at,omitempty"`
LeaseHolderType LeaseHolderType `json:"lease_holder_type,omitempty"`
LeaseHolderID string `json:"lease_holder_id,omitempty"`
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
LeaseHeartbeatAt *time.Time `json:"lease_heartbeat_at,omitempty"`
}
// DocumentContent 受控文档正文读取结果。
type DocumentContent struct {
DocID string `json:"doc_id"`
Version int `json:"version"`
ContentSHA256 string `json:"content_sha256"`
Markdown string `json:"markdown"`
IsTemplateOnly bool `json:"is_template_only"`
DocID string `json:"doc_id"`
Version int `json:"version"`
ContentSHA256 string `json:"content_sha256"`
Markdown string `json:"markdown"`
IsTemplateOnly bool `json:"is_template_only"`
}
// ListWorkflowDocumentsByProject 列出项目下工作流受控文档(task_key 非空)。
@@ -43,7 +47,9 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
if e.pool != nil {
rows, err := e.pool.Query(ctx, `
SELECT doc_id, project_id, task_key, title, current_state, is_template_only,
COALESCE(content_sha256, ''), COALESCE(work_dir, ''), version, updated_at
COALESCE(content_sha256, ''), COALESCE(work_dir, ''), version, updated_at,
COALESCE(lease_holder_type, ''), COALESCE(lease_holder_id, ''),
lease_expires_at, lease_heartbeat_at
FROM document_records
WHERE project_id = $1 AND task_key <> ''
ORDER BY task_key ASC, doc_id ASC
@@ -60,6 +66,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
if err := rows.Scan(
&item.DocID, &item.ProjectID, &item.TaskKey, &item.Title, &state,
&item.IsTemplateOnly, &item.ContentSHA256, &item.WorkDir, &item.Version, &updatedAt,
&item.LeaseHolderType, &item.LeaseHolderID, &item.LeaseExpiresAt, &item.LeaseHeartbeatAt,
); err != nil {
return nil, err
}
@@ -78,15 +85,19 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
for _, doc := range e.memDocs {
if doc.ProjectID == projectID && doc.TaskKey != "" {
out = append(out, WorkflowDocumentSummary{
DocID: doc.DocID,
ProjectID: doc.ProjectID,
TaskKey: doc.TaskKey,
Title: doc.Title,
LifecycleState: doc.CurrentState,
IsTemplateOnly: doc.IsTemplateOnly,
ContentSHA256: doc.ContentSHA256,
WorkDir: doc.WorkDir,
Version: doc.Version,
DocID: doc.DocID,
ProjectID: doc.ProjectID,
TaskKey: doc.TaskKey,
Title: doc.Title,
LifecycleState: doc.CurrentState,
IsTemplateOnly: doc.IsTemplateOnly,
ContentSHA256: doc.ContentSHA256,
WorkDir: doc.WorkDir,
Version: doc.Version,
LeaseHolderType: doc.LeaseHolderType,
LeaseHolderID: doc.LeaseHolderID,
LeaseExpiresAt: doc.LeaseExpiresAt,
LeaseHeartbeatAt: doc.LeaseHeartbeatAt,
})
}
}
+40
View File
@@ -0,0 +1,40 @@
package docgov_test
import (
"context"
"testing"
"time"
"chat-agent-system/internal/docgov"
)
func TestListWorkflowDocumentsIncludesActiveLease(t *testing.T) {
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
const docID = "doc_project_docs_lease"
if _, err := engine.CreateOrGetDocument(
ctx, docID, 143, "intake", "用户初始需求收集表", "V0.1", "",
9, "编制人", 2, "审核人", 3, "批准人",
); err != nil {
t.Fatal(err)
}
if _, err := engine.AcquireLease(ctx, docgov.LeaseAcquireRequest{
DocID: docID, HolderType: docgov.LeaseHolderSquad, HolderID: "run-visible", TTL: time.Minute,
}); err != nil {
t.Fatal(err)
}
docs, err := engine.ListWorkflowDocumentsByProject(ctx, 143)
if err != nil {
t.Fatal(err)
}
if len(docs) != 1 {
t.Fatalf("documents=%d", len(docs))
}
if docs[0].LeaseHolderType != docgov.LeaseHolderSquad || docs[0].LeaseHolderID != "run-visible" {
t.Fatalf("lease not projected: %+v", docs[0])
}
if docs[0].LeaseExpiresAt == nil || docs[0].LeaseHeartbeatAt == nil {
t.Fatalf("lease timestamps missing: %+v", docs[0])
}
}
+16 -1
View File
@@ -88,6 +88,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
return
}
runID := fmt.Sprintf("run_%d", time.Now().UnixNano())
var assistantMsgID int64
if ctxMgr != nil && req.UserMessage != "" {
userMeta := map[string]any{
@@ -98,16 +99,30 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
projectinstance.ApplyScopeToMetadata(userMeta, taskScope)
_, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, req.UserID, "user", req.UserMessage, "TRIGGER_TASK", "", userMeta)
asstMeta := map[string]any{
"run_id": runID,
"project_id": req.ProjectID,
"client_turn_id": req.ClientTurnID,
"task_key": req.TaskKey,
"task_status": "running",
"task_request": map[string]any{
"task_key": req.TaskKey,
"project_name": req.ProjectName,
"customer_name": req.CustomerName,
"device_type": req.DeviceType,
"intended_use": req.IntendedUse,
"target_market": req.TargetMarket,
"session_id": req.SessionID,
"user_id": req.UserID,
"project_id": req.ProjectID,
"client_turn_id": req.ClientTurnID,
"user_message": req.UserMessage,
"doc_id": req.DocID,
},
}
projectinstance.ApplyScopeToMetadata(asstMeta, taskScope)
assistantMsgID, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", "任务执行中…", "TRIGGER_TASK", "", asstMeta)
}
runID := fmt.Sprintf("run_%d", time.Now().UnixNano())
record := &taskrun.RunRecord{
RunID: runID,
SessionID: req.SessionID,
+6
View File
@@ -26,6 +26,12 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string)
}
switch {
case strings.Contains(msg, "编辑中") || strings.Contains(msg, "禁止并行修改") || strings.Contains(msg, "edit lease"):
info.Code = "DOC_BUSY"
info.Class = ErrorClassFatal
info.UserMessage = "该文档正在由项目助手或其他用户编辑中;当前请求未执行,请等待现有任务完成"
info.Actions = []string{}
case isTimeoutError(msg):
info.Code = "CHAT_TIMEOUT"
info.Class = ErrorClassTransient
+10
View File
@@ -78,3 +78,13 @@ func TestClassifyError_NetworkIsTransient(t *testing.T) {
t.Fatalf("class=%s", info.Class)
}
}
func TestClassifyError_DocumentBusyIsVisibleStateWithoutRetry(t *testing.T) {
info := ClassifyError(errors.New("文档正在被 squad(run_1) 编辑中,禁止并行修改"), "", "req_busy", "sess_1", "")
if info.Code != "DOC_BUSY" {
t.Fatalf("code=%s", info.Code)
}
if len(info.Actions) != 0 {
t.Fatalf("busy state must not encourage duplicate retry, actions=%v", info.Actions)
}
}
+9 -6
View File
@@ -43,7 +43,7 @@ type Manager struct {
const runRetention = time.Hour
var (
ErrRunNotResumable = errors.New("taskrun: run is not resumable")
ErrRunNotResumable = errors.New("taskrun: run is not resumable")
ErrRunAlreadyActive = errors.New("taskrun: run already active in memory")
)
@@ -230,9 +230,9 @@ func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.T
if execErr != nil {
if ctx.Err() == context.Canceled {
m.finishRun(ar, "cancelled", workspaceID, execErr.Error(), nil)
info := stream.ClassifyError(fmt.Errorf("task cancelled"), "task_cancel", ar.record.RunID, ar.record.SessionID, workspaceID)
m.appendEvent(ar, stream.StreamEvent{Type: stream.EventError, ErrorInfo: &info})
m.finishRun(ar, "cancelled", workspaceID, execErr.Error(), nil, &info)
return
}
classifyWS := workspaceID
@@ -241,7 +241,7 @@ func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.T
}
info := stream.ClassifyError(execErr, lastPhaseID, ar.record.RunID, ar.record.SessionID, classifyWS)
m.appendEvent(ar, stream.StreamEvent{Type: stream.EventError, ErrorInfo: &info})
m.finishRun(ar, "failed", workspaceID, execErr.Error(), taskResult)
m.finishRun(ar, "failed", workspaceID, execErr.Error(), taskResult, &info)
return
}
@@ -251,7 +251,7 @@ func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.T
if ts, finErr := m.finalize(ctx, ar.record, taskResult); finErr != nil {
info := stream.ClassifyError(finErr, lastPhaseID, ar.record.RunID, ar.record.SessionID, workspaceID)
m.appendEvent(ar, stream.StreamEvent{Type: stream.EventError, ErrorInfo: &info})
m.finishRun(ar, "finalizing_failed", workspaceID, finErr.Error(), taskResult)
m.finishRun(ar, "finalizing_failed", workspaceID, finErr.Error(), taskResult, &info)
return
} else if ts != "" {
terminalStatus = ts
@@ -268,10 +268,10 @@ func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.T
DurationMs: time.Since(start).Milliseconds(),
},
})
m.finishRun(ar, terminalStatus, workspaceID, "", taskResult)
m.finishRun(ar, terminalStatus, workspaceID, "", taskResult, nil)
}
func (m *Manager) finishRun(ar *activeRun, status, workspaceID, errMsg string, result *bizdev.TaskRunResult) {
func (m *Manager) finishRun(ar *activeRun, status, workspaceID, errMsg string, result *bizdev.TaskRunResult, errorInfo *stream.ErrorInfo) {
ar.mu.Lock()
ar.record.Status = status
ar.record.WorkspaceID = workspaceID
@@ -305,6 +305,9 @@ func (m *Manager) finishRun(ar *activeRun, status, workspaceID, errMsg string, r
"workspace_id": workspaceID,
"task_status": status,
}
if errorInfo != nil {
meta["error_info"] = errorInfo
}
content := "任务执行失败。"
if status == "cancelled" {
content = "任务已取消。"
+132 -10
View File
@@ -1626,6 +1626,60 @@ export default function App() {
};
}, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name]);
// 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。
useEffect(() => {
if (!session || sessionValidated !== true || currentProjectId <= 0) return;
let cancelled = false;
const syncLeases = async () => {
const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
if (cancelled || docs.length === 0) return;
const leases = new Map(
docs.map((doc) => [
doc.doc_id,
doc.lease_holder_type
? {
holderType: doc.lease_holder_type,
holderId: doc.lease_holder_id,
expiresAt: doc.lease_expires_at,
heartbeatAt: doc.lease_heartbeat_at,
}
: undefined,
]),
);
const sameLease = (
left: DeliverableItem['editLease'],
right: DeliverableItem['editLease'],
) =>
left?.holderType === right?.holderType &&
left?.holderId === right?.holderId &&
left?.expiresAt === right?.expiresAt &&
left?.heartbeatAt === right?.heartbeatAt;
setDeliverables((prev) =>
prev.map((doc) => {
if (doc.projectId !== currentProjectId || !leases.has(doc.docId)) return doc;
const editLease = leases.get(doc.docId);
return sameLease(doc.editLease, editLease) ? doc : { ...doc, editLease };
}),
);
setDocCache((prev) => {
let changed = false;
const next = { ...prev };
leases.forEach((editLease, docId) => {
if (!next[docId]) return;
if (sameLease(next[docId].editLease, editLease)) return;
next[docId] = { ...next[docId], editLease };
changed = true;
});
return changed ? next : prev;
});
};
const timer = window.setInterval(() => void syncLeases(), 5000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [session, sessionValidated, currentProjectId]);
// 模态弹窗与侧栏
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
@@ -1662,6 +1716,41 @@ export default function App() {
liveMessages: currentSessionMessages,
});
// 刷新后仍以服务端 run 状态为准;终态出现时重拉窗口,恢复正文、错误动作和产物信息。
const recoverableRunIds = useMemo(
() =>
chatWindow.allMessages
.filter(
(message) =>
message.role === 'assistant' &&
message.status === 'streaming_status' &&
!!message.taskRunId,
)
.map((message) => message.taskRunId as string)
.sort(),
[chatWindow.allMessages],
);
const recoverableRunKey = recoverableRunIds.join(',');
useEffect(() => {
if (!recoverableRunKey) return;
let cancelled = false;
const poll = async () => {
const records = await Promise.all(
recoverableRunIds.map((runId) => getTaskRun(runId).catch(() => null)),
);
if (cancelled) return;
if (records.some((record) => record && record.status !== 'running')) {
await chatWindow.refresh();
}
};
void poll();
const timer = window.setInterval(() => void poll(), 3000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [recoverableRunKey]);
// P1: 多维度历史导航与分类统计、日历、引用状态
const [categoryStats, setCategoryStats] = useState<ChatCategoryStat[]>([]);
const [dateStats, setDateStats] = useState<ChatDateStat[]>([]);
@@ -2310,9 +2399,6 @@ export default function App() {
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
? Number((_err as { httpStatus?: number }).httpStatus)
: undefined;
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'App.tsx:runStreamAttempt.catch',message:'stream fetch failed',data:{httpStatus,sessionId:streamSessionId,userId:currentUser.id},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
// #endregion
const errMeta = _err as { errorCode?: string; detail?: string; message?: string };
failOrRetry(
buildNetworkErrorInfo(httpStatus, {
@@ -2412,6 +2498,30 @@ export default function App() {
}
break;
}
case 'governance': {
const governance = ev.governance;
const docId = governance?.doc_id;
if (docId) {
const leaseReleased = governance.action === 'lease_released';
const editLease = leaseReleased
? undefined
: { holderType: 'squad' as const, holderId: taskRunStream.activeTaskRunId || undefined };
setDeliverables((prev) =>
prev.map((doc) => (doc.docId === docId ? { ...doc, editLease } : doc)),
);
setDocCache((prev) => {
const ast = prev[docId];
return ast ? { ...prev, [docId]: { ...ast, editLease } } : prev;
});
}
if (governance?.notification_msg) {
patchAsst({
status: 'streaming_status',
livePhaseLabel: governance.notification_msg,
});
}
break;
}
case 'error': {
failOrRetry(mapErrorInfo(ev));
break;
@@ -2489,9 +2599,6 @@ export default function App() {
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
? Number((_err as { httpStatus?: number }).httpStatus)
: undefined;
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'App.tsx:runTaskAttempt.catch',message:'task run failed',data:{httpStatus,taskProjectId:task.projectId,sessionId:task.sessionId,taskKey:task.taskKey},timestamp:Date.now(),hypothesisId:'H5'})}).catch(()=>{});
// #endregion
const errMeta = _err as { errorCode?: string; detail?: string; message?: string };
failOrRetry(
buildNetworkErrorInfo(httpStatus, {
@@ -2717,7 +2824,6 @@ export default function App() {
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
if (resolvedTask) {
const matchedTemplate = resolvedTask.template;
const asstId = 'ast-' + Date.now();
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
@@ -2731,9 +2837,25 @@ export default function App() {
const matchedDeliverable = deliverables.find(
(d) => d.projectId === projId && d.taskKey === taskKey,
);
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'App.tsx:handleSend.task',message:'resolved task template',data:{taskKey,hasTemplate:Boolean(matchedTemplate),textPreview:text.slice(0,40)},timestamp:Date.now(),hypothesisId:'H7'})}).catch(()=>{});
// #endregion
const activeLease = matchedDeliverable?.editLease;
const leaseIsActive =
!!activeLease?.holderType &&
(!activeLease.expiresAt || new Date(activeLease.expiresAt).getTime() > Date.now());
if (leaseIsActive) {
setMessages((prev) => [
...prev,
{
id: 'ast-busy-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
content: `《${matchedDeliverable?.title || '该文档'}》正在由${activeLease?.holderType === 'squad' ? '项目助手' : '其他用户'}编辑/生成中。当前请求未启动新任务;完成后状态会自动解除。`,
timestamp: new Date().toTimeString().substring(0, 5),
status: 'completed',
},
]);
return { accepted: true };
}
const baseTask: TaskRunContext = {
taskKey,
projectName: currentProj?.title || '医疗器械研发演示项目',
+10 -4
View File
@@ -23,6 +23,15 @@ export type StreamEvent = {
word_count?: number;
quality_score?: number;
};
governance?: {
doc_id?: string;
action?: string;
current_state?: string;
is_locked?: boolean;
author_claimed?: boolean;
notification_title?: string;
notification_msg?: string;
};
error_info?: {
user_message?: string;
code?: string;
@@ -155,9 +164,6 @@ export async function streamChat(
});
const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id');
if (rid) handlers.onRequestId?.(rid);
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'chatStream.ts:streamChat',message:'stream response headers',data:{status:resp.status,ok:resp.ok,hasBody:Boolean(resp.body),sessionId:body.session_id},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
// #endregion
if (!resp.ok || !resp.body) {
throw Object.assign(new Error(`HTTP ${resp.status}`), {
httpStatus: resp.status,
@@ -191,7 +197,7 @@ export function mapErrorInfo(ev: StreamEvent): ChatErrorInfo {
: fallbackErrorClass({ workspaceId: ei.workspace_id });
let actions = parseActions(ei.actions);
if (!actions.length) {
if (!Array.isArray(ei.actions)) {
actions = defaultActionsForClass(cls);
}
+62 -6
View File
@@ -7,6 +7,7 @@ import {
} from '../types/workbench';
import { MessageContentKind, MessageExportCapability } from '../types/replyExport';
import { getBearerAuthHeaders } from './authHeaders';
import { mapErrorInfo } from './chatStream';
export interface RawChatMessageRecord {
id: number;
@@ -60,6 +61,65 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
: new Date().toTimeString().substring(0, 5);
const meta = r.metadata || {};
const rawTaskRequest =
meta.task_request && typeof meta.task_request === 'object'
? (meta.task_request as Record<string, any>)
: undefined;
const taskRequest = rawTaskRequest
? {
taskKey: String(rawTaskRequest.task_key || meta.task_key || ''),
projectName: String(rawTaskRequest.project_name || ''),
customerName: String(rawTaskRequest.customer_name || ''),
deviceType: String(rawTaskRequest.device_type || ''),
intendedUse: rawTaskRequest.intended_use,
targetMarket: rawTaskRequest.target_market,
sessionId: String(rawTaskRequest.session_id || r.session_id),
userId: Number(rawTaskRequest.user_id || r.user_id || 0),
projectId: Number(rawTaskRequest.project_id || meta.project_id || 0),
clientTurnId: String(rawTaskRequest.client_turn_id || meta.client_turn_id || ''),
userMessage: String(rawTaskRequest.user_message || ''),
docId: rawTaskRequest.doc_id ? String(rawTaskRequest.doc_id) : undefined,
}
: meta.task_key && meta.project_id
? {
taskKey: String(meta.task_key),
projectName: '',
customerName: '',
deviceType: '',
sessionId: r.session_id,
userId: r.user_id,
projectId: Number(meta.project_id),
clientTurnId: String(meta.client_turn_id || ''),
}
: undefined;
const persistedError =
meta.error_info && typeof meta.error_info === 'object'
? mapErrorInfo({ type: 'error', error_info: meta.error_info })
: undefined;
const legacyBusyError =
r.role === 'assistant' && meta.task_status === 'failed' && r.content.includes('禁止并行修改')
? {
userMessage: '该文档正在由项目助手或其他用户编辑中;当前请求未执行,请等待现有任务完成',
code: 'DOC_BUSY',
class: 'fatal' as const,
actions: [],
}
: undefined;
const legacyTaskError =
r.role === 'assistant' && meta.task_status === 'failed' && !legacyBusyError
? {
userMessage: meta.workspace_id
? '任务执行中断,可继续或重新开始'
: '任务执行失败,可按原请求重新开始',
code: meta.workspace_id ? 'TASK_INTERRUPTED' : 'TASK_FAILED',
class: (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'>),
}
: undefined;
const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`);
return {
id: msgId,
@@ -90,6 +150,8 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
? 'completed'
: 'completed'
: undefined,
errorInfo: persistedError || legacyBusyError || legacyTaskError,
taskRequest,
taskRunId: typeof meta.run_id === 'string' ? meta.run_id : undefined,
taskWorkspaceId: typeof meta.workspace_id === 'string' ? meta.workspace_id : undefined,
taskArtifact:
@@ -125,9 +187,6 @@ export async function fetchChatWindow(
}
const res = await fetch(url, { signal, headers: getBearerAuthHeaders() });
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'chatWindow.ts:fetchChatWindow',message:'window response',data:{sessionId,status:res.status,ok:res.ok},timestamp:Date.now(),hypothesisId:'H2'})}).catch(()=>{});
// #endregion
if (!res.ok) {
throw Object.assign(new Error(`fetchChatWindow failed: HTTP ${res.status}`), {
httpStatus: res.status,
@@ -135,9 +194,6 @@ export async function fetchChatWindow(
}
const raw: RawChatWindowResult = await res.json();
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'chatWindow.ts:fetchChatWindow',message:'window payload',data:{sessionId,dayCount:(raw.days||[]).length,msgCount:((raw.days||[])[0]?.messages||[]).length,firstDayCount:(raw.days||[])[0]?.count},timestamp:Date.now(),hypothesisId:'H3'})}).catch(()=>{});
// #endregion
return {
sessionId: raw.session_id,
days: (raw.days || []).map(mapRawBucket),
+4
View File
@@ -13,6 +13,10 @@ export type WorkflowDocumentSummary = {
work_dir?: string;
version?: number;
updated_at?: string;
lease_holder_type?: 'squad' | 'human' | '';
lease_holder_id?: string;
lease_expires_at?: string;
lease_heartbeat_at?: string;
};
export type ControlledDocumentContent = {
-6
View File
@@ -21,9 +21,6 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise<CreateTask
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(body),
});
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'taskRuns.ts:createTaskRun',message:'create task run response',data:{status:res.status,ok:res.ok,projectId:body.project_id,taskKey:body.task_key,sessionId:body.session_id},timestamp:Date.now(),hypothesisId:'H5'})}).catch(()=>{});
// #endregion
if (!res.ok) {
let detail = '';
let errorCode = '';
@@ -34,9 +31,6 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise<CreateTask
} catch {
// ignore non-json body
}
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'taskRuns.ts:createTaskRun',message:'create task run error body',data:{status:res.status,errorCode,detail,projectId:body.project_id,taskKey:body.task_key},timestamp:Date.now(),hypothesisId:'H6'})}).catch(()=>{});
// #endregion
throw Object.assign(
new Error(`create task run failed: HTTP ${res.status}${detail ? `: ${detail}` : ''}`),
{ httpStatus: res.status, errorCode, detail },
@@ -27,4 +27,56 @@ describe('mapRecordToMessage artifact projection', () => {
expect(msg.taskArtifact?.doc_id).toBe('doc_proj56_intake');
expect(msg.status).toBe('completed');
});
it('restores structured task error and retry context after refresh', () => {
const msg = mapRecordToMessage({
id: 99,
session_id: 'sess-p143-u9',
user_id: 0,
day_key: '2026-09-13',
role: 'assistant',
content: '任务执行失败。',
metadata: {
project_id: 143,
task_key: 'intake',
task_status: 'failed',
run_id: 'run-99',
task_request: {
task_key: 'intake',
project_id: 143,
session_id: 'sess-p143-u9',
user_id: 9,
user_message: '开始编写用户初始需求表',
},
error_info: {
user_message: '任务执行中断,可继续或重新开始',
code: 'TASK_INTERRUPTED',
class: 'resumable',
workspace_id: '/work/intake',
actions: ['continue', 'restart'],
},
},
created_at: '2026-09-13T03:00:00Z',
});
expect(msg.errorInfo?.actions).toEqual(['continue', 'restart']);
expect(msg.taskRequest?.userMessage).toBe('开始编写用户初始需求表');
expect(msg.taskRequest?.projectId).toBe(143);
});
it('restores document busy as a non-retryable state for legacy rows', () => {
const msg = mapRecordToMessage({
id: 100,
session_id: 'sess-p143-u9',
user_id: 0,
day_key: '2026-09-13',
role: 'assistant',
content: '任务执行失败。\n\n文档正在被 squad(run_1) 编辑中,禁止并行修改',
metadata: { project_id: 143, task_key: 'intake', task_status: 'failed' },
created_at: '2026-09-13T03:00:00Z',
});
expect(msg.errorInfo?.code).toBe('DOC_BUSY');
expect(msg.errorInfo?.actions).toEqual([]);
});
});
@@ -40,6 +40,9 @@ describe('mergeWorkflowDocumentsFromServer', () => {
is_template_only: false,
content_sha256: 'abc123',
updated_at: '2026-09-11T15:42:50+08:00',
lease_holder_type: 'squad',
lease_holder_id: 'run-1',
lease_expires_at: '2026-09-13T04:00:00Z',
},
],
56,
@@ -50,5 +53,11 @@ describe('mergeWorkflowDocumentsFromServer', () => {
expect(merged[0].docId).toBe('doc_proj56_intake');
expect(merged[0].isTemplateOnly).toBe(false);
expect(merged[0].isClaimed).toBe(true);
expect(merged[0].editLease).toEqual({
holderType: 'squad',
holderId: 'run-1',
expiresAt: '2026-09-13T04:00:00Z',
heartbeatAt: undefined,
});
});
});
+16
View File
@@ -455,6 +455,22 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
e.stopPropagation();
onOpenDocVisibility?.(doc);
})}
{doc.editLease?.holderType &&
(!doc.editLease.expiresAt || new Date(doc.editLease.expiresAt).getTime() > Date.now()) && (
<span
style={{
fontSize: '10.5px',
fontWeight: 600,
color: 'var(--amber-600)',
background: 'var(--amber-50)',
padding: '2px 6px',
borderRadius: 'var(--r-pill)',
}}
title={`编辑占用:${doc.editLease.holderType === 'squad' ? '项目助手' : '其他用户'}`}
>
编辑中
</span>
)}
{renderBadge(doc.lifecycleState, doc.isClaimed, doc.isTemplateOnly)}
</div>
</div>
+20
View File
@@ -58,3 +58,23 @@ test('matchTemplateDeliverable prefers intake when both templates exist', () =>
const hit = matchTemplateDeliverable('根据上传的文档,开始编写用户初始需求表', bothPool);
assert.equal(hit?.taskKey, 'intake');
});
for (const query of [
'总结一下用户初始需求表',
'查看用户初始需求表',
'用户初始需求表现在谁在编辑',
'分析一下用户需求表里的风险',
'用户初始需求表',
]) {
test(`read-only request does not start a document task: ${query}`, () => {
assert.equal(resolveTaskTemplateForMessage(query, bothPool), undefined);
});
}
test('explicit write action still starts intake', () => {
assert.equal(resolveTaskTemplateForMessage('请开始编写用户初始需求表', bothPool)?.taskKey, 'intake');
});
test('explicit update action still starts URS', () => {
assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', bothPool)?.taskKey, 'user_requirements');
});
+14 -7
View File
@@ -45,16 +45,23 @@ function pickRequirementsTask(text: string, pool: DeliverableItem[]): Deliverabl
/** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */
export function resolveTaskKeyFromMessage(text: string): string | undefined {
if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) {
return resolveRequirementsTaskKey(text);
const normalized = text.trim();
const readOnlyIntent = /(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
const writeIntent = /(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做)/;
// 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。
if (readOnlyIntent.test(normalized) || !writeIntent.test(normalized)) return undefined;
if (normalized.includes('需求') || normalized.includes('摸底') || normalized.includes('用户')) {
return resolveRequirementsTaskKey(normalized);
}
if (text.includes('调研') || text.includes('竞品')) return 'research';
if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) {
if (text.includes('研发') || text.includes('dev')) return 'feasibility_dev';
if (normalized.includes('调研') || normalized.includes('竞品')) return 'research';
if (normalized.includes('可行性') || normalized.includes('注册') || normalized.includes('报告')) {
if (normalized.includes('研发') || normalized.includes('dev')) return 'feasibility_dev';
return 'feasibility_reg';
}
if (text.includes('设计') || text.includes('规格') || text.includes('架构')) return 'prelim_design';
if (text.includes('报价') || text.includes('工时') || text.includes('预算')) return 'cdo_quote';
if (normalized.includes('设计') || normalized.includes('规格') || normalized.includes('架构')) return 'prelim_design';
if (normalized.includes('报价') || normalized.includes('工时') || normalized.includes('预算')) return 'cdo_quote';
return undefined;
}
+3
View File
@@ -34,6 +34,7 @@ export function buildControlledLoadErrorSkeleton(doc: DeliverableItem): Document
approver: doc.approver,
});
ast.governanceMode = 'controlled';
ast.editLease = doc.editLease;
return ast;
}
@@ -54,6 +55,7 @@ export function buildControlledLoadingSkeleton(doc: DeliverableItem): DocumentAS
approver: doc.approver,
});
ast.governanceMode = 'controlled';
ast.editLease = doc.editLease;
return ast;
}
@@ -78,6 +80,7 @@ export function buildControlledDocumentAST(
approver: doc.approver,
});
fullAST.governanceMode = 'controlled';
fullAST.editLease = doc.editLease;
return fullAST;
}
+8
View File
@@ -68,6 +68,14 @@ export function mergeWorkflowDocumentsFromServer(
base.isClaimed = !s.is_template_only;
base.governanceMode = 'controlled';
base.isFixedWorkflow = true;
base.editLease = s.lease_holder_type
? {
holderType: s.lease_holder_type,
holderId: s.lease_holder_id,
expiresAt: s.lease_expires_at,
heartbeatAt: s.lease_heartbeat_at,
}
: undefined;
if (s.updated_at) {
base.updatedAt = s.updated_at.replace('T', ' ').substring(0, 16);
}
+7
View File
@@ -312,6 +312,13 @@ export interface DeliverableItem {
space?: 'my_private' | 'project_shared' | 'trash';
sourceType?: DeliverableSourceType;
fileMeta?: UploadedFileMetadata;
/** 服务端权威编辑租约;存在且未过期时文档必须只读并显示占用状态。 */
editLease?: {
holderType?: 'squad' | 'human' | '';
holderId?: string;
expiresAt?: string;
heartbeatAt?: string;
};
}
export interface WorkbenchState {