fix(chat): preserve intent and task runtime state
This commit is contained in:
@@ -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`
|
||||
|
||||
## 开放问题
|
||||
|
||||
- 已污染的四条诊断消息是否由项目创建者确认后做可审计软删除;本任务不擅自修改用户数据。
|
||||
- 误启动的最新任务是否保留其产物;本任务不擅自取消或删除。
|
||||
@@ -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
@@ -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`
|
||||
@@ -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) | 任务拆分与验收 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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”验收。真实会话中的诊断消息与误启动产物只记录证据,不在未获确认时删除或回滚。
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 || '医疗器械研发演示项目',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user