feat(ack): shard task records for merge-safe workspaces

This commit is contained in:
2026-09-20 19:15:43 +08:00
parent fd13dad2b0
commit 130dcc36c5
29 changed files with 985 additions and 218 deletions
+10 -2
View File
@@ -55,7 +55,8 @@ pouch init ack --project ~/code/my-app
```text ```text
.pouch/ack/ .pouch/ack/
├── project.md ├── project.md
├── tasks.yaml ├── tasks.yaml # 项目配置与 taskStore 指针
├── task-records/ # 追加式任务/执行记录
├── knowledge.yaml ├── knowledge.yaml
├── delivery.yaml # 默认 enabled: false ├── delivery.yaml # 默认 enabled: false
├── regression.yaml # 默认 cases: [] ├── regression.yaml # 默认 cases: []
@@ -64,7 +65,14 @@ pouch init ack --project ~/code/my-app
不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。 不会把 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 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
--task-id T-001 --task-json /tmp/T-001.json --parent EV-T-001-initial
```
## 使用示例 ## 使用示例
+18 -3
View File
@@ -8,12 +8,14 @@ description: >-
# ACK 项目协作入口 # 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` 角色启动覆盖配置。 `delivery.yaml`、`regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖配置。
不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、 不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、
`CLAUDE.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/knowledge.yaml`、`.pouch/ack/delivery.yaml` 和
`.pouch/ack/regression.yaml`。 `.pouch/ack/regression.yaml`。
@@ -38,7 +40,10 @@ description: >-
- `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。 - `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。
- `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。 - `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。
- 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。 - 不把完整 `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/<recordId>.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。 - 知识检查只经 `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 禁止 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` 不直接重试。 - 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。
@@ -61,9 +66,19 @@ python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yam
# 仅在存在临时角色配置时运行 # 仅在存在临时角色配置时运行
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
``` ```
4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。 4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
旧项目迁移前先预览 `python3 <ack-skill-dir>/scripts/migrate_tasks.py .pouch/ack/tasks.yaml`,确认后再加 `--apply`。
追加任务状态时使用完整快照 JSON,不修改已有记录:
```bash
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
--task-id <task-id> --task-json <task-snapshot.json> \
--parent <previous-record-id> --kind task.snapshot
```
```text ```text
## ack 初始化:完成 | 部分完成 | 阻塞 ## ack 初始化:完成 | 部分完成 | 阻塞
+6 -4
View File
@@ -4,7 +4,7 @@
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。 > 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 > 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`。 > 与 `regression.yaml`。
## 项目概览 ## 项目概览
@@ -13,7 +13,8 @@
- 技术栈:`TypeScript + React (Vite) + Go` - 技术栈:`TypeScript + React (Vite) + Go`
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端) - 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
- Base URL:`http://localhost:5173` - Base URL:`http://localhost:5173`
- 任务板:`.pouch/ack/tasks.yaml` - 任务板配置:`.pouch/ack/tasks.yaml`
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`
- 项目知识:`.pouch/ack/knowledge.yaml` - 项目知识:`.pouch/ack/knowledge.yaml`
- 交付契约:`.pouch/ack/delivery.yaml` - 交付契约:`.pouch/ack/delivery.yaml`
- 回归目录:`.pouch/ack/regression.yaml` - 回归目录:`.pouch/ack/regression.yaml`
@@ -57,7 +58,8 @@
| `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 | | `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 |
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 | | `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 | | `.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 通过回报提名或验证 | | `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 | | `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 | | `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
@@ -101,7 +103,7 @@ path/args。
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用; worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。 整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。 - `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。 - 只有 Coordinator 写 `task-records/` 和 `knowledge.yaml`;Test 与 Developer 只读。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识; - Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
`candidate` 不派发,知识库不全量注入。 `candidate` 不派发,知识库不全量注入。
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报 - Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
+1 -1
View File
@@ -4,7 +4,7 @@
- [ ] ACK Skill 已全局安装或安装到当前项目。 - [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `pouch init ack --project <project-root>`。 - [ ] 已运行 `pouch init ack --project <project-root>`。
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml`、 - [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。 默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的 - [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。 `project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
+16 -13
View File
@@ -2,7 +2,8 @@
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。 本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;已经验证且可跨 原则:调度消息只是运行时载体,任务结论必须追加到 `task-records/`(由读取层
materialize 成任务视图);已经验证且可跨
任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。 任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、 角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、
Developer 只实现。 Developer 只实现。
@@ -15,12 +16,12 @@ Developer 只实现。
| 抽象动作 | 含义 | Orca 实现 | 手动实现 | | 抽象动作 | 含义 | 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, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 | | `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 | | `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
| `gate(task)` | Coordinator 读 Test 证据、`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 | | `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 只做读证据的终检。 派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。
@@ -30,14 +31,14 @@ Developer 只实现。
```text ```text
Coordinator 发现或读取 open 任务 Coordinator 发现或读取 open 任务
-> prepare:写/补全 tasks.yaml 验收信号 -> prepare:写/补全 task-records/ 中的任务验收信号
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs -> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用 -> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」) -> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
-> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration -> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration
-> 运行 launcher plan 并审阅 launchFingerprint -> 运行 launcher plan 并审阅 launchFingerprint
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker -> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
-> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md) -> 把本次 task/attempt receipt 追加到 task-records/(见 orca-adapter.md)
-> dispatch 给 Developer(--to <worker handle>) -> dispatch 给 Developer(--to <worker handle>)
-> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理) -> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞) -> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
@@ -133,7 +134,7 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。 - 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。 - 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。 - 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
- 记录:`launch` 成功后把 receipt 追加到权威 `tasks.yaml.workerReceipts`,并把 - 记录:`launch` 成功后把 receipt 追加到当前任务记录的 `workerReceipts`,并把
`receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入 `receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入
`dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`; `dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`;
receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录 receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录
@@ -173,9 +174,10 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
| 要保持基线分支干净 | 新 worktree(feature 分支)| | 要保持基线分支干净 | 新 worktree(feature 分支)|
| 小改动、追求快 | 当前 worktree | | 小改动、追求快 | 当前 worktree |
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、 **项目配置 SSOT 只落一处**:无论开几个 worktree,`tasks.yaml`、
`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威 `knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威
副本(通常在基线/协调所在 worktree)。Coordinator 单写任务、知识、回归目录、 副本(通常在基线/协调所在 worktree)。任务执行状态追加到
`.pouch/ack/task-records/<recordId>.yaml`;Coordinator 单写任务记录、知识、回归目录、
`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个 `deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与 副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
@@ -190,20 +192,20 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担: 没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
1. Coordinator 在 `tasks.yaml` 写好任务和验收信号。 1. Coordinator 在 `task-records/` 写入包含完整任务快照的初始记录和验收信号。
2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的 2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的
`knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt, `knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt,
手动发给 Developer(另一个会话/终端/人)。 手动发给 Developer(另一个会话/终端/人)。
3. Developer 完成后按 worker_done 模板回报。 3. Developer 完成后按 worker_done 模板回报。
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。 4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。 5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。 6. Coordinator 做终检并追加新记录:通过 `verified`,不过 `failed_retest`。
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品 7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test, 复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
不派 Developer、不消耗产品轮次。 不派 Developer、不消耗产品轮次。
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator 手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有 写 `task-records/` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。 选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff, 手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
@@ -233,7 +235,7 @@ actual:
snapshot evidence: snapshot evidence:
``` ```
Test 只回传证据,不写 `tasks.yaml` 或 `knowledge.yaml`;由 Coordinator 落盘。 Test 只回传证据,不写 `task-records/` 或 `knowledge.yaml`;由 Coordinator 追加落盘。
每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或 每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或
`not_applicable` 及证据。 `not_applicable` 及证据。
@@ -277,7 +279,8 @@ worktreePath:
## 结果回写 ## 结果回写
Coordinator 回写 `tasks.yaml` 时按状态填写(字段结构见 `templates/tasks.schema.json`): Coordinator 追加任务记录时按状态填写(记录内的 `task` 字段结构见
`templates/tasks.schema.json`):
```yaml ```yaml
# 通过(Test 复测 + Coordinator 终检) # 通过(Test 复测 + Coordinator 终检)
+2 -2
View File
@@ -3,7 +3,7 @@
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和 本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入 Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入 交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入
`.pouch/ack/tasks.yaml.deliveryRuns`。 任务记录的 `deliveryRuns`(读取时 materialize 到任务视图)。
## 1. 配置与授权不是一回事 ## 1. 配置与授权不是一回事
@@ -78,7 +78,7 @@ intents:
口头「发版」不能代替 stable/production 的 `approval` 步骤。 口头「发版」不能代替 stable/production 的 `approval` 步骤。
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」 - 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。 写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
- 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填 - 用户触发的 intent 运行追加到任务记录的 `deliveryRuns`,`intent` 填
`testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记 `testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记
`deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用 `deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用
`verified` 任务。 `verified` 任务。
+2 -2
View File
@@ -33,7 +33,7 @@
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。 2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的 3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。 knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
4. 把任务写进 .pouch/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。 4. 把任务作为完整快照追加到 `.pouch/ack/task-records/`(只有你写),校验任务视图和 knowledge.yaml。
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付, 5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。 同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化 6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
@@ -41,7 +41,7 @@
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected 只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
fingerprint 创建 fresh worker; fingerprint 创建 fresh worker;
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 →
dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml; dispatch 测试独立复测 → 你读证据终检 → 追加 task-records;
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独 每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
记录、恢复并告诉我下一步,不占产品复验轮次。 记录、恢复并告诉我下一步,不占产品复验轮次。
7. 所选任务都 verified 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入 7. 所选任务都 verified 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入
+6 -5
View File
@@ -2,7 +2,8 @@
Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。 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。成功后: 标准输出是单一 JSON。成功后:
1. 校验返回 receipt 的 `receiptHash`; 1. 校验返回 receipt 的 `receiptHash`;
2. 把 receipt 原样追加到 `tasks.yaml.workerReceipts`; 2. 把 receipt 原样追加到当前任务的新记录的 `workerReceipts`;
3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入 3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入
`dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入 `dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入
`dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt `dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt
@@ -129,7 +130,7 @@ Policy:
- Each issue can have at most 3 valid product-retest rounds. - 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. - 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. - 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. - Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
EOF EOF
)" --json )" --json
@@ -324,7 +325,7 @@ orca orchestration send \
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>` 无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由 区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由
Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`: Coordinator 按 `closed-loop.md` 做终检并追加任务记录:
```bash ```bash
python3 <ack-skill-dir>/scripts/reclaim_workers.py \ python3 <ack-skill-dir>/scripts/reclaim_workers.py \
@@ -334,7 +335,7 @@ python3 <ack-skill-dir>/scripts/reclaim_workers.py \
只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。 只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。
收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`; 收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并追加任务记录;
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过 通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写 写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。 `dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
+2 -2
View File
@@ -10,10 +10,10 @@
项目状态是 `.pouch/ack/regression.yaml`。任务板用 项目状态是 `.pouch/ack/regression.yaml`。任务板用
`project.regressionFile: .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 写入任务的 `regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的
`regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改 `regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改
`.pouch/ack/`。 `.pouch/ack/`。
@@ -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` | | Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 | | Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 | | 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 又不破坏「验证者 ≠ 实现者」。 **模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
@@ -108,7 +108,8 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 | | `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 | | `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 | | `<local_config>` | 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 通过回报提名或验证 | | `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Test 通过 `regressionCandidates` 提名 | | `regression.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Test 通过 `regressionCandidates` 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 | | `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
@@ -143,7 +144,7 @@ launch / service / data / browser / tooling environment failure
-> 环境恢复后 retesting -> 环境恢复后 retesting
``` ```
状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同): 状态定义(所有状态都只由 Coordinator 追加到 `task-records/`,来源不同):
| 状态 | 依据来源 | 含义 | | 状态 | 依据来源 | 含义 |
|------|----------|------| |------|----------|------|
@@ -179,11 +180,11 @@ planned -> skipped
## 项目状态写入约定(并发安全) ## 项目状态写入约定(并发安全)
`tasks.yaml` 是任务、交付运行与回归运行事实源,`knowledge.yaml` 是跨任务项目知识 `task-records/` 是任务、交付运行与回归运行事实源,`tasks.yaml` 是稳定项目配置,
事实源,`regression.yaml` 是黑盒回归用例事实源,`delivery.yaml` 是项目交付能力 `knowledge.yaml` 是跨任务项目知识事实源,`regression.yaml` 是黑盒回归用例事实源,
事实源。为避免多 Agent 并发写冲突: `delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
- **只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`**。 - **只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`**。
Test 与 Developer 对它们都是只读的。 Test 与 Developer 对它们都是只读的。
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。 - Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在 - Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
+5 -14
View File
@@ -25,6 +25,7 @@ from pathlib import Path
from typing import Any from typing import Any
from approval_payload import approval_payload_hash, review_items 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 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 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]: def load_board(path: Path) -> dict[str, Any]:
try: try:
text = path.read_text(encoding="utf-8") value = parse_document(path)
if path.suffix.lower() == ".json": if is_task_store_manifest(value):
value = load_json_unique(text) value = materialize_board(value, path)
else: except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError, TaskStoreError) as exc:
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:
raise IntakeError(f"cannot read task board: {exc}") from 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 return value
+155
View File
@@ -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())
+161
View File
@@ -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())
+249
View File
@@ -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",
]
+5 -37
View File
@@ -22,15 +22,7 @@ import sys
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
from yaml_subset import ( from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$") ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$") RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[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]: def _load_document(path: Path, label: str) -> dict[str, Any]:
try: try:
content = path.read_text(encoding="utf-8") data = parse_document(path)
except OSError as exc: if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n") sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1) 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 return data
+5 -37
View File
@@ -21,15 +21,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from yaml_subset import ( from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$") 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}$") 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._/-]+$") 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]: def load_yaml(path: Path, label: str) -> dict[str, Any]:
try: try:
content = path.read_text(encoding="utf-8") data = parse_document(path)
except OSError as exc: if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n") sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1) 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 return data
+10 -37
View File
@@ -25,13 +25,7 @@ import sys
from pathlib import Path from pathlib import Path
from approval_payload import approval_payload_hash from approval_payload import approval_payload_hash
from yaml_subset import ( from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
from worker_profiles import validate_orchestration from worker_profiles import validate_orchestration
from worker_profiles import validate_routing_document from worker_profiles import validate_routing_document
@@ -243,38 +237,12 @@ def _nonempty_string(value: object) -> bool:
def load_document(path: Path) -> dict: def load_document(path: Path) -> dict:
try: try:
content = path.read_text(encoding="utf-8") data = parse_document(path)
except OSError as exc: if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"任务板读取失败: {exc}\n") sys.stderr.write(f"任务板读取失败: {exc}\n")
raise SystemExit(1) 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 return data
@@ -901,6 +869,11 @@ def validate_builtin(
orchestration_override: dict | None = None, orchestration_override: dict | None = None,
) -> list[str]: ) -> list[str]:
errors: 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" project_intake_workflow = "read-only-v1"
def validate_string_fields( def validate_string_fields(
+6 -4
View File
@@ -8,7 +8,7 @@
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK > 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 > 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。 > 无论叫什么,都在 `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` 角色启动覆盖, > `delivery.yaml`、空的 `regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖,
> 不复制或链接 Skill。 > 不复制或链接 Skill。
@@ -18,7 +18,8 @@
- 技术栈:`<tech_stack>` - 技术栈:`<tech_stack>`
- 运行命令:`<run_command>` - 运行命令:`<run_command>`
- Base URL:`<base_url>` - Base URL:`<base_url>`
- 任务板:`.pouch/ack/tasks.yaml` - 任务板配置:`.pouch/ack/tasks.yaml`
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`(追加式)
- 项目知识:`.pouch/ack/knowledge.yaml` - 项目知识:`.pouch/ack/knowledge.yaml`
- 交付契约:`.pouch/ack/delivery.yaml`(默认关闭) - 交付契约:`.pouch/ack/delivery.yaml`(默认关闭)
- 回归目录:`.pouch/ack/regression.yaml` - 回归目录:`.pouch/ack/regression.yaml`
@@ -68,7 +69,8 @@ receipt 以 `.pouch/ack/agents.yaml`(存在时)或 `.pouch/ack/tasks.yaml`
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 | | `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 | | `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 | | `<local_config_paths>` | 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 通过回报提名或验证 | | `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 | | `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 | | `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
@@ -117,7 +119,7 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。 - 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。 - 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。 - `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`; - 只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`;
Developer 与 Test 都只读,通过消息回报。 Developer 与 Test 都只读,通过消息回报。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识; - Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
`candidate` 不派发,知识库不全量注入。 `candidate` 不派发,知识库不全量注入。
+19
View File
@@ -115,6 +115,25 @@
"statusReference": { "statusReference": {
"type": "object" "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": { "workerReceipts": {
"type": "array", "type": "array",
"items": { "items": {
+4
View File
@@ -3,6 +3,10 @@ version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>" updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
source: "Coordinator (PM) Agent" source: "Coordinator (PM) Agent"
ackVersion: "<接入时的 ack skill 版本>" ackVersion: "<接入时的 ack skill 版本>"
# 任务板只保存项目配置;执行记录追加到 task-records/,避免多个工作区共同改写本文件。
taskStore:
version: 1
directory: "task-records"
project: project:
name: "<project_name>" name: "<project_name>"
baseUrl: "<base_url>" baseUrl: "<base_url>"
+1 -1
View File
@@ -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/<run-id>.*` 文件中。
- **维护**:目标 id 能 `查看` 到,且检查通过。 - **维护**:目标 id 能 `查看` 到,且检查通过。
- **运行**:有本次 `runs/*.yaml` 快照和同名 `.csv` 成绩单(`executed` 列区分已跑 / 未跑);没填 HOST 时会停,而不是编造失败。 - **运行**:有本次 `runs/*.yaml` 快照和同名 `.csv` 成绩单(`executed` 列区分已跑 / 未跑);没填 HOST 时会停,而不是编造失败。
+1 -1
View File
@@ -71,7 +71,7 @@ python3 <skill-dir>/scripts/catalog.py list --root <project-root>
python3 <skill-dir>/scripts/generate_cases_csv.py --root <project-root> python3 <skill-dir>/scripts/generate_cases_csv.py --root <project-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/<run-id>.yaml`,不会回写清单。
## 查看 ## 查看
+1 -1
View File
@@ -35,5 +35,5 @@ python3 <skill-dir>/scripts/catalog.py next-id --root <root> --category <CAT>
- 为了能在默认风险下跑而把 `risk` 改低 - 为了能在默认风险下跑而把 `risk` 改低
- 一次塞进无关的一批用例 - 一次塞进无关的一批用例
- 手改 `runs/` 冒充执行记录 - 手改 `runs/` 冒充执行记录
- 手改 `cases.csv`(必须从 `cases/*.yaml` 生成;`last_*` 列来自最近一次 `runs/*.yaml`) - 手改 `cases.csv`(必须从 `cases/*.yaml` 生成;运行结果只在对应 `runs/<run-id>.*` 中保存)
- 手改 `runs/*.csv`(必须从对应 yaml 用 `generate_run_csv.py` 生成) - 手改 `runs/*.csv`(必须从对应 yaml 用 `generate_run_csv.py` 生成)
+3 -2
View File
@@ -83,13 +83,14 @@ python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立),`not-run` 是没轮到(失败即停后剩下的 / 被前置整组挡住的 / 需人工而无人值守的)。 每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立),`not-run` 是没轮到(失败即停后剩下的 / 被前置整组挡住的 / 需人工而无人值守的)。
写入 `.pouch/tester/runs/<UTC时间>-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。立刻用同一快照生成 CSV,不要手改: 写入 `.pouch/tester/runs/<UTC时间>-<范围>-<随机后缀>.yaml`,可用
`generate_run_csv.new_run_id(<范围>)` 生成同名 `runId`。记录包含唯一 `runId`、筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。记录文件只创建一次,不覆盖已有文件。立刻用同一快照生成 CSV,不要手改:
```bash ```bash
python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml> python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml>
``` ```
这会写出 `.pouch/tester/runs/<UTC时间>-<范围>.csv`(`executed=yes|no`),并刷新 `cases.csv` 的 `last_result` / `last_executed` 列。对话里两个路径都报。 这会写出与快照同名的 `.pouch/tester/runs/<run-id>.csv`(`executed=yes|no`),不会修改 `cases.csv`。对话里两个路径都报。
对话报告: 对话报告:
+6 -2
View File
@@ -9,8 +9,8 @@ catalog.yaml 分类、风险阶梯、session、变量
local.yaml.example HOST 与授权开关模板(默认 127.0.0.1) local.yaml.example HOST 与授权开关模板(默认 127.0.0.1)
local.yaml 本机覆盖,不提交 local.yaml 本机覆盖,不提交
cases/<category>.yaml 一个分类一个文件(执行源) cases/<category>.yaml 一个分类一个文件(执行源)
cases.csv 给人看的扁平清单;generate_cases_csv.py 读 cases/*.yaml,并拼最近一次运行的 last_* 列 cases.csv 给人看的扁平清单;只由 cases/*.yaml 生成,不包含运行结果
runs/*.yaml 每次运行快照(执行源) runs/<run-id>.yaml 每次运行快照(执行源);run-id 必须唯一
runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对应 yaml 生成 runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对应 yaml 生成
``` ```
@@ -33,6 +33,10 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
`127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` > `127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` >
`catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。 `catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。
运行记录使用 UTC 时间加随机后缀命名,例如
`20260920T101530Z-smoke-a13f9c.yaml`,并在 YAML 顶层保存同一个 `runId`。
不要使用固定的 `latest.yaml` 或覆盖已有运行文件。
## 一条用例 ## 一条用例
写在对应分类文件的 `cases:` 列表里。 写在对应分类文件的 `cases:` 列表里。
+5 -33
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。""" """从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。
运行结果属于 runs/ 下的追加式记录,不回写本清单,避免多个工作区共同
刷新运行状态造成合并冲突。
"""
from __future__ import annotations from __future__ import annotations
@@ -32,11 +36,6 @@ FIELDS = (
"how", "how",
"expected", "expected",
"notes", "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: 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)) 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 = out or (tester_dir(root) / "cases.csv")
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8-sig", newline="") as handle: 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() writer.writeheader()
for case in cases: for case in cases:
cid = str(case.get("id") or "") 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( writer.writerow(
{ {
"category": case.get("category") or "", "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")), "how": how_brief(case.get("how")),
"expected": one_line(case.get("expected")), "expected": one_line(case.get("expected")),
"notes": one_line(case.get("notes")), "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 return path
+24 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import re
import sys import sys
from collections import Counter from collections import Counter
from pathlib import Path 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 from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run") 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_YES = frozenset({"pass", "fail", "environment-fail"})
EXECUTED_NO = frozenset({"skipped", "not-run"}) EXECUTED_NO = frozenset({"skipped", "not-run"})
FIELDS = ( FIELDS = (
@@ -56,6 +58,22 @@ def latest_run_yaml(root: Path) -> Path | None:
return files[-1] if files else 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]: def load_run(path: Path) -> dict[str, Any]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -63,6 +81,11 @@ def load_run(path: Path) -> dict[str, Any]:
results = data.get("results") results = data.get("results")
if not isinstance(results, list): if not isinstance(results, list):
raise SystemExit(f"{path} 缺少 results 列表") 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 return data
@@ -88,7 +111,6 @@ def export_run_csv(
root: Path, root: Path,
run_path: Path | None = None, run_path: Path | None = None,
out: Path | None = None, out: Path | None = None,
refresh_cases: bool = True,
) -> Path: ) -> Path:
path = run_path or latest_run_yaml(root) path = run_path or latest_run_yaml(root)
if path is None: if path is None:
@@ -122,10 +144,6 @@ def export_run_csv(
"maps_to": case.get("maps_to") or "", "maps_to": case.get("maps_to") or "",
} }
) )
if refresh_cases:
from generate_cases_csv import export_cases_csv
export_cases_csv(root)
return dest return dest
@@ -137,7 +155,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument( parser.add_argument(
"--no-refresh-cases", "--no-refresh-cases",
action="store_true", action="store_true",
help="不要顺便刷新 cases.csv 的 last_* 列", help="兼容旧命令;cases.csv 已不会被运行结果刷新",
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
root = resolve_root(args.root) root = resolve_root(args.root)
@@ -147,7 +165,6 @@ def main(argv: list[str] | None = None) -> int:
root, root,
run_path=run_path, run_path=run_path,
out=out, out=out,
refresh_cases=not args.no_refresh_cases,
) )
counts: Counter[str] = Counter() counts: Counter[str] = Counter()
with path.open(encoding="utf-8-sig", newline="") as handle: with path.open(encoding="utf-8-sig", newline="") as handle:
+197
View File
@@ -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()
+56
View File
@@ -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()