From 130dcc36c5509a1c907a74d5372e42c1018e70e4 Mon Sep 17 00:00:00 2001 From: laily Date: Sun, 20 Sep 2026 19:15:43 +0800 Subject: [PATCH] feat(ack): shard task records for merge-safe workspaces --- skills/ack/README.md | 12 +- skills/ack/SKILL.md | 21 +- skills/ack/examples/project.example.md | 10 +- skills/ack/references/adoption-checklist.md | 2 +- skills/ack/references/closed-loop.md | 29 +- skills/ack/references/delivery.md | 4 +- skills/ack/references/kickoff.md | 4 +- skills/ack/references/orca-adapter.md | 11 +- skills/ack/references/regression.md | 4 +- .../ack/references/roles-and-permissions.md | 17 +- skills/ack/scripts/feishu_bug_intake.py | 19 +- skills/ack/scripts/migrate_tasks.py | 155 +++++++++++ skills/ack/scripts/record_task.py | 161 +++++++++++ skills/ack/scripts/task_store.py | 249 ++++++++++++++++++ skills/ack/scripts/validate_delivery.py | 42 +-- skills/ack/scripts/validate_regression.py | 42 +-- skills/ack/scripts/validate_tasks.py | 47 +--- skills/ack/templates/project.template.md | 10 +- skills/ack/templates/tasks.schema.json | 19 ++ skills/ack/templates/tasks.template.yaml | 4 + skills/tester/README.md | 2 +- skills/tester/SKILL.md | 2 +- skills/tester/references/maintain.md | 2 +- skills/tester/references/run.md | 5 +- skills/tester/references/schema.md | 8 +- skills/tester/scripts/generate_cases_csv.py | 38 +-- skills/tester/scripts/generate_run_csv.py | 31 ++- tests/test_ack_task_store.py | 197 ++++++++++++++ tests/test_tester_run_records.py | 56 ++++ 29 files changed, 985 insertions(+), 218 deletions(-) create mode 100644 skills/ack/scripts/migrate_tasks.py create mode 100644 skills/ack/scripts/record_task.py create mode 100644 skills/ack/scripts/task_store.py create mode 100644 tests/test_ack_task_store.py create mode 100644 tests/test_tester_run_records.py diff --git a/skills/ack/README.md b/skills/ack/README.md index a686694..c60b29e 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -55,7 +55,8 @@ pouch init ack --project ~/code/my-app ```text .pouch/ack/ ├── project.md -├── tasks.yaml +├── tasks.yaml # 项目配置与 taskStore 指针 +├── task-records/ # 追加式任务/执行记录 ├── knowledge.yaml ├── delivery.yaml # 默认 enabled: false ├── regression.yaml # 默认 cases: [] @@ -64,7 +65,14 @@ pouch init ack --project ~/code/my-app 不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。 -旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件。 +旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件;需要消除单文件冲突时,先运行 `scripts/migrate_tasks.py` 的 dry-run。 + +任务状态追加示例: + +```bash +python3 /scripts/record_task.py .pouch/ack/tasks.yaml \ + --task-id T-001 --task-json /tmp/T-001.json --parent EV-T-001-initial +``` ## 使用示例 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index bf55954..4178484 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -8,12 +8,14 @@ description: >- # ACK 项目协作入口 -项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、 +项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、 `delivery.yaml`、`regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖配置。 不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、 `CLAUDE.md`。 -固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、 +固定配置文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`;任务执行记录从 +`.pouch/ack/task-records/` 追加读取。旧项目没有 `taskStore` 时仍兼容直接读取 +`tasks.yaml`。固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、 `.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml` 和 `.pouch/ack/regression.yaml`。 @@ -38,7 +40,10 @@ description: >- - `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。 - `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。 - 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。 -- 只有 Coordinator 写任务板、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。 +- 新项目不要把任务状态、worker receipt 或运行历史回写到 `tasks.yaml`;每次状态变化写入 + `task-records/.yaml`,记录必须包含 `taskId`、`parents` 和完整 `task` 快照。 + 同一任务出现多个 head 时停止并创建显式合并记录,禁止按时间静默覆盖。 +- 只有 Coordinator 写任务记录、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。 - 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。 - 自动 worker 禁止 full-access、bypass、关闭 sandbox、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。 - 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。 @@ -61,9 +66,19 @@ python3 /scripts/validate_regression.py .pouch/ack/regression.yam # 仅在存在临时角色配置时运行 python3 /scripts/validate_agents.py .pouch/ack/agents.yaml + ``` 4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。 + 旧项目迁移前先预览 `python3 /scripts/migrate_tasks.py .pouch/ack/tasks.yaml`,确认后再加 `--apply`。 + +追加任务状态时使用完整快照 JSON,不修改已有记录: + +```bash +python3 /scripts/record_task.py .pouch/ack/tasks.yaml \ + --task-id --task-json \ + --parent --kind task.snapshot +``` ```text ## ack 初始化:完成 | 部分完成 | 阻塞 diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index 54395d8..9e94524 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -4,7 +4,7 @@ > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 -> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` +> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、`delivery.yaml` > 与 `regression.yaml`。 ## 项目概览 @@ -13,7 +13,8 @@ - 技术栈:`TypeScript + React (Vite) + Go` - 运行命令:`npm run dev`(前端)、`go run ./server`(后端) - Base URL:`http://localhost:5173` -- 任务板:`.pouch/ack/tasks.yaml` +- 任务板配置:`.pouch/ack/tasks.yaml` +- 任务记录:`.pouch/ack/task-records/.yaml` - 项目知识:`.pouch/ack/knowledge.yaml` - 交付契约:`.pouch/ack/delivery.yaml` - 回归目录:`.pouch/ack/regression.yaml` @@ -57,7 +58,8 @@ | `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 | | `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 | | `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 | -| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 | +| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` | +| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 | | `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 | | `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 | | `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 | @@ -101,7 +103,7 @@ path/args。 worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用; 整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。 - `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。 -- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。 +- 只有 Coordinator 写 `task-records/` 和 `knowledge.yaml`;Test 与 Developer 只读。 - Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识; `candidate` 不派发,知识库不全量注入。 - Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报 diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index 2e36c40..05356ed 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -4,7 +4,7 @@ - [ ] ACK Skill 已全局安装或安装到当前项目。 - [ ] 已运行 `pouch init ack --project `。 -- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml`、 +- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、 默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。 - [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的 `project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。 diff --git a/skills/ack/references/closed-loop.md b/skills/ack/references/closed-loop.md index 6bc997c..65534d5 100644 --- a/skills/ack/references/closed-loop.md +++ b/skills/ack/references/closed-loop.md @@ -2,7 +2,8 @@ 本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。 -原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;已经验证且可跨 +原则:调度消息只是运行时载体,任务结论必须追加到 `task-records/`(由读取层 +materialize 成任务视图);已经验证且可跨 任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。 角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、 Developer 只实现。 @@ -15,12 +16,12 @@ Developer 只实现。 | 抽象动作 | 含义 | Orca 实现 | 手动实现 | |----------|------|-----------|----------| -| `prepare(task)` | Coordinator 把任务和验收信号写进 `tasks.yaml`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 | +| `prepare(task)` | Coordinator 把任务和验收信号写进 `task-records/`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 | | `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 | | `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 | | `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 | | `gate(task)` | Coordinator 读 Test 证据、`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 | -| `writeback(task, result)` | Coordinator 把任务结果写回 `tasks.yaml`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 | +| `writeback(task, result)` | Coordinator 把任务结果追加到 `task-records/`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 | 派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。 @@ -30,14 +31,14 @@ Developer 只实现。 ```text Coordinator 发现或读取 open 任务 - -> prepare:写/补全 tasks.yaml 验收信号 + -> prepare:写/补全 task-records/ 中的任务验收信号 -> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs -> 为新轮次生成稳定 attemptId(-A),Developer 与 Test 共用 -> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」) -> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration -> 运行 launcher plan 并审阅 launchFingerprint -> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker - -> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md) + -> 把本次 task/attempt receipt 追加到 task-records/(见 orca-adapter.md) -> dispatch 给 Developer(--to ) -> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理) -> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞) @@ -133,7 +134,7 @@ python3 /scripts/launch_worker.py launch \ - 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。 - 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。 - 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。 -- 记录:`launch` 成功后把 receipt 追加到权威 `tasks.yaml.workerReceipts`,并把 +- 记录:`launch` 成功后把 receipt 追加到当前任务记录的 `workerReceipts`,并把 `receipt.id` 写入 `dispatch..receiptId`、把 `profileId` 写入 `dispatch..profileId`、把本轮稳定 ID 写入 `dispatch..attemptId`; receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录 @@ -173,9 +174,10 @@ python3 /scripts/launch_worker.py launch \ | 要保持基线分支干净 | 新 worktree(feature 分支)| | 小改动、追求快 | 当前 worktree | -**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、 +**项目配置 SSOT 只落一处**:无论开几个 worktree,`tasks.yaml`、 `knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威 -副本(通常在基线/协调所在 worktree)。Coordinator 单写任务、知识、回归目录、 +副本(通常在基线/协调所在 worktree)。任务执行状态追加到 +`.pouch/ack/task-records/.yaml`;Coordinator 单写任务记录、知识、回归目录、 `deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个 副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。 @@ -190,20 +192,20 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful 没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担: -1. Coordinator 在 `tasks.yaml` 写好任务和验收信号。 +1. Coordinator 在 `task-records/` 写入包含完整任务快照的初始记录和验收信号。 2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的 `knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt, 手动发给 Developer(另一个会话/终端/人)。 3. Developer 完成后按 worker_done 模板回报。 4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。 5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。 -6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。 +6. Coordinator 做终检并追加新记录:通过 `verified`,不过 `failed_retest`。 7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品 复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test, 不派 Developer、不消耗产品轮次。 手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator -写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有 +写 `task-records/` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有 选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。 手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff, @@ -233,7 +235,7 @@ actual: snapshot evidence: ``` -Test 只回传证据,不写 `tasks.yaml` 或 `knowledge.yaml`;由 Coordinator 落盘。 +Test 只回传证据,不写 `task-records/` 或 `knowledge.yaml`;由 Coordinator 追加落盘。 每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或 `not_applicable` 及证据。 @@ -277,7 +279,8 @@ worktreePath: ## 结果回写 -Coordinator 回写 `tasks.yaml` 时按状态填写(字段结构见 `templates/tasks.schema.json`): +Coordinator 追加任务记录时按状态填写(记录内的 `task` 字段结构见 +`templates/tasks.schema.json`): ```yaml # 通过(Test 复测 + Coordinator 终检) diff --git a/skills/ack/references/delivery.md b/skills/ack/references/delivery.md index b536623..5dc6590 100644 --- a/skills/ack/references/delivery.md +++ b/skills/ack/references/delivery.md @@ -3,7 +3,7 @@ 本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和 Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入 交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入 -`.pouch/ack/tasks.yaml.deliveryRuns`。 +任务记录的 `deliveryRuns`(读取时 materialize 到任务视图)。 ## 1. 配置与授权不是一回事 @@ -78,7 +78,7 @@ intents: 口头「发版」不能代替 stable/production 的 `approval` 步骤。 - 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」 写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。 -- 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填 +- 用户触发的 intent 运行追加到任务记录的 `deliveryRuns`,`intent` 填 `testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记 `deployer-`。绑定任务的常规交付 run 不填 `intent`,仍只能引用 `verified` 任务。 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index c5e53a7..e34df59 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -33,7 +33,7 @@ 2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。 3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的 knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。 -4. 把任务写进 .pouch/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。 +4. 把任务作为完整快照追加到 `.pouch/ack/task-records/`(只有你写),校验任务视图和 knowledge.yaml。 5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付, 同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。 6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化 @@ -41,7 +41,7 @@ 只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected fingerprint 创建 fresh worker; dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → - dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml; + dispatch 测试独立复测 → 你读证据终检 → 追加 task-records; 每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独 记录、恢复并告诉我下一步,不占产品复验轮次。 7. 所选任务都 verified 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入 diff --git a/skills/ack/references/orca-adapter.md b/skills/ack/references/orca-adapter.md index b7d1f1b..0d75c8a 100644 --- a/skills/ack/references/orca-adapter.md +++ b/skills/ack/references/orca-adapter.md @@ -2,7 +2,8 @@ Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。 -所有结论仍以 `tasks.yaml` 为准,Orca 消息不是最终记录。 +所有结论仍以读取层 materialize 的任务视图为准,底层最终记录追加在 +`task-records/`;Orca 消息不是最终记录。 --- @@ -96,7 +97,7 @@ Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的 标准输出是单一 JSON。成功后: 1. 校验返回 receipt 的 `receiptHash`; -2. 把 receipt 原样追加到 `tasks.yaml.workerReceipts`; +2. 把 receipt 原样追加到当前任务的新记录的 `workerReceipts`; 3. 把 `receipt.id` 写入当前任务 `dispatch..receiptId`,把 `profileId` 写入 `dispatch..profileId`,把本轮 `-A` 写入 `dispatch..attemptId`;校验器会要求 receipt 的 task/role/profile/attempt @@ -129,7 +130,7 @@ Policy: - Each issue can have at most 3 valid product-retest rounds. - Environment failures are recorded in dispatch.environmentIncidents, reported with the next action, and do not consume a product-retest round. - If still failing after 3 valid product rounds, record as leftover and continue next issue. -- worker_done and retest reports are not final completion; only Coordinator writes tasks.yaml and knowledge.yaml. +- worker_done and retest reports are not final completion; only Coordinator writes task-records/ and knowledge.yaml. - Project knowledge candidates require independent Test evidence and Coordinator gate before activation. EOF )" --json @@ -324,7 +325,7 @@ orca orchestration send \ 无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round ` 区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由 -Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`: +Coordinator 按 `closed-loop.md` 做终检并追加任务记录: ```bash python3 /scripts/reclaim_workers.py \ @@ -334,7 +335,7 @@ python3 /scripts/reclaim_workers.py \ 只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。 -收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`; +收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并追加任务记录; 通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过 写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写 `dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。 diff --git a/skills/ack/references/regression.md b/skills/ack/references/regression.md index 3174039..b7e07a1 100644 --- a/skills/ack/references/regression.md +++ b/skills/ack/references/regression.md @@ -10,10 +10,10 @@ 项目状态是 `.pouch/ack/regression.yaml`。任务板用 `project.regressionFile: .pouch/ack/regression.yaml` 声明,运行证据写在 -`tasks.yaml.regressionRuns`。新项目初始化会生成空目录;旧项目没有该文件时, +任务记录的 `regressionRuns`(读取时 materialize 到任务视图)。新项目初始化会生成空目录;旧项目没有该文件时, 「运行回归」先停下来,用户授权后再从模板补齐。 -只有 Coordinator 写 `regression.yaml`。Test 在复测报告里提名 +只有 Coordinator 写 `regression.yaml` 和任务记录。Test 在复测报告里提名 `regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的 `regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改 `.pouch/ack/`。 diff --git a/skills/ack/references/roles-and-permissions.md b/skills/ack/references/roles-and-permissions.md index 26119e7..0b78fc8 100644 --- a/skills/ack/references/roles-and-permissions.md +++ b/skills/ack/references/roles-and-permissions.md @@ -12,12 +12,12 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo | 角色 | 主要职责 | 验证方式 | 不应做的事 | |------|----------|----------|------------| -| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 | +| Coordinator (PM) | 需求拆解、定验收信号、单写 `task-records/` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 | | Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` | | Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 | | User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 | -**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有落到 `tasks.yaml` 才算数。 +**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有追加到 `task-records/` 才算数。 **模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。 @@ -108,7 +108,8 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo | `` | Read-only | Read-only | R/W | 单元测试 | | `` | Read-only | Read-only | R/W | 可提交配置模板 | | `` | Read-only | Read-only | Read-only | 本地私有配置,不提交 | -| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 | +| `tasks.yaml` | R/W | Read-only | Read-only | 只保存稳定项目配置;任务状态见 `task-records/` | +| `task-records/` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 | | `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 | | `regression.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Test 通过 `regressionCandidates` 提名 | | `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 | @@ -143,7 +144,7 @@ launch / service / data / browser / tooling environment failure -> 环境恢复后 retesting ``` -状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同): +状态定义(所有状态都只由 Coordinator 追加到 `task-records/`,来源不同): | 状态 | 依据来源 | 含义 | |------|----------|------| @@ -179,11 +180,11 @@ planned -> skipped ## 项目状态写入约定(并发安全) -`tasks.yaml` 是任务、交付运行与回归运行事实源,`knowledge.yaml` 是跨任务项目知识 -事实源,`regression.yaml` 是黑盒回归用例事实源,`delivery.yaml` 是项目交付能力 -事实源。为避免多 Agent 并发写冲突: +`task-records/` 是任务、交付运行与回归运行事实源,`tasks.yaml` 是稳定项目配置, +`knowledge.yaml` 是跨任务项目知识事实源,`regression.yaml` 是黑盒回归用例事实源, +`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突: -- **只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`**。 +- **只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`**。 Test 与 Developer 对它们都是只读的。 - Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。 - Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在 diff --git a/skills/ack/scripts/feishu_bug_intake.py b/skills/ack/scripts/feishu_bug_intake.py index 1579eec..5811508 100644 --- a/skills/ack/scripts/feishu_bug_intake.py +++ b/skills/ack/scripts/feishu_bug_intake.py @@ -25,6 +25,7 @@ from pathlib import Path from typing import Any from approval_payload import approval_payload_hash, review_items +from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document from validate_tasks import validate_builtin as validate_task_board from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader @@ -180,21 +181,11 @@ def cli_environment() -> dict[str, str]: def load_board(path: Path) -> dict[str, Any]: try: - text = path.read_text(encoding="utf-8") - if path.suffix.lower() == ".json": - value = load_json_unique(text) - else: - try: - import yaml # type: ignore - value = yaml.load(text, Loader=make_unique_pyyaml_loader(yaml)) - except ImportError: - value = load_yaml_subset(text) - except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc: + value = parse_document(path) + if is_task_store_manifest(value): + value = materialize_board(value, path) + except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError, TaskStoreError) as exc: raise IntakeError(f"cannot read task board: {exc}") from exc - except Exception as exc: # PyYAML errors are intentionally not exposed verbatim. - raise IntakeError("cannot parse task board") from exc - if not isinstance(value, dict): - raise IntakeError("task board must be an object") return value diff --git a/skills/ack/scripts/migrate_tasks.py b/skills/ack/scripts/migrate_tasks.py new file mode 100644 index 0000000..478b539 --- /dev/null +++ b/skills/ack/scripts/migrate_tasks.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Migrate a legacy ACK tasks.yaml into immutable task records. + +The default is a read-only dry run. ``--apply`` writes a sibling +``tasks.legacy.yaml`` backup, creates one initial record per task, and replaces +the task board with a small manifest that points at ``task-records/``. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +from pathlib import Path +from typing import Any + +from task_store import GLOBAL_TASK_ID, STORE_VERSION, TaskStoreError, parse_document + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}") + try: + temporary.write_text(content, encoding="utf-8") + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def _record_id(task_id: str) -> str: + return f"EV-{task_id}-initial" + + +def _record(task_id: str, task: dict[str, Any] | None = None) -> dict[str, Any]: + value: dict[str, Any] = { + "recordVersion": STORE_VERSION, + "recordId": _record_id(task_id), + "taskId": task_id, + "parents": [], + "kind": "task.snapshot", + } + if task is not None: + value["task"] = task + return value + + +def build_records(board: dict[str, Any]) -> list[dict[str, Any]]: + tasks = board.get("tasks") + if not isinstance(tasks, list): + raise TaskStoreError("legacy tasks 必须是列表") + records: list[dict[str, Any]] = [] + task_ids: set[str] = set() + for task in tasks: + if not isinstance(task, dict) or not isinstance(task.get("id"), str): + raise TaskStoreError("每条 legacy task 必须包含字符串 id") + task_id = task["id"] + if task_id in task_ids: + raise TaskStoreError(f"task id 重复: {task_id}") + task_ids.add(task_id) + records.append(_record(task_id, dict(task))) + + receipts = board.get("workerReceipts") or [] + if not isinstance(receipts, list): + raise TaskStoreError("workerReceipts 必须是列表") + by_task: dict[str, list[dict[str, Any]]] = {task_id: [] for task_id in task_ids} + global_receipts: list[dict[str, Any]] = [] + for receipt in receipts: + if not isinstance(receipt, dict): + raise TaskStoreError("workerReceipts 每项必须是对象") + created_for = receipt.get("createdFor") + task_id = created_for.get("taskId") if isinstance(created_for, dict) else None + if isinstance(task_id, str) and task_id in by_task: + by_task[task_id].append(receipt) + else: + global_receipts.append(receipt) + for record in records: + task_id = record["taskId"] + if by_task[task_id]: + record["workerReceipts"] = by_task[task_id] + + global_record = _record(GLOBAL_TASK_ID) + if global_receipts: + global_record["workerReceipts"] = global_receipts + for field in ("deliveryRuns", "regressionRuns"): + value = board.get(field) + if value is not None: + if not isinstance(value, list): + raise TaskStoreError(f"{field} 必须是列表") + global_record[field] = value + if any(field in global_record for field in ("workerReceipts", "deliveryRuns", "regressionRuns")): + records.append(global_record) + return records + + +def migrate(path: Path, *, apply: bool, directory: str | None) -> dict[str, Any]: + board = parse_document(path) + if "taskStore" in board: + raise TaskStoreError("任务板已经启用 taskStore,无需重复迁移") + records = build_records(board) + record_directory = directory or "task-records" + relative = Path(record_directory) + if relative.is_absolute() or ".." in relative.parts: + raise TaskStoreError("records directory 必须是任务板目录下的相对路径") + output_dir = path.parent / relative + backup = path.with_name(f"{path.stem}.legacy{path.suffix}") + if apply and backup.exists(): + raise TaskStoreError(f"备份文件已存在,拒绝覆盖: {backup}") + + manifest = dict(board) + for field in ("summary", "workerReceipts", "deliveryRuns", "regressionRuns"): + manifest.pop(field, None) + manifest["tasks"] = [] + manifest["taskStore"] = {"version": STORE_VERSION, "directory": record_directory} + + if apply: + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, backup) + output_dir.mkdir(parents=True, exist_ok=True) + for record in records: + record_path = output_dir / f"{record['recordId']}.yaml" + if record_path.exists(): + raise TaskStoreError(f"记录文件已存在,拒绝覆盖: {record_path}") + _atomic_write(record_path, json.dumps(record, ensure_ascii=False, indent=2) + "\n") + _atomic_write(path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + + return { + "mode": "apply" if apply else "dry-run", + "tasks": len([record for record in records if record.get("taskId") != GLOBAL_TASK_ID]), + "records": len(records), + "recordsDirectory": str(output_dir), + "backup": str(backup), + "taskStore": manifest["taskStore"], + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml") + parser.add_argument("--apply", action="store_true", help="写入记录并替换任务板") + parser.add_argument("--records-dir", help="任务记录目录,默认 task-records") + args = parser.parse_args(argv) + try: + result = migrate(Path(args.tasks), apply=args.apply, directory=args.records_dir) + except (OSError, TaskStoreError) as exc: + print(f"迁移失败: {exc}", file=sys.stderr) + return 1 + print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ack/scripts/record_task.py b/skills/ack/scripts/record_task.py new file mode 100644 index 0000000..c0e7233 --- /dev/null +++ b/skills/ack/scripts/record_task.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Append one immutable ACK task record. + +The input task is a complete snapshot. The command never edits an existing +record; it creates a new file with exclusive creation and lets the materializer +report multiple heads when two workspaces branched from the same parent. +""" + +from __future__ import annotations + +import argparse +import json +import os +import secrets +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from task_store import ( + RECORD_ID_RE, + STORE_VERSION, + TASK_ID_RE, + TaskStoreError, + materialize_board, + parse_document, +) + + +def _read_json_mapping(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise TaskStoreError(f"{label}读取失败: {path}") from exc + if not isinstance(value, dict): + raise TaskStoreError(f"{label}必须是对象") + return value + + +def _read_json_list(path: Path, label: str) -> list[dict[str, Any]]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise TaskStoreError(f"{label}读取失败: {path}") from exc + if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw): + raise TaskStoreError(f"{label}必须是对象列表") + return raw + + +def _auto_record_id(task_id: str) -> str: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return f"EV-{task_id}-{stamp}-{secrets.token_hex(4)}" + + +def append_record( + board_path: Path, + task: dict[str, Any], + *, + task_id: str, + record_id: str | None, + parents: list[str], + kind: str, + worker_receipts: list[dict[str, Any]] | None = None, + delivery_runs: list[dict[str, Any]] | None = None, + regression_runs: list[dict[str, Any]] | None = None, +) -> Path: + manifest = parse_document(board_path) + if not isinstance(manifest.get("taskStore"), dict): + raise TaskStoreError("任务板没有启用 taskStore;先运行 migrate_tasks.py --apply") + if TASK_ID_RE.fullmatch(task_id) is None: + raise TaskStoreError("taskId 格式无效") + if task.get("id") != task_id: + raise TaskStoreError("输入 task.id 必须与 --task-id 相同") + if not isinstance(kind, str) or not kind.strip(): + raise TaskStoreError("kind 必须是非空字符串") + chosen_id = record_id or _auto_record_id(task_id) + if RECORD_ID_RE.fullmatch(chosen_id) is None: + raise TaskStoreError("recordId 格式无效") + if any(not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None for parent in parents): + raise TaskStoreError("parents 必须是 recordId 字符串列表") + + # Materialize first so malformed existing records or unresolved heads stop + # the next write instead of compounding an already ambiguous store. + materialized = materialize_board(manifest, board_path) + existing_errors = materialized.get("taskStoreErrors") + if isinstance(existing_errors, list) and existing_errors: + raise TaskStoreError("现有 taskStore 已有冲突,先解决: " + "; ".join(map(str, existing_errors))) + + directory = board_path.parent / str(manifest["taskStore"].get("directory", "task-records")) + directory.mkdir(parents=True, exist_ok=True) + output = directory / f"{chosen_id}.yaml" + record: dict[str, Any] = { + "recordVersion": STORE_VERSION, + "recordId": chosen_id, + "taskId": task_id, + "parents": parents, + "kind": kind, + "task": task, + } + for field, value in ( + ("workerReceipts", worker_receipts), + ("deliveryRuns", delivery_runs), + ("regressionRuns", regression_runs), + ): + if value: + record[field] = value + content = json.dumps(record, ensure_ascii=False, indent=2) + "\n" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + descriptor: int | None = None + try: + descriptor = os.open(output, flags, 0o644) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = None + handle.write(content) + except FileExistsError as exc: + raise TaskStoreError(f"记录已存在,拒绝覆盖: {output}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + return output + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml") + parser.add_argument("--task-id", required=True) + parser.add_argument("--task-json", type=Path, required=True, help="完整 task 快照 JSON") + parser.add_argument("--record-id") + parser.add_argument("--parent", action="append", default=[]) + parser.add_argument("--kind", default="task.snapshot") + parser.add_argument("--worker-receipts-json", type=Path) + parser.add_argument("--delivery-runs-json", type=Path) + parser.add_argument("--regression-runs-json", type=Path) + args = parser.parse_args(argv) + try: + task = _read_json_mapping(args.task_json, "task") + output = append_record( + Path(args.tasks), task, task_id=args.task_id, + record_id=args.record_id, parents=args.parent, kind=args.kind, + worker_receipts=( + _read_json_list(args.worker_receipts_json, "worker receipt") + if args.worker_receipts_json else None + ), + delivery_runs=( + _read_json_list(args.delivery_runs_json, "delivery run") + if args.delivery_runs_json else None + ), + regression_runs=( + _read_json_list(args.regression_runs_json, "regression run") + if args.regression_runs_json else None + ), + ) + except (OSError, TaskStoreError) as exc: + print(f"追加任务记录失败: {exc}", file=sys.stderr) + return 1 + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ack/scripts/task_store.py b/skills/ack/scripts/task_store.py new file mode 100644 index 0000000..d49df23 --- /dev/null +++ b/skills/ack/scripts/task_store.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Merge-friendly ACK task records. + +``tasks.yaml`` remains the small project manifest. When it contains a +``taskStore`` mapping, task state is materialized from immutable record files +under the configured directory. Legacy boards without that mapping keep +their original single-file behavior. + +Records are complete task snapshots rather than patches. A later record +names its parent record(s), so a concurrent edit produces multiple heads and +is reported as a semantic merge conflict instead of being silently resolved +by file order or timestamps. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from yaml_subset import ( + DuplicateKeyError, + YamlSubsetError, + load_json_unique, + load_yaml_subset, + make_unique_pyyaml_loader, +) + + +STORE_VERSION = 1 +GLOBAL_TASK_ID = "__project__" +TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") +RECORD_SUFFIXES = {".yaml", ".yml", ".json"} + + +class TaskStoreError(ValueError): + """A malformed task store or record.""" + + +def parse_document(path: Path) -> dict[str, Any]: + """Parse one YAML/JSON mapping without importing the validator.""" + + try: + content = path.read_text(encoding="utf-8") + except OSError as exc: + raise TaskStoreError(f"读取失败: {path}: {exc}") from exc + + try: + if path.suffix.lower() == ".json": + value = load_json_unique(content) + else: + try: + import yaml # type: ignore + except ImportError: + value = load_yaml_subset(content) + else: + value = yaml.load( + content, + Loader=make_unique_pyyaml_loader(yaml), + ) + except (json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc: + raise TaskStoreError(f"解析失败: {path}: {exc}") from exc + except Exception as exc: # PyYAML errors are intentionally bounded. + raise TaskStoreError(f"解析失败: {path}") from exc + + if not isinstance(value, dict): + raise TaskStoreError(f"{path}: 顶层必须是对象(mapping)") + return value + + +def _record_directory(manifest: dict[str, Any], manifest_path: Path) -> Path: + config = manifest.get("taskStore") + if not isinstance(config, dict): + raise TaskStoreError("taskStore 必须是对象") + version = config.get("version", STORE_VERSION) + if version != STORE_VERSION: + raise TaskStoreError(f"不支持 taskStore.version={version!r}") + directory = config.get("directory", "task-records") + if not isinstance(directory, str) or not directory.strip(): + raise TaskStoreError("taskStore.directory 必须是非空相对路径") + relative = Path(directory) + if relative.is_absolute() or ".." in relative.parts: + raise TaskStoreError("taskStore.directory 必须位于任务板目录下") + return manifest_path.parent / relative + + +def _record_paths(directory: Path) -> list[Path]: + if not directory.exists(): + return [] + if directory.is_symlink() or not directory.is_dir(): + raise TaskStoreError(f"taskStore.directory 不是普通目录: {directory}") + paths: list[Path] = [] + for path in sorted(directory.iterdir(), key=lambda item: item.name): + if path.name.startswith("."): + continue + if path.is_symlink() or not path.is_file(): + raise TaskStoreError(f"任务记录必须是普通文件: {path}") + if path.suffix.lower() in RECORD_SUFFIXES: + paths.append(path) + return paths + + +def _unique_collection( + records: list[dict[str, Any]], + field: str, + errors: list[str], +) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + by_id: dict[str, dict[str, Any]] = {} + for record in records: + values = record.get(field) + if values is None: + continue + if not isinstance(values, list): + errors.append(f"{field} 必须是列表") + continue + for index, item in enumerate(values): + if not isinstance(item, dict): + errors.append(f"{field}[{index}] 必须是对象") + continue + item_id = item.get("id") + if not isinstance(item_id, str) or not item_id: + errors.append(f"{field}[{index}].id 必须是非空字符串") + continue + previous = by_id.get(item_id) + if previous is not None and previous != item: + errors.append(f"{field} 出现相同 id 的不同记录: {item_id}") + continue + if previous is None: + by_id[item_id] = item + result.append(item) + return result + + +def materialize_board(manifest: dict[str, Any], manifest_path: Path) -> dict[str, Any]: + """Return a board-shaped view from a manifest and immutable records.""" + + directory = _record_directory(manifest, manifest_path) + paths = _record_paths(directory) + # This permits a staged rollout: a freshly initialized board has an empty + # store and the legacy empty ``tasks: []`` field until its first record. + if not paths: + return manifest + + errors: list[str] = [] + records: list[dict[str, Any]] = [] + by_record_id: dict[str, dict[str, Any]] = {} + for path in paths: + record = parse_document(path) + where = str(path.relative_to(manifest_path.parent)) + version = record.get("recordVersion", STORE_VERSION) + record_id = record.get("recordId") + task_id = record.get("taskId") + parents = record.get("parents", []) + if version != STORE_VERSION: + errors.append(f"{where}: 不支持 recordVersion={version!r}") + if not isinstance(record_id, str) or RECORD_ID_RE.fullmatch(record_id) is None: + errors.append(f"{where}: recordId 格式无效") + elif record_id in by_record_id: + errors.append(f"{where}: recordId 重复: {record_id}") + if not isinstance(task_id, str) or ( + task_id != GLOBAL_TASK_ID and TASK_ID_RE.fullmatch(task_id) is None + ): + errors.append(f"{where}: taskId 格式无效") + if not isinstance(parents, list) or any( + not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None + for parent in parents + ): + errors.append(f"{where}: parents 必须是 recordId 字符串列表") + task = record.get("task") + if task_id != GLOBAL_TASK_ID and not isinstance(task, dict): + errors.append(f"{where}: 非全局记录必须包含 task 对象") + if isinstance(record_id, str) and record_id not in by_record_id: + by_record_id[record_id] = record + records.append(record) + + for record in records: + record_id = record.get("recordId") + task_id = record.get("taskId") + if not isinstance(record_id, str) or not isinstance(task_id, str): + continue + for parent in record.get("parents", []): + if parent not in by_record_id: + errors.append(f"{record_id}: 引用了不存在的 parent: {parent}") + elif by_record_id[parent].get("taskId") != task_id: + errors.append(f"{record_id}: parent 必须属于同一个 taskId") + + task_records: dict[str, list[dict[str, Any]]] = {} + for record in records: + task_id = record.get("taskId") + if isinstance(task_id, str) and task_id != GLOBAL_TASK_ID: + task_records.setdefault(task_id, []).append(record) + + tasks: list[dict[str, Any]] = [] + for task_id in sorted(task_records): + items = task_records[task_id] + referenced = { + parent + for item in items + for parent in item.get("parents", []) + if isinstance(parent, str) + } + heads = [ + item for item in items + if isinstance(item.get("recordId"), str) + and item["recordId"] not in referenced + ] + if len(heads) != 1: + errors.append( + f"task {task_id}: 发现 {len(heads)} 个未合并 head,必须创建同时引用全部 head 的合并记录" + ) + continue + task = heads[0].get("task") + if isinstance(task, dict): + tasks.append(task) + + result = dict(manifest) + result["tasks"] = tasks + result["workerReceipts"] = _unique_collection(records, "workerReceipts", errors) + for field in ("deliveryRuns", "regressionRuns"): + if field in manifest or any(field in record for record in records): + result[field] = _unique_collection(records, field, errors) + result["summary"] = { + "open": [task["id"] for task in tasks if task.get("status") == "open"], + "verified": [task["id"] for task in tasks if task.get("status") == "verified"], + "failedRetest": [task["id"] for task in tasks if task.get("status") == "failed_retest"], + "leftovers": [task["id"] for task in tasks if task.get("status") == "leftover"], + } + if errors: + result["taskStoreErrors"] = errors + else: + result.pop("taskStoreErrors", None) + return result + + +def is_task_store_manifest(value: object) -> bool: + return isinstance(value, dict) and "taskStore" in value + + +__all__ = [ + "GLOBAL_TASK_ID", + "STORE_VERSION", + "TaskStoreError", + "is_task_store_manifest", + "materialize_board", + "parse_document", +] diff --git a/skills/ack/scripts/validate_delivery.py b/skills/ack/scripts/validate_delivery.py index 28506b3..5935589 100755 --- a/skills/ack/scripts/validate_delivery.py +++ b/skills/ack/scripts/validate_delivery.py @@ -22,15 +22,7 @@ import sys from pathlib import Path, PurePosixPath from typing import Any -from yaml_subset import ( - DuplicateKeyError, - YamlSubsetError, - load_json_unique, - load_yaml_subset, - make_unique_pyyaml_loader, -) - - +from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$") RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$") PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$") @@ -162,36 +154,12 @@ def _reject_unknown( def _load_document(path: Path, label: str) -> dict[str, Any]: try: - content = path.read_text(encoding="utf-8") - except OSError as exc: + data = parse_document(path) + if is_task_store_manifest(data): + data = materialize_board(data, path) + except TaskStoreError as exc: sys.stderr.write(f"{label}读取失败: {exc}\n") raise SystemExit(1) - - if path.suffix.lower() == ".json": - try: - data = load_json_unique(content) - except (json.JSONDecodeError, DuplicateKeyError) as exc: - sys.stderr.write(f"{label} JSON 解析失败: {exc}\n") - raise SystemExit(1) - else: - try: - import yaml # type: ignore - except ImportError: - try: - data = load_yaml_subset(content) - except YamlSubsetError as exc: - sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n") - raise SystemExit(1) - else: - try: - data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml)) - except yaml.YAMLError as exc: # type: ignore - sys.stderr.write(f"{label} YAML 解析失败: {exc}\n") - raise SystemExit(1) - - if not isinstance(data, dict): - sys.stderr.write(f"{label}顶层必须是对象(mapping)\n") - raise SystemExit(1) return data diff --git a/skills/ack/scripts/validate_regression.py b/skills/ack/scripts/validate_regression.py index 5df890c..fb5add4 100644 --- a/skills/ack/scripts/validate_regression.py +++ b/skills/ack/scripts/validate_regression.py @@ -21,15 +21,7 @@ import sys from pathlib import Path from typing import Any -from yaml_subset import ( - DuplicateKeyError, - YamlSubsetError, - load_json_unique, - load_yaml_subset, - make_unique_pyyaml_loader, -) - - +from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$") TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") @@ -75,36 +67,12 @@ def _reject_unknown( def load_yaml(path: Path, label: str) -> dict[str, Any]: try: - content = path.read_text(encoding="utf-8") - except OSError as exc: + data = parse_document(path) + if is_task_store_manifest(data): + data = materialize_board(data, path) + except TaskStoreError as exc: sys.stderr.write(f"{label}读取失败: {exc}\n") raise SystemExit(1) - - if path.suffix.lower() == ".json": - try: - data = load_json_unique(content) - except (json.JSONDecodeError, DuplicateKeyError) as exc: - sys.stderr.write(f"{label} JSON 解析失败: {exc}\n") - raise SystemExit(1) - else: - try: - import yaml # type: ignore - except ImportError: - try: - data = load_yaml_subset(content) - except YamlSubsetError as exc: - sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n") - raise SystemExit(1) - else: - try: - data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml)) - except yaml.YAMLError as exc: # type: ignore - sys.stderr.write(f"{label} YAML 解析失败: {exc}\n") - raise SystemExit(1) - - if not isinstance(data, dict): - sys.stderr.write(f"{label}顶层必须是对象(mapping)\n") - raise SystemExit(1) return data diff --git a/skills/ack/scripts/validate_tasks.py b/skills/ack/scripts/validate_tasks.py index a2e2476..47b0636 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -25,13 +25,7 @@ import sys from pathlib import Path from approval_payload import approval_payload_hash -from yaml_subset import ( - DuplicateKeyError, - YamlSubsetError, - load_json_unique, - load_yaml_subset, - make_unique_pyyaml_loader, -) +from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document from worker_profiles import validate_orchestration from worker_profiles import validate_routing_document @@ -243,38 +237,12 @@ def _nonempty_string(value: object) -> bool: def load_document(path: Path) -> dict: try: - content = path.read_text(encoding="utf-8") - except OSError as exc: + data = parse_document(path) + if is_task_store_manifest(data): + data = materialize_board(data, path) + except TaskStoreError as exc: sys.stderr.write(f"任务板读取失败: {exc}\n") raise SystemExit(1) - - if path.suffix.lower() == ".json": - try: - data = load_json_unique(content) - except (json.JSONDecodeError, DuplicateKeyError) as exc: - sys.stderr.write(f"JSON 解析失败: {exc}\n") - raise SystemExit(1) - else: - try: - import yaml # type: ignore - except ImportError: - try: - data = load_yaml_subset(content) - except YamlSubsetError as exc: - sys.stderr.write(f"YAML 子集解析失败: {exc}\n") - raise SystemExit(1) - else: - try: - data = yaml.load( - content, - Loader=make_unique_pyyaml_loader(yaml), - ) - except yaml.YAMLError as exc: # type: ignore - sys.stderr.write(f"YAML 解析失败: {exc}\n") - raise SystemExit(1) - if not isinstance(data, dict): - sys.stderr.write("任务板顶层必须是对象(mapping)\n") - raise SystemExit(1) return data @@ -901,6 +869,11 @@ def validate_builtin( orchestration_override: dict | None = None, ) -> list[str]: errors: list[str] = [] + store_errors = data.get("taskStoreErrors") + if isinstance(store_errors, list): + for error in store_errors: + if isinstance(error, str) and error.strip(): + errors.append(f"taskStore: {error}") project_intake_workflow = "read-only-v1" def validate_string_fields( diff --git a/skills/ack/templates/project.template.md b/skills/ack/templates/project.template.md index 6906d41..a2931c7 100644 --- a/skills/ack/templates/project.template.md +++ b/skills/ack/templates/project.template.md @@ -8,7 +8,7 @@ > 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK > 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 > 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。 -> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml`、默认关闭的 +> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、默认关闭的 > `delivery.yaml`、空的 `regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖, > 不复制或链接 Skill。 @@ -18,7 +18,8 @@ - 技术栈:`` - 运行命令:`` - Base URL:`` -- 任务板:`.pouch/ack/tasks.yaml` +- 任务板配置:`.pouch/ack/tasks.yaml` +- 任务记录:`.pouch/ack/task-records/.yaml`(追加式) - 项目知识:`.pouch/ack/knowledge.yaml` - 交付契约:`.pouch/ack/delivery.yaml`(默认关闭) - 回归目录:`.pouch/ack/regression.yaml` @@ -68,7 +69,8 @@ receipt 以 `.pouch/ack/agents.yaml`(存在时)或 `.pouch/ack/tasks.yaml` | `` | Read-only | Read-only | R/W | 单元测试 | | `` | Read-only | Read-only | R/W | 可提交配置模板 | | `` | Read-only | Read-only | Read-only | 本地私有配置 | -| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 | +| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` | +| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 | | `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 | | `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 | | `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 | @@ -117,7 +119,7 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 - 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。 - 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。 - `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。 -- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`; +- 只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`; Developer 与 Test 都只读,通过消息回报。 - Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识; `candidate` 不派发,知识库不全量注入。 diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 56cc3fc..4724776 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -115,6 +115,25 @@ "statusReference": { "type": "object" }, + "taskStore": { + "type": "object", + "required": [ + "version", + "directory" + ], + "properties": { + "version": { + "type": "integer", + "const": 1 + }, + "directory": { + "type": "string", + "minLength": 1, + "description": "相对 tasks.yaml 的不可变任务记录目录" + } + }, + "additionalProperties": true + }, "workerReceipts": { "type": "array", "items": { diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index c00d8c1..bc45438 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -3,6 +3,10 @@ version: 1 updatedAt: "" source: "Coordinator (PM) Agent" ackVersion: "<接入时的 ack skill 版本>" +# 任务板只保存项目配置;执行记录追加到 task-records/,避免多个工作区共同改写本文件。 +taskStore: + version: 1 + directory: "task-records" project: name: "" baseUrl: "" diff --git a/skills/tester/README.md b/skills/tester/README.md index 43767a4..47d3ec4 100644 --- a/skills/tester/README.md +++ b/skills/tester/README.md @@ -47,6 +47,6 @@ ## 如何判断完成 -- **列出**:能看到分类表和总条数;`.pouch/tester/cases.csv` 已由 skill 里的 `generate_cases_csv.py` 从 `cases/` 重新生成。最近一次运行的结果在同一张表的 `last_result` / `last_executed` 列。 +- **列出**:能看到分类表和总条数;`.pouch/tester/cases.csv` 已由 skill 里的 `generate_cases_csv.py` 从 `cases/` 重新生成。每次运行的结果保存在独立的 `.pouch/tester/runs/.*` 文件中。 - **维护**:目标 id 能 `查看` 到,且检查通过。 - **运行**:有本次 `runs/*.yaml` 快照和同名 `.csv` 成绩单(`executed` 列区分已跑 / 未跑);没填 HOST 时会停,而不是编造失败。 diff --git a/skills/tester/SKILL.md b/skills/tester/SKILL.md index 762d589..a5e751f 100644 --- a/skills/tester/SKILL.md +++ b/skills/tester/SKILL.md @@ -71,7 +71,7 @@ python3 /scripts/catalog.py list --root python3 /scripts/generate_cases_csv.py --root ``` -`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。有运行快照时,这份 CSV 末尾有 `last_result` / `last_executed`(`yes` 已跑,`no` 未跑——含 `skipped` 与 `not-run`,空=未进入最近一次运行)。 +`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。该 CSV 只包含用例定义;运行结果只写入独立的 `.pouch/tester/runs/.yaml`,不会回写清单。 ## 查看 diff --git a/skills/tester/references/maintain.md b/skills/tester/references/maintain.md index 1eb13b4..5e98e10 100644 --- a/skills/tester/references/maintain.md +++ b/skills/tester/references/maintain.md @@ -35,5 +35,5 @@ python3 /scripts/catalog.py next-id --root --category - 为了能在默认风险下跑而把 `risk` 改低 - 一次塞进无关的一批用例 - 手改 `runs/` 冒充执行记录 -- 手改 `cases.csv`(必须从 `cases/*.yaml` 生成;`last_*` 列来自最近一次 `runs/*.yaml`) +- 手改 `cases.csv`(必须从 `cases/*.yaml` 生成;运行结果只在对应 `runs/.*` 中保存) - 手改 `runs/*.csv`(必须从对应 yaml 用 `generate_run_csv.py` 生成) diff --git a/skills/tester/references/run.md b/skills/tester/references/run.md index b5076fa..cd1fdf3 100644 --- a/skills/tester/references/run.md +++ b/skills/tester/references/run.md @@ -83,13 +83,14 @@ python3 /scripts/catalog.py run-script --root --id 每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立),`not-run` 是没轮到(失败即停后剩下的 / 被前置整组挡住的 / 需人工而无人值守的)。 -写入 `.pouch/tester/runs/-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。立刻用同一快照生成 CSV,不要手改: +写入 `.pouch/tester/runs/-<范围>-<随机后缀>.yaml`,可用 +`generate_run_csv.new_run_id(<范围>)` 生成同名 `runId`。记录包含唯一 `runId`、筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。记录文件只创建一次,不覆盖已有文件。立刻用同一快照生成 CSV,不要手改: ```bash python3 /scripts/generate_run_csv.py --root --run ``` -这会写出 `.pouch/tester/runs/-<范围>.csv`(`executed=yes|no`),并刷新 `cases.csv` 的 `last_result` / `last_executed` 列。对话里两个路径都报。 +这会写出与快照同名的 `.pouch/tester/runs/.csv`(`executed=yes|no`),不会修改 `cases.csv`。对话里两个路径都报。 对话报告: diff --git a/skills/tester/references/schema.md b/skills/tester/references/schema.md index 4591412..c0c8424 100644 --- a/skills/tester/references/schema.md +++ b/skills/tester/references/schema.md @@ -9,8 +9,8 @@ catalog.yaml 分类、风险阶梯、session、变量 local.yaml.example HOST 与授权开关模板(默认 127.0.0.1) local.yaml 本机覆盖,不提交 cases/.yaml 一个分类一个文件(执行源) -cases.csv 给人看的扁平清单;generate_cases_csv.py 读 cases/*.yaml,并拼最近一次运行的 last_* 列 -runs/*.yaml 每次运行快照(执行源) +cases.csv 给人看的扁平清单;只由 cases/*.yaml 生成,不包含运行结果 +runs/.yaml 每次运行快照(执行源);run-id 必须唯一 runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对应 yaml 生成 ``` @@ -33,6 +33,10 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对 `127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` > `catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。 +运行记录使用 UTC 时间加随机后缀命名,例如 +`20260920T101530Z-smoke-a13f9c.yaml`,并在 YAML 顶层保存同一个 `runId`。 +不要使用固定的 `latest.yaml` 或覆盖已有运行文件。 + ## 一条用例 写在对应分类文件的 `cases:` 列表里。 diff --git a/skills/tester/scripts/generate_cases_csv.py b/skills/tester/scripts/generate_cases_csv.py index 7004d47..c90d11b 100755 --- a/skills/tester/scripts/generate_cases_csv.py +++ b/skills/tester/scripts/generate_cases_csv.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 -"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。""" +"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。 + +运行结果属于 runs/ 下的追加式记录,不回写本清单,避免多个工作区共同 +刷新运行状态造成合并冲突。 +""" from __future__ import annotations @@ -32,11 +36,6 @@ FIELDS = ( "how", "expected", "notes", - "last_run", - "last_result", - "last_executed", - "last_evidence", - "last_notes", ) @@ -148,28 +147,8 @@ def sort_cases(root: Path, cases: list[dict[str, Any]]) -> list[dict[str, Any]]: ) -def last_run_lookup(root: Path) -> tuple[str, dict[str, dict[str, Any]]]: - from generate_run_csv import latest_run_yaml, load_run, results_by_id - - run_path = latest_run_yaml(root) - if run_path is None: - return "", {} - try: - run = load_run(run_path) - except SystemExit: - return "", {} - try: - rel = str(run_path.relative_to(root)) - except ValueError: - rel = str(run_path) - return rel, results_by_id(run) - - def export_cases_csv(root: Path, out: Path | None = None) -> Path: - from generate_run_csv import executed_cell - cases = sort_cases(root, load_cases(root)) - last_run, last_by_id = last_run_lookup(root) path = out or (tester_dir(root) / "cases.csv") path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8-sig", newline="") as handle: @@ -177,8 +156,6 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path: writer.writeheader() for case in cases: cid = str(case.get("id") or "") - last = last_by_id.get(cid) or {} - last_result = str(last.get("result") or "") if last else "" writer.writerow( { "category": case.get("category") or "", @@ -197,11 +174,6 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path: "how": how_brief(case.get("how")), "expected": one_line(case.get("expected")), "notes": one_line(case.get("notes")), - "last_run": last_run if last else "", - "last_result": last_result, - "last_executed": executed_cell(last_result) if last else "", - "last_evidence": one_line(last.get("evidence")) if last else "", - "last_notes": one_line(last.get("notes")) if last else "", } ) return path diff --git a/skills/tester/scripts/generate_run_csv.py b/skills/tester/scripts/generate_run_csv.py index 80bdc63..40518fe 100644 --- a/skills/tester/scripts/generate_run_csv.py +++ b/skills/tester/scripts/generate_run_csv.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import csv +import re import sys from collections import Counter from pathlib import Path @@ -19,6 +20,7 @@ except ImportError: # pragma: no cover from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run") +RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"}) EXECUTED_NO = frozenset({"skipped", "not-run"}) FIELDS = ( @@ -56,6 +58,22 @@ def latest_run_yaml(root: Path) -> Path | None: return files[-1] if files else None +def new_run_id(scope: str, suffix: str | None = None) -> str: + """Return a collision-resistant filename/id for a new run snapshot.""" + from datetime import datetime, timezone + from secrets import token_hex + + clean_scope = re.sub(r"[^A-Za-z0-9._-]+", "-", scope).strip("-") or "run" + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + clean_suffix = re.sub(r"[^A-Za-z0-9._-]+", "-", suffix or "").strip("-") + random_part = token_hex(3) + parts = [stamp, clean_scope] + if clean_suffix: + parts.append(clean_suffix) + parts.append(random_part) + return "-".join(parts) + + def load_run(path: Path) -> dict[str, Any]: data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} if not isinstance(data, dict): @@ -63,6 +81,11 @@ def load_run(path: Path) -> dict[str, Any]: results = data.get("results") if not isinstance(results, list): raise SystemExit(f"{path} 缺少 results 列表") + run_id = data.get("runId") + if run_id is not None and ( + not isinstance(run_id, str) or RUN_ID_RE.fullmatch(run_id) is None + ): + raise SystemExit(f"{path} 的 runId 格式无效") return data @@ -88,7 +111,6 @@ def export_run_csv( root: Path, run_path: Path | None = None, out: Path | None = None, - refresh_cases: bool = True, ) -> Path: path = run_path or latest_run_yaml(root) if path is None: @@ -122,10 +144,6 @@ def export_run_csv( "maps_to": case.get("maps_to") or "", } ) - if refresh_cases: - from generate_cases_csv import export_cases_csv - - export_cases_csv(root) return dest @@ -137,7 +155,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--no-refresh-cases", action="store_true", - help="不要顺便刷新 cases.csv 的 last_* 列", + help="兼容旧命令;cases.csv 已不会被运行结果刷新", ) args = parser.parse_args(argv) root = resolve_root(args.root) @@ -147,7 +165,6 @@ def main(argv: list[str] | None = None) -> int: root, run_path=run_path, out=out, - refresh_cases=not args.no_refresh_cases, ) counts: Counter[str] = Counter() with path.open(encoding="utf-8-sig", newline="") as handle: diff --git a/tests/test_ack_task_store.py b/tests/test_ack_task_store.py new file mode 100644 index 0000000..5d21d44 --- /dev/null +++ b/tests/test_ack_task_store.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = REPO_ROOT / "skills" / "ack" / "scripts" +VALIDATOR = SCRIPTS / "validate_tasks.py" +SELECTOR = SCRIPTS / "select_tasks.py" +MIGRATOR = SCRIPTS / "migrate_tasks.py" +RECORDER = SCRIPTS / "record_task.py" +sys.path.insert(0, str(SCRIPTS)) + +from task_store import materialize_board # noqa: E402 + + +def manifest() -> dict: + return { + "version": 1, + "ackVersion": "0.20.0", + "taskStore": {"version": 1, "directory": "task-records"}, + "project": {"name": "demo", "orchestration": { + "profileVersion": 1, + "mode": "manual", + "allowedWorktrees": [], + "modelAllowlist": {}, + "profiles": {}, + "defaults": {}, + }}, + "tasks": [], + "workerReceipts": [], + } + + +def record(record_id: str, task_id: str, task: dict, parents: list[str] | None = None) -> dict: + return { + "recordVersion": 1, + "recordId": record_id, + "taskId": task_id, + "parents": parents or [], + "kind": "task.snapshot", + "task": task, + } + + +class AckTaskStoreTests(unittest.TestCase): + def write_board(self, root: Path, records: list[dict]) -> Path: + board = root / "tasks.yaml" + board.write_text(json.dumps(manifest()), encoding="utf-8") + record_dir = root / "task-records" + record_dir.mkdir() + for item in records: + (record_dir / f"{item['recordId']}.yaml").write_text( + json.dumps(item), encoding="utf-8" + ) + return board + + def test_materializes_current_task_from_one_head(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + path = self.write_board( + root, + [record("EV-1", "T-1", {"id": "T-1", "title": "one", "status": "open"})], + ) + from validate_tasks import load_document + + board = load_document(path) + self.assertEqual([task["id"] for task in board["tasks"]], ["T-1"]) + self.assertEqual(board["summary"]["open"], ["T-1"]) + self.assertNotIn("taskStoreErrors", board) + + def test_two_heads_are_reported_instead_of_last_write_wins(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + path = self.write_board( + root, + [ + record("EV-1", "T-1", {"id": "T-1", "title": "one", "status": "open"}), + record("EV-2", "T-1", {"id": "T-1", "title": "two", "status": "verified"}, ["EV-1"]), + record("EV-3", "T-1", {"id": "T-1", "title": "three", "status": "blocked"}, ["EV-1"]), + ], + ) + result = subprocess.run( + [sys.executable, str(VALIDATOR), str(path)], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("未合并 head", result.stderr) + + def test_selector_reads_records_without_changing_cli_shape(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + path = self.write_board( + root, + [record("EV-1", "T-1", {"id": "T-1", "title": "one", "status": "open"})], + ) + result = subprocess.run( + [sys.executable, str(SELECTOR), str(path)], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["selection"]["selectedTaskIds"], ["T-1"]) + + def test_migrator_dry_run_does_not_modify_legacy_board(self) -> None: + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "tasks.yaml" + original = { + "version": 1, + "project": {"name": "demo"}, + "tasks": [{"id": "T-1", "title": "one", "status": "open"}], + "workerReceipts": [], + } + path.write_text(json.dumps(original), encoding="utf-8") + result = subprocess.run( + [sys.executable, str(MIGRATOR), str(path)], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(path.read_text()), original) + self.assertFalse((path.parent / "task-records").exists()) + + def test_migrator_apply_keeps_backup_and_materializes_same_board(self) -> None: + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "tasks.yaml" + path.write_text(json.dumps({ + "version": 1, + "project": {"name": "demo"}, + "tasks": [{"id": "T-1", "title": "one", "status": "open"}], + "workerReceipts": [], + }), encoding="utf-8") + result = subprocess.run( + [sys.executable, str(MIGRATOR), str(path), "--apply"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((path.parent / "tasks.legacy.yaml").is_file()) + self.assertTrue((path.parent / "task-records" / "EV-T-1-initial.yaml").is_file()) + from validate_tasks import load_document + + board = load_document(path) + self.assertEqual(board["tasks"][0]["title"], "one") + + def test_recorder_appends_exclusive_snapshot(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + path = self.write_board(root, []) + snapshot = root / "task.json" + snapshot.write_text( + json.dumps({"id": "T-1", "title": "one", "status": "open"}), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, str(RECORDER), str(path), "--task-id", "T-1", + "--task-json", str(snapshot), "--record-id", "EV-1", + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((root / "task-records" / "EV-1.yaml").is_file()) + duplicate = subprocess.run( + [ + sys.executable, str(RECORDER), str(path), "--task-id", "T-1", + "--task-json", str(snapshot), "--record-id", "EV-1", + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(duplicate.returncode, 1) + self.assertIn("拒绝覆盖", duplicate.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tester_run_records.py b/tests/test_tester_run_records.py new file mode 100644 index 0000000..9d5de4c --- /dev/null +++ b/tests/test_tester_run_records.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import csv +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CASES = REPO_ROOT / "skills" / "tester" / "scripts" / "generate_cases_csv.py" +RUNS = REPO_ROOT / "skills" / "tester" / "scripts" / "generate_run_csv.py" +sys.path.insert(0, str(RUNS.parent)) + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TesterRunRecordTests(unittest.TestCase): + def test_cases_csv_contains_definitions_only(self) -> None: + cases = load_module("tester_cases_csv", CASES) + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + cases_dir = root / ".pouch" / "tester" / "cases" + cases_dir.mkdir(parents=True) + (root / ".pouch" / "tester" / "catalog.yaml").write_text( + "categories:\n - id: smoke\n title: Smoke\n", + encoding="utf-8", + ) + (cases_dir / "smoke.yaml").write_text( + "category: smoke\ncases:\n - id: TC-1\n title: One\n what: one\n lifecycle: active\n tag: smoke\n risk: readonly\n how: {steps: [open]}\n expected: [{kind: visible-text, value: ready}]\n", + encoding="utf-8", + ) + output = cases.export_cases_csv(root) + with output.open(encoding="utf-8-sig", newline="") as handle: + rows = list(csv.DictReader(handle)) + self.assertEqual(rows[0]["id"], "TC-1") + self.assertNotIn("last_result", rows[0]) + self.assertNotIn("last_executed", rows[0]) + + def test_run_id_has_random_collision_suffix(self) -> None: + runs = load_module("tester_run_csv", RUNS) + first = runs.new_run_id("smoke") + second = runs.new_run_id("smoke") + self.assertNotEqual(first, second) + self.assertRegex(first, r"^\d{8}T\d{6}Z-smoke-[0-9a-f]{6}$") + + +if __name__ == "__main__": + unittest.main()