merge: candidate refresh-task-state-recovery-2026-09-28 into dev

This commit is contained in:
weichen
2026-09-28 06:18:08 +00:00
40 changed files with 2371 additions and 83 deletions
+3 -1
View File
@@ -8,13 +8,15 @@
|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|
| ISSUE-20260928-quality-heading-drift | squad / quality_gate / 缺少必填章节 / State-of-the-Art / H3 | 调研报告章节在,只因 H3 和少空格被判缺章,受控文档不投影 | high | verifying | Cursor | 2026-09-28;dev `research_1790566833205199368` manifest score 85;单测已覆盖,dev 重跑未做 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-quality-heading-drift) | [LESSON-20260928-quality-heading-normalize](../ops/lessons/2026-09-28-quality-heading-normalize.md) | | ISSUE-20260928-quality-heading-drift | squad / quality_gate / 缺少必填章节 / State-of-the-Art / H3 | 调研报告章节在,只因 H3 和少空格被判缺章,受控文档不投影 | high | verifying | Cursor | 2026-09-28;dev `research_1790566833205199368` manifest score 85;单测已覆盖,dev 重跑未做 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-quality-heading-drift) | [LESSON-20260928-quality-heading-normalize](../ops/lessons/2026-09-28-quality-heading-normalize.md) |
| ISSUE-20260928-data-dir-doubled | workspace / data/dev/data/dev / DataDir | 出现双层 `data/dev/data/dev/workspaces` 且只有孤立 output.md,写入点未证实 | medium | open | — | 2026-09-28;`intake_1790565513115586191` 双路径对照 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-data-dir-doubled) | 本轮不修 | | ISSUE-20260928-data-dir-doubled | workspace / data/dev/data/dev / DataDir | 出现双层 `data/dev/data/dev/workspaces` 且只有孤立 output.md,写入点未证实 | medium | open | — | 2026-09-28;`intake_1790565513115586191` 双路径对照 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-data-dir-doubled) | 本轮不修 |
| ISSUE-20260928-progress-query-trigger | intent / matchTriggerTask / QUERY_PROGRESS / 编写进度 | 询问调研报告编写进度时,写动词加任务名词被识别成启动新任务,进度回复不读真实 run | medium | verifying | Cursor | 2026-09-28;「调研报告编写进度怎么样了」单测为 QUERY_PROGRESS;「开始做调研报告」仍为 research。浏览器复验见测试报告 | [主记录](../ops/short-term/2026-09-28-refresh-task-state-recovery.md) | 疑问进度句必须让行查询;显式「开始/重新」仍启动任务 |
| ISSUE-20260928-step-empty-output | squad / stepOutputAccepted / output_digest / e3b0c442 / MaterializeTargets | 步骤已把正文写到 output.md,判定仍因 runner Success=false 或空 patch 记空输出失败 | high | verifying | Cursor | 2026-09-28;11:27 `research_1790566028724181453` 的 06_draft 盘上 41KB 但 digest 为空;单测覆盖缺交付物但盘上有正文,以及连接错误仍失败关闭 | [主记录](../ops/short-term/2026-09-28-refresh-task-state-recovery.md) | 传输/可重试失败留下的文件可能是截断的,不能当成功;缺交付物且盘上非空才接受 |
| ISSUE-20260928-task-write-verb | intent / hasTaskWriteVerb / 帮我定 / 制定 / AMBIGUOUS_FALLBACK | 「帮我定/制定」设计开发计划任务书未进入任务触发,对话只有掂量阶段模板 | medium | resolved | Cursor | 2026-09-28;test sess-p8-u33 msg 89/90;意图单测 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) | | ISSUE-20260928-task-write-verb | intent / hasTaskWriteVerb / 帮我定 / 制定 / AMBIGUOUS_FALLBACK | 「帮我定/制定」设计开发计划任务书未进入任务触发,对话只有掂量阶段模板 | medium | resolved | Cursor | 2026-09-28;test sess-p8-u33 msg 89/90;意图单测 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) |
| ISSUE-20260928-workflow-scope-reply | coordinator / ClarificationQuestion / ErrPrerequisiteNotMet / rd_design_plan | 跨工作流澄清与研发前置未满足时,原因没有进入对话 | medium | resolved | Cursor | 2026-09-28;协调器单测。test 原例停在动词未命中,未走到本分支 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) | | ISSUE-20260928-workflow-scope-reply | coordinator / ClarificationQuestion / ErrPrerequisiteNotMet / rd_design_plan | 跨工作流澄清与研发前置未满足时,原因没有进入对话 | medium | resolved | Cursor | 2026-09-28;协调器单测。test 原例停在动词未命中,未走到本分支 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) |
| ISSUE-20260928-quality-failed-success-copy | bizdev / quality_failed / 正文已保存至 / output.md | 质量未通过仍声称正文已保存,并写出工作区绝对路径 | medium | resolved | Cursor | 2026-09-28;test cdo_quote_1790560155851280170;同步回复单测 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) | | ISSUE-20260928-quality-failed-success-copy | bizdev / quality_failed / 正文已保存至 / output.md | 质量未通过仍声称正文已保存,并写出工作区绝对路径 | medium | resolved | Cursor | 2026-09-28;test cdo_quote_1790560155851280170;同步回复单测 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) |
| ISSUE-20260928-pi-runner-data-dir | agent / PI runner / DATA_DIR / MISSING_TARGET / 18866 | test 启动时根 .env 的 dev runner 地址盖过本环境 sidecar,工作区被重写到空目录 | high | resolved | Cursor | 2026-09-28;dev `4f581306`。当时 test 二进制仍是修复前制品 | [验收报告](../test-reports/2026-09-28-pi-runner-env-mismatch-test-report.md) | — | | ISSUE-20260928-pi-runner-data-dir | agent / PI runner / DATA_DIR / MISSING_TARGET / 18866 | test 启动时根 .env 的 dev runner 地址盖过本环境 sidecar,工作区被重写到空目录 | high | resolved | Cursor | 2026-09-28;dev `4f581306`。当时 test 二进制仍是修复前制品 | [验收报告](../test-reports/2026-09-28-pi-runner-env-mismatch-test-report.md) | — |
| ISSUE-20260928-material-chunk-embed | knowledge / chunk index failed / 8192 / 34635 tokens | 材料分块批超过嵌入模型上下文,索引失败 | medium | open | — | 2026-09-28;test 日志 project 8,文件 W020251117353305579317.docx | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | 本轮不修 | | ISSUE-20260928-material-chunk-embed | knowledge / chunk index failed / 8192 / 34635 tokens | 材料分块批超过嵌入模型上下文,索引失败 | medium | open | — | 2026-09-28;test 日志 project 8,文件 W020251117353305579317.docx | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | 本轮不修 |
| ISSUE-20260928-attachment-only-turn | chat / chat/stream / message cannot be empty / HTTP_400 / attachments | 只上传文档、不输入文字时 turn 被空消息 400 拒绝,前端不读信封,用户看不到任何回复 | high | resolved | Cursor | 2026-09-28;dev :8080 重载后浏览器仅附件得到梳理审阅,无关文字先回答再追问;pending 分支 API 200 | [主记录](../ops/short-term/2026-09-28-attachment-only-turn.md) | 空消息门禁要放在附件判定之后;4xx 必须读信封。见[测试报告](../test-reports/2026-09-28-attachment-only-turn-test-report.md) | | ISSUE-20260928-attachment-only-turn | chat / chat/stream / message cannot be empty / HTTP_400 / attachments | 只上传文档、不输入文字时 turn 被空消息 400 拒绝,前端不读信封,用户看不到任何回复 | high | resolved | Cursor | 2026-09-28;dev :8080 重载后浏览器仅附件得到梳理审阅,无关文字先回答再追问;pending 分支 API 200 | [主记录](../ops/short-term/2026-09-28-attachment-only-turn.md) | 空消息门禁要放在附件判定之后;4xx 必须读信封。见[测试报告](../test-reports/2026-09-28-attachment-only-turn-test-report.md) |
| ISSUE-20260928-task-run-replay-502 | web / useRecoveredTaskPhases / tasks/runs / ERR_HTTP2_PROTOCOL_ERROR / 502 | 历史失败气泡恢复订阅 task run 事件时出现 HTTP/2 协议错误和 502,与附件空消息 400 无因果关系 | medium | investigating | Cursor | 2026-09-28;用户控制台 `run_1790558010622744831`;本轮未改订阅逻辑 | [主记录](../ops/short-term/2026-09-28-attachment-only-turn.md#范围外) | 未修,保持观察 | | ISSUE-20260928-task-run-replay-502 | web / useRecoveredTaskPhases / tasks/runs / ERR_HTTP2_PROTOCOL_ERROR / 502 | 历史失败气泡恢复订阅 task run 事件时出现 HTTP/2 协议错误和 502,与附件空消息 400 无因果关系 | medium | verifying | Cursor | 2026-09-28;终态重放改为最多 4 次退避,401/404 立即停止;进行中订阅外层只再补 1 次,避免和内部 40 次重试相乘。尚未在真实 502 上复验 | [主记录](../ops/short-term/2026-09-28-refresh-task-state-recovery.md) | 重放失败要可重试;鉴权与不存在不要空转 |
| ISSUE-20260928-activation-preview-precheck | web / projectSettings / activation-preview / NO_GOVERNED_DOCUMENT / clientDutyIssues | 全部文档未纳入人工审批时前端仍 POST activation-preview,控制台 400 | medium | resolved | Cursor | 2026-09-28;dev 项目 6023 默认计划无 activation-preview 请求,兼任后 preview 200 | [主记录](../ops/short-term/2026-09-28-activation-preview-precheck.md#issue-20260928-activation-preview-precheck) | [LESSON-20260928-activation-preview-client-gate](../ops/lessons/2026-09-28-activation-preview-client-gate.md) | | ISSUE-20260928-activation-preview-precheck | web / projectSettings / activation-preview / NO_GOVERNED_DOCUMENT / clientDutyIssues | 全部文档未纳入人工审批时前端仍 POST activation-preview,控制台 400 | medium | resolved | Cursor | 2026-09-28;dev 项目 6023 默认计划无 activation-preview 请求,兼任后 preview 200 | [主记录](../ops/short-term/2026-09-28-activation-preview-precheck.md#issue-20260928-activation-preview-precheck) | [LESSON-20260928-activation-preview-client-gate](../ops/lessons/2026-09-28-activation-preview-client-gate.md) |
| ISSUE-20260928-admin-secret-password-form | web / EnvSettingsSection / password / form / secrets | 管理台 5 个密钥输入不在 form 内,Chromium 报 password DOM 警告 | low | resolved | Cursor | 2026-09-28;dev 模型与密钥页 passwordInputs=5 outsideForm=0 | [主记录](../ops/short-term/2026-09-28-activation-preview-precheck.md#issue-20260928-admin-secret-password-form) | 密码框放进 form;autocomplete 不能单独消除该 DOM 警告 | | ISSUE-20260928-admin-secret-password-form | web / EnvSettingsSection / password / form / secrets | 管理台 5 个密钥输入不在 form 内,Chromium 报 password DOM 警告 | low | resolved | Cursor | 2026-09-28;dev 模型与密钥页 passwordInputs=5 outsideForm=0 | [主记录](../ops/short-term/2026-09-28-activation-preview-precheck.md#issue-20260928-admin-secret-password-form) | 密码框放进 form;autocomplete 不能单独消除该 DOM 警告 |
| ISSUE-20260928-squad-commit-authority | bizdev / docflow / dutyAuthorizer / uow permission denied / OnBehalfOf / bd_intake | 非编制人触发受控小队任务时,账本提交用触发者身份,被编制人职责门拒绝,正文无法落盘 | high | fixing | Cursor | 2026-09-28;dev task_runs user 74 ≠ author 4(doc_proj6022_bd_intake);单测已绿,dev 重跑未做 | [主记录](../ops/short-term/2026-09-28-squad-commit-authority.md#issue-20260928-squad-commit-authority) | [LESSON-20260928-squad-commit-as-author](../ops/lessons/2026-09-28-squad-commit-as-author.md) | | ISSUE-20260928-squad-commit-authority | bizdev / docflow / dutyAuthorizer / uow permission denied / OnBehalfOf / bd_intake | 非编制人触发受控小队任务时,账本提交用触发者身份,被编制人职责门拒绝,正文无法落盘 | high | fixing | Cursor | 2026-09-28;dev task_runs user 74 ≠ author 4(doc_proj6022_bd_intake);单测已绿,dev 重跑未做 | [主记录](../ops/short-term/2026-09-28-squad-commit-authority.md#issue-20260928-squad-commit-authority) | [LESSON-20260928-squad-commit-as-author](../ops/lessons/2026-09-28-squad-commit-as-author.md) |
@@ -0,0 +1,57 @@
---
title: "刷新后恢复任务进度并纠正进度问询"
task_id: refresh-task-state-recovery-2026-09-28
status: in_progress
created: 2026-09-28
updated: 2026-09-28
owner: Cursor
task_type: fix
policy_revision: "AGENTS.md + agent-worktree-workflow @ 2026-09-21"
branch: fix/refresh-task-state-recovery-2026-09-28
worktree: .worktrees/refresh-task-state-recovery-2026-09-28
base_dev_sha: "4c42d1bf74e262930bcf127e517ab6b76a0dda71"
write_scope:
- internal
- web
- .doc
mutex: refresh-task-state-recovery
review_state: pending
integration_state: branch_only
deployment: not_loaded
cleanup: pending
remote_sync: not_requested
---
# 刷新后恢复任务进度并纠正进度问询
## 用户目标与授权
用户发起调研报告后,聊天下方的任务进度在刷新页面时消失。随后两次询问「调研报告编写进度怎么样了」,没有看到调研报告生成。用户要求浏览器和服务器同时记下进行中的任务:刷新或重连后先显示本地缓存,再按会话和任务标识向服务器收敛。授权范围同时包含进度问句被当成新任务,以及 11:27 / 11:38 两次步骤被判空输出失败。
## 检索
- 问题台账命中 ISSUE-20260928-task-run-replay-502(历史失败气泡重放 502)。本轮给重放补了退避,并保持终态 `replay_only`。
- catalog overlap 探测无硬重叠。mutex 为 `refresh-task-state-recovery`,不占用 `chat+coordinator` 与 `bizdev+docflow`。
## 根因
1. `run_id` 与阶段快照只在任务终态写回助手消息。窗口缓存只保存服务端窗口,刷新后没有锚点,进度卡片无法重放。
2. 「调研报告编写进度怎么样了」含写动词「编写」和任务名词「调研报告」。`matchTriggerTask` 排在进度查询之前,于是又启动了 research。进度回复只读模板,不读真实 run。
3. 步骤判定把 runner 的 `Success=false` 当成失败,即使盘上已经有正文。连接错误留下的文件仍可能是截断的,这条不能放宽。
## 修复
- run 启动和绑定助手消息时写入 `{run_id, task_status: running}`。
- `GET /api/v1/tasks/runs?session_id=&active=1` 只返回当前用户、当前会话里仍为 pending/running 的 run。
- 浏览器 `smdf_agent_workbench_v6_active_tasks` 在窗口就绪后先画出进行中卡片,再用活跃列表收敛;终态删除缓存。
- 有 run 锚点但还没有 `task_status` 和阶段快照时,消息保持 `streaming_status`。终态重放对 502 退避,401/404 不再试。
- 疑问进度句让行 `QUERY_PROGRESS`。回复读取该会话最近一次 run 的真实状态。
- 盘上已有非空正文,且失败原因不是传输/可重试错误时,步骤接受该文件。空 patch 不覆盖已有 `output.md`。
## 验证
见 [测试报告](../../test-reports/2026-09-28-refresh-task-state-recovery-test-report.md)。
## 五态
合入与 dev 重载完成后回写。当前仍是任务分支上的实现。
@@ -0,0 +1,80 @@
# 刷新后任务状态恢复 测试报告
## 一、 测试背景 (Background)
刷新页面后,进行中的调研报告进度卡片消失。询问「调研报告编写进度怎么样了」又启动了新的 research 工作区。11:27 的 `step_06_draft` 与 11:38 的并行步骤被记成空输出失败,盘上的 `output.md` 已经有正文。
## 二、 测试目标 (Test Objectives)
- 进行中的 run 在刷新后仍能回到进度卡片。
- 疑问进度句查询真实 run,不新建工作区。
- 盘上已有非空正文且不是传输失败时,步骤不再被空结果否决。
- 连接错误留下的文件仍然失败关闭。
## 三、 测试方案与环境 (Test Plan & Environment)
- OS:Linux。工作树:`.worktrees/refresh-task-state-recovery-2026-09-28`,基线 `4c42d1bf`。
- 层级:Go/接口短测、前端单测与构建、架构边界检查。产品 PI 浏览器链路在 dev 重载后另记,本文件先记录已跑命令。
- 证据目录:`.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/`。
## 四、 预期结果 (Expected Results)
- `go vet ./...` 无输出。
- 本改动涉及的包 `go test -short` 通过。
- `web` 的 `npm test` 与 `npm run build` 通过。
- 「调研报告编写进度怎么样了」不是写意图;「开始做调研报告」解析为 research。
- 缺交付物但盘上有正文时步骤接受;`Connection error` 且可重试时拒绝。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 用例 1:意图与进度回复
- 输入:`go test -short -count=1 ./internal/intent/ ./internal/coordinator/`
- 实际输出:两包 `ok`(intent 0.179s,coordinator 0.034s,见 `go-test-short.txt`)。新增用例「调研报告编写进度怎么样了」为 `QUERY_PROGRESS`,「开始做调研报告」为 `research`。
- 判定:PASS
- 耗时:见短测日志
### 用例 2:空输出判定与空 patch
- 输入:`go test -short -count=1 ./internal/squad/ ./internal/agent/`
- 实际输出:两包 `ok`(squad 0.041s,agent 2.252s)。连接错误仍失败关闭的既有用例通过。
- 判定:PASS
### 用例 3:锚点、活跃列表、消息映射、重放退避、本地缓存
- 输入:`go test -short ./internal/taskrun/ ./internal/server/`;`npm test` 中的 `activeTaskCache`、`useRecoveredTaskPhases`、`useChatWindow`、`templateMatch`
- 实际输出:taskrun `ok` 0.171s,server `ok` 20.914s。`npm test` 532 通过、vitest 34 通过,`NPM_TEST:0`。
- 判定:PASS
### 用例 4:全量短测与架构边界
- 输入:`go vet ./...`;`go test -short -count=1 ./...`;`./scripts/arch-check.sh --mode block`
- 实际输出:`VET:0`。短测唯一失败是 `internal/archtest`:`NEW import coordinator internal/coordinator/engine_task.go projectworkflow`。该文件相对基线 `4c42d1bf` 的 diff 为 0,导入来自已合入的 `agent-reply-llm-narrative`,不是本任务新增。`ARCH:1` 与之相同。
- 判定:本任务包 PASS;架构边界 FAIL 记为基线既有项,本任务未改该导入
### 用例 5:前端构建
- 输入:`npm run build`
- 实际输出:`NPM_BUILD:0`,precompress 写出 66 个 br/gz。
- 判定:PASS
### 用例 6:dev 浏览器与真实 PI Runner
- 输入:发起调研报告,执行中刷新,完成后刷新,再问编写进度。
- 实际输出:本段写入时尚未在已加载新代码的 dev 上运行。
- 判定:未运行
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 检查 | 结果 |
|---|---|
| go vet ./... | PASS(VET:0) |
| go test -short ./... | 除既有 archtest 外 PASS |
| arch-check --mode block | FAIL(基线既有 coordinator→projectworkflow) |
| npm test | PASS(532 + 34) |
| npm run build | PASS |
| dev 浏览器 + 真实 PI | 未运行 |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
实现与单测、前端构建已通过。交付终点还差:把候选合入 local dev、重载服务后,用真实 PI Runner 走刷新恢复和进度问句。架构边界失败不由本 diff 引入,不在本 mutex 里改 `engine_task.go`。
@@ -0,0 +1,16 @@
arch-check mode=block
boundaries=/home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/.doc/system-design/module-boundaries.yaml
--- FAIL: TestArchitectureBoundaries (0.19s)
arch_test.go:32: archtest findings=1 excepted=0 unmatched=1
import=1
sql_write=0
path=0
event=0
REGRESSION import: 1 > baseline 0
NEW import coordinator internal/coordinator/engine_task.go projectworkflow (production import coordinator -> projectworkflow)
FAIL
FAIL chat-agent-system/internal/archtest 0.195s
FAIL
arch-check: new, expired, or unowned findings failed go test ./internal/archtest
ARCH:1
@@ -0,0 +1,99 @@
? chat-agent-system/cmd/artifact-vault [no test files]
ok chat-agent-system/cmd/bizdev-cli 2.985s
? chat-agent-system/cmd/dataset [no test files]
ok chat-agent-system/cmd/document-content-reconcile 0.003s
? chat-agent-system/cmd/document-revision-backfill [no test files]
? chat-agent-system/cmd/eventmode [no test files]
? chat-agent-system/cmd/events [no test files]
? chat-agent-system/cmd/maintpage [no test files]
? chat-agent-system/cmd/recategorize [no test files]
ok chat-agent-system/cmd/server 0.012s
ok chat-agent-system/internal/agent 2.252s
ok chat-agent-system/internal/agentx 0.023s
ok chat-agent-system/internal/agentx/abtest 0.004s
ok chat-agent-system/internal/agentx/llm 0.024s
ok chat-agent-system/internal/agentx/session 0.008s
ok chat-agent-system/internal/agentx/tools 0.006s
ok chat-agent-system/internal/app 0.046s
ok chat-agent-system/internal/app/chatflow 0.018s
ok chat-agent-system/internal/app/docadvance 0.015s
ok chat-agent-system/internal/app/docflow 0.009s
ok chat-agent-system/internal/app/docnotify 0.005s
ok chat-agent-system/internal/app/docproject 0.003s
? chat-agent-system/internal/app/eventsconsumer [no test files]
ok chat-agent-system/internal/app/fav 0.007s
ok chat-agent-system/internal/app/intentflow 0.088s
ok chat-agent-system/internal/app/stageflow 0.010s
ok chat-agent-system/internal/app/uow 0.005s
--- FAIL: TestArchitectureBoundaries (0.50s)
arch_test.go:32: archtest findings=1 excepted=0 unmatched=1
import=1
sql_write=0
path=0
event=0
REGRESSION import: 1 > baseline 0
NEW import coordinator internal/coordinator/engine_task.go projectworkflow (production import coordinator -> projectworkflow)
FAIL
FAIL chat-agent-system/internal/archtest 0.507s
ok chat-agent-system/internal/artifactvault 0.005s
ok chat-agent-system/internal/auth 1.494s
ok chat-agent-system/internal/authz 0.007s
ok chat-agent-system/internal/bizdev 0.418s
ok chat-agent-system/internal/chat 0.067s
ok chat-agent-system/internal/config 0.009s
ok chat-agent-system/internal/conflictresolve 0.006s
? chat-agent-system/internal/contracts/vocabulary [no test files]
ok chat-agent-system/internal/coordinator 0.034s
ok chat-agent-system/internal/ctxmgr 0.174s
ok chat-agent-system/internal/dataset 0.010s
ok chat-agent-system/internal/db 0.007s
ok chat-agent-system/internal/dialoguectx 0.007s
ok chat-agent-system/internal/doccontent 0.004s
ok chat-agent-system/internal/docduty 0.004s
ok chat-agent-system/internal/docgov 0.006s
ok chat-agent-system/internal/docquery 0.004s
ok chat-agent-system/internal/docrevision 0.017s
ok chat-agent-system/internal/errcode 0.007s
ok chat-agent-system/internal/errorevents 0.008s
ok chat-agent-system/internal/events 0.012s
ok chat-agent-system/internal/freedoc 0.035s
ok chat-agent-system/internal/fsutil 0.005s
ok chat-agent-system/internal/handoff 0.008s
ok chat-agent-system/internal/httpx 0.004s
ok chat-agent-system/internal/intent 0.179s
ok chat-agent-system/internal/jevdecide 0.274s
ok chat-agent-system/internal/kb 0.013s
ok chat-agent-system/internal/localpg 0.598s
ok chat-agent-system/internal/maintenance 0.005s
ok chat-agent-system/internal/materialindex 0.007s
ok chat-agent-system/internal/maturity 0.007s
ok chat-agent-system/internal/mineru 3.008s
ok chat-agent-system/internal/notification 0.005s
? chat-agent-system/internal/observability [no test files]
ok chat-agent-system/internal/projectcontext 0.005s
ok chat-agent-system/internal/projectinstance 0.010s
ok chat-agent-system/internal/projectworkflow 0.003s
ok chat-agent-system/internal/quote 0.012s
ok chat-agent-system/internal/replyexport 0.002s
ok chat-agent-system/internal/server 20.914s
ok chat-agent-system/internal/squad 0.041s
ok chat-agent-system/internal/storage 0.003s
ok chat-agent-system/internal/stream 0.003s
ok chat-agent-system/internal/stream/transport 0.003s
ok chat-agent-system/internal/supplementimpact 0.003s
ok chat-agent-system/internal/swboot 0.070s
ok chat-agent-system/internal/sysadmin 0.003s
ok chat-agent-system/internal/taskrun 0.171s
ok chat-agent-system/internal/template 1.059s
ok chat-agent-system/internal/traceimpact 0.003s
ok chat-agent-system/internal/upload 0.002s
? chat-agent-system/internal/workflow [no test files]
ok chat-agent-system/internal/workflow/runtime 0.004s
ok chat-agent-system/internal/workflowdef 0.004s
ok chat-agent-system/internal/workflowscope 0.002s
ok chat-agent-system/internal/workspace 0.003s
? chat-agent-system/templates [no test files]
? chat-agent-system/web [no test files]
FAIL
TEST:1
@@ -0,0 +1,291 @@
> shuimu-workbench@1.0.0 build
> tsc && vite build && node scripts/precompress-dist.mjs
vite v7.3.6 building client environment for production...
transforming...
✓ 4169 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.72 kB │ gzip: 0.45 kB
dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB
dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB
dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB
dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB
dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB
dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB
dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB
dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB
dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB
dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB
dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB
dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB
dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB
dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB
dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB
dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB
dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB
dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB
dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB
dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB
dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB
dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB
dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB
dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB
dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB
dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB
dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB
dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB
dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB
dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB
dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB
dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB
dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB
dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB
dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB
dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB
dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB
dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB
dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB
dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB
dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB
dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB
dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB
dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB
dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB
dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB
dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB
dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB
dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB
dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB
dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB
dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB
dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB
dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB
dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB
dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB
dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB
dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB
dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB
dist/assets/index-Bq7tVxen.css 6.58 kB │ gzip: 1.99 kB
dist/assets/katex-BOvhZl6_.css 30.25 kB │ gzip: 8.09 kB
dist/assets/channel-DHR9WBLM.js 0.12 kB │ gzip: 0.13 kB
dist/assets/katexRender-DyvI-2y4.js 0.15 kB │ gzip: 0.15 kB
dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB
dist/assets/chunk-2Q5K7J3B-CHsI7zem.js 0.19 kB │ gzip: 0.16 kB
dist/assets/chunk-JWPE2WC7-CeOXuZlw.js 0.23 kB │ gzip: 0.17 kB
dist/assets/chunk-XXDRQBXY-Cu_mBr7x.js 0.23 kB │ gzip: 0.21 kB
dist/assets/git-branch-D2R7ViCv.js 0.26 kB │ gzip: 0.21 kB
dist/assets/chunk-5VM5RSS4-lzTX0tjp.js 0.37 kB │ gzip: 0.27 kB
dist/assets/stateDiagram-v2-MP3YSRHH-dJKbyi-1.js 0.43 kB │ gzip: 0.30 kB
dist/assets/classDiagram-ZZMXUADV-CTPa2PBn.js 0.47 kB │ gzip: 0.32 kB
dist/assets/classDiagram-v2-VYDZK3BY-CTPa2PBn.js 0.47 kB │ gzip: 0.32 kB
dist/assets/chunk-POPQ4Y6H-B2jpZUkp.js 0.51 kB │ gzip: 0.36 kB
dist/assets/swimlanesDiagram-VR7AAH4N-jP-BQxXC.js 0.55 kB │ gzip: 0.37 kB
dist/assets/calculator-K0r_LA6K.js 0.56 kB │ gzip: 0.30 kB
dist/assets/infoDiagram-27XIBGKW-BO8R-0BH.js 0.59 kB │ gzip: 0.40 kB
dist/assets/sizeCapture-INFHLROL-KBPVYQON.js 0.94 kB │ gzip: 0.53 kB
dist/assets/downloadDocx-Ep6Y-mWJ.js 1.13 kB │ gzip: 0.77 kB
dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB
dist/assets/railroadDiagram-O6MQD6OU-3o3NI4V-.js 1.61 kB │ gzip: 0.76 kB
dist/assets/MathBlockView-CP3gIAfz.js 1.67 kB │ gzip: 0.97 kB
dist/assets/abnfDiagram-VCTEODGH-Bv_S-VQO.js 1.81 kB │ gzip: 0.89 kB
dist/assets/chunk-F27PBJKO-DwG2Zkg1.js 1.88 kB │ gzip: 0.83 kB
dist/assets/pegDiagram-XKGWAZYB-BRKb5jpA.js 1.96 kB │ gzip: 0.92 kB
dist/assets/ebnfDiagram-PWID7BFC-CAlTqs1O.js 2.01 kB │ gzip: 0.88 kB
dist/assets/xlsxRenderer-BQPATLFB.js 2.79 kB │ gzip: 1.69 kB
dist/assets/arc-DOxBXeCg.js 3.44 kB │ gzip: 1.48 kB
dist/assets/MermaidBlockView-BNZ12-FU.js 3.69 kB │ gzip: 1.80 kB
dist/assets/diagram-Z3DM3KII-iO8-m9Hl.js 4.21 kB │ gzip: 1.83 kB
dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB
dist/assets/renderClipboardHtml-BXoFMW-3.js 5.18 kB │ gzip: 1.78 kB
dist/assets/ExcelViewer-BZSJZn5Y.js 5.55 kB │ gzip: 2.21 kB
dist/assets/linear-BDZQyaJG.js 5.66 kB │ gzip: 2.31 kB
dist/assets/diagram-UQ7AKVKN-BnBJ_2R3.js 6.08 kB │ gzip: 2.62 kB
dist/assets/pieDiagram-E7YTZNPT-nrtuGrNr.js 6.29 kB │ gzip: 2.62 kB
dist/assets/ChatExportModal-Bfjz4958.js 7.58 kB │ gzip: 3.29 kB
dist/assets/diagram-S7CK7UJ4-T-Bxl4t1.js 8.22 kB │ gzip: 3.67 kB
dist/assets/cynefinDiagram-5FMLGOSQ-CnH3Z2JB.js 10.10 kB │ gzip: 3.63 kB
dist/assets/stateDiagram-D77RDMKH-DVKtcg-v.js 10.36 kB │ gzip: 3.62 kB
dist/assets/dagre-GXQ25YYZ-4R6bcum3.js 10.68 kB │ gzip: 4.03 kB
dist/assets/diagram-VSXAHHWV-DLnsWaIP.js 10.72 kB │ gzip: 4.13 kB
dist/assets/chunk-SVP7TREG-BhgGIDFD.js 15.74 kB │ gzip: 4.41 kB
dist/assets/diagram-VX7I27RA-Duotn1-e.js 15.96 kB │ gzip: 5.70 kB
dist/assets/ishikawaDiagram-5VMMS53U-Cg4LfJyo.js 17.47 kB │ gzip: 6.64 kB
dist/assets/kanban-definition-UXKFOSKX-BALPAjTC.js 20.29 kB │ gzip: 7.20 kB
dist/assets/BlockEditorModal-BOLlSDTH.js 22.24 kB │ gzip: 6.47 kB
dist/assets/mindmap-definition-YA3MSWOX-pgZMQ8uL.js 23.18 kB │ gzip: 7.83 kB
dist/assets/sankeyDiagram-P5KCCOFB-cHAs8eQM.js 23.20 kB │ gzip: 8.56 kB
dist/assets/journeyDiagram-3NMN7TZE-DXSneWK0.js 23.57 kB │ gzip: 8.35 kB
dist/assets/wardleyDiagram-VM6X3IG4-83YMC1gW.js 25.50 kB │ gzip: 6.79 kB
dist/assets/gitGraphDiagram-WWUBYQGX-3ajohVe1.js 29.42 kB │ gzip: 8.70 kB
dist/assets/FilePreviewPanel-BaAl4sya.js 30.12 kB │ gzip: 8.97 kB
dist/assets/erDiagram-RLTQ6QDP-BDpOdOIc.js 30.79 kB │ gzip: 10.59 kB
dist/assets/timeline-definition-24CTP7MA-d7EudEZG.js 30.83 kB │ gzip: 10.15 kB
dist/assets/requirementDiagram-BXWQKSXE-2YVxwHhy.js 31.00 kB │ gzip: 9.73 kB
dist/assets/quadrantDiagram-AXDQQJYC-DHNugfXZ.js 34.01 kB │ gzip: 9.98 kB
dist/assets/layout-BrexQxSk.js 35.44 kB │ gzip: 12.75 kB
dist/assets/chunk-IMKFNOWR-DL3J6pk5.js 38.08 kB │ gzip: 12.51 kB
dist/assets/docxRenderer-Ilu9VYYG.js 40.90 kB │ gzip: 12.04 kB
dist/assets/blockDiagram-I7D4REHJ-ozi4Xk9Q.js 41.76 kB │ gzip: 13.58 kB
dist/assets/vennDiagram-4TSXK5OY-Dswu0LDK.js 41.96 kB │ gzip: 15.70 kB
dist/assets/xychartDiagram-S5SC5T6Z-Do0p5dm5.js 44.51 kB │ gzip: 12.60 kB
dist/assets/chunk-TICWLB2K-CsHC9dlc.js 49.18 kB │ gzip: 15.74 kB
dist/assets/flowDiagram-HODETNUW-BGwlzTt0.js 61.85 kB │ gzip: 19.87 kB
dist/assets/c4Diagram-7LVT6UL2-MxYk0iqL.js 65.47 kB │ gzip: 18.79 kB
dist/assets/ganttDiagram-EL5Y4UJY-ijkjYohu.js 69.70 kB │ gzip: 23.56 kB
dist/assets/AdminConsoleView-C6f6_5zf.js 74.34 kB │ gzip: 18.18 kB
dist/assets/cose-bilkent-JH36ORCC-DW6Qeju_.js 81.70 kB │ gzip: 22.47 kB
dist/assets/swimlanes-42K2YHIH-BaJ44xBg.js 115.79 kB │ gzip: 42.51 kB
dist/assets/sequenceDiagram-WJ2MYXX4-BqEzakUS.js 116.96 kB │ gzip: 31.13 kB
dist/assets/architectureDiagram-5GKGNRK7-BW5RJe7u.js 151.83 kB │ gzip: 43.14 kB
dist/assets/DocxViewer-thvChHTS.js 175.87 kB │ gzip: 52.31 kB
dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB
dist/assets/katex-BJxdM42i.js 261.44 kB │ gzip: 77.80 kB
dist/assets/index-BXtlAAcH.js 365.75 kB │ gzip: 104.72 kB
dist/assets/xlsx-Cig0qvpi.js 424.39 kB │ gzip: 141.60 kB
dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB
dist/assets/mermaid.core-C3o1vFDn.js 682.57 kB │ gzip: 168.90 kB
dist/assets/cynefin-OW5HDTMX-Ca8YX88f.js 687.91 kB │ gzip: 154.14 kB
dist/assets/index-cbAtdfhj.js 814.04 kB │ gzip: 239.32 kB
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 8.74s
precompress: 66 candidate(s) under /home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web/dist
br assets/AdminConsoleView-C6f6_5zf.js 74340 → 14847 (−80.0%)
gzip assets/AdminConsoleView-C6f6_5zf.js 74340 → 17742 (−76.1%)
br assets/BlockEditorModal-BOLlSDTH.js 22241 → 5389 (−75.8%)
gzip assets/BlockEditorModal-BOLlSDTH.js 22241 → 6375 (−71.3%)
br assets/ChatExportModal-Bfjz4958.js 7581 → 2732 (−64.0%)
gzip assets/ChatExportModal-Bfjz4958.js 7581 → 3242 (−57.2%)
br assets/DocxViewer-thvChHTS.js 175867 → 46191 (−73.7%)
gzip assets/DocxViewer-thvChHTS.js 175867 → 52082 (−70.4%)
br assets/ExcelViewer-BZSJZn5Y.js 5552 → 1885 (−66.0%)
gzip assets/ExcelViewer-BZSJZn5Y.js 5552 → 2194 (−60.5%)
br assets/FilePreviewPanel-BaAl4sya.js 30116 → 7564 (−74.9%)
gzip assets/FilePreviewPanel-BaAl4sya.js 30116 → 8813 (−70.7%)
br assets/MathBlockView-CP3gIAfz.js 1669 → 800 (−52.1%)
gzip assets/MathBlockView-CP3gIAfz.js 1669 → 962 (−42.4%)
br assets/MermaidBlockView-BNZ12-FU.js 3690 → 1499 (−59.4%)
gzip assets/MermaidBlockView-BNZ12-FU.js 3690 → 1794 (−51.4%)
br assets/abnfDiagram-VCTEODGH-Bv_S-VQO.js 1809 → 785 (−56.6%)
gzip assets/abnfDiagram-VCTEODGH-Bv_S-VQO.js 1809 → 895 (−50.5%)
br assets/arc-DOxBXeCg.js 3441 → 1296 (−62.3%)
gzip assets/arc-DOxBXeCg.js 3441 → 1455 (−57.7%)
br assets/architectureDiagram-5GKGNRK7-BW5RJe7u.js 151827 → 36975 (−75.6%)
gzip assets/architectureDiagram-5GKGNRK7-BW5RJe7u.js 151827 → 42841 (−71.8%)
br assets/blockDiagram-I7D4REHJ-ozi4Xk9Q.js 41757 → 11969 (−71.3%)
gzip assets/blockDiagram-I7D4REHJ-ozi4Xk9Q.js 41757 → 13469 (−67.7%)
br assets/c4Diagram-7LVT6UL2-MxYk0iqL.js 65473 → 15133 (−76.9%)
gzip assets/c4Diagram-7LVT6UL2-MxYk0iqL.js 65473 → 18679 (−71.5%)
br assets/chunk-F27PBJKO-DwG2Zkg1.js 1883 → 742 (−60.6%)
gzip assets/chunk-F27PBJKO-DwG2Zkg1.js 1883 → 831 (−55.9%)
br assets/chunk-IMKFNOWR-DL3J6pk5.js 38076 → 10974 (−71.2%)
gzip assets/chunk-IMKFNOWR-DL3J6pk5.js 38076 → 12434 (−67.3%)
br assets/chunk-SVP7TREG-BhgGIDFD.js 15735 → 3966 (−74.8%)
gzip assets/chunk-SVP7TREG-BhgGIDFD.js 15735 → 4418 (−71.9%)
br assets/chunk-TICWLB2K-CsHC9dlc.js 49178 → 13033 (−73.5%)
gzip assets/chunk-TICWLB2K-CsHC9dlc.js 49178 → 15645 (−68.2%)
br assets/cose-bilkent-JH36ORCC-DW6Qeju_.js 81701 → 19574 (−76.0%)
gzip assets/cose-bilkent-JH36ORCC-DW6Qeju_.js 81701 → 22391 (−72.6%)
br assets/cynefin-OW5HDTMX-Ca8YX88f.js 687913 → 124540 (−81.9%)
gzip assets/cynefin-OW5HDTMX-Ca8YX88f.js 687913 → 153068 (−77.7%)
br assets/cynefinDiagram-5FMLGOSQ-CnH3Z2JB.js 10097 → 3248 (−67.8%)
gzip assets/cynefinDiagram-5FMLGOSQ-CnH3Z2JB.js 10097 → 3620 (−64.1%)
br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%)
gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141741 (−68.0%)
br assets/dagre-GXQ25YYZ-4R6bcum3.js 10676 → 3680 (−65.5%)
gzip assets/dagre-GXQ25YYZ-4R6bcum3.js 10676 → 4046 (−62.1%)
br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%)
gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2166 (−53.8%)
br assets/diagram-S7CK7UJ4-T-Bxl4t1.js 8217 → 3326 (−59.5%)
gzip assets/diagram-S7CK7UJ4-T-Bxl4t1.js 8217 → 3677 (−55.3%)
br assets/diagram-UQ7AKVKN-BnBJ_2R3.js 6079 → 2367 (−61.1%)
gzip assets/diagram-UQ7AKVKN-BnBJ_2R3.js 6079 → 2617 (−57.0%)
br assets/diagram-VSXAHHWV-DLnsWaIP.js 10716 → 3782 (−64.7%)
gzip assets/diagram-VSXAHHWV-DLnsWaIP.js 10716 → 4154 (−61.2%)
br assets/diagram-VX7I27RA-Duotn1-e.js 15957 → 5171 (−67.6%)
gzip assets/diagram-VX7I27RA-Duotn1-e.js 15957 → 5707 (−64.2%)
br assets/diagram-Z3DM3KII-iO8-m9Hl.js 4211 → 1642 (−61.0%)
gzip assets/diagram-Z3DM3KII-iO8-m9Hl.js 4211 → 1818 (−56.8%)
br assets/docxRenderer-Ilu9VYYG.js 40904 → 10539 (−74.2%)
gzip assets/docxRenderer-Ilu9VYYG.js 40904 → 11977 (−70.7%)
br assets/downloadDocx-Ep6Y-mWJ.js 1130 → 612 (−45.8%)
gzip assets/downloadDocx-Ep6Y-mWJ.js 1130 → 767 (−32.1%)
br assets/ebnfDiagram-PWID7BFC-CAlTqs1O.js 2005 → 784 (−60.9%)
gzip assets/ebnfDiagram-PWID7BFC-CAlTqs1O.js 2005 → 884 (−55.9%)
br assets/erDiagram-RLTQ6QDP-BDpOdOIc.js 30790 → 9175 (−70.2%)
gzip assets/erDiagram-RLTQ6QDP-BDpOdOIc.js 30790 → 10542 (−65.8%)
br assets/flowDiagram-HODETNUW-BGwlzTt0.js 61847 → 16384 (−73.5%)
gzip assets/flowDiagram-HODETNUW-BGwlzTt0.js 61847 → 19717 (−68.1%)
br assets/ganttDiagram-EL5Y4UJY-ijkjYohu.js 69695 → 20666 (−70.3%)
gzip assets/ganttDiagram-EL5Y4UJY-ijkjYohu.js 69695 → 23425 (−66.4%)
br assets/gitGraphDiagram-WWUBYQGX-3ajohVe1.js 29417 → 7789 (−73.5%)
gzip assets/gitGraphDiagram-WWUBYQGX-3ajohVe1.js 29417 → 8695 (−70.4%)
br assets/index-BXtlAAcH.js 365745 → 89917 (−75.4%)
gzip assets/index-BXtlAAcH.js 365745 → 104064 (−71.5%)
br assets/index-Bq7tVxen.css 6576 → 1656 (−74.8%)
gzip assets/index-Bq7tVxen.css 6576 → 1974 (−70.0%)
br assets/index-cbAtdfhj.js 814041 → 187943 (−76.9%)
gzip assets/index-cbAtdfhj.js 814041 → 236064 (−71.0%)
br assets/ishikawaDiagram-5VMMS53U-Cg4LfJyo.js 17469 → 6007 (−65.6%)
gzip assets/ishikawaDiagram-5VMMS53U-Cg4LfJyo.js 17469 → 6634 (−62.0%)
br assets/journeyDiagram-3NMN7TZE-DXSneWK0.js 23571 → 7531 (−68.0%)
gzip assets/journeyDiagram-3NMN7TZE-DXSneWK0.js 23571 → 8331 (−64.7%)
br assets/kanban-definition-UXKFOSKX-BALPAjTC.js 20291 → 6409 (−68.4%)
gzip assets/kanban-definition-UXKFOSKX-BALPAjTC.js 20291 → 7172 (−64.7%)
br assets/katex-BJxdM42i.js 261435 → 63900 (−75.6%)
gzip assets/katex-BJxdM42i.js 261435 → 77355 (−70.4%)
br assets/katex-BOvhZl6_.css 30249 → 6978 (−76.9%)
gzip assets/katex-BOvhZl6_.css 30249 → 8010 (−73.5%)
br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%)
gzip assets/katex-HP8lGamR.js 261313 → 77109 (−70.5%)
br assets/layout-BrexQxSk.js 35436 → 11571 (−67.3%)
gzip assets/layout-BrexQxSk.js 35436 → 12751 (−64.0%)
br assets/linear-BDZQyaJG.js 5661 → 2117 (−62.6%)
gzip assets/linear-BDZQyaJG.js 5661 → 2318 (−59.1%)
br assets/mermaid.core-C3o1vFDn.js 682569 → 140363 (−79.4%)
gzip assets/mermaid.core-C3o1vFDn.js 682569 → 168123 (−75.4%)
br assets/mindmap-definition-YA3MSWOX-pgZMQ8uL.js 23175 → 6998 (−69.8%)
gzip assets/mindmap-definition-YA3MSWOX-pgZMQ8uL.js 23175 → 7799 (−66.3%)
br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%)
gzip assets/ordinal-Cboi1Yqb.js 1189 → 574 (−51.7%)
br assets/pegDiagram-XKGWAZYB-BRKb5jpA.js 1956 → 821 (−58.0%)
gzip assets/pegDiagram-XKGWAZYB-BRKb5jpA.js 1956 → 927 (−52.6%)
br assets/pieDiagram-E7YTZNPT-nrtuGrNr.js 6294 → 2407 (−61.8%)
gzip assets/pieDiagram-E7YTZNPT-nrtuGrNr.js 6294 → 2629 (−58.2%)
br assets/quadrantDiagram-AXDQQJYC-DHNugfXZ.js 34010 → 8523 (−74.9%)
gzip assets/quadrantDiagram-AXDQQJYC-DHNugfXZ.js 34010 → 9876 (−71.0%)
br assets/railroadDiagram-O6MQD6OU-3o3NI4V-.js 1606 → 683 (−57.5%)
gzip assets/railroadDiagram-O6MQD6OU-3o3NI4V-.js 1606 → 758 (−52.8%)
br assets/renderClipboardHtml-BXoFMW-3.js 5180 → 1452 (−72.0%)
gzip assets/renderClipboardHtml-BXoFMW-3.js 5180 → 1778 (−65.7%)
br assets/requirementDiagram-BXWQKSXE-2YVxwHhy.js 31000 → 8241 (−73.4%)
gzip assets/requirementDiagram-BXWQKSXE-2YVxwHhy.js 31000 → 9661 (−68.8%)
br assets/sankeyDiagram-P5KCCOFB-cHAs8eQM.js 23198 → 7819 (−66.3%)
gzip assets/sankeyDiagram-P5KCCOFB-cHAs8eQM.js 23198 → 8565 (−63.1%)
br assets/sequenceDiagram-WJ2MYXX4-BqEzakUS.js 116962 → 25735 (−78.0%)
gzip assets/sequenceDiagram-WJ2MYXX4-BqEzakUS.js 116962 → 30955 (−73.5%)
br assets/stateDiagram-D77RDMKH-DVKtcg-v.js 10361 → 3334 (−67.8%)
gzip assets/stateDiagram-D77RDMKH-DVKtcg-v.js 10361 → 3623 (−65.0%)
br assets/swimlanes-42K2YHIH-BaJ44xBg.js 115791 → 36679 (−68.3%)
gzip assets/swimlanes-42K2YHIH-BaJ44xBg.js 115791 → 42170 (−63.6%)
br assets/timeline-definition-24CTP7MA-d7EudEZG.js 30829 → 9133 (−70.4%)
gzip assets/timeline-definition-24CTP7MA-d7EudEZG.js 30829 → 10143 (−67.1%)
br assets/vennDiagram-4TSXK5OY-Dswu0LDK.js 41961 → 13993 (−66.7%)
gzip assets/vennDiagram-4TSXK5OY-Dswu0LDK.js 41961 → 15643 (−62.7%)
br assets/wardleyDiagram-VM6X3IG4-83YMC1gW.js 25502 → 6077 (−76.2%)
gzip assets/wardleyDiagram-VM6X3IG4-83YMC1gW.js 25502 → 6751 (−73.5%)
br assets/xlsx-Cig0qvpi.js 424392 → 117659 (−72.3%)
gzip assets/xlsx-Cig0qvpi.js 424392 → 140814 (−66.8%)
br assets/xlsxRenderer-BQPATLFB.js 2785 → 1354 (−51.4%)
gzip assets/xlsxRenderer-BQPATLFB.js 2785 → 1677 (−39.8%)
br assets/xychartDiagram-S5SC5T6Z-Do0p5dm5.js 44509 → 11058 (−75.2%)
gzip assets/xychartDiagram-S5SC5T6Z-Do0p5dm5.js 44509 → 12534 (−71.8%)
br fonts/OFL.txt 4474 → 1437 (−67.9%)
gzip fonts/OFL.txt 4474 → 1980 (−55.7%)
precompress: wrote 66 .br and 66 .gz
NPM_BUILD:0
@@ -0,0 +1,651 @@
> shuimu-workbench@1.0.0 test
> tsx --test "src/**/!(*InteractiveCard*|*chatWindowArtifact*|*deliverableSync*).test.ts" && vitest run src/components/chat/InteractiveCard.test.ts src/components/chat/chatWindowArtifact.test.ts src/components/chat/deliverableSync.test.ts
✔ AuthClient: 基础登录与 .env 超级管理员鉴权 (2.388929ms)
✔ AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派) (0.448527ms)
✔ AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录 (0.495266ms)
✔ AuthClient: 管理员创建账号必须在首次登录修改密码 (0.258262ms)
✔ authErrorMessage: 优先信封 error 领域原因,避免裸 JSON / 无权访问误导 (0.227843ms)
✔ mapUserAccount: snake_case Go contract → camelCase (1.231233ms)
✔ mapUserAccount: prefers snake_case when both present (0.164515ms)
✔ mapLoginResult/mapMeResult use top-level must_change_password as sole authority (0.211534ms)
✔ mapUserAccountList maps admin users list (0.116027ms)
✔ AuthClient.login + fetchMe map real snake_case wire payloads (36.529405ms)
✔ shouldAutoRetryChat: disabled until idempotency (0.913842ms)
✔ shouldAutoRetryTask: transient only, max 2 (0.229563ms)
✔ shouldAutoRetryTask: document busy never retries (0.136656ms)
✔ shouldAutoRetry alias delegates to task path (0.137816ms)
✔ autoRetryDelayMs: 1s then 2s (0.137336ms)
✔ fallbackErrorClass: network → transient, workspace → resumable (1.663131ms)
✔ upsertChatSession surfaces safe INTERNAL envelope instead of raw JSON text (79.905537ms)
[sse] 忽略无法解析的帧: {not valid json SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
at JSON.parse (<anonymous>)
at parseFrame (/home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web/src/api/chatStream.ts:126:26)
at parseSSEChunk (/home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web/src/api/chatStream.ts:199:20)
at TestContext.<anonymous> (/home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web/src/api/chatStream.test.ts:41:31)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
✔ parseSSEChunk: parses event and data lines (1.00906ms)
✔ parseSSEChunk: keeps incomplete frame in rest (0.231474ms)
✔ parseSSEChunk: flush parses final frame without trailing delimiter (0.224793ms)
✔ parseSSEChunk: ignores heartbeat comments and keeps last id (0.139066ms)
✔ parseSSEChunk: skips a single malformed frame and keeps parsing the next valid frame (5.017202ms)
✔ parseSSEChunk: parses CRLF-delimited frames (0.229503ms)
✔ parseSSEChunk: mixed LF and CRLF delimiters (0.137336ms)
✔ API_BASE is empty relative base (1.037719ms)
✔ apiFetch attaches Bearer from session storage (64.929112ms)
✔ apiFetch retries GET on 503 then succeeds (201.72881ms)
✔ apiFetch does not retry POST by default (0.516094ms)
✔ readApiError preserves typed envelope for error cards (1.498965ms)
✔ apiFetch auth:false skips Authorization (0.530665ms)
▶ docgov lifecycle API client
✔ claimDocument posts to /api/v1/docgov/claim with author role (23.291168ms)
✔ submitForReview posts to /api/v1/docgov/submit-review (0.706349ms)
✔ reviewDecision posts action and optional reason (0.817416ms)
✔ approverDecision posts action and rollback_to (0.954792ms)
✔ proposeModification posts to /api/v1/docgov/proposal (1.198325ms)
✔ decideModificationProposal posts to /api/v1/docgov/proposal/decide (0.720289ms)
✔ docgov lifecycle API client (28.742506ms)
✔ updateFreeformDoc exposes CONCURRENCY_CONFLICT on 409 (22.916699ms)
▶ notifications inbox helpers
✔ ranks action_required / proposal ahead of info (0.65737ms)
✔ notifications inbox helpers (1.486036ms)
✔ addProjectMember exposes structured ProjectSettingsApiError on 403 (25.801144ms)
✔ updateProjectMember surfaces safe INTERNAL message on 500 duty-sync failure (0.822326ms)
▶ projectSettingsError
✔ keeps status, code, message and issues on PLAN_FROZEN (1.624582ms)
✔ maps revision and concurrent conflicts to distinct recovery (0.172775ms)
✔ joins validation issues but still keeps typed codes (0.189844ms)
✔ prefers envelope code over legacy error field (0.138986ms)
✔ unwraps nested business JSON inside VERSION_CONFLICT envelope (0.187354ms)
✔ maps bare VERSION_CONFLICT envelope to retry_after_switch (0.106467ms)
✔ retains the local draft when a late tab hits PLAN_FROZEN (0.114897ms)
✔ prefers error_code over legacy bare error string (DOC_BUSY) (0.128807ms)
✔ projectSettingsError (4.087899ms)
✔ fetchProjectTrace turns null arrays into empty arrays (25.555001ms)
✔ fetchProjectTrace keeps real arrays and rejects failed responses (1.302701ms)
✔ Project workflow types validation (2.125657ms)
✔ createTaskRun exposes DOC_BUSY detail without a retryable run (27.347498ms)
✔ parseSSEChunk tracks id seq for after_seq reconnect (0.474346ms)
✔ subscribeTaskRunEvents reconnects on EOF with advancing after_seq (6.424009ms)
✔ readTaskRunSSEOnce marks non-terminal EOF for reconnect (0.68087ms)
✔ subscribeTaskRunEvents fails fast on 401 without retrying (0.491175ms)
✔ subscribeTaskRunEvents fails fast on 404 without retrying (1.400458ms)
✔ buildTaskRequest: maps five project fields + task_key + user identity (0.757228ms)
✔ buildTaskRequest: omits optional fields when absent (0.164035ms)
✔ buildTaskRequest: includes materials when provided and non-empty (0.755688ms)
✔ buildResumeRequest: adds work_dir on top of task request (0.175045ms)
✔ buildTaskRequest: canonicalizes legacy doc-p doc_id (0.260083ms)
✔ buildTaskRequest: UI-05 pinned intake keeps task_key + doc_proj6109_intake (0.193434ms)
✔ buildResumeRequest: forwards add_steps when provided (0.153185ms)
✔ CategoryDock: 渲染顶部 🔔 未读提醒项及角标数字 (20.272788ms)
✔ CategoryDock: isAllReadCompleted 为 true 时渲染完成态提示 (9.797689ms)
✔ CategoryDock: 0 未读时不渲染红色未读角标 (6.548825ms)
✔ CategoryDock: 分类项包含依次跳转与遍历提示信息 (5.285423ms)
✔ chat column lives outside the workbench shell (0.954951ms)
✔ InputArea keeps draft when send preflight fails (1.420548ms)
✔ InputArea clears draft only after accepted send (2.463867ms)
✔ InputArea shows thinking block message (0.197614ms)
✔ InputArea keeps draft when attachment registration fails (0.247822ms)
✔ TaskRunCard renders timeline with title badge and current step (13.771851ms)
✔ TaskRunCard completed state keeps card with duration and artifact (2.308901ms)
✔ TaskRunCard sanitizes frozen progress labels on completed steps (1.979611ms)
✔ TaskRunCard keeps shell when only taskRunId is present (0.864224ms)
✔ active document pane lives outside the workbench shell (1.437047ms)
✔ isTemplateOnly 交付物应正确识别为初始模板展示态 (7.921545ms)
✔ workbench shell lives outside App (4.726439ms)
✔ 左侧栏鼠标严密绝对跟随与夹紧测试 (Left Rail Exact Pointer Following & Clamping) (0.713178ms)
✔ 右侧栏鼠标严密绝对跟随与反向位移测试 (Right Rail Exact Pointer Following & Inverted Delta) (0.126506ms)
✔ trace panel is not mounted above the chat stream (5.641603ms)
✔ detects legacy mock and neuropulse/ten-projects ids (0.864744ms)
✔ detects colliding demo project ids (0.132876ms)
✔ applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目 (1.154566ms)
✔ applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用 (0.187955ms)
✔ login member sync stays one request for 1, 30 and 100 projects (6.07364ms)
✔ viewer seed keeps session visibility without a member-list call (0.397488ms)
✔ later session response cannot overwrite a newer user (0.190665ms)
✔ in-flight member sync collapses to one call (0.263752ms)
✔ 每人独立私聊:同一项目不同用户拥有不同 sessionId (0.881204ms)
✔ 左侧会话列表按当前用户过滤,不可见他人私聊 (0.256262ms)
✔ 消息严格归属各自私聊 session,无跨用户混聊 (0.870234ms)
✔ 切换用户时 resolve 到该用户同项目私聊会话 (0.244812ms)
✔ resolve 优先有消息的私聊,跳过空壳 sess-p{N}-u{id} (1.166125ms)
✔ ensurePrivateSessionsForUser 为缺失用户补齐空私聊 (0.593802ms)
✔ 多项目会话消息隔离测试仍成立(endo vs ultrasound) (0.33904ms)
✔ window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目 (1.36061ms)
✔ window cache: 无 schema 的旧载荷不当作有效缓存 (7.710341ms)
✔ window cache: 读写同一 userId 前缀,跨用户不串读 (0.273892ms)
✔ 残留的 legacy demo sessionId 被读取侧清除并返回 fallback (0.905753ms)
✔ 真实 sessionId 不被误清 (0.152565ms)
✔ 无 window 环境(SSR)安全返回 fallback (0.068078ms)
✔ same account and first login do not reset workbench storage (1.144056ms)
✔ switching accounts resets workbench storage (0.182865ms)
✔ resetWorkbenchStorage preserves org user directory for cross-account project create (1.219384ms)
✔ parseAnchorHash: 正确解析标准深链 hash (1.438807ms)
✔ formatAnchorHash: 正确构造深链格式 (0.106107ms)
✔ navigateToAnchor: 快路径直接命中 DOM 节点 (0.388478ms)
✔ navigateToAnchor: 慢路径拉取分片后命中 (79.542228ms)
✔ resolveTurnAnchorId: 方案 A 轮次归一化测试 (0.507675ms)
✔ 全链路场景 1: 用户自主受控注册(仅填用户名/密码/姓名,职位严格绑定访客待指派) (1.552684ms)
✔ 全链路场景 2: 系统准入策略(关闭自主注册与开启强制审批流转) (0.647091ms)
✔ 全链路场景 3: 管理员开通账号首次强制改密闭环 (0.242653ms)
✔ 全链路场景 4: 环境标识随登录会话返回(dev/test/main) (0.261022ms)
✔ cleanHeadingContent: 手工多级序号前缀清洗与专业词汇保护 (2.277532ms)
✔ 标题多层级自动编号:单 H1 文档总标题自适应模式 (1 -> 1.1 -> 1.1.1) (0.34501ms)
✔ 标题多层级自动编号:多 H1 标准大章模式 (0.137276ms)
✔ 跨章节图、表、公式多级按章重编号测试 (表 1-1, 表 2-1, 图 3-1, 式 (4-1)) (0.154156ms)
✔ 有序列表与无序列表编号与渲染功能测试 (2.399788ms)
✔ 动态块操作闭环重编号验证 (Insert, Move, Delete) (3.589093ms)
✔ findMatchingBlockForReview: 能够精准按审查关键词(电气安全/EMC/竞品)定位段落块 (0.848975ms)
✔ findMatchingBlockForReview: 能够按外部指定 hint 线索精确命中目标块 (0.170635ms)
✔ createRejectConfirmationCard: 生成合规危险级驳回门禁卡片 (0.605742ms)
✔ isAffirmativeResponse: 准确识别双轨交互中的肯定意图输入 (0.205574ms)
✔ findLastActiveCard: 正确在会话历史中提取最新的待决策卡片 (0.201834ms)
✔ decrementActiveCardsTtl: 多轮对话超时递减与自动失效归档 (0.163195ms)
✔ applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环 (0.251372ms)
✔ resolveCardInMessages & cancelCardInMessages: 消息流中卡片终态沉淀 (0.151626ms)
✔ classifyRejectUtterance: 询问原因 → QUERY_REJECT_REASON (1.295522ms)
✔ classifyRejectUtterance: 明确执行 → EXECUTE_REJECT (0.177635ms)
✔ classifyRejectUtterance: 模糊 → OTHER (0.197495ms)
✔ resolveDocGovCard: 编制人查询 → 信息卡且无 confirm_reject (0.788967ms)
✔ resolveDocGovCard: 审核人执行 + reviewing → 确认卡 (0.289862ms)
✔ resolveDocGovCard: 编制人误说执行驳回 → 无权纯文本 (0.141016ms)
✔ resolveDocGovCard: 批准人 + approving → 终审确认卡 (0.274622ms)
✔ resolveDocGovCard: 编制人 OTHER → 引导查询无危险按钮 (0.128557ms)
✔ canConfirmReject: 仅审核/批准 (0.537784ms)
✔ deriveFallbackCategory identifies categories correctly (0.870374ms)
✔ enrichMessagesWithCategories backfills categories without overwriting existing ones (0.313601ms)
✔ Markdown ➔ Block AST 解析测试 (标题自适应编号与图表公式全链路) (4.275193ms)
✔ 动态重编号引擎测试 (多 H1 规范章节重排验证) (0.980431ms)
✔ Block AST ➔ Markdown 双向无损往返还原测试 (0.843655ms)
✔ 三层交付物多用户隐私绝对隔离测试 (Multi-user Strict Privacy Isolation) (1.088098ms)
✔ 一键共享至项目参考库流转测试 (Promote to Shared Reference Test) (0.221383ms)
✔ 撤回共享恢复私有草稿测试 (Revoke Shared Reference Test) (0.408948ms)
✔ 受控工作流纯净性红线检验 (Workflow Purity Security Red Line) (0.129456ms)
✔ isSameMessage: clientTurnId match (1.861744ms)
✔ isSameMessage: serverId match without turn (0.175055ms)
✔ filterPendingLiveMessages: real temp vs server id (0.137566ms)
✔ mergeLiveOverHistory preserves server id from live (0.173595ms)
✔ mergeLiveOverHistory does not let inflight live mask terminal history (0.134716ms)
✔ mergeLiveOverHistory keeps live terminal over history inflight (0.112497ms)
✔ applyPhaseEvent: appends first phase as running node (1.403038ms)
✔ applyPhaseEvent: completes predecessors when a new phase arrives (0.249813ms)
✔ applyPhaseEvent: same phase refreshes label in place (no duplicate node) (0.243692ms)
✔ applyPhaseEvent: unknown phase falls back to hourglass meta (0.130056ms)
✔ applyPhaseEvent: prefers backend message over short label (0.114206ms)
✔ applyPhaseEvent: step_completed closes running entry without appending (0.239473ms)
✔ applyPhaseEvent: stage_completed closes entries (0.163325ms)
✔ applyPhaseEvent: task_failed appends warning node (0.133246ms)
✔ applyPhaseEvent: decorates label with step index / total steps (0.137436ms)
✔ applyPhaseEvent: repeated step_executing with step_index gets separate nodes (0.200365ms)
✔ applyPhaseEvent: ignores event without phase payload (0.097807ms)
✔ applyPhaseEvent: step_failed marks running entry as failed (keeps step label) (0.114317ms)
✔ applyPhaseEvent: step_failed appends failed node when no running entry exists (1.234163ms)
✔ applyPhaseEvent: retry of a failed step resets it to running (0.141366ms)
✔ applyPhaseEvent: stores detail from phase.message (0.075458ms)
✔ PHASE_META: document_parsing and document_summarizing are registered (0.114747ms)
✔ PLAN_CONFIRM 卡片来自 PlanView 且不触达 AST (1.988191ms)
✔ 409 冲突把卡片标为 outdated 并换上最新 revision (0.252702ms)
✔ GET reconcile 用权威 PlanView 覆盖本地 executing,不假装成功 (0.226923ms)
✔ 过期计划映射 expired (0.125736ms)
✔ 非计划卡不被误判 (0.112107ms)
✔ 集成验证:项目切换时交付物严格隔离,新建项目即刻包含 7 份模板副本 (6.355481ms)
✔ 集成验证:三层隐私与项目物理隔离双重约束 (8.259005ms)
✔ 用例 1: DEFAULT_ROLES 包含 5 种默认角色且具备预期权限 (4.314872ms)
✔ 用例 2: 系统管理员 (ADMIN) 与具备 perm:manage 用户的通配豁免特权 (0.226003ms)
✔ 用例 3: 常规工作流基线可见性 (workflow:view_all) 判定 (0.249052ms)
✔ 用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截) (0.145636ms)
✔ 用例 5: 单文档覆盖 - role_whitelist (仅白名单内角色可见,其他角色被精确拦截) (0.127306ms)
✔ 用例 6: 单文档覆盖 - public (放宽可见性,无 workflow:view_all 权限的访客也能查阅) (0.100067ms)
✔ 用例 7: 私密文档隔离强红线 (category === private 无论任何管理员或单文档覆盖,非创建者绝对不可见) (0.111796ms)
✔ 用例 8: 核心业务动作权限拦截判定 (canPerformDocAction & hasPermission) (0.149616ms)
✔ 用例 9: filterVisibleDeliverablesWithRBAC 能够批处理过滤大盘交付物 (5.913474ms)
✔ 集成场景 1: 动态新增合规审计员角色,并配置其仅能查阅特定单文档覆盖 (1.047099ms)
✔ 集成场景 2: 服务端 capabilities 变更后权限即刻生效 (0.168345ms)
✔ 集成场景 3: 单文档覆盖 designated_only 严格收紧与撤回恢复全流程 (0.148375ms)
✔ renderInlineMarkdown: renders bold, italic, strikethrough (1.362779ms)
✔ renderInlineMarkdown: escapes raw HTML tags to prevent XSS (0.653981ms)
✔ renderInlineMarkdown: blocks javascript: link hrefs (0.305061ms)
✔ renderInlineMarkdown: renders inline code and preserved line breaks (0.290592ms)
✔ renderInlineMarkdown: renders headings lists and tables for chat replies (0.487936ms)
✔ renderInlineMarkdown loads katex only after a formula is seen (19.51147ms)
✔ sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators (0.422077ms)
(node:1877659) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided.
(Use `node --trace-warnings ...` to show where the warning was created)
✔ DOCX 渲染器行内文本与公式解析测试 (parseInlineContent) (4.879835ms)
✔ DOCX 渲染器 LaTeX 原生 OMML 转换引擎测试 (latexToOmml) (2.009401ms)
✔ DOCX 渲染器 Mermaid 语法净化与容错测试 (sanitizeMermaidCode) (0.3103ms)
✔ DOCX 渲染器 SVG 净化防 Canvas 污染测试 (cleanSvgForCanvas) (0.367549ms)
✔ DOCX 渲染器全量编译测试 (buildDocxModel & exportDocumentToDocx) (61.249852ms)
✔ XLSX 渲染器多表提取与工作簿编译测试 (buildXlsxWorkbook & exportDocumentToXlsx) (6.587554ms)
▶ Reply Document IR & Export Engine
✔ deriveReplyTitle and sanitizeFilename derive title and safe file name (1.768507ms)
✔ deriveReplyTitle handles text without heading (0.285461ms)
✔ parseReplyMarkdown parses headers, lists, code, and tables (1.728138ms)
✔ renderClipboardHtml renders self-contained styling for office tools (0.462936ms)
✔ renderClipboardHtml converts LaTeX formulas to native MathML for Word/Office (4.016571ms)
✔ renderPlainText strips formatting cleanly (0.481286ms)
✔ serializeMarkdown round-trips to valid markdown (0.33855ms)
✔ stripUnsafeHtml strips script tags and dangerous event handlers (0.549014ms)
✔ escapeHtml escapes math error payloads used by BlockEditorModal catch path (0.196264ms)
✔ Reply Document IR & Export Engine (11.579927ms)
✔ createProjectTemplateDeliverables: 必须精确生成 7 份全生命周期受控文档副本 (3.852105ms)
✔ resolveRequirementsTaskKey prefers intake for 初始需求表 (1.199405ms)
✔ resolveRequirementsTaskKey selects user_requirements for explicit URS (0.156685ms)
✔ UI-02 first-round prompt with 首轮+用户需求+摸底 must be intake not user_requirements (0.478466ms)
✔ 首轮 / 需求摸底 / 摸底 beat bare 用户需求 (0.106416ms)
✔ bare 用户需求 without V0.1 markers stays user_requirements (0.107757ms)
✔ matchTemplateDeliverable uses intake key when pool only has URS template (0.258242ms)
✔ resolveTaskTemplateForMessage keeps intake key with URS-only pool (0.149816ms)
✔ matchTemplateDeliverable prefers intake when both templates exist (0.126717ms)
✔ read-only request does not start a document task: 总结一下用户初始需求表 (0.294921ms)
✔ read-only request does not start a document task: 查看用户初始需求表 (0.145125ms)
✔ read-only request does not start a document task: 用户初始需求表现在谁在编辑 (0.090967ms)
✔ read-only request does not start a document task: 分析一下用户需求表里的风险 (0.070788ms)
✔ read-only request does not start a document task: 用户初始需求表 (0.054098ms)
✔ explicit write action still starts intake (0.136246ms)
✔ explicit update action still starts URS (0.190135ms)
✔ UI-05 prompt without active doc still keyword-routes to feasibility_reg (0.294701ms)
✔ UI-05 prompt + registered intake pins intake and doc_proj6109_intake (0.235983ms)
✔ read-only current-doc request does not start a task (0.071978ms)
✔ summary/analysis with write verbs stays chat (read-only priority) (0.089587ms)
✔ pure write intent still routes to task (0.053668ms)
✔ template-only active slot is not pinned; keywords still apply (0.096717ms)
✔ explicit named deliverable can switch away from active intake (0.165225ms)
✔ domain 注册法规 without current-doc deixis still routes feasibility_reg (0.088787ms)
✔ mismatched title vs docId suffix prefers docId taskKey (0.093428ms)
✔ deixis 当前文档 pins registered intake (0.095807ms)
✔ deixis 本文件 pins registered intake (0.047478ms)
✔ deixis 这份文档 pins registered intake (0.050108ms)
✔ deixis 本文档 pins registered intake (0.044218ms)
✔ deixis 本受控文档 pins registered intake (0.041788ms)
✔ no active doc does not invent a pin (0.096247ms)
✔ UI-11 named 系统设计输入 beats negated 需求 background (0.076237ms)
✔ mixed generate-design + do-not-cover-requirements stays prelim_design (0.055108ms)
✔ current-doc pin still wins when no other deliverable is named (0.083417ms)
✔ v3 bd_ task key wins over unprefixed intake alias (0.124346ms)
✔ word-order variants of the intake sheet stay on intake, not the shorter URS title (0.195294ms)
✔ resolveTaskTemplateForMessage blocks cross-workflow document mentions (2.185825ms)
✔ resolveTaskTemplateForMessage keeps in-scope main docs when active is main (0.129286ms)
✔ weighted mode does not let a cross-workflow title hijack an in-scope write (0.537474ms)
✔ progress question is read-only; explicit start still resolves research (0.093728ms)
▶ document revision overlay modes
✔ pure hides all decorations (0.811806ms)
✔ manual only marks human hunks (0.160956ms)
✔ all marks human and agent differently (0.137306ms)
✔ same human hunk stays amber in manual and all (no pairwise style guarantee) (0.135026ms)
✔ exposes needs-content error code for empty orphan docs (0.094397ms)
✔ matches overlay hunks by section or text when ids differ (0.272582ms)
✔ aligns client block ids from head_block_map by order (0.217134ms)
✔ document revision overlay modes (2.925713ms)
▶ toolbar refresh + report entry reachability
✔ pickLatestChangeReportId prefers highest version with report (0.33787ms)
✔ first squad run alone still exposes a reachable report id (0.165395ms)
✔ second squad run keeps both history report entries and latest shortcut (0.778567ms)
✔ revisionRefreshKey is an independent refresh signal from docId (0.127386ms)
✔ toolbar refresh + report entry reachability (1.69243ms)
▶ change report view model
✔ surfaces risk items, project impact, doc impacts with advice, and open questions (0.67743ms)
✔ marks complete analysis as PI narrative vs factual fields (0.187924ms)
✔ preferReportId prioritizes pending over committed latest (0.096757ms)
✔ change report view model (1.147986ms)
▶ revisionToolbarMount
✔ exposes clear non-error template preview hint copy (2.417999ms)
✔ template doc-p + isTemplateOnly=true does not mount (0.162285ms)
✔ legacy doc-001 does not mount (0.156295ms)
✔ unregistered doc-upload orphan does not mount (must register first) (0.145726ms)
✔ server-registered upload (doc-free) and freeform mount (0.156935ms)
✔ canonical doc_proj and generated non-template doc-p mount (0.193634ms)
✔ revisionToolbarMount (4.318292ms)
✔ login workspace sync lives in one hook (1.204984ms)
✔ block editing lives in one hook (0.957632ms)
✔ interactive card actions live in one hook (1.048618ms)
✔ useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId (1.066258ms)
✔ loadOlder race: stale resolve ignored by generation (1.557084ms)
✔ window fetch race: stale response ignored by generation (4.517886ms)
✔ mapRecordToMessage: 正确映射服务端消息记录为前端 ChatMessage (1.654151ms)
✔ refresh window: 空 messages 日桶不算有效窗口 (0.192455ms)
✔ mapRawBucket: 正确映射服务端按日分片桶 (0.312121ms)
✔ Category navigation: chip 选中后准确匹配目标分类最新轮次并触发归一化定位 (0.286702ms)
✔ mapRecordToMessage: restores task phase snapshot into card fields (0.236783ms)
✔ mapRecordToMessage: run anchor without task_status stays streaming (0.180904ms)
✔ controlled document hydration lives in one hook (1.096208ms)
✔ project creation lives in one hook called before the login return (0.755868ms)
✔ document lifecycle actions live in one hook (1.109877ms)
✔ freeform document actions live in one hook (1.755868ms)
✔ in-flight states cover pending and running only (6.667272ms)
✔ hasInFlightParse detects parse polling needs (0.246373ms)
✔ hook + reparse API surface wired (0.298551ms)
✔ project member writes live in one hook before the login return (1.318591ms)
✔ replay retries 502 and stops on auth or not-found (0.66772ms)
✔ sending a message lives in one hook (1.01656ms)
✔ server document list sync lives in one hook (1.393729ms)
✔ chat and task attempts live in one hook (1.34513ms)
✔ stage unresolved and not-in-active-stage degrade to chat once (0.155916ms)
✔ failed run with error_message surfaces TASK_FAILED not NETWORK_ERROR (0.770138ms)
✔ snake_case blocked_pending_governance is governance, not a network error (0.34381ms)
✔ unknown 4xx business code is not reported as a network error (0.377559ms)
✔ turn runtime: session A stream survives session B switch (0.649891ms)
✔ abortAllSessions aborts controllers across all sessions (0.192954ms)
✔ App persists workbench state through one hook and migrates legacy messages once (0.968861ms)
✔ active task cache keeps an in-progress run and drops it when finished (1.106048ms)
✔ syncAdminUserDirectories calls local reload then parent onRefreshUsers (1.624912ms)
✔ syncAdminUserDirectories fails closed when onRefreshUsers is a dead/missing prop (0.614612ms)
✔ syncAdminUserDirectories surfaces parent refresh errors (no silent swallow) (2.138076ms)
✔ afterProjectMemberPersisted syncs members then refreshes workflow docs (1.172795ms)
✔ afterProjectMemberPersisted does not refresh when member sync fails (0.463506ms)
▶ blockEditorDialogPolicy
✔ pins an accessible dialog label and exact save name (0.492506ms)
✔ rejects page-wide fuzzy /保存/ that would hit private-library buttons (0.258003ms)
✔ blockEditorDialogPolicy (1.557624ms)
▶ resolveSaveTargetBlock
✔ keeps the editor and does not fake success when the block id is gone (0.635451ms)
✔ never retargets another block that happens to share the same content (0.705099ms)
✔ resolveSaveTargetBlock (2.244724ms)
▶ editor session document binding
✔ fail-closes when activeDocId drifts away from the opened editor doc (0.173244ms)
✔ accepts a save only when the editor is still bound to the active doc (0.099087ms)
✔ blocks switching documents while the editor is open (0.139706ms)
✔ editor session document binding (0.614882ms)
▶ interpretBlockSaveError
✔ 409 keeps the draft in the editor (0.170955ms)
✔ other PATCH failures also keep the editor (0.192604ms)
✔ interpretBlockSaveError (0.782167ms)
▶ blockedGovernanceCopy
✔ recognizes blocked run status and fixed hint, not generation success (0.736178ms)
✔ blockedGovernanceCopy (1.654011ms)
▶ resolveChatComposerStreaming
✔ is false when no inflight assistant bubbles (0.472606ms)
✔ is true when inflight bubble and live turn (0.106117ms)
✔ is true when inflight bubble and live task run (0.078888ms)
✔ is false for orphan streaming_status after runtime cleared (0.059708ms)
✔ resolveChatComposerStreaming (1.616372ms)
▶ commentPolicy
✔ applies stage matrix for add (0.6784ms)
✔ keeps viewer read-only (0.206644ms)
✔ resolves author role literally (0.119417ms)
✔ gates resolve by stage (0.206814ms)
✔ commentPolicy (4.325182ms)
▶ controlledContentInvalidation
✔ hydrate success then late list_sync keeps cache/ready and does not bump resume (1.722119ms)
✔ post_task invalidation drops active cache and requires a new hydrate epoch (0.221513ms)
✔ editor open / save epoch keeps draft and rejects a stale GET after PATCH (0.351609ms)
✔ never drops the active doc without signaling rehydrate (0.138126ms)
✔ candidate helper keeps template / doc-001 / freeform / upload out of wipe lists (0.450837ms)
✔ dropRecordKeys is a no-op when ids are absent (0.219383ms)
✔ controlledContentInvalidation (4.35165ms)
▶ controlledDocHydrate
✔ shouldLoadControlledContent when non-template workflow doc (3.012441ms)
✔ shouldLoadControlledContent rejects local legacy/mock ids like doc-001 (0.181415ms)
✔ shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules (0.164175ms)
✔ hydrateControlledDocumentContent never requests content/revision for doc-001 (0.383468ms)
✔ hydrateControlledDocumentContent never requests for template preview or registered freeform (0.181555ms)
✔ shouldLoadControlledContent stays inside toolbar server-backed eligibility (0.313571ms)
✔ resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake (0.490665ms)
✔ canonicalDocIdForTask maps doc-p56-research to doc_proj56_research (0.230063ms)
✔ hydrate treats projection missing as ledger content, not 404 (1.823986ms)
✔ attaches hydrate ETag as PATCH baseline and ignores a later GET (1.028349ms)
✔ rejects hydrate when revision-state network or 5xx fails (0.841565ms)
✔ aligns markdownToBlockAST random ids onto revision-state head_block_map (1.830716ms)
✔ leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain) (1.067668ms)
✔ rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT (0.377559ms)
✔ fingerprint stays stable when only object identity changes (0.269112ms)
✔ dedupes in-flight hydrate for the same doc fingerprint (0.449247ms)
✔ aligned late hydrate still cannot overwrite a PATCH-success baseline (0.455086ms)
✔ controlledDocHydrate (15.550208ms)
✔ createClientTurnId uses randomUUID when available (0.815456ms)
✔ createClientTurnId falls back to getRandomValues UUID v4 (0.421077ms)
✔ createClientTurnId throws when no secure random API exists (0.35659ms)
✔ selectNonCreatorInitialMembers skips creator only (1.587573ms)
✔ buildManagedMemberUpsertPayload sends document_duty on add/update/restore (0.270002ms)
✔ buildInitialMemberUpsertPayload maps reviewer UI role to member API role (0.191914ms)
✔ persistInitialProjectMembers awaits each add and stops on failure (0.65355ms)
✔ ensureCreatedProjectWithMembers skips recreate on bootstrap id and retries members (0.549973ms)
✔ beginCreateProjectSubmit rejects concurrent second entry until end (31.813816ms)
✔ resolveCreateProjectSubmitPhase uses bootstrap to choose syncing label (0.723658ms)
✔ createProjectSubmitButtonLabel and close block while submitting (0.200754ms)
✔ shouldCloneMedicalTemplates skips the 7-pack for software domain (0.131006ms)
▶ resolveCurrentDocRole
✔ resolves A as author by authoritative user id (0.752448ms)
✔ does not promote reviewer B or stranger to author (0.123077ms)
✔ resolves real USER_B id as reviewer and never name-promotes placeholder 2 (0.095468ms)
✔ fail-closes unknown identity to viewer even if projectPermission says author (0.076527ms)
✔ matches name only for legacy local docs without duty ids (0.105767ms)
✔ does not name-promote when server controlled duties are zero ids (0.160535ms)
✔ resolveCurrentDocRole (3.184245ms)
▶ currentProjectRestore
✔ rejects empty project names (0.827155ms)
✔ rejects page-wide project name when banner and sidebar both match (0.185724ms)
✔ scopes the visible entry to complementary exact title (2.776877ms)
✔ scopes the current project assertion to banner exact text (0.35035ms)
✔ currentProjectRestore (5.071659ms)
▶ deliverableLeaseSync
✔ returns the same array reference when leases are unchanged (0.753007ms)
✔ returns a new array only when a lease actually changes (0.177275ms)
✔ deliverableLeaseSync (1.904263ms)
▶ docGovActionVisibility
✔ hides all approval actions on unmanaged docs (1.605673ms)
✔ gates separated duties by user id, not folded role label (0.172974ms)
✔ author_only: same user holds all three duties and sees all actions (0.106427ms)
✔ author_only: non-duty user sees no actions (0.104037ms)
✔ docGovActionVisibility (3.05592ms)
▶ documentBlockDomAttrs
✔ marks a real DocumentBlock in pure mode without overlay kind (1.241673ms)
✔ keeps the same stable attrs when overlay is later attached separately (0.197974ms)
✔ documentBlockDomAttrs (2.573574ms)
✔ author can edit drafting when unlocked (0.720368ms)
✔ active project member can edit drafting when unlocked (deny_only_submit) (0.129316ms)
✔ non-member viewer cannot edit drafting (0.089708ms)
✔ lease blocks edit even for author and member (0.093477ms)
✔ reviewing is not editable via drafting affordance (0.080617ms)
▶ durableBodySnippet
✔ rejects empty persisted snippets (0.850954ms)
✔ lets a later successful revision replace the old snippet (0.988341ms)
✔ does not promote a conflict-failed draft to the expected body (0.174115ms)
✔ durableBodySnippet (2.940223ms)
▶ dutyCellAssertion
✔ parses author, reviewer, and viewer from select value or badge role (0.890893ms)
✔ must not infer author from a row whose option labels include 编制负责人 (0.499835ms)
✔ accepts only a disabled non-assignable reviewer select (0.122916ms)
✔ rejects an enabled reviewer select when the reviewer cannot assign leads (0.098187ms)
✔ rejects canAssignLead=true even if the reviewer select is disabled (0.249163ms)
✔ rejects viewer, author, approver, and unknown values for USER_B (0.265472ms)
✔ dutyCellAssertion (6.592245ms)
▶ decideHydrationApply
✔ applies only when gen, doc, save epoch match and editor is closed (1.170076ms)
✔ rejects a response for another document (0.146755ms)
✔ rejects a superseded generation (0.104287ms)
✔ decideHydrationApply (2.515795ms)
▶ createEditorHydrationGate
✔ does not start a new hydrate while the editor is open (UI-06 fill-window GET) (0.449467ms)
✔ deferred hydrate that resolves after the editor opens does not replace the session (0.162255ms)
✔ covers hydrate resolving between fill and save (0.149286ms)
✔ asks for a hydrate resume after editor_open blocked the first apply (0.370859ms)
✔ asks for a hydrate resume when beginHydration is skipped while editing (0.298281ms)
✔ requestHydrationResume is consumed once after a protected edit session (0.252152ms)
✔ late hydrate after a successful PATCH does not roll back the new baseline (0.412608ms)
✔ createEditorHydrationGate (2.69034ms)
▶ formatFileSize
✔ renders bytes below 1KB without rounding to 0 (0.834686ms)
✔ renders KB/MB with sensible precision (0.260662ms)
✔ degrades gracefully for invalid input (0.270781ms)
✔ formatFileSize (2.69142ms)
▶ deliverableFromServerSummary
✔ restores uploaded file routing metadata from server facts (0.903473ms)
✔ keeps plain freeform docs without upload metadata on the editor path (0.214224ms)
✔ handles legacy rows with filename but zero size gracefully (0.130076ms)
✔ maps shared space to shared category (0.139396ms)
✔ deliverableFromServerSummary (3.529395ms)
✔ redactResourceUrl drops query and origin (1.247093ms)
✔ summarizeResources flags deferred heavy chunks (1.146446ms)
✔ firstPaintHeavyHits lists only deferred module paths (0.186145ms)
✔ countInitRequests separates member fanout from session lists (0.283052ms)
✔ percentile uses nearest-rank and stays null on empty input (0.167675ms)
✔ summarizeLatencies reports p50/p75/p95 (0.216413ms)
✔ mergeSessionUser prefers session identity over stale catalog entry (1.251713ms)
▶ playwrightPhaseArtifacts
✔ defaults unknown or empty UI_PHASE to main (0.723149ms)
✔ keeps post-restart distinct from main so the second run cannot overwrite traces (0.178675ms)
✔ keeps main results at the historical filename and isolates post-restart recordPass (2.574403ms)
✔ shares phase-state and secrets across phases (0.211744ms)
✔ defaults assets to the 2026-09-22 rerun tree, not project-143 history (0.330121ms)
✔ refuses historical controlled-content and Round20 evidence dirs (2.076958ms)
✔ lists closeout groups covering UI-03/05/06/07-08-09/11/13/post-restart (0.811166ms)
✔ playwrightPhaseArtifacts (9.540066ms)
✔ resolvePostTaskActiveDocId prefers server doc_proj for taskKey (0.9999ms)
✔ resolvePostTaskActiveDocId honors preferred server doc id (0.195745ms)
✔ shouldSwitchTemplateSlotToServer when active is local template slot (0.169585ms)
✔ pollUntilPostTaskServerDoc retries until same taskKey server doc appears (0.495385ms)
✔ pollUntilPostTaskServerDoc returns null after maxAttempts without relying on setState (0.206394ms)
✔ mapServerProjectToProjectInfo maps creator and preserves local members (0.899954ms)
✔ mergeProjectLists drops local-only demo seed projects by default (0.32681ms)
✔ mergeProjectLists can retain local-only demo when explicitly opted in (0.158635ms)
✔ documentDutyFromMemberRole maps UI reviewer and creator (0.739068ms)
✔ exclusiveMemberRoleChange keeps a single reviewer (0.189214ms)
✔ memberRoleFromDocumentDuty does not invent reviewer from access role (0.116077ms)
✔ toApiProjectRole maps doc roles to member and creator to owner (0.735568ms)
✔ fromApiProjectRole maps owner to creator for project creator (0.143375ms)
✔ mapApiMemberToProjectMember builds UI record (0.188675ms)
▶ projectSettingsDuties
✔ toggles full to none by atomically clearing duties (1.726969ms)
✔ blocks partial saves and locates required/governance issue codes (0.607871ms)
✔ blocks activation preview when no document is under human approval (0.31043ms)
✔ allows an all-none configurable draft without three members (0.430327ms)
✔ requires three members once any document is managed (0.135766ms)
✔ resolves author_only to A/A/A without three-member requirement (0.243022ms)
✔ rejects author_only without author or with conflicting reviewers (0.159025ms)
✔ mixed full + author_only still requires three members for the full doc (0.324181ms)
✔ toggleAuthorOnly clears review/approve slots and restores on disable (0.178825ms)
✔ folds later rdev stages and batches human approval keys (0.263173ms)
✔ projectSettingsDuties (6.032391ms)
▶ projectSettingsMode
✔ enters frozen when can_edit_plan is false or the project is active (0.766867ms)
✔ keeps an active workflow editable except the definition switch (0.161595ms)
✔ stays planning only for an editable draft (0.076008ms)
✔ lists enabled duties for the frozen snapshot and skips disabled tasks (0.714029ms)
✔ keeps the local draft after PLAN_FROZEN refresh and switches to frozen (0.123676ms)
✔ projectSettingsMode (2.846246ms)
▶ recheckAck
✔ requires an explicit acknowledgement before advance (0.745668ms)
✔ recheckAck (1.588373ms)
▶ revisionEtag
✔ uses stale hydrate ETag for PATCH and never the latest GET (0.492745ms)
✔ does not invent 0:0 when controlled hydrate misses etag or errors (0.153046ms)
✔ refuses controlled save without a hydrate ETag (0.493985ms)
✔ allows freeform bootstrap 0:0 only (0.125246ms)
✔ revisionEtag (2.533935ms)
▶ saveControlledBlockEdit
✔ successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map (2.507325ms)
✔ does not PATCH when editorDocId and activeDocId diverge (0.239383ms)
✔ does not PATCH on stale block id and keeps the draft (0.185995ms)
✔ 409 does not close the editor or drop the draft (0.854364ms)
✔ network errors do not close the editor or drop the draft (0.441767ms)
✔ saveControlledBlockEdit (5.514056ms)
✔ parseProjectIdFromSessionId decodes sess-p{id}-u{user} (1.520394ms)
✔ resolveSessionProjectId prefers explicit id then session id encoding (0.212014ms)
✔ mapServerSessionToProjectSession fills project metadata (0.627971ms)
✔ mergeSessionLists keeps local stage while applying server counts (0.223494ms)
▶ squadCompletionObservation
✔ accepts a directly completed adjacent assistant without requiring inflight (1.388318ms)
✔ accepts a turn that passed through inflight then completed (0.127666ms)
✔ rejects error terminals, missing correspondence, and visible errors (0.093147ms)
✔ keeps waiting while still inflight and does not treat inflight as success (0.067458ms)
✔ cancelInflightAssistantMessages settles only this-session assistant inflight (0.128636ms)
✔ squadCompletionObservation (2.6832ms)
✔ running bubble without phases needs replay (0.889653ms)
✔ interrupted bubble without phases needs replay (1.515235ms)
✔ bubble that already has phases is not replayed (0.164685ms)
✔ completed bubble without snapshot needs replay for legacy rows (0.096507ms)
✔ completed bubble with snapshot is not replayed (0.113237ms)
✔ phase replay key ignores completed chatter (0.153825ms)
✔ resolveTaskRunCardTitle prefers artifact title (0.909703ms)
✔ resolveTaskRunCardTitle falls back to user message then taskKey (0.208703ms)
✔ truncateTitle ellipsizes long text (0.164275ms)
✔ formatTaskDuration covers seconds minutes hours (0.160255ms)
✔ resolveTaskRunCardBadge maps terminal and live states (0.775347ms)
✔ shouldRenderTaskRunCard keeps card shape when run id exists (0.158936ms)
✔ runningPhaseDetail prefers live label when it differs from step (0.142095ms)
✔ 403 revision denial is not reported as a network error (0.930763ms)
✔ 403 author-duty denial surfaces draft-vs-submit guidance (0.157385ms)
✔ DOC_DUTY_FORBIDDEN keeps draft-vs-submit guidance (0.754727ms)
✔ TASK_SCOPE_UNRESOLVED names the missing workflow stage (0.094747ms)
✔ resolveReconnectRunId prefers explicit opts and ignores attempt without opts (0.622101ms)
✔ shouldSubscribeExistingRun only for in-flight statuses (0.139905ms)
✔ isRemoteTaskTerminal covers confirmation and quality_failed (0.112477ms)
✔ nextAttemptOpts carries retained runId for reconnect without new POST (1.610892ms)
✔ template documents are labeled as preview, not 编制中 or 已生效受控 (0.958361ms)
✔ turnAttachmentRefs keeps doc and parse facts (1.178685ms)
✔ describeChatHttpFailure prefers the public validation error (0.460687ms)
▶ uploadFreeformRegister
✔ buildUploadClientKey prefers explicit hint then doc-upload stamp (0.955591ms)
✔ registerUploadAttachments hard-fails without creating orphan on API error (16.603197ms)
✔ registerUploadAttachments rejects local doc-upload id from server (0.370409ms)
✔ registerUploadAttachments returns server doc-free ids and freeform facts (0.548384ms)
✔ deliverableFromFreeformUpload maps server id and upload facts (0.167475ms)
✔ buildUploadRegistrationMarkdown embeds real text content when provided (0.144066ms)
✔ buildUploadRegistrationMarkdown keeps placeholder for binary files without textContent (0.250482ms)
✔ registerUploadAttachments persists source file metadata as server facts (0.301371ms)
✔ uploadFreeformRegister (20.688145ms)
▶ workflowCategoryNavigation
✔ private menu cannot see doc_proj* so it must switch to workflow before exact select (1.049129ms)
✔ fail-closes when the workflow menu is showing but the exact doc is missing (0.190554ms)
✔ label and taskKey opens always require the workflow category (0.170195ms)
✔ workflowCategoryNavigation (3.520565ms)
✔ rdev selection explains the CDO prerequisite (0.787547ms)
✔ login shell does not statically import export, preview, admin, or chart engines (27.783405ms)
▶ no external Google Fonts on login shell
✔ index.html has no fonts.googleapis / fonts.gstatic (0.930232ms)
✔ styles reference self-hosted fonts.css (0.365889ms)
✔ no external Google Fonts on login shell (2.449468ms)
▶ 用户上传文档多格式在线预览体系与渲染分流测试
✔ 1. 上传附件时正确注入 sourceType: "uploaded_file" 与完整 fileMeta 元数据 (0.865104ms)
✔ 2. 扩展名分类规则准确分发至对应的高保真渲染器类型 (0.258543ms)
✔ 3. 受控工作流交付物严格隔离:绝不命中 FilePreviewPanel,保持 BlockRenderer 纯净 (0.176665ms)
✔ 4. 上传文档支持受控流转:从个人私密一键共享至项目参考库后,在线预览依然可用且权限标态正确 (0.179385ms)
✔ 用户上传文档多格式在线预览体系与渲染分流测试 (2.772037ms)
▶ 项目立项、成员职责分配与退出留痕治理测试套件
✔ 1. 默认项目库包含完整元数据与法定三权成员 (active 状态) (0.6818ms)
✔ 2. 跨用户会话可见性严格隔离:未加入成员完全不可见 (0.273272ms)
✔ 3. 创建者添加新成员并分配职责后,新成员即刻可见该项目会话 (0.165275ms)
✔ 4. 中途加入后期退出 (exited):保留项目会话但标记已退出只读留痕 (0.162316ms)
✔ 5. 超级管理员 (ADMIN) 享有全量通配可见与管理兜底特权 (0.093258ms)
✔ 项目立项、成员职责分配与退出留痕治理测试套件 (2.459847ms)
▶ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统)
✔ 1. 必须精确生成 7 份官方全流程受控工作流模板且标记纯净 (0.848355ms)
✔ 2. 杜绝旧项目 CUSA-NeuroPulse 串号泄露,动态注入新项目信息 (0.701719ms)
✔ 3. 严格对标参考系统模板规范要素,绝非 3 行残缺摘要 (0.411778ms)
✔ 4. 缓存丢失场景下即时自愈重建 AST 能力验证 (0.532624ms)
✔ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) (7.526547ms)
▶ 用户上传文档私密归档与共享流转测试
✔ 10 个评审项目均包含已共享的 URS 交付物与 AST,支持跨角色合规查阅且会话均以 assistant 闭环 (1.469307ms)
✔ 用户上传附件自动生成私密交付物并在 AST 中建立隔离,支持流转至项目参考库 (1.644061ms)
✔ 单轮对话支持一次上传多个文档(上限5个),批量生成私密交付物并遵循数量约束 (0.552124ms)
✔ 用户上传文档私密归档与共享流转测试 (4.558774ms)
▶ 工作流切换与消息展示时序测试套件
✔ 1. 当存在历史 seed 消息且追加新实时消息时,新消息必须严格沉淀在末尾 (1.243133ms)
✔ 2. 切回项目时相同正文不应因 temp/server id 差异重复展示 (0.185805ms)
✔ 工作流切换与消息展示时序测试套件 (2.221984ms)
✔ DeliverableItem 必须包含 projectId 与 isTemplateOnly 属性 (0.775697ms)
ℹ tests 532
ℹ suites 41
ℹ pass 532
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1586.5667
RUN v5.0.0 /home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web
Test Files 3 passed (3)
Tests 34 passed (34)
Start at 06:12:03
Duration 333ms (transform 67%, import 25%, tests 6%, worker 2%)
NPM_TEST:0
+25
View File
@@ -83,6 +83,31 @@ func TestMaterializeTargets_MissingAndEmptyFail(t *testing.T) {
} }
} }
func TestMaterializeTargets_EmptyPatchKeepsExistingFile(t *testing.T) {
workDir := t.TempDir()
body := []byte("# 调研报告\n已写在盘上的正文\n")
path := filepath.Join(workDir, "output.md")
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatal(err)
}
got, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{
"output.md": nil,
})
if err != nil {
t.Fatal(err)
}
if string(got["output.md"]) != string(body) {
t.Fatalf("collected %q", got["output.md"])
}
disk, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(disk) != string(body) {
t.Fatalf("disk overwritten with %q", disk)
}
}
func TestMaterializeTargets_RejectsSymlinkEscape(t *testing.T) { func TestMaterializeTargets_RejectsSymlinkEscape(t *testing.T) {
root := t.TempDir() root := t.TempDir()
outside := t.TempDir() outside := t.TempDir()
+1
View File
@@ -244,6 +244,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
stages := stageflow.New(svc) stages := stageflow.New(svc)
coordEngine.SetStageTaskRunner(stages) coordEngine.SetStageTaskRunner(stages)
coordEngine.SetContextServices(parts.Ctx, parts.KB) coordEngine.SetContextServices(parts.Ctx, parts.KB)
coordEngine.SetSessionRunReader(sessionRunAdapter{mgr: taskMgr})
if instSvc != nil && instSvc.Available() { if instSvc != nil && instSvc.Available() {
coordEngine.SetInstanceService(instSvc) coordEngine.SetInstanceService(instSvc)
} }
+27
View File
@@ -7,6 +7,7 @@ import (
"chat-agent-system/internal/app/intentflow" "chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/bizdev" "chat-agent-system/internal/bizdev"
"chat-agent-system/internal/chat" "chat-agent-system/internal/chat"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/taskrun" "chat-agent-system/internal/taskrun"
) )
@@ -117,6 +118,10 @@ func (a chatTaskStarter) CreateAndStart(ctx context.Context, runID string, recor
}, bizdev.StageRunRequest{}, resumeDir) }, bizdev.StageRunRequest{}, resumeDir)
} }
func (a chatTaskStarter) BindAssistantMsgID(ctx context.Context, runID string, assistantMsgID int64) error {
return a.inner.BindAssistantMsgID(ctx, runID, assistantMsgID)
}
// BindChatSession attaches composition-root adapters onto a chat session. // BindChatSession attaches composition-root adapters onto a chat session.
// Callers that keep a mutable intentflow.Service (e.g. server test hooks) should // Callers that keep a mutable intentflow.Service (e.g. server test hooks) should
// prefer AdaptChat* helpers with their own fields so swaps stay visible. // prefer AdaptChat* helpers with their own fields so swaps stay visible.
@@ -138,3 +143,25 @@ func (rt *Runtime) BindChatSession(sess *chat.Session, env string) {
Environment: env, Environment: env,
} }
} }
type sessionRunAdapter struct {
mgr *taskrun.Manager
}
func (a sessionRunAdapter) LatestSessionRun(ctx context.Context, sessionID string) (*coordinator.SessionRunSnapshot, error) {
if a.mgr == nil {
return nil, nil
}
rec, err := a.mgr.LatestSessionRun(ctx, sessionID)
if err != nil || rec == nil {
return nil, err
}
return &coordinator.SessionRunSnapshot{
RunID: rec.RunID,
TaskKey: rec.TaskKey,
Status: rec.Status,
WorkspaceID: rec.WorkspaceID,
ErrorMessage: rec.ErrorMessage,
UpdatedAt: rec.UpdatedAt,
}, nil
}
+1
View File
@@ -70,6 +70,7 @@ type IntentStepView struct {
// TaskStarter starts a task run without exposing bizdev/stageflow request types to chat. // TaskStarter starts a task run without exposing bizdev/stageflow request types to chat.
type TaskStarter interface { type TaskStarter interface {
CreateAndStart(ctx context.Context, runID string, record *taskrun.RunRecord, start TaskStartRequest, resumeDir string) (*taskrun.RunRecord, error) CreateAndStart(ctx context.Context, runID string, record *taskrun.RunRecord, start TaskStartRequest, resumeDir string) (*taskrun.RunRecord, error)
BindAssistantMsgID(ctx context.Context, runID string, assistantMsgID int64) error
} }
// TaskStartRequest carries the chat-owned fields needed to start or resume a run. // TaskStartRequest carries the chat-owned fields needed to start or resume a run.
+16
View File
@@ -465,6 +465,9 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40) streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40)
} }
s.EnsureDocResolver() s.EnsureDocResolver()
if s.UserCtx != nil {
s.UserCtx.ChatSessionID = s.ID
}
coordRes, err := s.Coordinator.ProcessIntent(ctx, recRes, s.UserCtx) coordRes, err := s.Coordinator.ProcessIntent(ctx, recRes, s.UserCtx)
if err != nil { if err != nil {
if streamer != nil { if streamer != nil {
@@ -473,8 +476,12 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
return nil, fmt.Errorf("coordination execution failed: %w", err) return nil, fmt.Errorf("coordination execution failed: %w", err)
} }
var startedRunID string
if coordRes.PendingAction != nil { if coordRes.PendingAction != nil {
execResult, execErr := s.executeAction(ctx, coordRes.PendingAction, s.UserCtx) execResult, execErr := s.executeAction(ctx, coordRes.PendingAction, s.UserCtx)
if execResult != nil {
startedRunID = execResult.RunID
}
coordRes.NaturalReply = renderActionReply(coordRes.PendingAction, execResult, execErr) coordRes.NaturalReply = renderActionReply(coordRes.PendingAction, execResult, execErr)
coordRes.PendingAction = nil coordRes.PendingAction = nil
} }
@@ -592,8 +599,17 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
asstMeta["client_turn_id"] = clientTurnID asstMeta["client_turn_id"] = clientTurnID
asstMeta["client_id"] = clientTurnID + ":assistant" asstMeta["client_id"] = clientTurnID + ":assistant"
} }
if startedRunID != "" {
asstMeta["run_id"] = startedRunID
if asstMeta["task_status"] == nil || asstMeta["task_status"] == "" {
asstMeta["task_status"] = "running"
}
}
asstMsgID, _ := s.CtxMgr.RecordUserMessageWithID(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta) asstMsgID, _ := s.CtxMgr.RecordUserMessageWithID(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta)
if startedRunID != "" && asstMsgID > 0 && s.Actions != nil && s.Actions.TaskRuns != nil {
_ = s.Actions.TaskRuns.BindAssistantMsgID(ctx, startedRunID, asstMsgID)
}
// 异步记录问答切片到向量记忆库 (P2 检索增强,不阻塞流式或同步答复) // 异步记录问答切片到向量记忆库 (P2 检索增强,不阻塞流式或同步答复)
sessID := s.ID sessID := s.ID
+2
View File
@@ -222,6 +222,8 @@ type UserProjectContext struct {
RegulationHits []kb.HitItem `json:"-"` RegulationHits []kb.HitItem `json:"-"`
// UpgradeOffered 路由升级建议冷却标记(每会话至多主动建议一次,防打扰——对齐"参谋不强推"人设) // UpgradeOffered 路由升级建议冷却标记(每会话至多主动建议一次,防打扰——对齐"参谋不强推"人设)
UpgradeOffered bool `json:"-"` UpgradeOffered bool `json:"-"`
// ChatSessionID 当前对话会话,供进度问询读取真实 task run。不序列化。
ChatSessionID string `json:"-"`
// DocumentSummaryReader 仅用于服务端鉴权后的只读正文访问,不序列化。 // DocumentSummaryReader 仅用于服务端鉴权后的只读正文访问,不序列化。
DocumentSummaryReader DocumentSummaryReader `json:"-"` DocumentSummaryReader DocumentSummaryReader `json:"-"`
// RealtimeSnippet 本轮实时检索(天气/新闻等)成功结果,供 Prompt 注入;不序列化。 // RealtimeSnippet 本轮实时检索(天气/新闻等)成功结果,供 Prompt 注入;不序列化。
+22
View File
@@ -83,6 +83,7 @@ type CoordinatorEngine struct {
docResolver DocResolver docResolver DocResolver
pendingSupps PendingSupplementReader pendingSupps PendingSupplementReader
reopenLister ReopenLister reopenLister ReopenLister
sessionRuns SessionRunReader
// OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync. // OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync.
// Nil-safe: unset hooks are skipped. // Nil-safe: unset hooks are skipped.
OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)
@@ -108,6 +109,27 @@ func (e *CoordinatorEngine) tasks() StageTaskRunner {
return e.stageTasks return e.stageTasks
} }
// SessionRunSnapshot 是进度问询可读的任务运行投影,不含工作目录绝对路径。
type SessionRunSnapshot struct {
RunID string
TaskKey string
Status string
WorkspaceID string
ErrorMessage string
UpdatedAt time.Time
}
// SessionRunReader 按会话读取最近一次任务运行。未注入时进度问询保持模板答复。
type SessionRunReader interface {
LatestSessionRun(ctx context.Context, sessionID string) (*SessionRunSnapshot, error)
}
func (e *CoordinatorEngine) SetSessionRunReader(reader SessionRunReader) {
if e != nil {
e.sessionRuns = reader
}
}
func (e *CoordinatorEngine) SetContextServices(ctxMgr *ctxmgr.ContextManager, kbClient *kb.Client) { func (e *CoordinatorEngine) SetContextServices(ctxMgr *ctxmgr.ContextManager, kbClient *kb.Client) {
if e != nil { if e != nil {
e.ctxMgr = ctxMgr e.ctxMgr = ctxMgr
+50
View File
@@ -151,6 +151,13 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten
sb.WriteString(brief.CommunicationKit) sb.WriteString(brief.CommunicationKit)
case "progress": case "progress":
if snap := e.latestSessionRun(ctx, userCtx); snap != nil {
sb.WriteString(formatSessionRunReply(snap))
if brief != nil {
sb.WriteString(fmt.Sprintf("\n项目成熟度仍为 %s(%d/100)。", brief.MaturityLevel, brief.MaturityScore))
}
break
}
sb.WriteString(fmt.Sprintf("【进展汇报】 项目当前成熟度等级为 %s(评分 %d/100)。\n", brief.MaturityLevel, brief.MaturityScore)) sb.WriteString(fmt.Sprintf("【进展汇报】 项目当前成熟度等级为 %s(评分 %d/100)。\n", brief.MaturityLevel, brief.MaturityScore))
sb.WriteString(fmt.Sprintf("诊断结论:%s\n\n", brief.MaturityHeadline)) sb.WriteString(fmt.Sprintf("诊断结论:%s\n\n", brief.MaturityHeadline))
if len(brief.NextBestActions) > 0 { if len(brief.NextBestActions) > 0 {
@@ -196,6 +203,49 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten
}), nil }), nil
} }
func (e *CoordinatorEngine) latestSessionRun(ctx context.Context, userCtx *UserProjectContext) *SessionRunSnapshot {
if e == nil || e.sessionRuns == nil || userCtx == nil || strings.TrimSpace(userCtx.ChatSessionID) == "" {
return nil
}
snap, err := e.sessionRuns.LatestSessionRun(ctx, userCtx.ChatSessionID)
if err != nil || snap == nil || snap.RunID == "" {
return nil
}
return snap
}
func formatSessionRunReply(snap *SessionRunSnapshot) string {
label := map[string]string{
"pending": "排队中",
"running": "正在编写",
"completed": "已完成",
"failed": "执行失败",
"finalizing_failed": "收尾失败",
"interrupted": "已中断,可以继续",
"cancelled": "已取消",
"pending_author_confirmation": "待编制人确认",
"quality_failed": "质量未通过",
"blocked_pending_governance": "待激活工作流",
}
status := label[snap.Status]
if status == "" {
status = snap.Status
}
taskName := snap.TaskKey
if taskName == "" {
taskName = "当前任务"
}
var b strings.Builder
b.WriteString(fmt.Sprintf("「%s」当前状态:%s。", taskName, status))
if snap.WorkspaceID != "" {
b.WriteString(fmt.Sprintf("工作区标识:%s。", snap.WorkspaceID))
}
if snap.ErrorMessage != "" && (snap.Status == "failed" || snap.Status == "interrupted" || snap.Status == "finalizing_failed") {
b.WriteString("原因:" + snap.ErrorMessage + "。")
}
return b.String()
}
// synthesizeBaselineBrief 在用户尚未执行过任何阶段/任务(无持久化参谋简报)时, // synthesizeBaselineBrief 在用户尚未执行过任何阶段/任务(无持久化参谋简报)时,
// 基于黑板事实与项目信息合成 intake 级基准参谋简报,确保查询类意图始终有实质研判可用。 // 基于黑板事实与项目信息合成 intake 级基准参谋简报,确保查询类意图始终有实质研判可用。
func synthesizeBaselineBrief(userCtx *UserProjectContext) *squad.AdvisorBriefing { func synthesizeBaselineBrief(userCtx *UserProjectContext) *squad.AdvisorBriefing {
@@ -0,0 +1,45 @@
package coordinator
import (
"context"
"strings"
"testing"
)
type fakeSessionRuns struct {
snap *SessionRunSnapshot
err error
}
func (f fakeSessionRuns) LatestSessionRun(context.Context, string) (*SessionRunSnapshot, error) {
return f.snap, f.err
}
func TestFormatSessionRunReply_RunningIncludesWorkspace(t *testing.T) {
got := formatSessionRunReply(&SessionRunSnapshot{
TaskKey: "research",
Status: "running",
WorkspaceID: "ws_research_1",
})
for _, want := range []string{"research", "正在编写", "ws_research_1"} {
if !strings.Contains(got, want) {
t.Fatalf("reply %q missing %q", got, want)
}
}
}
func TestLatestSessionRun_ReadsInjectedReader(t *testing.T) {
engine := NewCoordinatorEngine(nil)
engine.SetSessionRunReader(fakeSessionRuns{snap: &SessionRunSnapshot{
RunID: "run-1",
TaskKey: "research",
Status: "running",
}})
got := engine.latestSessionRun(context.Background(), &UserProjectContext{ChatSessionID: "sess-1"})
if got == nil || got.RunID != "run-1" {
t.Fatalf("latest = %#v", got)
}
if engine.latestSessionRun(context.Background(), &UserProjectContext{}) != nil {
t.Fatal("empty chat session must not invent a run")
}
}
@@ -11,6 +11,30 @@ import (
"chat-agent-system/internal/squad" "chat-agent-system/internal/squad"
) )
// isProgressInquiry 为真时,带任务名词的疑问句应让行 QUERY_PROGRESS,而不是 TRIGGER_TASK。
func isProgressInquiry(input string) bool {
if !isInterrogative(input) {
return false
}
markers := []string{"进度", "进展", "怎么样了", "状态如何", "写完", "完成了吗", "完成没有", "好了吗", "好了没有"}
hit := false
for _, marker := range markers {
if strings.Contains(input, marker) {
hit = true
break
}
}
if !hit {
return false
}
for _, verb := range []string{"开始", "重新", "再来", "重跑", "再生成", "重新生成"} {
if strings.Contains(input, verb) {
return false
}
}
return true
}
// isInterrogative 判断输入是否疑问句:句尾问号/「吗」「呢」,或句中出现 // isInterrogative 判断输入是否疑问句:句尾问号/「吗」「呢」,或句中出现
// 「怎么/如何/什么/多少/哪/是否/大概/多久/为什么」等疑问词。 // 「怎么/如何/什么/多少/哪/是否/大概/多久/为什么」等疑问词。
// 用于把「查询/咨询」类输入从祈使句「指令」类意图(阶段推进等)中排除。 // 用于把「查询/咨询」类输入从祈使句「指令」类意图(阶段推进等)中排除。
@@ -248,6 +272,11 @@ func requirementsTriggerPhrase(taskKey string) string {
} }
func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent { func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent {
// 疑问句里的「编写进度怎么样了」是在问状态,不是再启动一份交付物。
// 显式启动词(开始/重新/重跑)仍走任务触发。
if isProgressInquiry(input) {
return nil
}
type phrase struct { type phrase struct {
text string text string
key string key string
+24
View File
@@ -72,6 +72,30 @@ func TestIntentRecognition_AdvanceStage(t *testing.T) {
} }
} }
func TestIntentRecognition_ProgressQuestionDoesNotStartTask(t *testing.T) {
rec := intent.NewRecognizer()
ctx := &intent.ContextSnapshot{SnapshotID: "ctx_progress"}
progress, err := rec.Recognize(context.Background(), "调研报告编写进度怎么样了", ctx)
if err != nil {
t.Fatalf("recognize progress: %v", err)
}
if progress.TopIntent.Intent != intent.IntentQueryProgress {
t.Fatalf("progress question intent = %s, want QUERY_PROGRESS", progress.TopIntent.Intent)
}
start, err := rec.Recognize(context.Background(), "开始做调研报告", ctx)
if err != nil {
t.Fatalf("recognize start: %v", err)
}
if start.TopIntent.Intent != intent.IntentTriggerTask {
t.Fatalf("start intent = %s, want TRIGGER_TASK", start.TopIntent.Intent)
}
if start.TopIntent.FilledSlots[intent.SlotTargetTask] != "research" {
t.Fatalf("start task = %s, want research", start.TopIntent.FilledSlots[intent.SlotTargetTask])
}
}
func TestIntentRecognition_TriggerTask(t *testing.T) { func TestIntentRecognition_TriggerTask(t *testing.T) {
rec := intent.NewRecognizer() rec := intent.NewRecognizer()
ctx := &intent.ContextSnapshot{ ctx := &intent.ContextSnapshot{
+1
View File
@@ -230,6 +230,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/tasks/run/stream", s.handleRunTaskStream) mux.HandleFunc("POST /api/v1/tasks/run/stream", s.handleRunTaskStream)
mux.HandleFunc("GET /api/v1/tasks/run/stream", s.handleRunTaskStream) mux.HandleFunc("GET /api/v1/tasks/run/stream", s.handleRunTaskStream)
mux.HandleFunc("POST /api/v1/tasks/runs", s.handleCreateTaskRun) mux.HandleFunc("POST /api/v1/tasks/runs", s.handleCreateTaskRun)
mux.HandleFunc("GET /api/v1/tasks/runs", s.handleListTaskRuns)
mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}", s.handleGetTaskRun) mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}", s.handleGetTaskRun)
mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}/events", s.handleTaskRunEvents) mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}/events", s.handleTaskRunEvents)
mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/cancel", s.handleCancelTaskRun) mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/cancel", s.handleCancelTaskRun)
+6
View File
@@ -49,6 +49,12 @@ func (failInsertStore) HeartbeatLease(context.Context, string, string, time.Dura
func (failInsertStore) ReleaseLease(context.Context, string, string) error { return nil } func (failInsertStore) ReleaseLease(context.Context, string, string) error { return nil }
func (failInsertStore) ResetForResume(context.Context, string, int) error { return nil } func (failInsertStore) ResetForResume(context.Context, string, int) error { return nil }
func (failInsertStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, nil } func (failInsertStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, nil }
func (failInsertStore) ListActiveBySession(context.Context, string) ([]*taskrun.RunRecord, error) {
return nil, nil
}
func (failInsertStore) LatestBySession(context.Context, string) (*taskrun.RunRecord, error) {
return nil, nil
}
func TestRunTaskStream_InvalidJSON_Returns400(t *testing.T) { func TestRunTaskStream_InvalidJSON_Returns400(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
+42
View File
@@ -209,6 +209,9 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
"doc_id": req.DocID, "doc_id": req.DocID,
}, },
} }
if req.ClientTurnID != "" {
asstMeta["client_id"] = req.ClientTurnID + ":assistant"
}
projectinstance.ApplyScopeToMetadata(asstMeta, taskScope) projectinstance.ApplyScopeToMetadata(asstMeta, taskScope)
assistantMsgID, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", "任务执行中…", "TRIGGER_TASK", "", asstMeta) assistantMsgID, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", "任务执行中…", "TRIGGER_TASK", "", asstMeta)
claimed.AssistantMsgID = assistantMsgID claimed.AssistantMsgID = assistantMsgID
@@ -613,6 +616,45 @@ func (s *Server) cancelPiAgentRunner(workspaceID string) {
_ = resp.Body.Close() _ = resp.Body.Close()
} }
func (s *Server) handleListTaskRuns(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
writeSafeError(w, r, http.StatusBadRequest, "session_id required")
return
}
if r.URL.Query().Get("active") != "1" {
writeSafeError(w, r, http.StatusBadRequest, "active=1 required")
return
}
if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) {
return
}
if s.taskRuns() == nil {
writeJSONResponse(w, http.StatusOK, map[string]any{"runs": []taskRunResponse{}})
return
}
listed, err := s.taskRuns().ListActiveBySession(r.Context(), sessionID)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
out := make([]taskRunResponse, 0, len(listed))
for _, rec := range listed {
if rec == nil {
continue
}
if !s.isSystemAdminUser(r.Context(), user) && rec.UserID > 0 && rec.UserID != user.ID {
continue
}
out = append(out, s.buildTaskRunResponse(rec))
}
writeJSONResponse(w, http.StatusOK, map[string]any{"runs": out})
}
func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r) user, ok := s.requirePrincipal(w, r)
if !ok { if !ok {
+18 -13
View File
@@ -26,7 +26,7 @@ import (
// 步骤状态常量(与既有 workflow.json 的 legacy 状态字符串完全兼容) // 步骤状态常量(与既有 workflow.json 的 legacy 状态字符串完全兼容)
const ( const (
StepStatePending = "pending" StepStatePending = "pending"
StepStateExecuting = "running" // legacy 值保持不变 StepStateExecuting = "running" // legacy 值保持不变
StepStateVerifying = "verifying" // 新增:校验态 StepStateVerifying = "verifying" // 新增:校验态
StepStateRepairing = "repairing" // 新增:修复态 StepStateRepairing = "repairing" // 新增:修复态
StepStateCompleted = "completed" StepStateCompleted = "completed"
@@ -213,15 +213,23 @@ func (l *BudgetLedger) UsageSummary() string {
// TraceEntry 单次底层 agent 调用的轨迹记录 // TraceEntry 单次底层 agent 调用的轨迹记录
type TraceEntry struct { type TraceEntry struct {
Seq int `json:"seq"` Seq int `json:"seq"`
StepID string `json:"step_id"` StepID string `json:"step_id"`
Role string `json:"role"` Role string `json:"role"`
Timestamp time.Time `json:"ts"` Timestamp time.Time `json:"ts"`
PromptDigest string `json:"prompt_digest"` PromptDigest string `json:"prompt_digest"`
OutputDigest string `json:"output_digest"` OutputDigest string `json:"output_digest"`
SimilarityToPrev float64 `json:"similarity_to_prev"` SimilarityToPrev float64 `json:"similarity_to_prev"`
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
Success bool `json:"success"` Success bool `json:"success"`
ExecErr string `json:"exec_err,omitempty"`
ResultSuccess bool `json:"res_success,omitempty"`
ResultError string `json:"res_error_reason,omitempty"`
HasTrustedPatch bool `json:"has_trusted_patch,omitempty"`
WroteThisInvocation bool `json:"wrote_this_invocation,omitempty"`
MatErr string `json:"mat_err,omitempty"`
RecoveredFromDisk bool `json:"recovered_from_disk,omitempty"`
CancelReason string `json:"cancel_reason,omitempty"`
} }
// AppendTrace 追加一条执行轨迹(jsonl,天然即历史轨迹审计源) // AppendTrace 追加一条执行轨迹(jsonl,天然即历史轨迹审计源)
@@ -462,6 +470,3 @@ func SortedStepIDs(wf *WorkflowPlan) []string {
sort.Strings(ids) sort.Strings(ids)
return ids return ids
} }
+137 -55
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -264,28 +265,31 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
for _, s := range parallelGroup { for _, s := range parallelGroup {
sCopy := s sCopy := s
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
defer func() { defer func() {
if rec := recover(); rec != nil { if rec := recover(); rec != nil {
mu.Lock() mu.Lock()
if groupErr == nil { if groupErr == nil {
groupErr = fmt.Errorf("parallel step panic: %v", rec) groupErr = fmt.Errorf("parallel step panic: %v", rec)
} }
mu.Unlock() mu.Unlock()
} }
}() }()
if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
mu.Lock() mu.Lock()
if groupErr == nil { if groupErr == nil {
groupErr = err groupErr = err
} }
mu.Unlock() mu.Unlock()
} }
}() }()
} }
wg.Wait() wg.Wait()
if groupErr != nil { if groupErr != nil {
if cerr := contextErrString(runCtx); cerr != "" {
slog.Warn("squad_parallel_cancelled", "error", groupErr.Error(), "cancel_reason", cerr, "parent_cancel", contextErrString(ctx))
}
wf.Status = "failed" wf.Status = "failed"
_ = d.SaveWorkflow(wsDir, wf) _ = d.SaveWorkflow(wsDir, wf)
_ = d.updateWorkspaceStatus(wsDir, "failed") _ = d.updateWorkspaceStatus(wsDir, "failed")
@@ -335,25 +339,25 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
Duration: time.Since(start), Duration: time.Since(start),
}, rErr }, rErr
} }
// 治理:受控迁移至执行态(legacy 重跑场景直接放行) // 治理:受控迁移至执行态(legacy 重跑场景直接放行)
markStepExecuting(wf, step.ID) markStepExecuting(wf, step.ID)
if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
// A5 非关键步骤降级:记录 degraded 继续推进,收口统一标 degraded // A5 非关键步骤降级:记录 degraded 继续推进,收口统一标 degraded
if !step.IsCritical() { if !step.IsCritical() {
markStepDegraded(wsDir, wf, step, err) markStepDegraded(wsDir, wf, step, err)
stepsRun++ stepsRun++
_ = d.SaveWorkflow(wsDir, wf)
continue
}
wf.Status = "failed"
_ = d.SaveWorkflow(wsDir, wf) _ = d.SaveWorkflow(wsDir, wf)
continue _ = d.updateWorkspaceStatus(wsDir, "failed")
return &WorkflowExecutionResult{
Success: false,
ErrorReason: err.Error(),
Duration: time.Since(start),
}, err
} }
wf.Status = "failed"
_ = d.SaveWorkflow(wsDir, wf)
_ = d.updateWorkspaceStatus(wsDir, "failed")
return &WorkflowExecutionResult{
Success: false,
ErrorReason: err.Error(),
Duration: time.Since(start),
}, err
}
stepsRun++ stepsRun++
// 治理:FSM 受控迁移 + 步骤归档锁定 + 软线提醒 // 治理:FSM 受控迁移 + 步骤归档锁定 + 软线提醒
if tErr := TransitionStep(wf, step.ID, StepStateCompleted); tErr == nil { if tErr := TransitionStep(wf, step.ID, StepStateCompleted); tErr == nil {
@@ -549,13 +553,13 @@ func (d *WorkflowDriver) runOneStep(
} }
var ( var (
res *agent.Result res *agent.Result
execErr error execErr error
matErr error matErr error
duration int64 duration int64
ok bool ok bool
lastAssess agent.FailureAssessment lastAssess agent.FailureAssessment
basePrompt = string(taskData) basePrompt = string(taskData)
) )
for attempt := 1; attempt <= agent.MaxTransientStepInvocations; attempt++ { for attempt := 1; attempt <= agent.MaxTransientStepInvocations; attempt++ {
onDisk, _ := os.ReadFile(prevOutPath) onDisk, _ := os.ReadFile(prevOutPath)
@@ -611,22 +615,51 @@ func (d *WorkflowDriver) runOneStep(
if newContent == "" && wroteThisInvocation { if newContent == "" && wroteThisInvocation {
newContent = string(onDisk) newContent = string(onDisk)
} }
ok = execErr == nil && res != nil && res.Success && matErr == nil recoveredFromDisk := false
if strings.TrimSpace(newContent) == "" {
if again, rerr := os.ReadFile(prevOutPath); rerr == nil && deliverableWroteThisInvocation(prevContent, again) {
newContent = string(again)
wroteThisInvocation = true
recoveredFromDisk = true
}
}
ok = stepOutputAccepted(execErr, matErr, wroteThisInvocation, newContent, res)
if !ok || recoveredFromDisk {
slog.Info("squad_step_output_judge",
"step_id", step.ID,
"exec_err", errString(execErr),
"res_success", res != nil && res.Success,
"res_error_reason", resultErrorReason(res),
"has_trusted_patch", hasTrustedPatch,
"wrote_this_invocation", wroteThisInvocation,
"mat_err", errString(matErr),
"recovered_from_disk", recoveredFromDisk,
"accepted", ok,
)
}
sim := OutputSimilarity(prevContent, newContent) sim := OutputSimilarity(prevContent, newContent)
if mu != nil { if mu != nil {
mu.Lock() mu.Lock()
} }
traceSeq := len(LoadTrace(wsDir)) + 1 traceSeq := len(LoadTrace(wsDir)) + 1
_ = AppendTrace(wsDir, TraceEntry{ _ = AppendTrace(wsDir, TraceEntry{
Seq: traceSeq, Seq: traceSeq,
StepID: step.ID, StepID: step.ID,
Role: step.Role, Role: step.Role,
Timestamp: time.Now(), Timestamp: time.Now(),
PromptDigest: digestString(string(taskData)), PromptDigest: digestString(string(taskData)),
OutputDigest: digestString(newContent), OutputDigest: digestString(newContent),
SimilarityToPrev: sim, SimilarityToPrev: sim,
DurationMs: duration, DurationMs: duration,
Success: ok, Success: ok,
ExecErr: errString(execErr),
ResultSuccess: res != nil && res.Success,
ResultError: resultErrorReason(res),
HasTrustedPatch: hasTrustedPatch,
WroteThisInvocation: wroteThisInvocation,
MatErr: errString(matErr),
RecoveredFromDisk: strings.TrimSpace(newContent) != "" && !(res != nil && res.Success),
CancelReason: contextErrString(ctx),
}) })
if mu != nil { if mu != nil {
mu.Unlock() mu.Unlock()
@@ -662,7 +695,7 @@ func (d *WorkflowDriver) runOneStep(
} }
} }
if execErr != nil { if !ok && execErr != nil {
if streamer != nil { if streamer != nil {
streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, execErr.Error()), step.Role, 0, 0, 0) streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, execErr.Error()), step.Role, 0, 0, 0)
} }
@@ -680,7 +713,7 @@ func (d *WorkflowDriver) runOneStep(
return fmt.Errorf("step %s (%s) backend execute: %w", step.ID, step.Role, execErr) return fmt.Errorf("step %s (%s) backend execute: %w", step.ID, step.Role, execErr)
} }
if res != nil && !res.Success { if !ok && res != nil && !res.Success {
errMsg := res.ErrorReason errMsg := res.ErrorReason
if streamer != nil { if streamer != nil {
streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行未成功:%s", roleTitle, errMsg), step.Role, 0, 0, 0) streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行未成功:%s", roleTitle, errMsg), step.Role, 0, 0, 0)
@@ -761,6 +794,55 @@ func (d *WorkflowDriver) runOneStep(
return nil return nil
} }
// stepOutputAccepted 以本轮写盘产物为事实源。runner 把 Success 标成 false
// (例如 typed result 没带回 patch)但文件已经写好时,不让这一处否决步骤。
func stepOutputAccepted(execErr, matErr error, wrote bool, newContent string, res *agent.Result) bool {
if execErr != nil || matErr != nil {
return false
}
if res != nil && res.Success {
return true
}
if !wrote || strings.TrimSpace(newContent) == "" {
return false
}
// 连接中断或可重试失败留下的文件可能是截断的,不能当成交付成功。
// 交付物缺失/空结果但盘上已有正文时,以文件为准。
if res != nil && (res.Retryable || hardRunnerFailure(res.ErrorReason)) {
return false
}
return true
}
func hardRunnerFailure(reason string) bool {
reason = strings.ToLower(strings.TrimSpace(reason))
if reason == "" || strings.Contains(reason, "missing target deliverable") {
return false
}
return true
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func resultErrorReason(res *agent.Result) string {
if res == nil {
return ""
}
return res.ErrorReason
}
func contextErrString(ctx context.Context) string {
if ctx == nil || ctx.Err() == nil {
return ""
}
return ctx.Err().Error()
}
func deliverableWroteThisInvocation(baseline string, onDisk []byte) bool { func deliverableWroteThisInvocation(baseline string, onDisk []byte) bool {
if len(bytes.TrimSpace(onDisk)) == 0 { if len(bytes.TrimSpace(onDisk)) == 0 {
return false return false
+31
View File
@@ -0,0 +1,31 @@
package squad
import (
"errors"
"testing"
"chat-agent-system/internal/agent"
)
func TestStepOutputAccepted_DiskContentBeatsMissingDeliverable(t *testing.T) {
missing := &agent.Result{Success: false, ErrorReason: "missing target deliverable output.md"}
if !stepOutputAccepted(nil, nil, true, "# 调研报告\n正文", missing) {
t.Fatal("non-empty file must be accepted when the runner only reported a missing deliverable")
}
truncated := &agent.Result{Success: false, ErrorReason: "Connection error.", Retryable: true}
if stepOutputAccepted(nil, nil, true, "# partial", truncated) {
t.Fatal("retryable connection failure must stay fail-closed even if a file exists")
}
if stepOutputAccepted(nil, nil, true, " \n", missing) {
t.Fatal("blank content must not override a failed runner result")
}
if stepOutputAccepted(errors.New("backend"), nil, true, "# 调研报告", missing) {
t.Fatal("execute error must reject the step")
}
if stepOutputAccepted(nil, errors.New("materialize"), true, "# 调研报告", &agent.Result{Success: true}) {
t.Fatal("materialize error must reject the step")
}
if !stepOutputAccepted(nil, nil, false, "", &agent.Result{Success: true}) {
t.Fatal("runner success without a new file still counts")
}
}
+36
View File
@@ -0,0 +1,36 @@
package taskrun
import (
"context"
"testing"
)
func TestListActiveBySession_MemoryOnly(t *testing.T) {
m := NewManager(nil, nil)
m.mu.Lock()
m.runs["run-live"] = &activeRun{record: &RunRecord{
RunID: "run-live", SessionID: "sess-1", Status: "running", TaskKey: "research",
}}
m.runs["run-done"] = &activeRun{record: &RunRecord{
RunID: "run-done", SessionID: "sess-1", Status: "completed",
}}
m.runs["other"] = &activeRun{record: &RunRecord{
RunID: "other", SessionID: "sess-2", Status: "running",
}}
m.mu.Unlock()
listed, err := m.ListActiveBySession(context.Background(), "sess-1")
if err != nil {
t.Fatal(err)
}
if len(listed) != 1 || listed[0].RunID != "run-live" {
t.Fatalf("listed = %#v", listed)
}
}
func TestWriteRunningAnchor_NoContextManagerIsNoop(t *testing.T) {
m := NewManager(nil, nil)
m.writeRunningAnchor(context.Background(), &RunRecord{
RunID: "run-1", AssistantMsgID: 9, TaskKey: "research", Status: "running",
})
}
@@ -91,6 +91,37 @@ func (m *memIdemStore) HeartbeatLease(context.Context, string, string, time.Dura
func (m *memIdemStore) ReleaseLease(context.Context, string, string) error { return nil } func (m *memIdemStore) ReleaseLease(context.Context, string, string) error { return nil }
func (m *memIdemStore) ResetForResume(context.Context, string, int) error { return nil } func (m *memIdemStore) ResetForResume(context.Context, string, int) error { return nil }
func (m *memIdemStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, nil } func (m *memIdemStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, nil }
func (m *memIdemStore) ListActiveBySession(_ context.Context, sessionID string) ([]*RunRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]*RunRecord, 0)
for _, rec := range m.byID {
if rec == nil || rec.SessionID != sessionID {
continue
}
if rec.Status != "pending" && rec.Status != "running" {
continue
}
cpy := *rec
out = append(out, &cpy)
}
return out, nil
}
func (m *memIdemStore) LatestBySession(_ context.Context, sessionID string) (*RunRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
var best *RunRecord
for _, rec := range m.byID {
if rec == nil || rec.SessionID != sessionID {
continue
}
if best == nil || rec.UpdatedAt.After(best.UpdatedAt) {
cpy := *rec
best = &cpy
}
}
return best, nil
}
func TestClaimRun_ConcurrentSameKey_OneOwner(t *testing.T) { func TestClaimRun_ConcurrentSameKey_OneOwner(t *testing.T) {
store := newMemIdemStore() store := newMemIdemStore()
+117 -2
View File
@@ -255,8 +255,11 @@ func (m *Manager) BindAssistantMsgID(ctx context.Context, runID string, assistan
rec := *ar.record rec := *ar.record
ar.mu.Unlock() ar.mu.Unlock()
if m.store != nil && m.store.Available() { if m.store != nil && m.store.Available() {
return m.store.UpdateRun(ctx, &rec) if err := m.store.UpdateRun(ctx, &rec); err != nil {
return err
}
} }
m.writeRunningAnchor(ctx, &rec)
return nil return nil
} }
if m.store != nil && m.store.Available() { if m.store != nil && m.store.Available() {
@@ -266,11 +269,121 @@ func (m *Manager) BindAssistantMsgID(ctx context.Context, runID string, assistan
} }
rec.AssistantMsgID = assistantMsgID rec.AssistantMsgID = assistantMsgID
rec.UpdatedAt = time.Now() rec.UpdatedAt = time.Now()
return m.store.UpdateRun(ctx, rec) if err := m.store.UpdateRun(ctx, rec); err != nil {
return err
}
m.writeRunningAnchor(ctx, rec)
return nil
} }
return nil return nil
} }
// writeRunningAnchor 在 run 仍进行中时就把 run_id / task_status 写进助手消息。
// 刷新后窗口能按 taskRunId 重放进度,不必等终态 finalize。
func (m *Manager) writeRunningAnchor(ctx context.Context, rec *RunRecord) {
if m == nil || m.ctxMgr == nil || rec == nil || rec.AssistantMsgID <= 0 || rec.RunID == "" {
return
}
meta := map[string]any{
"run_id": rec.RunID,
"task_key": rec.TaskKey,
"task_status": "running",
}
if rec.WorkspaceID != "" {
meta["workspace_id"] = rec.WorkspaceID
}
if err := m.ctxMgr.UpdateMessageMetadata(ctx, rec.AssistantMsgID, meta, "running"); err != nil {
slog.Warn("taskrun_running_anchor_failed", "run_id", rec.RunID, "message_id", rec.AssistantMsgID, "error", err)
}
}
// ListActiveBySession 返回该会话仍在 pending/running 且租约未过期的 run。
// 内存中的活跃 run 与存储合并,覆盖尚未落库的窗口。
func (m *Manager) ListActiveBySession(ctx context.Context, sessionID string) ([]*RunRecord, error) {
if m == nil || sessionID == "" {
return nil, nil
}
var stored []*RunRecord
if m.store != nil && m.store.Available() {
listed, err := m.store.ListActiveBySession(ctx, sessionID)
if err != nil {
return nil, err
}
stored = listed
}
return mergeRuns(stored, m.memorySessionRuns(sessionID, true)), nil
}
// LatestSessionRun 返回该会话最近一次 run(含已结束),供进度问询读取真实状态。
func (m *Manager) LatestSessionRun(ctx context.Context, sessionID string) (*RunRecord, error) {
if m == nil || sessionID == "" {
return nil, nil
}
var best *RunRecord
if m.store != nil && m.store.Available() {
rec, err := m.store.LatestBySession(ctx, sessionID)
if err != nil {
return nil, err
}
best = rec
}
for _, mem := range m.memorySessionRuns(sessionID, false) {
if best == nil || mem.UpdatedAt.After(best.UpdatedAt) {
cpy := *mem
best = &cpy
}
}
return best, nil
}
func (m *Manager) memorySessionRuns(sessionID string, activeOnly bool) []*RunRecord {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*RunRecord, 0)
for _, ar := range m.runs {
if ar == nil || ar.record == nil || ar.record.SessionID != sessionID {
continue
}
ar.mu.Lock()
rec := *ar.record
ar.mu.Unlock()
if activeOnly && rec.Status != "pending" && rec.Status != "running" {
continue
}
out = append(out, &rec)
}
return out
}
func mergeRuns(stored, memory []*RunRecord) []*RunRecord {
byID := make(map[string]*RunRecord, len(stored)+len(memory))
order := make([]string, 0, len(stored)+len(memory))
add := func(rec *RunRecord) {
if rec == nil || rec.RunID == "" {
return
}
if prev, ok := byID[rec.RunID]; ok {
if rec.UpdatedAt.After(prev.UpdatedAt) {
byID[rec.RunID] = rec
}
return
}
byID[rec.RunID] = rec
order = append(order, rec.RunID)
}
for _, rec := range stored {
add(rec)
}
for _, rec := range memory {
add(rec)
}
out := make([]*RunRecord, 0, len(order))
for _, id := range order {
out = append(out, byID[id])
}
return out
}
// StartClaimedRun 仅对 ClaimRun 成功的 owner 启 worker;禁止在 Insert 冲突后调用。 // StartClaimedRun 仅对 ClaimRun 成功的 owner 启 worker;禁止在 Insert 冲突后调用。
func (m *Manager) StartClaimedRun(parent context.Context, record *RunRecord, taskReq bizdev.TaskRunRequest, stageReq bizdev.StageRunRequest, resumeWorkDir string) (*RunRecord, error) { func (m *Manager) StartClaimedRun(parent context.Context, record *RunRecord, taskReq bizdev.TaskRunRequest, stageReq bizdev.StageRunRequest, resumeWorkDir string) (*RunRecord, error) {
if record == nil || record.RunID == "" { if record == nil || record.RunID == "" {
@@ -303,6 +416,8 @@ func (m *Manager) StartClaimedRun(parent context.Context, record *RunRecord, tas
m.runs[record.RunID] = ar m.runs[record.RunID] = ar
m.mu.Unlock() m.mu.Unlock()
m.writeRunningAnchor(parent, record)
if m.pwSvc != nil && record.ProjectID > 0 { if m.pwSvc != nil && record.ProjectID > 0 {
startedBy := record.UserID startedBy := record.UserID
_, _ = m.pwSvc.CreateWorkflowRun(workerCtx, &projectworkflow.WorkflowRun{ _, _ = m.pwSvc.CreateWorkflowRun(workerCtx, &projectworkflow.WorkflowRun{
+110 -1
View File
@@ -26,6 +26,8 @@ type Store interface {
GetByIdempotencyKey(ctx context.Context, key string) (*RunRecord, error) GetByIdempotencyKey(ctx context.Context, key string) (*RunRecord, error)
AppendEvent(ctx context.Context, runID string, seq int64, ev stream.StreamEvent) error AppendEvent(ctx context.Context, runID string, seq int64, ev stream.StreamEvent) error
GetRun(ctx context.Context, runID string) (*RunRecord, error) GetRun(ctx context.Context, runID string) (*RunRecord, error)
ListActiveBySession(ctx context.Context, sessionID string) ([]*RunRecord, error)
LatestBySession(ctx context.Context, sessionID string) (*RunRecord, error)
ListEvents(ctx context.Context, runID string, afterSeq int64) ([]StoredEvent, error) ListEvents(ctx context.Context, runID string, afterSeq int64) ([]StoredEvent, error)
AcquireLease(ctx context.Context, runID, workerID string, ttl time.Duration) error AcquireLease(ctx context.Context, runID, workerID string, ttl time.Duration) error
HeartbeatLease(ctx context.Context, runID, workerID string, ttl time.Duration) error HeartbeatLease(ctx context.Context, runID, workerID string, ttl time.Duration) error
@@ -248,6 +250,108 @@ func (s *PGStore) GetRun(ctx context.Context, runID string) (*RunRecord, error)
return &rec, nil return &rec, nil
} }
func (s *PGStore) ListActiveBySession(ctx context.Context, sessionID string) ([]*RunRecord, error) {
if !s.Available() {
return nil, ErrStoreNotAvailable
}
if sessionID == "" {
return nil, nil
}
where := `WHERE session_id = $1 AND status IN ('pending', 'running')`
if s.hasLeaseColumns(ctx) {
where += ` AND (lease_expires_at IS NULL OR lease_expires_at > now())`
}
where += ` ORDER BY updated_at DESC`
return s.queryRuns(ctx, where, sessionID)
}
func (s *PGStore) LatestBySession(ctx context.Context, sessionID string) (*RunRecord, error) {
if !s.Available() {
return nil, ErrStoreNotAvailable
}
if sessionID == "" {
return nil, nil
}
rows, err := s.queryRuns(ctx, `WHERE session_id = $1 ORDER BY updated_at DESC LIMIT 1`, sessionID)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
return rows[0], nil
}
func (s *PGStore) queryRuns(ctx context.Context, where string, args ...any) ([]*RunRecord, error) {
withLease := s.hasLeaseColumns(ctx)
q := runSelectSQL(withLease) + " " + where
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]*RunRecord, 0)
for rows.Next() {
rec, err := scanRunRecord(rows, withLease)
if err != nil {
return nil, err
}
out = append(out, rec)
}
return out, rows.Err()
}
func runSelectSQL(withLease bool) string {
if withLease {
return `SELECT run_id, session_id, project_id, user_id, client_turn_id, idempotency_key,
task_key, stage, status, workspace_id, assistant_message_id,
error_message, worker_id, lease_expires_at, attempt, created_at, updated_at
FROM task_runs`
}
return `SELECT run_id, session_id, project_id, user_id, client_turn_id, idempotency_key,
task_key, stage, status, workspace_id, assistant_message_id,
error_message, created_at, updated_at
FROM task_runs`
}
type runScanner interface {
Scan(dest ...any) error
}
func scanRunRecord(row runScanner, withLease bool) (*RunRecord, error) {
var rec RunRecord
var projectID, assistantMsgID *int64
var idempotencyKey *string
var err error
if withLease {
err = row.Scan(
&rec.RunID, &rec.SessionID, &projectID, &rec.UserID, &rec.ClientTurnID, &idempotencyKey,
&rec.TaskKey, &rec.Stage, &rec.Status, &rec.WorkspaceID, &assistantMsgID,
&rec.ErrorMessage, &rec.WorkerID, &rec.LeaseExpiresAt, &rec.Attempt,
&rec.CreatedAt, &rec.UpdatedAt,
)
} else {
err = row.Scan(
&rec.RunID, &rec.SessionID, &projectID, &rec.UserID, &rec.ClientTurnID, &idempotencyKey,
&rec.TaskKey, &rec.Stage, &rec.Status, &rec.WorkspaceID, &assistantMsgID,
&rec.ErrorMessage, &rec.CreatedAt, &rec.UpdatedAt,
)
}
if err != nil {
return nil, err
}
if projectID != nil {
rec.ProjectID = *projectID
}
if assistantMsgID != nil {
rec.AssistantMsgID = *assistantMsgID
}
if idempotencyKey != nil {
rec.IdempotencyKey = *idempotencyKey
}
return &rec, nil
}
// GetByIdempotencyKey 按稳定幂等键查找既有 run;无键或未命中返回 (nil, nil)。 // GetByIdempotencyKey 按稳定幂等键查找既有 run;无键或未命中返回 (nil, nil)。
func (s *PGStore) GetByIdempotencyKey(ctx context.Context, key string) (*RunRecord, error) { func (s *PGStore) GetByIdempotencyKey(ctx context.Context, key string) (*RunRecord, error) {
if !s.Available() { if !s.Available() {
@@ -302,7 +406,6 @@ func (s *PGStore) ListEvents(ctx context.Context, runID string, afterSeq int64)
return out, rows.Err() return out, rows.Err()
} }
// CancelOpenByProject cancels pending/running runs for a project inside an existing transaction. // CancelOpenByProject cancels pending/running runs for a project inside an existing transaction.
// Table ownership stays with taskrun; workflow terminate must call this instead of writing task_runs directly. // Table ownership stays with taskrun; workflow terminate must call this instead of writing task_runs directly.
func CancelOpenByProject(ctx context.Context, tx pgx.Tx, projectID int64, at time.Time, errMsg string) error { func CancelOpenByProject(ctx context.Context, tx pgx.Tx, projectID int64, at time.Time, errMsg string) error {
@@ -500,6 +603,12 @@ func (noopStore) AppendEvent(context.Context, string, int64, stream.StreamEvent)
func (noopStore) GetRun(context.Context, string) (*RunRecord, error) { func (noopStore) GetRun(context.Context, string) (*RunRecord, error) {
return nil, ErrStoreNotAvailable return nil, ErrStoreNotAvailable
} }
func (noopStore) ListActiveBySession(context.Context, string) ([]*RunRecord, error) {
return nil, ErrStoreNotAvailable
}
func (noopStore) LatestBySession(context.Context, string) (*RunRecord, error) {
return nil, ErrStoreNotAvailable
}
func (noopStore) GetByIdempotencyKey(context.Context, string) (*RunRecord, error) { func (noopStore) GetByIdempotencyKey(context.Context, string) (*RunRecord, error) {
return nil, ErrStoreNotAvailable return nil, ErrStoreNotAvailable
} }
+9
View File
@@ -120,6 +120,7 @@ import { saveFile } from './data/fileStore';
import { cancelTaskRun, getTaskRun } from './api/taskRuns'; import { cancelTaskRun, getTaskRun } from './api/taskRuns';
import { cancelInflightAssistantMessages } from './lib/squadCompletionObservation'; import { cancelInflightAssistantMessages } from './lib/squadCompletionObservation';
import { useRecoveredTaskPhases } from './hooks/useRecoveredTaskPhases'; import { useRecoveredTaskPhases } from './hooks/useRecoveredTaskPhases';
import { useActiveTaskRecovery } from './hooks/useActiveTaskRecovery';
export { export {
ALL_INITIAL_DELIVERABLES, ALL_INITIAL_DELIVERABLES,
@@ -1012,6 +1013,14 @@ export default function App() {
chatWindow.updateLiveMessage, chatWindow.updateLiveMessage,
); );
useActiveTaskRecovery(
authenticatedSessionId,
chatWindow.phase === 'ready',
chatWindow.allMessages,
chatWindow.updateLiveMessage,
chatWindow.appendLiveMessage,
);
// P1: 多维度历史导航与分类统计、日历、引用状态 // P1: 多维度历史导航与分类统计、日历、引用状态
const [categoryStats, setCategoryStats] = useState<ChatCategoryStat[]>([]); const [categoryStats, setCategoryStats] = useState<ChatCategoryStat[]>([]);
const [dateStats, setDateStats] = useState<ChatDateStat[]>([]); const [dateStats, setDateStats] = useState<ChatDateStat[]>([]);
+14 -1
View File
@@ -230,6 +230,19 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
: undefined; : undefined;
const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`); const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`);
const phaseSnapshot = parseTaskPhaseSnapshot(meta.task_phase_snapshot); const phaseSnapshot = parseTaskPhaseSnapshot(meta.task_phase_snapshot);
const hasRunID = typeof meta.run_id === 'string' && meta.run_id !== '';
const rawTaskStatus = typeof meta.task_status === 'string' ? meta.task_status : '';
let messageStatus = mapTaskStatusToMessageStatus(meta.task_status, r.role);
// 有 run 锚点但还没有终态快照、也没有 task_status 时,不能当成已完成。
if (
r.role === 'assistant' &&
hasRunID &&
!(phaseSnapshot?.steps && phaseSnapshot.steps.length > 0) &&
rawTaskStatus === '' &&
messageStatus === 'completed'
) {
messageStatus = 'streaming_status';
}
return { return {
id: msgId, id: msgId,
clientTurnId: meta.client_turn_id || undefined, clientTurnId: meta.client_turn_id || undefined,
@@ -249,7 +262,7 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
interactiveCard: meta.interactive_card, interactiveCard: meta.interactive_card,
recommendedCases: meta.recommended_cases, recommendedCases: meta.recommended_cases,
quotes: meta.quotes || (meta.quote ? [meta.quote] : undefined), quotes: meta.quotes || (meta.quote ? [meta.quote] : undefined),
status: mapTaskStatusToMessageStatus(meta.task_status, r.role), status: messageStatus,
errorInfo: persistedError || legacyBusyError || legacyTaskError, errorInfo: persistedError || legacyBusyError || legacyTaskError,
taskRequest, taskRequest,
taskRunId: typeof meta.run_id === 'string' ? meta.run_id : undefined, taskRunId: typeof meta.run_id === 'string' ? meta.run_id : undefined,
+20
View File
@@ -180,6 +180,26 @@ export async function cancelTaskRun(runId: string): Promise<void> {
} }
} }
export type ActiveTaskRun = {
run_id: string;
status: string;
task_key?: string;
workspace_id?: string;
assistant_message_id?: number;
error_message?: string;
session_id?: string;
};
export async function listActiveTaskRuns(sessionId: string): Promise<ActiveTaskRun[]> {
const url = `/api/v1/tasks/runs?session_id=${encodeURIComponent(sessionId)}&active=1`;
const res = await apiFetch(url, { headers: getBearerAuthHeaders() });
if (!res.ok) {
throw new Error(`list active task runs failed: HTTP ${res.status}`);
}
const body = (await res.json()) as { runs?: ActiveTaskRun[] };
return Array.isArray(body.runs) ? body.runs : [];
}
export async function getTaskRun(runId: string) { export async function getTaskRun(runId: string) {
const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, { const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, {
headers: getBearerAuthHeaders(), headers: getBearerAuthHeaders(),
+6
View File
@@ -359,3 +359,9 @@ test('weighted mode does not let a cross-workflow title hijack an in-scope write
const fuzzy = resolveTaskTemplateForMessage('编写建议', pool, null, 'main', 'weighted'); const fuzzy = resolveTaskTemplateForMessage('编写建议', pool, null, 'main', 'weighted');
assert.equal(fuzzy?.wrongWorkflow, undefined); assert.equal(fuzzy?.wrongWorkflow, undefined);
}); });
test('progress question is read-only; explicit start still resolves research', () => {
assert.equal(hasDocumentWriteIntent('调研报告编写进度怎么样了'), false);
assert.equal(resolveTaskKeyFromMessage('调研报告编写进度怎么样了'), undefined);
assert.equal(resolveTaskKeyFromMessage('开始做调研报告'), 'research');
});
+114
View File
@@ -0,0 +1,114 @@
import { useEffect } from 'react';
import { listActiveTaskRuns, type ActiveTaskRun } from '../api/taskRuns';
import {
placeholderFromActiveTask,
readActiveTasks,
removeActiveTask,
upsertActiveTask,
type ActiveTaskCacheEntry,
} from '../lib/activeTaskCache';
import type { ChatMessage } from '../types/workbench';
const FRESH_MS = 20_000;
/**
* 刷新后先用本地进行中缓存画出进度卡片,再用会话活跃 run 列表收敛。
* 服务端没有的旧缓存会被清掉;刚写入、列表还没跟上的条目保留一小段时间。
*/
export function useActiveTaskRecovery(
sessionId: string,
windowReady: boolean,
messages: ChatMessage[],
updateMessage: (id: string, patch: Partial<ChatMessage>) => void,
appendMessage: (message: ChatMessage) => void,
) {
useEffect(() => {
if (!sessionId || !windowReady) return;
let cancelled = false;
const cached = readActiveTasks(sessionId);
for (const entry of cached) {
if (messages.some((message) => message.taskRunId === entry.runId)) continue;
const byClient = messages.find(
(message) => message.id === entry.assistantClientId || message.clientMessageId === entry.assistantClientId,
);
if (byClient) {
updateMessage(byClient.id, {
taskRunId: entry.runId,
status: 'streaming_status',
livePhaseLabel: entry.lastPhaseLabel || byClient.livePhaseLabel || '任务执行中…',
serverId: byClient.serverId || entry.serverMessageId,
});
continue;
}
appendMessage(placeholderFromActiveTask(entry));
}
void listActiveTaskRuns(sessionId)
.then((runs) => {
if (cancelled) return;
const activeIDs = new Set(runs.map((run) => run.run_id).filter(Boolean));
for (const run of runs) {
applyServerRun(sessionId, run, messages, updateMessage, appendMessage);
}
for (const entry of readActiveTasks(sessionId)) {
if (activeIDs.has(entry.runId)) continue;
const age = Date.now() - Date.parse(entry.startedAt);
if (Number.isFinite(age) && age >= 0 && age < FRESH_MS) continue;
removeActiveTask(entry.runId);
}
})
.catch((err) => {
console.warn('active task sync failed', err);
});
return () => {
cancelled = true;
};
// 窗口 ready 后再画本地缓存并拉活跃 run,避免被随后的窗口替换清掉。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, windowReady]);
}
function applyServerRun(
sessionId: string,
run: ActiveTaskRun,
messages: ChatMessage[],
updateMessage: (id: string, patch: Partial<ChatMessage>) => void,
appendMessage: (message: ChatMessage) => void,
) {
if (!run.run_id) return;
const existing =
messages.find((message) => message.taskRunId === run.run_id) ||
messages.find((message) => run.assistant_message_id && message.serverId === run.assistant_message_id);
const label = existing?.livePhaseLabel || '任务执行中…';
if (existing) {
updateMessage(existing.id, {
taskRunId: run.run_id,
status: existing.status === 'completed' ? existing.status : 'streaming_status',
taskWorkspaceId: run.workspace_id || existing.taskWorkspaceId,
serverId: existing.serverId || run.assistant_message_id,
livePhaseLabel: label,
});
} else {
const entry: ActiveTaskCacheEntry = {
sessionId,
runId: run.run_id,
assistantClientId: run.assistant_message_id ? `msg-${run.assistant_message_id}` : `ast-active-${run.run_id}`,
taskKey: run.task_key || '',
startedAt: new Date().toISOString(),
lastPhaseLabel: label,
serverMessageId: run.assistant_message_id,
};
appendMessage(placeholderFromActiveTask(entry));
}
const previous = readActiveTasks(sessionId).find((row) => row.runId === run.run_id);
upsertActiveTask({
sessionId,
runId: run.run_id,
assistantClientId: previous?.assistantClientId || existing?.id || `ast-active-${run.run_id}`,
taskKey: run.task_key || previous?.taskKey || '',
startedAt: previous?.startedAt || new Date().toISOString(),
lastPhaseLabel: previous?.lastPhaseLabel || label,
serverMessageId: run.assistant_message_id || previous?.serverMessageId,
});
}
+15
View File
@@ -165,3 +165,18 @@ test('mapRecordToMessage: restores task phase snapshot into card fields', () =>
assert.equal(msg.phaseSnapshot?.title, '用户初始需求收集表'); assert.equal(msg.phaseSnapshot?.title, '用户初始需求收集表');
assert.equal(msg.phaseEvents?.[1].detail, '门禁'); assert.equal(msg.phaseEvents?.[1].detail, '门禁');
}); });
test('mapRecordToMessage: run anchor without task_status stays streaming', () => {
const msg = mapRecordToMessage({
id: 42,
session_id: 'sess-1',
user_id: 1,
role: 'assistant',
content: '任务执行中…',
metadata: { run_id: 'run-live', task_key: 'research' },
day_key: '2026-09-28',
created_at: '2026-09-28T03:27:00Z',
});
assert.equal(msg.taskRunId, 'run-live');
assert.equal(msg.status, 'streaming_status');
});
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
replayAttemptLimit,
replayBackoffMs,
shouldAbandonTaskReplay,
} from './useRecoveredTaskPhases';
test('replay retries 502 and stops on auth or not-found', () => {
assert.equal(replayAttemptLimit(false), 4);
assert.equal(replayAttemptLimit(true), 2);
assert.equal(replayBackoffMs(0), 500);
assert.equal(replayBackoffMs(20), 5000);
assert.equal(shouldAbandonTaskReplay(502, 0, 4), false);
assert.equal(shouldAbandonTaskReplay(502, 3, 4), true);
assert.equal(shouldAbandonTaskReplay(401, 0, 4), true);
assert.equal(shouldAbandonTaskReplay(404, 0, 4), true);
});
+32 -9
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { StreamEvent } from '../api/chatStream'; import type { StreamEvent } from '../api/chatStream';
import { readTaskRunSSEOnce, subscribeTaskRunEvents } from '../api/taskRuns'; import { readTaskRunSSEOnce, subscribeTaskRunEvents, TaskRunHttpError } from '../api/taskRuns';
import { applyPhaseEvent } from '../engine/phaseEventMapper'; import { applyPhaseEvent } from '../engine/phaseEventMapper';
import { import {
messageNeedsPhaseReplay, messageNeedsPhaseReplay,
@@ -80,15 +80,38 @@ async function replayRun(
cache.set(runId, { events: strip, label }); cache.set(runId, { events: strip, label });
updateMessage(messageId, { phaseEvents: strip, livePhaseLabel: label }); updateMessage(messageId, { phaseEvents: strip, livePhaseLabel: label });
}; };
try { const attempts = replayAttemptLimit(live);
if (live) { for (let attempt = 0; attempt < attempts; attempt++) {
await subscribeTaskRunEvents(runId, { onEvent }, signal); if (signal.aborted) return;
try {
if (live) {
await subscribeTaskRunEvents(runId, { onEvent }, signal);
return;
}
await readTaskRunSSEOnce(runId, 0, onEvent, signal, { replayOnly: true });
return; return;
} } catch (err) {
await readTaskRunSSEOnce(runId, 0, onEvent, signal, { replayOnly: true }); if (signal.aborted) return;
} catch (err) { const status = err instanceof TaskRunHttpError ? err.status : 0;
if (!signal.aborted) { if (shouldAbandonTaskReplay(status, attempt, attempts)) {
console.warn('task phase replay failed', runId, err); console.warn('task phase replay failed', runId, err);
return;
}
await new Promise((resolve) => setTimeout(resolve, replayBackoffMs(attempt)));
} }
} }
} }
/** 终态重放多试几次;进行中订阅内部已有重试,外层只再补一次。 */
export function replayAttemptLimit(live: boolean): number {
return live ? 2 : 4;
}
export function replayBackoffMs(attempt: number): number {
return Math.min(500 * (attempt + 1), 5000);
}
export function shouldAbandonTaskReplay(status: number, attempt: number, attempts: number): boolean {
if (status === 401 || status === 404) return true;
return attempt >= attempts - 1;
}
+35 -1
View File
@@ -9,6 +9,7 @@ import { fetchChatTurnStatus } from '../api/chatTurn';
import { mapTaskStatusToMessageStatus } from '../api/chatWindow'; import { mapTaskStatusToMessageStatus } from '../api/chatWindow';
import { listProjectWorkflowDocuments } from '../api/projectDocuments'; import { listProjectWorkflowDocuments } from '../api/projectDocuments';
import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns';
import { readActiveTasks, removeActiveTask, touchActiveTaskPhase, upsertActiveTask } from '../lib/activeTaskCache';
import { buildTaskRequest, streamTask } from '../api/taskStream'; import { buildTaskRequest, streamTask } from '../api/taskStream';
import { applyPhaseEvent } from '../engine/phaseEventMapper'; import { applyPhaseEvent } from '../engine/phaseEventMapper';
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch'; import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
@@ -371,6 +372,24 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
let accumulated = ''; let accumulated = '';
let settled = false; let settled = false;
const rememberRun = (runId: string, label?: string, serverMessageId?: number) => {
if (!sessionId || !runId) return;
const previous = readActiveTasks(sessionId).find((row) => row.runId === runId);
upsertActiveTask({
sessionId,
runId,
assistantClientId: asstId,
taskKey: task.taskKey || previous?.taskKey || '',
startedAt: previous?.startedAt || new Date().toISOString(),
lastPhaseLabel: label || previous?.lastPhaseLabel,
serverMessageId: serverMessageId || previous?.serverMessageId,
});
};
const forgetRun = (runId?: string) => {
const id = runId || retainedRunId;
if (id) removeActiveTask(id);
};
const historyOnly = !messages.some((m) => m.id === asstId); const historyOnly = !messages.some((m) => m.id === asstId);
let historySnapshot = historyOnly let historySnapshot = historyOnly
? chatWindow.allMessages.find((m) => m.id === asstId) ? chatWindow.allMessages.find((m) => m.id === asstId)
@@ -412,6 +431,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
scheduleRetry(delayMs); scheduleRetry(delayMs);
return; return;
} }
forgetRun();
patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined }); patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined });
taskRunStream.setThinking(sessionId, false); taskRunStream.setThinking(sessionId, false);
}; };
@@ -472,9 +492,11 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
if (controller.signal.aborted || settled) return; if (controller.signal.aborted || settled) return;
switch (ev.type) { switch (ev.type) {
case 'phase': { case 'phase': {
const phaseLabel = ev.phase?.message;
if (retainedRunId) touchActiveTaskPhase(retainedRunId, phaseLabel);
patchAsst((m) => ({ patchAsst((m) => ({
status: 'streaming_status', status: 'streaming_status',
livePhaseLabel: ev.phase?.message || m.livePhaseLabel, livePhaseLabel: phaseLabel || m.livePhaseLabel,
phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase), phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase),
})); }));
break; break;
@@ -542,6 +564,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
case 'done': { case 'done': {
settled = true; settled = true;
const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId; const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId;
forgetRun(finishedRunId || undefined);
taskRunStream.setRunId(sessionId, null); taskRunStream.setRunId(sessionId, null);
taskRunStream.setThinking(sessionId, false); taskRunStream.setThinking(sessionId, false);
void (async () => { void (async () => {
@@ -583,6 +606,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
const view = readRemoteTaskRun(remote); const view = readRemoteTaskRun(remote);
if (view.status === 'quality_failed') { if (view.status === 'quality_failed') {
settled = true; settled = true;
forgetRun(runId);
patchAsst({ patchAsst({
status: 'quality_failed', status: 'quality_failed',
livePhaseLabel: undefined, livePhaseLabel: undefined,
@@ -594,6 +618,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
} }
if (isBlockedGovernanceStatus(view.status)) { if (isBlockedGovernanceStatus(view.status)) {
settled = true; settled = true;
forgetRun(runId);
patchAsst({ patchAsst({
status: 'blocked_pending_governance', status: 'blocked_pending_governance',
content: GOVERNANCE_BLOCKED_HINT, content: GOVERNANCE_BLOCKED_HINT,
@@ -606,6 +631,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
} }
if (view.status === 'completed' || view.status === 'pending_author_confirmation') { if (view.status === 'completed' || view.status === 'pending_author_confirmation') {
settled = true; settled = true;
forgetRun(runId);
const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant') || 'completed'; const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant') || 'completed';
patchAsst({ patchAsst({
status: mapped, status: mapped,
@@ -624,6 +650,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
} }
if (view.status === 'interrupted') { if (view.status === 'interrupted') {
settled = true; settled = true;
forgetRun(runId);
patchAsst({ patchAsst({
status: 'failed', status: 'failed',
livePhaseLabel: undefined, livePhaseLabel: undefined,
@@ -693,6 +720,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
} }
const resumed = await resumeTaskRun(runId); const resumed = await resumeTaskRun(runId);
retainedRunId = resumed.run_id || runId; retainedRunId = resumed.run_id || runId;
rememberRun(retainedRunId, '正在继续任务…');
taskRunStream.setRunId(sessionId, retainedRunId); taskRunStream.setRunId(sessionId, retainedRunId);
taskRunStream.setThinking(sessionId, true); taskRunStream.setThinking(sessionId, true);
patchAsst({ patchAsst({
@@ -720,6 +748,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
const view = readRemoteTaskRun(remote); const view = readRemoteTaskRun(remote);
if (remote && shouldSubscribeExistingRun(view.status)) { if (remote && shouldSubscribeExistingRun(view.status)) {
retainedRunId = existingRunId; retainedRunId = existingRunId;
rememberRun(existingRunId, '正在重连任务…');
taskRunStream.setRunId(sessionId, existingRunId); taskRunStream.setRunId(sessionId, existingRunId);
patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' }); patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' });
await subscribeWithGapHeal(existingRunId); await subscribeWithGapHeal(existingRunId);
@@ -738,6 +767,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
? buildInterruptedTaskErrorInfo(view.errorMessage, view.workspaceId) ? buildInterruptedTaskErrorInfo(view.errorMessage, view.workspaceId)
: undefined, : undefined,
}); });
forgetRun(existingRunId);
taskRunStream.setThinking(sessionId, false); taskRunStream.setThinking(sessionId, false);
if (mapped === 'completed' || mapped === 'pending_author_confirmation') { if (mapped === 'completed' || mapped === 'pending_author_confirmation') {
await syncControlledDocsAfterTask(task.taskKey || 'intake'); await syncControlledDocsAfterTask(task.taskKey || 'intake');
@@ -760,6 +790,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
typeof created.assistant_msg_id === 'number' && created.assistant_msg_id > 0 typeof created.assistant_msg_id === 'number' && created.assistant_msg_id > 0
? created.assistant_msg_id ? created.assistant_msg_id
: 0; : 0;
rememberRun(created.run_id, '任务执行中…', assistantMsgId > 0 ? assistantMsgId : undefined);
if (assistantMsgId > 0) { if (assistantMsgId > 0) {
chatWindow.replaceMessageServerId(asstId, assistantMsgId); chatWindow.replaceMessageServerId(asstId, assistantMsgId);
patchAsst({ taskRunId: created.run_id, serverId: assistantMsgId }); patchAsst({ taskRunId: created.run_id, serverId: assistantMsgId });
@@ -774,6 +805,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
livePhaseLabel: undefined, livePhaseLabel: undefined,
taskRunId: created.run_id, taskRunId: created.run_id,
}); });
forgetRun(created.run_id);
taskRunStream.setRunId(sessionId, null); taskRunStream.setRunId(sessionId, null);
taskRunStream.setThinking(sessionId, false); taskRunStream.setThinking(sessionId, false);
await chatWindow.refresh().catch(() => undefined); await chatWindow.refresh().catch(() => undefined);
@@ -789,6 +821,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
void startObserver().catch(async (_err) => { void startObserver().catch(async (_err) => {
if (settled) return; if (settled) return;
if (controller.signal.aborted) { if (controller.signal.aborted) {
forgetRun();
patchAsst({ status: 'cancelled', livePhaseLabel: undefined }); patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
taskRunStream.setThinking(sessionId, false); taskRunStream.setThinking(sessionId, false);
return; return;
@@ -805,6 +838,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
// 原 run 仍在跑:重订阅,不 POST 新 run // 原 run 仍在跑:重订阅,不 POST 新 run
settled = false; settled = false;
retainedRunId = runId; retainedRunId = runId;
rememberRun(runId, '正在重连任务…');
patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId }); patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId });
await subscribeWithGapHeal(runId); await subscribeWithGapHeal(runId);
return; return;
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
readActiveTasks,
removeActiveTask,
touchActiveTaskPhase,
upsertActiveTask,
} from './activeTaskCache';
const mem = new Map<string, string>();
test('active task cache keeps an in-progress run and drops it when finished', () => {
(globalThis as { window?: unknown }).window = {
localStorage: {
getItem: (key: string) => mem.get(key) ?? null,
setItem: (key: string, value: string) => {
mem.set(key, value);
},
},
};
mem.clear();
upsertActiveTask({
sessionId: 'sess-1',
runId: 'run-1',
assistantClientId: 'turn:assistant',
taskKey: 'research',
startedAt: '2026-09-28T03:27:00Z',
});
upsertActiveTask({
sessionId: 'sess-2',
runId: 'run-2',
assistantClientId: 'other',
taskKey: 'intake',
startedAt: '2026-09-28T03:28:00Z',
});
touchActiveTaskPhase('run-1', '正在撰写调研报告');
const mine = readActiveTasks('sess-1');
assert.equal(mine.length, 1);
assert.equal(mine[0].runId, 'run-1');
assert.equal(mine[0].lastPhaseLabel, '正在撰写调研报告');
removeActiveTask('run-1');
assert.equal(readActiveTasks('sess-1').length, 0);
assert.equal(readActiveTasks('sess-2').length, 1);
});
+95
View File
@@ -0,0 +1,95 @@
import type { ChatMessage } from '../types/workbench';
/** 进行中任务锚点。窗口缓存只存服务端已落库消息,刷新会丢掉还没写回的进度。 */
const STORAGE_KEY = 'smdf_agent_workbench_v6_active_tasks';
export type ActiveTaskCacheEntry = {
sessionId: string;
runId: string;
assistantClientId: string;
taskKey: string;
startedAt: string;
lastPhaseLabel?: string;
serverMessageId?: number;
};
function readAll(): ActiveTaskCacheEntry[] {
if (typeof window === 'undefined' || !window.localStorage) return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(isEntry);
} catch {
return [];
}
}
function writeAll(entries: ActiveTaskCacheEntry[]): void {
if (typeof window === 'undefined' || !window.localStorage) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// 配额不足时放弃缓存,服务端锚点仍可恢复。
}
}
function isEntry(value: unknown): value is ActiveTaskCacheEntry {
if (!value || typeof value !== 'object') return false;
const row = value as Partial<ActiveTaskCacheEntry>;
return typeof row.sessionId === 'string' && typeof row.runId === 'string' && row.runId !== '';
}
export function readActiveTasks(sessionId: string): ActiveTaskCacheEntry[] {
if (!sessionId) return [];
return readAll().filter((entry) => entry.sessionId === sessionId);
}
export function upsertActiveTask(entry: ActiveTaskCacheEntry): void {
if (!entry.sessionId || !entry.runId) return;
const rest = readAll().filter((row) => row.runId !== entry.runId);
writeAll([...rest, entry]);
}
export function touchActiveTaskPhase(runId: string, label: string | undefined): void {
if (!runId || !label) return;
const all = readAll();
const hit = all.find((row) => row.runId === runId);
if (!hit || hit.lastPhaseLabel === label) return;
hit.lastPhaseLabel = label;
writeAll(all);
}
export function removeActiveTask(runId: string): void {
if (!runId) return;
writeAll(readAll().filter((row) => row.runId !== runId));
}
export function placeholderFromActiveTask(entry: ActiveTaskCacheEntry): ChatMessage {
return {
id: entry.assistantClientId || `ast-active-${entry.runId}`,
clientMessageId: entry.assistantClientId || undefined,
serverId: entry.serverMessageId,
sessionId: entry.sessionId,
role: 'assistant',
authorName: '项目助手',
content: '任务执行中…',
timestamp: new Date().toTimeString().substring(0, 5),
status: 'streaming_status',
livePhaseLabel: entry.lastPhaseLabel || '任务执行中…',
taskRunId: entry.runId,
taskRequest: entry.taskKey
? {
taskKey: entry.taskKey,
projectName: '',
customerName: '',
deviceType: '',
sessionId: entry.sessionId,
userId: 0,
projectId: 0,
clientTurnId: '',
}
: undefined,
};
}