diff --git a/.doc/audit/2026-09-13-conversation-intent-runtime-state.md b/.doc/audit/2026-09-13-conversation-intent-runtime-state.md new file mode 100644 index 00000000..cda1a70d --- /dev/null +++ b/.doc/audit/2026-09-13-conversation-intent-runtime-state.md @@ -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` + +## 开放问题 + +- 已污染的四条诊断消息是否由项目创建者确认后做可审计软删除;本任务不擅自修改用户数据。 +- 误启动的最新任务是否保留其产物;本任务不擅自取消或删除。 diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index 25b1b06e..ce89f04d 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -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) **结论**: 消息仍在数据库;文档任务与激活前消息缺阶段作用域,被刷新后的窗口过滤器剔除,而统计仍返回未过滤数量。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index be268ca8..d38edd74 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 diff --git a/.doc/ops/short-term/2026-09-13-conversation-intent-runtime-state.md b/.doc/ops/short-term/2026-09-13-conversation-intent-runtime-state.md new file mode 100644 index 00000000..f73772cd --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-conversation-intent-runtime-state.md @@ -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) diff --git a/.doc/remediation/2026-09-13-conversation-intent-runtime-state-remediation.md b/.doc/remediation/2026-09-13-conversation-intent-runtime-state-remediation.md new file mode 100644 index 00000000..2e191133 --- /dev/null +++ b/.doc/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` diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index c6b78eeb..e491c68c 100644 --- a/.doc/remediation/INDEX.md +++ b/.doc/remediation/INDEX.md @@ -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) | 任务拆分与验收 | diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index 1fdb9a42..f1187bf0 100644 --- a/.doc/remediation/gap-matrix.md +++ b/.doc/remediation/gap-matrix.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) | --- diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 053d0ab0..e0ddd168 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.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` | diff --git a/.doc/test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md b/.doc/test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md new file mode 100644 index 00000000..4e9913e5 --- /dev/null +++ b/.doc/test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md @@ -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”验收。真实会话中的诊断消息与误启动产物只记录证据,不在未获确认时删除或回滚。 diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index 06498985..aca0a205 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -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, }) } } diff --git a/internal/docgov/project_docs_test.go b/internal/docgov/project_docs_test.go new file mode 100644 index 00000000..7d521de4 --- /dev/null +++ b/internal/docgov/project_docs_test.go @@ -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]) + } +} diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 1947e5a3..f236ae32 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -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, diff --git a/internal/stream/classify.go b/internal/stream/classify.go index 60bc1098..705f87c3 100644 --- a/internal/stream/classify.go +++ b/internal/stream/classify.go @@ -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 diff --git a/internal/stream/classify_test.go b/internal/stream/classify_test.go index dcc3eef1..3242f34a 100644 --- a/internal/stream/classify_test.go +++ b/internal/stream/classify_test.go @@ -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) + } +} diff --git a/internal/taskrun/manager.go b/internal/taskrun/manager.go index a88572be..286c7493 100644 --- a/internal/taskrun/manager.go +++ b/internal/taskrun/manager.go @@ -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 = "任务已取消。" diff --git a/web/src/App.tsx b/web/src/App.tsx index 3842f526..7cfaaeda 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(null); const [commentingBlock, setCommentingBlock] = useState(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([]); const [dateStats, setDateStats] = useState([]); @@ -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 || '医疗器械研发演示项目', diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index a5eaf547..fe7716e4 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -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); } diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index cef70aae..8a1cba46 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -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) + : 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), diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index be3234ad..a9290186 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -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 = { diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 3e5ba218..f04cec67 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -21,9 +21,6 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise{}); - // #endregion if (!res.ok) { let detail = ''; let errorCode = ''; @@ -34,9 +31,6 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise{}); - // #endregion throw Object.assign( new Error(`create task run failed: HTTP ${res.status}${detail ? `: ${detail}` : ''}`), { httpStatus: res.status, errorCode, detail }, diff --git a/web/src/components/chat/chatWindowArtifact.test.ts b/web/src/components/chat/chatWindowArtifact.test.ts index b42c4c9e..a80be8d8 100644 --- a/web/src/components/chat/chatWindowArtifact.test.ts +++ b/web/src/components/chat/chatWindowArtifact.test.ts @@ -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([]); + }); }); diff --git a/web/src/components/chat/deliverableSync.test.ts b/web/src/components/chat/deliverableSync.test.ts index 48a03625..5650c3e1 100644 --- a/web/src/components/chat/deliverableSync.test.ts +++ b/web/src/components/chat/deliverableSync.test.ts @@ -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, + }); }); }); diff --git a/web/src/components/doc/DeckDrawer.tsx b/web/src/components/doc/DeckDrawer.tsx index 3c8be9e4..973f1a38 100644 --- a/web/src/components/doc/DeckDrawer.tsx +++ b/web/src/components/doc/DeckDrawer.tsx @@ -455,6 +455,22 @@ export const DeckDrawer: React.FC = ({ e.stopPropagation(); onOpenDocVisibility?.(doc); })} + {doc.editLease?.holderType && + (!doc.editLease.expiresAt || new Date(doc.editLease.expiresAt).getTime() > Date.now()) && ( + + 编辑中 + + )} {renderBadge(doc.lifecycleState, doc.isClaimed, doc.isTemplateOnly)} diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index ce30e523..01eff442 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -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'); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 2e9251b2..b3c43ec3 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -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; } diff --git a/web/src/lib/controlledDocHydrate.ts b/web/src/lib/controlledDocHydrate.ts index 5c236037..7eacc06a 100644 --- a/web/src/lib/controlledDocHydrate.ts +++ b/web/src/lib/controlledDocHydrate.ts @@ -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; } diff --git a/web/src/lib/deliverableSync.ts b/web/src/lib/deliverableSync.ts index 302a7afe..ffdfa925 100644 --- a/web/src/lib/deliverableSync.ts +++ b/web/src/lib/deliverableSync.ts @@ -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); } diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 0c776218..f16a427d 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -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 {